diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 9563be6125..79126fd658 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -5,8 +5,7 @@
"immich-server",
"redis",
"database",
- "immich-machine-learning",
- "init"
+ "immich-machine-learning"
],
"dockerComposeFile": [
"../docker/docker-compose.dev.yml",
diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index 71fa358942..1b57731b23 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -32,24 +32,18 @@ jobs:
permissions:
contents: read
outputs:
- should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
mobile:
- 'mobile/**'
- workflow:
- - '.github/workflows/build-mobile.yml'
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/build-mobile.yml'
+ force-events: 'workflow_call,workflow_dispatch'
build-sign-android:
name: Build and sign Android
@@ -57,7 +51,7 @@ jobs:
permissions:
contents: read
# Skip when PR from a fork
- if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }}
+ if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: mich
steps:
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 6e2bcdb84d..a630d27809 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -20,15 +20,11 @@ jobs:
permissions:
contents: read
outputs:
- should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
server:
@@ -38,14 +34,11 @@ jobs:
- 'i18n/**'
machine-learning:
- 'machine-learning/**'
- workflow:
- - '.github/workflows/docker.yml'
- - '.github/workflows/multi-runner-build.yml'
- - '.github/actions/image-build'
-
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/docker.yml'
+ - '.github/workflows/multi-runner-build.yml'
+ - '.github/actions/image-build'
+ force-events: 'workflow_dispatch,release'
retag_ml:
name: Re-Tag ML
@@ -53,7 +46,7 @@ jobs:
permissions:
contents: read
packages: write
- if: ${{ needs.pre-job.outputs.should_run_ml == 'false' && !github.event.pull_request.head.repo.fork }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
strategy:
matrix:
@@ -82,7 +75,7 @@ jobs:
permissions:
contents: read
packages: write
- if: ${{ needs.pre-job.outputs.should_run_server == 'false' && !github.event.pull_request.head.repo.fork }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
strategy:
matrix:
@@ -108,7 +101,7 @@ jobs:
machine-learning:
name: Build and Push ML
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
strategy:
fail-fast: false
matrix:
@@ -153,7 +146,7 @@ jobs:
server:
name: Build and Push Server
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
permissions:
contents: read
diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
index 2514ee8639..9a35a0ae91 100644
--- a/.github/workflows/docs-build.yml
+++ b/.github/workflows/docs-build.yml
@@ -18,32 +18,28 @@ jobs:
permissions:
contents: read
outputs:
- should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.found_paths.outputs.open-api == 'true' || steps.should_force.outputs.should_force == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
docs:
- 'docs/**'
- workflow:
- - '.github/workflows/docs-build.yml'
open-api:
- 'open-api/immich-openapi-specs.json'
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' || github.ref_name == 'main' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/docs-build.yml'
+ force-events: 'release'
+ force-branches: 'main'
build:
name: Docs Build
needs: pre-job
permissions:
contents: read
- if: ${{ needs.pre-job.outputs.should_run == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).docs == true }}
runs-on: ubuntu-latest
defaults:
run:
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
index 7f1ce1988e..f5e68fb42d 100644
--- a/.github/workflows/static_analysis.yml
+++ b/.github/workflows/static_analysis.yml
@@ -17,28 +17,23 @@ jobs:
permissions:
contents: read
outputs:
- should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
mobile:
- 'mobile/**'
- workflow:
- - '.github/workflows/static_analysis.yml'
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/static_analysis.yml'
+ force-events: 'workflow_dispatch,release'
mobile-dart-analyze:
name: Run Dart Code Analysis
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -100,8 +95,9 @@ jobs:
- name: Run dart format
run: make format
- - name: Run dart custom_lint
- run: dart run custom_lint
+ # TODO: Re-enable after upgrading custom_lint
+ # - name: Run dart custom_lint
+ # run: dart run custom_lint
# TODO: Use https://github.com/CQLabs/dcm-action
- name: Run DCM
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index cd02d8341a..773d14e171 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,23 +14,11 @@ jobs:
permissions:
contents: read
outputs:
- should_run_i18n: ${{ steps.found_paths.outputs.i18n == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_e2e: ${{ steps.found_paths.outputs.e2e == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_mobile: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_e2e_web: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_e2e_server_cli: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.server == 'true' || steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_.github: ${{ steps.found_paths.outputs['.github'] == 'true' || steps.should_force.outputs.should_force == 'true' }} # redundant to have should_force but if someone changes the trigger then this won't have to be changed
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
i18n:
@@ -50,17 +38,16 @@ jobs:
- 'mobile/**'
machine-learning:
- 'machine-learning/**'
- workflow:
- - '.github/workflows/test.yml'
.github:
- '.github/**'
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/test.yml'
+ force-events: 'workflow_dispatch'
+
server-unit-tests:
name: Test & Lint Server
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -97,7 +84,7 @@ jobs:
cli-unit-tests:
name: Unit Test CLI
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -137,7 +124,7 @@ jobs:
cli-unit-tests-win:
name: Unit Test CLI (Windows)
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: windows-latest
permissions:
contents: read
@@ -172,7 +159,7 @@ jobs:
web-lint:
name: Lint Web
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: mich
permissions:
contents: read
@@ -209,7 +196,7 @@ jobs:
web-unit-tests:
name: Test Web
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -243,7 +230,7 @@ jobs:
i18n-tests:
name: Test i18n
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_i18n == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -281,7 +268,7 @@ jobs:
e2e-tests-lint:
name: End-to-End Lint
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_e2e == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -320,7 +307,7 @@ jobs:
server-medium-tests:
name: Medium Tests (Server)
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -348,7 +335,7 @@ jobs:
e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI)
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
@@ -396,7 +383,7 @@ jobs:
e2e-tests-web:
name: End-to-End Tests (Web)
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
@@ -449,7 +436,7 @@ jobs:
mobile-unit-tests:
name: Unit Test Mobile
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -471,7 +458,7 @@ jobs:
ml-unit-tests:
name: Unit Test ML
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -507,7 +494,7 @@ jobs:
github-files-formatting:
name: .github Files Formatting
needs: pre-job
- if: ${{ needs.pre-job.outputs['should_run_.github'] == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run)['.github'] == true }}
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml
index d765db6c1a..36544d4eed 100644
--- a/.github/workflows/weblate-lock.yml
+++ b/.github/workflows/weblate-lock.yml
@@ -21,25 +21,24 @@ jobs:
permissions:
contents: read
outputs:
- should_run: ${{ steps.found_paths.outputs.i18n == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
i18n:
- 'i18n/!(en)**\.json'
+ exclude-branches: 'chore/translations'
+ skip-force-logic: 'true'
enforce-lock:
name: Check Weblate Lock
needs: [pre-job]
runs-on: ubuntu-latest
permissions: {}
- if: ${{ needs.pre-job.outputs.should_run == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- name: Bot review status
env:
diff --git a/.gitignore b/.gitignore
index 25731cc2aa..3220701cc6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ mobile/libisar.dylib
mobile/openapi/test
mobile/openapi/doc
mobile/openapi/.openapi-generator/FILES
+mobile/ios/build
open-api/typescript-sdk/build
mobile/android/fastlane/report.xml
diff --git a/CODEOWNERS b/CODEOWNERS
index f9687762d0..8759cf2357 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -4,3 +4,4 @@
/web/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler
+/mobile/ @shenlong-tanwen
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index 928e0b26e5..4e081c8966 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -169,8 +169,6 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
-| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server |
-| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
diff --git a/i18n/en.json b/i18n/en.json
index 297b807d34..187f86c0b5 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -123,6 +123,13 @@
"logging_enable_description": "Enable logging",
"logging_level_description": "When enabled, what log level to use.",
"logging_settings": "Logging",
+ "machine_learning_availability_checks": "Availability checks",
+ "machine_learning_availability_checks_description": "Automatically detect and prefer available machine learning servers",
+ "machine_learning_availability_checks_enabled": "Enable availability checks",
+ "machine_learning_availability_checks_interval": "Check interval",
+ "machine_learning_availability_checks_interval_description": "Interval in milliseconds between availability checks",
+ "machine_learning_availability_checks_timeout": "Request timeout",
+ "machine_learning_availability_checks_timeout_description": "Timeout in milliseconds for availability checks",
"machine_learning_clip_model": "CLIP model",
"machine_learning_clip_model_description": "The name of a CLIP model listed here. Note that you must re-run the 'Smart Search' job for all images upon changing a model.",
"machine_learning_duplicate_detection": "Duplicate Detection",
@@ -546,6 +553,7 @@
"background_backup_running_error": "Background backup is currently running, cannot start manual backup",
"background_location_permission": "Background location permission",
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
+ "background_options": "Background Options",
"backup": "Backup",
"backup_album_selection_page_albums_device": "Albums on device ({count})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
@@ -553,6 +561,7 @@
"backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
+ "backup_albums_sync": "Backup albums synchronization",
"backup_all": "All",
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
@@ -669,6 +678,8 @@
"change_pin_code": "Change PIN code",
"change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully",
+ "charging": "Charging",
+ "charging_requirement_mobile_backup": "Background backup requires the device to be charging",
"check_corrupt_asset_backup": "Check for corrupt asset backups",
"check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
@@ -922,6 +933,7 @@
"cant_get_number_of_comments": "Can't get number of comments",
"cant_search_people": "Can't search people",
"cant_search_places": "Can't search places",
+ "clipboard_unsupported_mime_type": "The system clipboard does not support copying this type of content: {mimeType}",
"error_adding_assets_to_album": "Error adding assets to album",
"error_adding_users_to_album": "Error adding users to album",
"error_deleting_shared_user": "Error deleting shared user",
@@ -1364,6 +1376,7 @@
"name_or_nickname": "Name or nickname",
"network_requirement_photos_upload": "Use cellular data to backup photos",
"network_requirement_videos_upload": "Use cellular data to backup videos",
+ "network_requirements": "Network Requirements",
"network_requirements_updated": "Network requirements changed, resetting backup queue",
"networking_settings": "Networking",
"networking_subtitle": "Manage the server endpoint settings",
@@ -1928,6 +1941,7 @@
"stacktrace": "Stacktrace",
"start": "Start",
"start_date": "Start date",
+ "start_date_before_end_date": "Start date must be before end date",
"state": "State",
"status": "Status",
"stop_casting": "Stop casting",
diff --git a/mise.lock b/mise.lock
deleted file mode 100644
index 112b1ca6eb..0000000000
--- a/mise.lock
+++ /dev/null
@@ -1,34 +0,0 @@
-[tools.dart]
-version = "3.8.2"
-backend = "asdf:dart"
-
-[tools.flutter]
-version = "3.32.8-stable"
-backend = "asdf:flutter"
-
-[tools."github:CQLabs/homebrew-dcm"]
-version = "1.31.4"
-backend = "github:CQLabs/homebrew-dcm"
-
-[tools."github:CQLabs/homebrew-dcm".platforms.linux-x64]
-checksum = "blake3:e9df5b765df327e1248fccf2c6165a89d632a065667f99c01765bf3047b94955"
-size = 8821083
-url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.31.4/dcm-linux-x64-release.zip"
-
-[tools.node]
-version = "22.18.0"
-backend = "core:node"
-
-[tools.node.platforms.linux-x64]
-checksum = "sha256:a2e703725d8683be86bb5da967bf8272f4518bdaf10f21389e2b2c9eaeae8c8a"
-size = 54824343
-url = "https://nodejs.org/dist/v22.18.0/node-v22.18.0-linux-x64.tar.gz"
-
-[tools.pnpm]
-version = "10.14.0"
-backend = "aqua:pnpm/pnpm"
-
-[tools.pnpm.platforms.linux-x64]
-checksum = "blake3:13dfa46b7173d3cad3bad60a756a492ecf0bce48b23eb9f793e7ccec5a09b46d"
-size = 66231525
-url = "https://github.com/pnpm/pnpm/releases/download/v10.14.0/pnpm-linux-x64"
diff --git a/mise.toml b/mise.toml
index ff9fa3895d..34503fb8d3 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,17 +1,15 @@
[tools]
node = "22.19.0"
-flutter = "3.32.8"
-pnpm = "10.14.0"
-dart = "3.8.2"
+flutter = "3.35.4"
+pnpm = "10.15.1"
[tools."github:CQLabs/homebrew-dcm"]
-version = "1.31.4"
+version = "1.30.0"
bin = "dcm"
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
[settings]
experimental = true
-lockfile = true
pin = true
# .github
@@ -300,7 +298,7 @@ run = "tsc --noEmit"
depends = "web:svelte-kit-sync"
env._.path = "web/node_modules/.bin"
dir = "web"
-run = "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/timeline/Timeline.svelte"
+run = "svelte-check --no-tsconfig --fail-on-warnings"
[tasks."web:checklist"]
run = [
@@ -310,3 +308,185 @@ run = [
"mise run web:test --run",
"mise run web:lint",
]
+
+
+# mobile
+[tasks."mobile:codegen:dart"]
+alias = "mobile:codegen"
+description = "Execute build_runner to auto-generate dart code"
+dir = "mobile"
+sources = ["pubspec.yaml", "build.yaml", "lib/**/*.dart"]
+outputs = { auto = true }
+run = "dart run build_runner build --delete-conflicting-outputs"
+
+[tasks."mobile:codegen:pigeon"]
+alias = "mobile:pigeon"
+description = "Generate pigeon platform code"
+dir = "mobile"
+depends = [
+ "mobile:pigeon:native-sync",
+ "mobile:pigeon:thumbnail",
+ "mobile:pigeon:background-worker",
+ "mobile:pigeon:connectivity",
+]
+
+[tasks."mobile:codegen:translation"]
+alias = "mobile:translation"
+description = "Generate translations from i18n JSONs"
+dir = "mobile"
+run = [
+ { task = "i18n:format-fix" },
+ { tasks = [
+ "mobile:i18n:loader",
+ "mobile:i18n:keys",
+ ] },
+]
+
+[tasks."mobile:codegen:app-icon"]
+description = "Generate app icons"
+dir = "mobile"
+run = "flutter pub run flutter_launcher_icons:main"
+
+[tasks."mobile:codegen:splash"]
+description = "Generate splash screen"
+dir = "mobile"
+run = "flutter pub run flutter_native_splash:create"
+
+[tasks."mobile:test"]
+description = "Run mobile tests"
+dir = "mobile"
+run = "flutter test"
+
+[tasks."mobile:lint"]
+description = "Analyze Dart code"
+dir = "mobile"
+depends = ["mobile:analyze:dart", "mobile:analyze:dcm"]
+
+[tasks."mobile:lint-fix"]
+description = "Auto-fix Dart code"
+dir = "mobile"
+depends = ["mobile:analyze:fix:dart", "mobile:analyze:fix:dcm"]
+
+[tasks."mobile:format"]
+description = "Format Dart code"
+dir = "mobile"
+run = "dart format --set-exit-if-changed $(find lib -name '*.dart' -not \\( -name '*.g.dart' -o -name '*.drift.dart' -o -name '*.gr.dart' \\))"
+
+[tasks."mobile:build:android"]
+description = "Build Android release"
+dir = "mobile"
+run = "flutter build appbundle"
+
+[tasks."mobile:drift:migration"]
+alias = "mobile:migration"
+description = "Generate database migrations"
+dir = "mobile"
+run = "dart run drift_dev make-migrations"
+
+
+# mobile internal tasks
+[tasks."mobile:pigeon:native-sync"]
+description = "Generate native sync API pigeon code"
+dir = "mobile"
+hide = true
+sources = ["pigeon/native_sync_api.dart"]
+outputs = [
+ "lib/platform/native_sync_api.g.dart",
+ "ios/Runner/Sync/Messages.g.swift",
+ "android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt",
+]
+run = [
+ "dart run pigeon --input pigeon/native_sync_api.dart",
+ "dart format lib/platform/native_sync_api.g.dart",
+]
+
+[tasks."mobile:pigeon:thumbnail"]
+description = "Generate thumbnail API pigeon code"
+dir = "mobile"
+hide = true
+sources = ["pigeon/thumbnail_api.dart"]
+outputs = [
+ "lib/platform/thumbnail_api.g.dart",
+ "ios/Runner/Images/Thumbnails.g.swift",
+ "android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt",
+]
+run = [
+ "dart run pigeon --input pigeon/thumbnail_api.dart",
+ "dart format lib/platform/thumbnail_api.g.dart",
+]
+
+[tasks."mobile:pigeon:background-worker"]
+description = "Generate background worker API pigeon code"
+dir = "mobile"
+hide = true
+sources = ["pigeon/background_worker_api.dart"]
+outputs = [
+ "lib/platform/background_worker_api.g.dart",
+ "ios/Runner/Background/BackgroundWorker.g.swift",
+ "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt",
+]
+run = [
+ "dart run pigeon --input pigeon/background_worker_api.dart",
+ "dart format lib/platform/background_worker_api.g.dart",
+]
+
+[tasks."mobile:pigeon:connectivity"]
+description = "Generate connectivity API pigeon code"
+dir = "mobile"
+hide = true
+sources = ["pigeon/connectivity_api.dart"]
+outputs = [
+ "lib/platform/connectivity_api.g.dart",
+ "ios/Runner/Connectivity/Connectivity.g.swift",
+ "android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt",
+]
+run = [
+ "dart run pigeon --input pigeon/connectivity_api.dart",
+ "dart format lib/platform/connectivity_api.g.dart",
+]
+
+[tasks."mobile:i18n:loader"]
+description = "Generate i18n loader"
+dir = "mobile"
+hide = true
+sources = ["i18n/"]
+outputs = "lib/generated/codegen_loader.g.dart"
+run = [
+ "dart run easy_localization:generate -S ../i18n",
+ "dart format lib/generated/codegen_loader.g.dart",
+]
+
+[tasks."mobile:i18n:keys"]
+description = "Generate i18n keys"
+dir = "mobile"
+hide = true
+sources = ["i18n/en.json"]
+outputs = "lib/generated/intl_keys.g.dart"
+run = [
+ "dart run bin/generate_keys.dart",
+ "dart format lib/generated/intl_keys.g.dart",
+]
+
+[tasks."mobile:analyze:dart"]
+description = "Run Dart analysis"
+dir = "mobile"
+hide = true
+run = "dart analyze --fatal-infos"
+
+[tasks."mobile:analyze:dcm"]
+description = "Run Dart Code Metrics"
+dir = "mobile"
+hide = true
+run = "dcm analyze lib --fatal-style --fatal-warnings"
+
+[tasks."mobile:analyze:fix:dart"]
+description = "Auto-fix Dart analysis"
+dir = "mobile"
+hide = true
+run = "dart fix --apply"
+
+[tasks."mobile:analyze:fix:dcm"]
+description = "Auto-fix Dart Code Metrics"
+dir = "mobile"
+hide = true
+run = "dcm fix lib"
diff --git a/mobile/.fvmrc b/mobile/.fvmrc
index 3ca65ffc7c..a4d5f6d9b7 100644
--- a/mobile/.fvmrc
+++ b/mobile/.fvmrc
@@ -1,3 +1,3 @@
{
- "flutter": "3.32.8"
+ "flutter": "3.35.4"
}
\ No newline at end of file
diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json
index 9a9fb67ce3..9c6057e582 100644
--- a/mobile/.vscode/settings.json
+++ b/mobile/.vscode/settings.json
@@ -1,8 +1,8 @@
{
- "dart.flutterSdkPath": ".fvm/versions/3.32.8",
+ "dart.flutterSdkPath": ".fvm/versions/3.35.4",
"dart.lineLength": 120,
"[dart]": {
- "editor.rulers": [120],
+ "editor.rulers": [120]
},
"search.exclude": {
"**/.fvm": true
diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
index c26e1c6649..c04e1dafdc 100644
--- a/mobile/analysis_options.yaml
+++ b/mobile/analysis_options.yaml
@@ -43,8 +43,9 @@ analyzer:
- lib/**/*.g.dart
- lib/**/*.drift.dart
- plugins:
- - custom_lint
+ # TODO: Re-enable after upgrading custom_lint
+ # plugins:
+ # - custom_lint
custom_lint:
debug: true
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt
index 4237643233..5a3b0e1f3d 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt
@@ -3,6 +3,7 @@ package app.alextran.immich
import android.app.Application
import androidx.work.Configuration
import androidx.work.WorkManager
+import app.alextran.immich.background.BackgroundWorkerApiImpl
class ImmichApp : Application() {
override fun onCreate() {
@@ -14,6 +15,8 @@ class ImmichApp : Application() {
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
// (because of low memory etc.), the backup is never performed.
// As a workaround, we also run a backup check when initializing the application
+
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
+ BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
}
}
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 d395cc2243..4e811c8dfc 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
@@ -3,6 +3,7 @@ package app.alextran.immich
import android.content.Context
import android.os.Build
import android.os.ext.SdkExtensions
+import app.alextran.immich.background.BackgroundEngineLock
import app.alextran.immich.background.BackgroundWorkerApiImpl
import app.alextran.immich.background.BackgroundWorkerFgHostApi
import app.alextran.immich.connectivity.ConnectivityApi
@@ -25,6 +26,7 @@ class MainActivity : FlutterFragmentActivity() {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
+ flutterEngine.plugins.add(BackgroundEngineLock())
val messenger = flutterEngine.dartExecutor.binaryMessenger
val nativeSyncApiImpl =
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt
new file mode 100644
index 0000000000..6d6f45a708
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt
@@ -0,0 +1,33 @@
+package app.alextran.immich.background
+
+import android.util.Log
+import androidx.work.WorkManager
+import io.flutter.embedding.engine.FlutterEngineCache
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import java.util.concurrent.atomic.AtomicInteger
+
+private const val TAG = "BackgroundEngineLock"
+
+class BackgroundEngineLock : FlutterPlugin {
+ companion object {
+ const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
+ var engineCount = AtomicInteger(0)
+ }
+
+ override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ // work manager task is running while the main app is opened, cancel the worker
+ if (engineCount.incrementAndGet() > 1 && FlutterEngineCache.getInstance()
+ .get(ENGINE_CACHE_KEY) != null
+ ) {
+ WorkManager.getInstance(binding.applicationContext)
+ .cancelUniqueWork(BackgroundWorkerApiImpl.BACKGROUND_WORKER_NAME)
+ FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
+ }
+ Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ engineCount.decrementAndGet()
+ Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt
index 4d9e2c0caf..052395c172 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt
@@ -37,6 +37,36 @@ private object BackgroundWorkerPigeonUtils {
)
}
}
+ fun deepEquals(a: Any?, b: Any?): Boolean {
+ 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) {
+ return a.contentEquals(b)
+ }
+ if (a is Array<*> && b is Array<*>) {
+ return a.size == b.size &&
+ a.indices.all{ deepEquals(a[it], b[it]) }
+ }
+ if (a is List<*> && b is List<*>) {
+ return a.size == b.size &&
+ a.indices.all{ deepEquals(a[it], b[it]) }
+ }
+ if (a is Map<*, *> && b is Map<*, *>) {
+ return a.size == b.size && a.all {
+ (b as Map).containsKey(it.key) &&
+ deepEquals(it.value, b[it.key])
+ }
+ }
+ return a == b
+ }
+
}
/**
@@ -50,18 +80,63 @@ class FlutterError (
override val message: String? = null,
val details: Any? = null
) : Throwable()
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class BackgroundWorkerSettings (
+ val requiresCharging: Boolean,
+ val minimumDelaySeconds: Long
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): BackgroundWorkerSettings {
+ val requiresCharging = pigeonVar_list[0] as Boolean
+ val minimumDelaySeconds = pigeonVar_list[1] as Long
+ return BackgroundWorkerSettings(requiresCharging, minimumDelaySeconds)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ requiresCharging,
+ minimumDelaySeconds,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other !is BackgroundWorkerSettings) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return BackgroundWorkerPigeonUtils.deepEquals(toList(), other.toList()) }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
- return super.readValueOfType(type, buffer)
+ return when (type) {
+ 129.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ BackgroundWorkerSettings.fromList(it)
+ }
+ }
+ else -> super.readValueOfType(type, buffer)
+ }
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
- super.writeValue(stream, value)
+ when (value) {
+ is BackgroundWorkerSettings -> {
+ 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 BackgroundWorkerFgHostApi {
fun enable()
+ fun configure(settings: BackgroundWorkerSettings)
fun disable()
companion object {
@@ -89,6 +164,24 @@ interface BackgroundWorkerFgHostApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val settingsArg = args[0] as BackgroundWorkerSettings
+ val wrapped: List = try {
+ api.configure(settingsArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ BackgroundWorkerPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
run {
val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
if (api != null) {
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt
index 33eb60dc82..7d30319af4 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt
@@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader
import java.util.concurrent.TimeUnit
@@ -75,6 +76,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
engine = FlutterEngine(ctx)
+ FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
+ FlutterEngineCache.getInstance()
+ .put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!)
// Register custom plugins
MainActivity.registerPlugins(ctx, engine!!)
@@ -188,6 +192,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
isComplete = true
engine?.destroy()
engine = null
+ FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
flutterApi = null
notificationManager.cancel(NOTIFICATION_ID)
waitForForegroundPromotion()
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt
index 4c2d98be71..259e3244bc 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt
@@ -1,6 +1,7 @@
package app.alextran.immich.background
import android.content.Context
+import android.content.SharedPreferences
import android.provider.MediaStore
import android.util.Log
import androidx.work.BackoffPolicy
@@ -10,7 +11,7 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
-private const val TAG = "BackgroundUploadImpl"
+private const val TAG = "BackgroundWorkerApiImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
@@ -19,25 +20,34 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
enqueueMediaObserver(ctx)
}
+ override fun configure(settings: BackgroundWorkerSettings) {
+ BackgroundWorkerPreferences(ctx).updateSettings(settings)
+ enqueueMediaObserver(ctx)
+ }
+
override fun disable() {
- WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
- WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
+ WorkManager.getInstance(ctx).apply {
+ cancelUniqueWork(OBSERVER_WORKER_NAME)
+ cancelUniqueWork(BACKGROUND_WORKER_NAME)
+ }
Log.i(TAG, "Cancelled background upload tasks")
}
companion object {
- private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
+ const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
fun enqueueMediaObserver(ctx: Context) {
- val constraints = Constraints.Builder()
- .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
- .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
- .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
- .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
- .setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
- .setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
- .build()
+ val settings = BackgroundWorkerPreferences(ctx).getSettings()
+ val constraints = Constraints.Builder().apply {
+ addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
+ addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
+ addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
+ addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
+ setTriggerContentUpdateDelay(settings.minimumDelaySeconds, TimeUnit.SECONDS)
+ setTriggerContentMaxDelay(settings.minimumDelaySeconds * 10, TimeUnit.MINUTES)
+ setRequiresCharging(settings.requiresCharging)
+ }.build()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
.setConstraints(constraints)
@@ -45,7 +55,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
WorkManager.getInstance(ctx)
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
- Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
+ Log.i(
+ TAG,
+ "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME and settings: $settings"
+ )
}
fun enqueueBackgroundWorker(ctx: Context) {
@@ -56,9 +69,39 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(ctx)
- .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
+ .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work)
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
}
}
}
+
+private class BackgroundWorkerPreferences(private val ctx: Context) {
+ companion object {
+ private const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
+ private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
+ private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
+
+ private const val DEFAULT_MIN_DELAY_SECONDS = 30L
+ private const val DEFAULT_REQUIRE_CHARGING = false
+ }
+
+ private val sp: SharedPreferences by lazy {
+ ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
+ }
+
+ fun updateSettings(settings: BackgroundWorkerSettings) {
+ sp.edit().apply {
+ putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
+ putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
+ apply()
+ }
+ }
+
+ fun getSettings(): BackgroundWorkerSettings {
+ return BackgroundWorkerSettings(
+ minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS),
+ requiresCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, DEFAULT_REQUIRE_CHARGING),
+ )
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt
index 1b1716f55c..1ccd742d67 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt
@@ -8,7 +8,6 @@ import android.net.Uri
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
-import android.provider.MediaStore
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
@@ -19,7 +18,6 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DecodeFormat
import java.util.Base64
-import java.util.HashMap
import java.util.concurrent.CancellationException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Future
@@ -202,8 +200,10 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
val source = ImageDecoder.createSource(resolver, uri)
signal.throwIfCanceled()
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
- val sampleSize = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
- decoder.setTargetSampleSize(sampleSize)
+ if (targetWidth > 0 && targetHeight > 0) {
+ val sample = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
+ decoder.setTargetSampleSize(sample)
+ }
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
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 9c618d9ed0..28400c803f 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
@@ -209,6 +209,40 @@ data class SyncDelta (
override fun hashCode(): Int = toList().hashCode()
}
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class HashResult (
+ val assetId: String,
+ val error: String? = null,
+ val hash: String? = null
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): HashResult {
+ val assetId = pigeonVar_list[0] as String
+ val error = pigeonVar_list[1] as String?
+ val hash = pigeonVar_list[2] as String?
+ return HashResult(assetId, error, hash)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ assetId,
+ error,
+ hash,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other !is HashResult) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -227,6 +261,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
SyncDelta.fromList(it)
}
}
+ 132.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ HashResult.fromList(it)
+ }
+ }
else -> super.readValueOfType(type, buffer)
}
}
@@ -244,11 +283,16 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(131)
writeValue(stream, value.toList())
}
+ is HashResult -> {
+ stream.write(132)
+ writeValue(stream, value.toList())
+ }
else -> super.writeValue(stream, value)
}
}
}
+
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi {
fun shouldFullSync(): Boolean
@@ -259,7 +303,8 @@ interface NativeSyncApi {
fun getAlbums(): List
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List
- fun hashPaths(paths: List): List
+ fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit)
+ fun cancelHashing()
companion object {
/** The codec used by NativeSyncApi. */
@@ -402,13 +447,33 @@ interface NativeSyncApi {
}
}
run {
- val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$separatedMessageChannelSuffix", codec, taskQueue)
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List
- val pathsArg = args[0] as List
+ val assetIdsArg = args[0] as List
+ val allowNetworkAccessArg = args[1] as Boolean
+ api.hashAssets(assetIdsArg, allowNetworkAccessArg) { result: Result> ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
val wrapped: List = try {
- listOf(api.hashPaths(pathsArg))
+ api.cancelHashing()
+ listOf(null)
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
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 b2ceb8a9f2..868f3c6cdd 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
@@ -1,14 +1,25 @@
package app.alextran.immich.sync
import android.annotation.SuppressLint
+import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.provider.MediaStore
-import android.util.Log
+import android.util.Base64
import androidx.core.database.getStringOrNull
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
import java.io.File
-import java.io.FileInputStream
import java.security.MessageDigest
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.coroutines.coroutineContext
sealed class AssetResult {
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
@@ -19,8 +30,12 @@ sealed class AssetResult {
open class NativeSyncApiImplBase(context: Context) {
private val ctx: Context = context.applicationContext
+ private var hashTask: Job? = null
+
companion object {
- private const val TAG = "NativeSyncApiImplBase"
+ private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
+ private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
+ private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
const val MEDIA_SELECTION =
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
@@ -215,23 +230,74 @@ open class NativeSyncApiImplBase(context: Context) {
.toList()
}
- fun hashPaths(paths: List): List {
- val buffer = ByteArray(HASH_BUFFER_SIZE)
- val digest = MessageDigest.getInstance("SHA-1")
+ fun hashAssets(
+ assetIds: List,
+ // allowNetworkAccess is only used on the iOS implementation
+ @Suppress("UNUSED_PARAMETER") allowNetworkAccess: Boolean,
+ callback: (Result>) -> Unit
+ ) {
+ if (assetIds.isEmpty()) {
+ callback(Result.success(emptyList()))
+ return
+ }
- return paths.map { path ->
+ hashTask?.cancel()
+ hashTask = CoroutineScope(Dispatchers.IO).launch {
try {
- FileInputStream(path).use { file ->
- var bytesRead: Int
- while (file.read(buffer).also { bytesRead = it } > 0) {
- digest.update(buffer, 0, bytesRead)
+ val results = assetIds.map { assetId ->
+ async {
+ hashSemaphore.withPermit {
+ ensureActive()
+ hashAsset(assetId)
+ }
}
- }
- digest.digest()
+ }.awaitAll()
+
+ callback(Result.success(results))
+ } catch (e: CancellationException) {
+ callback(
+ Result.failure(
+ FlutterError(
+ HASHING_CANCELLED_CODE,
+ "Hashing operation was cancelled",
+ null
+ )
+ )
+ )
} catch (e: Exception) {
- Log.w(TAG, "Failed to hash file $path: $e")
- null
+ callback(Result.failure(e))
}
}
}
+
+ private suspend fun hashAsset(assetId: String): HashResult {
+ return try {
+ val assetUri = ContentUris.withAppendedId(
+ MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
+ assetId.toLong()
+ )
+
+ val digest = MessageDigest.getInstance("SHA-1")
+ ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
+ var bytesRead: Int
+ val buffer = ByteArray(HASH_BUFFER_SIZE)
+ while (inputStream.read(buffer).also { bytesRead = it } > 0) {
+ coroutineContext.ensureActive()
+ digest.update(buffer, 0, bytesRead)
+ }
+ } ?: return HashResult(assetId, "Cannot open input stream for asset", null)
+
+ val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
+ HashResult(assetId, null, hashString)
+ } catch (e: SecurityException) {
+ HashResult(assetId, "Permission denied accessing asset: ${e.message}", null)
+ } catch (e: Exception) {
+ HashResult(assetId, "Failed to hash asset: ${e.message}", null)
+ }
+ }
+
+ fun cancelHashing() {
+ hashTask?.cancel()
+ hashTask = null
+ }
}
diff --git a/mobile/drift_schemas/main/drift_schema_v11.json b/mobile/drift_schemas/main/drift_schema_v11.json
new file mode 100644
index 0000000000..1c100ab37f
--- /dev/null
+++ b/mobile/drift_schemas/main/drift_schema_v11.json
@@ -0,0 +1 @@
+{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.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_in_seconds","getter_name":"durationInSeconds","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":["unknown"]},{"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":"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":[]}],"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":["unknown"]},{"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_in_seconds","getter_name":"durationInSeconds","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":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,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":"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":["unknown"]},{"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":["unknown"]},{"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":["unknown"]},{"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":["unknown"]},{"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":["unknown"]},{"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":[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":8,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":9,"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":10,"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":11,"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":12,"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":13,"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":["unknown"]},{"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":14,"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":["unknown"]},{"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":["unknown"]},{"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":15,"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":["unknown"]},{"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":16,"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":["unknown"]},{"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":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":17,"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":["unknown"]},{"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":["unknown"]},{"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":18,"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":["unknown"]},{"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":19,"references":[1,18],"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":["unknown"]},{"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":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":20,"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":["unknown"]},{"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":21,"references":[1,20],"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":["unknown"]},{"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":["unknown"]},{"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":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":22,"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":23,"references":[15],"type":"index","data":{"on":15,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}}]}
\ No newline at end of file
diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore
index f312f249a3..e32cadbf68 100644
--- a/mobile/ios/.gitignore
+++ b/mobile/ios/.gitignore
@@ -4,7 +4,6 @@
*.moved-aside
*.pbxuser
*.perspectivev3
-**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist
index 7c56964006..1dc6cf7652 100644
--- a/mobile/ios/Flutter/AppFrameworkInfo.plist
+++ b/mobile/ios/Flutter/AppFrameworkInfo.plist
@@ -21,6 +21,6 @@
CFBundleVersion
1.0
MinimumOSVersion
- 12.0
+ 13.0
diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock
index 09bd36022b..502fd9008f 100644
--- a/mobile/ios/Podfile.lock
+++ b/mobile/ios/Podfile.lock
@@ -253,7 +253,7 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
- Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
+ Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
diff --git a/mobile/ios/Runner/Background/BackgroundWorker.g.swift b/mobile/ios/Runner/Background/BackgroundWorker.g.swift
index 45a6402fe8..ece5cd5f64 100644
--- a/mobile/ios/Runner/Background/BackgroundWorker.g.swift
+++ b/mobile/ios/Runner/Background/BackgroundWorker.g.swift
@@ -50,11 +50,119 @@ private func nilOrValue(_ value: Any?) -> T? {
return value as! T?
}
+func deepEqualsBackgroundWorker(_ lhs: Any?, _ rhs: Any?) -> Bool {
+ let cleanLhs = nilOrValue(lhs) as Any?
+ let cleanRhs = nilOrValue(rhs) as Any?
+ switch (cleanLhs, cleanRhs) {
+ case (nil, nil):
+ return true
+
+ case (nil, _), (_, nil):
+ return false
+
+ case is (Void, Void):
+ return true
+
+ case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
+ return cleanLhsHashable == cleanRhsHashable
+
+ case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
+ guard cleanLhsArray.count == cleanRhsArray.count else { return false }
+ for (index, element) in cleanLhsArray.enumerated() {
+ if !deepEqualsBackgroundWorker(element, cleanRhsArray[index]) {
+ return false
+ }
+ }
+ return true
+
+ case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
+ guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
+ for (key, cleanLhsValue) in cleanLhsDictionary {
+ guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
+ if !deepEqualsBackgroundWorker(cleanLhsValue, cleanRhsDictionary[key]!) {
+ return false
+ }
+ }
+ return true
+
+ default:
+ // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
+ return false
+ }
+}
+
+func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
+ if let valueList = value as? [AnyHashable] {
+ for item in valueList { deepHashBackgroundWorker(value: item, hasher: &hasher) }
+ return
+ }
+
+ if let valueDict = value as? [AnyHashable: AnyHashable] {
+ for key in valueDict.keys {
+ hasher.combine(key)
+ deepHashBackgroundWorker(value: valueDict[key]!, hasher: &hasher)
+ }
+ return
+ }
+
+ if let hashableValue = value as? AnyHashable {
+ hasher.combine(hashableValue.hashValue)
+ }
+
+ return hasher.combine(String(describing: value))
+}
+
+
+
+/// Generated class from Pigeon that represents data sent in messages.
+struct BackgroundWorkerSettings: Hashable {
+ var requiresCharging: Bool
+ var minimumDelaySeconds: Int64
+
+
+ // swift-format-ignore: AlwaysUseLowerCamelCase
+ static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? {
+ let requiresCharging = pigeonVar_list[0] as! Bool
+ let minimumDelaySeconds = pigeonVar_list[1] as! Int64
+
+ return BackgroundWorkerSettings(
+ requiresCharging: requiresCharging,
+ minimumDelaySeconds: minimumDelaySeconds
+ )
+ }
+ func toList() -> [Any?] {
+ return [
+ requiresCharging,
+ minimumDelaySeconds,
+ ]
+ }
+ static func == (lhs: BackgroundWorkerSettings, rhs: BackgroundWorkerSettings) -> Bool {
+ return deepEqualsBackgroundWorker(lhs.toList(), rhs.toList()) }
+ func hash(into hasher: inout Hasher) {
+ deepHashBackgroundWorker(value: toList(), hasher: &hasher)
+ }
+}
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
+ override func readValue(ofType type: UInt8) -> Any? {
+ switch type {
+ case 129:
+ return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?])
+ default:
+ return super.readValue(ofType: type)
+ }
+ }
}
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
+ override func writeValue(_ value: Any) {
+ if let value = value as? BackgroundWorkerSettings {
+ super.writeByte(129)
+ super.writeValue(value.toList())
+ } else {
+ super.writeValue(value)
+ }
+ }
}
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
@@ -74,6 +182,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enable() throws
+ func configure(settings: BackgroundWorkerSettings) throws
func disable() throws
}
@@ -96,6 +205,21 @@ class BackgroundWorkerFgHostApiSetup {
} else {
enableChannel.setMessageHandler(nil)
}
+ let configureChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ configureChannel.setMessageHandler { message, reply in
+ let args = message as! [Any?]
+ let settingsArg = args[0] as! BackgroundWorkerSettings
+ do {
+ try api.configure(settings: settingsArg)
+ reply(wrapResult(nil))
+ } catch {
+ reply(wrapError(error))
+ }
+ }
+ } else {
+ configureChannel.setMessageHandler(nil)
+ }
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
disableChannel.setMessageHandler { _, reply in
diff --git a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift
index 184b797133..f7f8f69989 100644
--- a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift
+++ b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift
@@ -5,13 +5,17 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
- print("BackgroundUploadImpl:enbale Background worker scheduled")
+ print("BackgroundWorkerApiImpl:enable Background worker scheduled")
+ }
+
+ func configure(settings: BackgroundWorkerSettings) throws {
+ // Android only
}
func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
- print("BackgroundUploadImpl:disableUploadWorker Disabled background workers")
+ print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
}
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
diff --git a/mobile/ios/Runner/Images/ThumbnailsImpl.swift b/mobile/ios/Runner/Images/ThumbnailsImpl.swift
index d1ea2cc0e0..452ca62377 100644
--- a/mobile/ios/Runner/Images/ThumbnailsImpl.swift
+++ b/mobile/ios/Runner/Images/ThumbnailsImpl.swift
@@ -105,7 +105,7 @@ class ThumbnailApiImpl: ThumbnailApi {
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
- targetSize: CGSize(width: Double(width), height: Double(height)),
+ targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift
index 19f4384672..305aca5266 100644
--- a/mobile/ios/Runner/Sync/Messages.g.swift
+++ b/mobile/ios/Runner/Sync/Messages.g.swift
@@ -267,6 +267,39 @@ struct SyncDelta: Hashable {
}
}
+/// Generated class from Pigeon that represents data sent in messages.
+struct HashResult: Hashable {
+ var assetId: String
+ var error: String? = nil
+ var hash: String? = nil
+
+
+ // swift-format-ignore: AlwaysUseLowerCamelCase
+ static func fromList(_ pigeonVar_list: [Any?]) -> HashResult? {
+ let assetId = pigeonVar_list[0] as! String
+ let error: String? = nilOrValue(pigeonVar_list[1])
+ let hash: String? = nilOrValue(pigeonVar_list[2])
+
+ return HashResult(
+ assetId: assetId,
+ error: error,
+ hash: hash
+ )
+ }
+ func toList() -> [Any?] {
+ return [
+ assetId,
+ error,
+ hash,
+ ]
+ }
+ static func == (lhs: HashResult, rhs: HashResult) -> Bool {
+ return deepEqualsMessages(lhs.toList(), rhs.toList()) }
+ func hash(into hasher: inout Hasher) {
+ deepHashMessages(value: toList(), hasher: &hasher)
+ }
+}
+
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
@@ -276,6 +309,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 131:
return SyncDelta.fromList(self.readValue() as! [Any?])
+ case 132:
+ return HashResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@@ -293,6 +328,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? SyncDelta {
super.writeByte(131)
super.writeValue(value.toList())
+ } else if let value = value as? HashResult {
+ super.writeByte(132)
+ super.writeValue(value.toList())
} else {
super.writeValue(value)
}
@@ -313,6 +351,7 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
}
+
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi {
func shouldFullSync() throws -> Bool
@@ -323,7 +362,8 @@ protocol NativeSyncApi {
func getAlbums() throws -> [PlatformAlbum]
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
- func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?]
+ func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
+ func cancelHashing() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -459,22 +499,38 @@ class NativeSyncApiSetup {
} else {
getAssetsForAlbumChannel.setMessageHandler(nil)
}
- let hashPathsChannel = taskQueue == nil
- ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
- : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+ let hashAssetsChannel = taskQueue == nil
+ ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
- hashPathsChannel.setMessageHandler { message, reply in
+ hashAssetsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
- let pathsArg = args[0] as! [String]
+ let assetIdsArg = args[0] as! [String]
+ let allowNetworkAccessArg = args[1] as! Bool
+ api.hashAssets(assetIds: assetIdsArg, allowNetworkAccess: allowNetworkAccessArg) { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
+ }
+ }
+ } else {
+ hashAssetsChannel.setMessageHandler(nil)
+ }
+ let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ cancelHashingChannel.setMessageHandler { _, reply in
do {
- let result = try api.hashPaths(paths: pathsArg)
- reply(wrapResult(result))
+ try api.cancelHashing()
+ reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
- hashPathsChannel.setMessageHandler(nil)
+ cancelHashingChannel.setMessageHandler(nil)
}
}
}
diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift
index 2810dee7c1..bb23bae6b6 100644
--- a/mobile/ios/Runner/Sync/MessagesImpl.swift
+++ b/mobile/ios/Runner/Sync/MessagesImpl.swift
@@ -17,30 +17,16 @@ struct AssetWrapper: Hashable, Equatable {
}
}
-extension PHAsset {
- func toPlatformAsset() -> PlatformAsset {
- return PlatformAsset(
- id: localIdentifier,
- name: title(),
- type: Int64(mediaType.rawValue),
- createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
- updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
- width: Int64(pixelWidth),
- height: Int64(pixelHeight),
- durationInSeconds: Int64(duration),
- orientation: 0,
- isFavorite: isFavorite
- )
- }
-}
-
class NativeSyncApiImpl: NativeSyncApi {
private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219
- private let hashBufferSize = 2 * 1024 * 1024
+ private var hashTask: Task?
+ private static let hashCancelledCode = "HASH_CANCELLED"
+ private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
+
init(with defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -96,7 +82,7 @@ class NativeSyncApiImpl: NativeSyncApi {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0.. %@ OR modificationDate > %@", date, date)
}
-
+
let result = PHAsset.fetchAssets(in: album, options: options)
if(result.count == 0) {
return []
@@ -267,23 +253,114 @@ class NativeSyncApiImpl: NativeSyncApi {
return assets
}
- func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] {
- return paths.map { path in
- guard let file = FileHandle(forReadingAtPath: path) else {
- print("Cannot open file: \(path)")
- return nil
- }
-
- var hasher = Insecure.SHA1()
- while autoreleasepool(invoking: {
- let chunk = file.readData(ofLength: hashBufferSize)
- guard !chunk.isEmpty else { return false }
- hasher.update(data: chunk)
- return true
- }) { }
-
- let digest = hasher.finalize()
- return FlutterStandardTypedData(bytes: Data(digest))
+ func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
+ if let prevTask = hashTask {
+ prevTask.cancel()
+ hashTask = nil
+ }
+ hashTask = Task { [weak self] in
+ var missingAssetIds = Set(assetIds)
+ var assets = [PHAsset]()
+ assets.reserveCapacity(assetIds.count)
+ PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
+ if Task.isCancelled {
+ stop.pointee = true
+ return
+ }
+ missingAssetIds.remove(asset.localIdentifier)
+ assets.append(asset)
}
+
+ if Task.isCancelled {
+ return completion(Self.hashCancelled)
+ }
+
+ await withTaskGroup(of: HashResult?.self) { taskGroup in
+ var results = [HashResult]()
+ results.reserveCapacity(assets.count)
+ for asset in assets {
+ if Task.isCancelled {
+ return completion(Self.hashCancelled)
+ }
+ taskGroup.addTask {
+ guard let self = self else { return nil }
+ return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
+ }
+ }
+
+ for await result in taskGroup {
+ guard let result = result else {
+ return completion(Self.hashCancelled)
+ }
+ results.append(result)
+ }
+
+ for missing in missingAssetIds {
+ results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
+ }
+
+ completion(.success(results))
+ }
+ }
+ }
+
+ func cancelHashing() {
+ hashTask?.cancel()
+ hashTask = nil
+ }
+
+ private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
+ class RequestRef {
+ var id: PHAssetResourceDataRequestID?
+ }
+ let requestRef = RequestRef()
+ return await withTaskCancellationHandler(operation: {
+ if Task.isCancelled {
+ return nil
+ }
+
+ guard let resource = asset.getResource() else {
+ return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
+ }
+
+ if Task.isCancelled {
+ return nil
+ }
+
+ let options = PHAssetResourceRequestOptions()
+ options.isNetworkAccessAllowed = allowNetworkAccess
+
+ return await withCheckedContinuation { continuation in
+ var hasher = Insecure.SHA1()
+
+ requestRef.id = PHAssetResourceManager.default().requestData(
+ for: resource,
+ options: options,
+ dataReceivedHandler: { data in
+ hasher.update(data: data)
+ },
+ completionHandler: { error in
+ let result: HashResult? = switch (error) {
+ case let e as PHPhotosError where e.code == .userCancelled: nil
+ case let .some(e): HashResult(
+ assetId: asset.localIdentifier,
+ error: "Failed to hash asset: \(e.localizedDescription)",
+ hash: nil
+ )
+ case .none:
+ HashResult(
+ assetId: asset.localIdentifier,
+ error: nil,
+ hash: Data(hasher.finalize()).base64EncodedString()
+ )
+ }
+ continuation.resume(returning: result)
+ }
+ )
+ }
+ }, onCancel: {
+ guard let requestId = requestRef.id else { return }
+ PHAssetResourceManager.default().cancelDataRequest(requestId)
+ })
}
}
diff --git a/mobile/ios/Runner/Sync/PHAssetExtensions.swift b/mobile/ios/Runner/Sync/PHAssetExtensions.swift
new file mode 100644
index 0000000000..2b1ef6ac88
--- /dev/null
+++ b/mobile/ios/Runner/Sync/PHAssetExtensions.swift
@@ -0,0 +1,77 @@
+import Photos
+
+extension PHAsset {
+ func toPlatformAsset() -> PlatformAsset {
+ return PlatformAsset(
+ id: localIdentifier,
+ name: title,
+ type: Int64(mediaType.rawValue),
+ createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
+ updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
+ width: Int64(pixelWidth),
+ height: Int64(pixelHeight),
+ durationInSeconds: Int64(duration),
+ orientation: 0,
+ isFavorite: isFavorite
+ )
+ }
+
+ var title: String {
+ return filename ?? originalFilename ?? ""
+ }
+
+ var filename: String? {
+ return value(forKey: "filename") as? String
+ }
+
+ // This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
+ var originalFilename: String? {
+ return getResource()?.originalFilename
+ }
+
+ func getResource() -> PHAssetResource? {
+ let resources = PHAssetResource.assetResources(for: self)
+
+ let filteredResources = resources.filter { $0.isMediaResource && isValidResourceType($0.type) }
+
+ guard !filteredResources.isEmpty else {
+ return nil
+ }
+
+ if filteredResources.count == 1 {
+ return filteredResources.first
+ }
+
+ if let currentResource = filteredResources.first(where: { $0.isCurrent }) {
+ return currentResource
+ }
+
+ if let fullSizeResource = filteredResources.first(where: { isFullSizeResourceType($0.type) }) {
+ return fullSizeResource
+ }
+
+ return nil
+ }
+
+ private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
+ switch mediaType {
+ case .image:
+ return [.photo, .alternatePhoto, .fullSizePhoto].contains(type)
+ case .video:
+ return [.video, .fullSizeVideo, .fullSizePairedVideo].contains(type)
+ default:
+ return false
+ }
+ }
+
+ private func isFullSizeResourceType(_ type: PHAssetResourceType) -> Bool {
+ switch mediaType {
+ case .image:
+ return type == .fullSizePhoto
+ case .video:
+ return type == .fullSizeVideo
+ default:
+ return false
+ }
+ }
+}
diff --git a/mobile/ios/Runner/Sync/PHAssetResourceExtensions.swift b/mobile/ios/Runner/Sync/PHAssetResourceExtensions.swift
new file mode 100644
index 0000000000..699d55a98d
--- /dev/null
+++ b/mobile/ios/Runner/Sync/PHAssetResourceExtensions.swift
@@ -0,0 +1,16 @@
+
+import Photos
+
+extension PHAssetResource {
+ var isCurrent: Bool {
+ return value(forKey: "isCurrent") as? Bool ?? false
+ }
+
+ var isMediaResource: Bool {
+ var isMedia = type != .adjustmentData
+ if #available(iOS 17, *) {
+ isMedia = isMedia && type != .photoProxy
+ }
+ return isMedia
+ }
+}
diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart
index 60c9b92cf9..7429616f14 100644
--- a/mobile/lib/constants/constants.dart
+++ b/mobile/lib/constants/constants.dart
@@ -1,3 +1,5 @@
+import 'dart:io';
+
const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1;
const double downloadFailed = -2;
@@ -10,7 +12,7 @@ const int kSyncEventBatchSize = 5000;
const int kFetchLocalAssetsBatchSize = 40000;
// Hash batch limits
-const int kBatchHashFileLimit = 256;
+final int kBatchHashFileLimit = Platform.isIOS ? 32 : 512;
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
// Secure storage keys
diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart
index a5fb5dd70e..84f675f68b 100644
--- a/mobile/lib/domain/services/asset.service.dart
+++ b/mobile/lib/domain/services/asset.service.dart
@@ -40,13 +40,12 @@ class AssetService {
Future> getStack(RemoteAsset asset) async {
if (asset.stackId == null) {
- return [];
+ return const [];
}
- return _remoteAssetRepository.getStackChildren(asset).then((assets) {
- // Include the primary asset in the stack as the first item
- return [asset, ...assets];
- });
+ final stack = await _remoteAssetRepository.getStackChildren(asset);
+ // Include the primary asset in the stack as the first item
+ return [asset, ...stack];
}
Future getExif(BaseAsset asset) async {
diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart
index 90e2ddff4c..78dd1e980f 100644
--- a/mobile/lib/domain/services/background_worker.service.dart
+++ b/mobile/lib/domain/services/background_worker.service.dart
@@ -43,6 +43,17 @@ class BackgroundWorkerFgService {
// TODO: Move this call to native side once old timeline is removed
Future enable() => _foregroundHostApi.enable();
+ Future configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
+ BackgroundWorkerSettings(
+ minimumDelaySeconds:
+ minimumDelaySeconds ??
+ Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
+ requiresCharging:
+ requireCharging ??
+ Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
+ ),
+ );
+
Future disable() => _foregroundHostApi.disable();
}
@@ -173,6 +184,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
try {
final backgroundSyncManager = _ref.read(backgroundSyncProvider);
+ final nativeSyncApi = _ref.read(nativeSyncApiProvider);
_isCleanedUp = true;
_ref.dispose();
@@ -188,7 +200,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_drift.close(),
_driftLogger.close(),
backgroundSyncManager.cancel(),
- backgroundSyncManager.cancelLocal(),
+ nativeSyncApi.cancelHashing(),
];
if (_isar.isOpen) {
diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart
index 8044b298d3..90f29b8bc1 100644
--- a/mobile/lib/domain/services/hash.service.dart
+++ b/mobile/lib/domain/services/hash.service.dart
@@ -1,20 +1,18 @@
-import 'dart:convert';
-
+import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
-import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart';
+const String _kHashCancelledCode = "HASH_CANCELLED";
+
class HashService {
- final int batchSizeLimit;
- final int batchFileLimit;
+ final int _batchSize;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
- final StorageRepository _storageRepository;
final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
@@ -22,37 +20,42 @@ class HashService {
HashService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
- required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker,
- this.batchSizeLimit = kBatchHashSizeLimit,
- this.batchFileLimit = kBatchHashFileLimit,
+ int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
- _storageRepository = storageRepository,
_cancelChecker = cancelChecker,
- _nativeSyncApi = nativeSyncApi;
+ _nativeSyncApi = nativeSyncApi,
+ _batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future hashAssets() async {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start();
- // Sorted by backupSelection followed by isCloud
- final localAlbums = await _localAlbumRepository.getAll(
- sortBy: {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum},
- );
+ try {
+ // Sorted by backupSelection followed by isCloud
+ final localAlbums = await _localAlbumRepository.getBackupAlbums();
- for (final album in localAlbums) {
- if (isCancelled) {
- _log.warning("Hashing cancelled. Stopped processing albums.");
- break;
- }
+ for (final album in localAlbums) {
+ if (isCancelled) {
+ _log.warning("Hashing cancelled. Stopped processing albums.");
+ break;
+ }
- final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
- if (assetsToHash.isNotEmpty) {
- await _hashAssets(album, assetsToHash);
+ final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
+ if (assetsToHash.isNotEmpty) {
+ await _hashAssets(album, assetsToHash);
+ }
}
+ } on PlatformException catch (e) {
+ if (e.code == _kHashCancelledCode) {
+ _log.warning("Hashing cancelled by platform");
+ return;
+ }
+ } catch (e, s) {
+ _log.severe("Error during hashing", e, s);
}
stopwatch.stop();
@@ -63,8 +66,7 @@ class HashService {
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future _hashAssets(LocalAlbum album, List assetsToHash) async {
- int bytesProcessed = 0;
- final toHash = <_AssetToPath>[];
+ final toHash = {};
for (final asset in assetsToHash) {
if (isCancelled) {
@@ -72,21 +74,10 @@ class HashService {
return;
}
- final file = await _storageRepository.getFileForAsset(asset.id);
- if (file == null) {
- _log.warning(
- "Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
- );
- continue;
- }
-
- bytesProcessed += await file.length();
- toHash.add(_AssetToPath(asset: asset, path: file.path));
-
- if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
+ toHash[asset.id] = asset;
+ if (toHash.length == _batchSize) {
await _processBatch(album, toHash);
toHash.clear();
- bytesProcessed = 0;
}
}
@@ -94,33 +85,36 @@ class HashService {
}
/// Processes a batch of assets.
- Future _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async {
+ Future _processBatch(LocalAlbum album, Map toHash) async {
if (toHash.isEmpty) {
return;
}
_log.fine("Hashing ${toHash.length} files");
- final hashed = [];
- final hashes = await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList());
+ final hashed = {};
+ final hashResults = await _nativeSyncApi.hashAssets(
+ toHash.keys.toList(),
+ allowNetworkAccess: album.backupSelection == BackupSelection.selected,
+ );
assert(
- hashes.length == toHash.length,
- "Hashes length does not match toHash length: ${hashes.length} != ${toHash.length}",
+ hashResults.length == toHash.length,
+ "Hashes length does not match toHash length: ${hashResults.length} != ${toHash.length}",
);
- for (int i = 0; i < hashes.length; i++) {
+ for (int i = 0; i < hashResults.length; i++) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing batch.");
return;
}
- final hash = hashes[i];
- final asset = toHash[i].asset;
- if (hash?.length == 20) {
- hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
+ final hashResult = hashResults[i];
+ if (hashResult.hash != null) {
+ hashed[hashResult.assetId] = hashResult.hash!;
} else {
+ final asset = toHash[hashResult.assetId];
_log.warning(
- "Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
+ "Failed to hash asset with id: ${hashResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}, from album: ${album.name}. Error: ${hashResult.error ?? "unknown"}",
);
}
}
@@ -128,13 +122,5 @@ class HashService {
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
await _localAssetRepository.updateHashes(hashed);
- await _storageRepository.clearCache();
}
}
-
-class _AssetToPath {
- final LocalAsset asset;
- final String path;
-
- const _AssetToPath({required this.asset, required this.path});
-}
diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart
index 8de879a09d..53f1a10662 100644
--- a/mobile/lib/infrastructure/entities/local_album_asset.entity.dart
+++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart
@@ -10,6 +10,9 @@ class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin {
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
+ // Used for mark & sweep
+ BoolColumn get marker_ => boolean().nullable()();
+
@override
Set get primaryKey => {assetId, albumId};
}
diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart
index 78da361f62..70c298332b 100644
--- a/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart
+++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart
@@ -15,11 +15,13 @@ typedef $$LocalAlbumAssetEntityTableCreateCompanionBuilder =
i1.LocalAlbumAssetEntityCompanion Function({
required String assetId,
required String albumId,
+ i0.Value marker_,
});
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
i1.LocalAlbumAssetEntityCompanion Function({
i0.Value assetId,
i0.Value albumId,
+ i0.Value marker_,
});
final class $$LocalAlbumAssetEntityTableReferences
@@ -113,6 +115,11 @@ class $$LocalAlbumAssetEntityTableFilterComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
+ i0.ColumnFilters get marker_ => $composableBuilder(
+ column: $table.marker_,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
i3.$$LocalAssetEntityTableFilterComposer get assetId {
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
@@ -177,6 +184,11 @@ class $$LocalAlbumAssetEntityTableOrderingComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
+ i0.ColumnOrderings get marker_ => $composableBuilder(
+ column: $table.marker_,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
i3.$$LocalAssetEntityTableOrderingComposer get assetId {
final i3.$$LocalAssetEntityTableOrderingComposer composer =
$composerBuilder(
@@ -243,6 +255,9 @@ class $$LocalAlbumAssetEntityTableAnnotationComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
+ i0.GeneratedColumn get marker_ =>
+ $composableBuilder(column: $table.marker_, builder: (column) => column);
+
i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
final i3.$$LocalAssetEntityTableAnnotationComposer composer =
$composerBuilder(
@@ -344,16 +359,22 @@ class $$LocalAlbumAssetEntityTableTableManager
({
i0.Value assetId = const i0.Value.absent(),
i0.Value albumId = const i0.Value.absent(),
+ i0.Value marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumAssetEntityCompanion(
assetId: assetId,
albumId: albumId,
+ marker_: marker_,
),
createCompanionCallback:
- ({required String assetId, required String albumId}) =>
- i1.LocalAlbumAssetEntityCompanion.insert(
- assetId: assetId,
- albumId: albumId,
- ),
+ ({
+ required String assetId,
+ required String albumId,
+ i0.Value marker_ = const i0.Value.absent(),
+ }) => i1.LocalAlbumAssetEntityCompanion.insert(
+ assetId: assetId,
+ albumId: albumId,
+ marker_: marker_,
+ ),
withReferenceMapper: (p0) => p0
.map(
(e) => (
@@ -477,8 +498,22 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
'REFERENCES local_album_entity (id) ON DELETE CASCADE',
),
);
+ static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta(
+ 'marker_',
+ );
@override
- List get $columns => [assetId, albumId];
+ late final i0.GeneratedColumn marker_ = i0.GeneratedColumn(
+ 'marker',
+ aliasedName,
+ true,
+ type: i0.DriftSqlType.bool,
+ requiredDuringInsert: false,
+ defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
+ 'CHECK ("marker" IN (0, 1))',
+ ),
+ );
+ @override
+ List get $columns => [assetId, albumId, marker_];
@override
String get aliasedName => _alias ?? actualTableName;
@override
@@ -507,6 +542,12 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
} else if (isInserting) {
context.missing(_albumIdMeta);
}
+ if (data.containsKey('marker')) {
+ context.handle(
+ _marker_Meta,
+ marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta),
+ );
+ }
return context;
}
@@ -527,6 +568,10 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
i0.DriftSqlType.string,
data['${effectivePrefix}album_id'],
)!,
+ marker_: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.bool,
+ data['${effectivePrefix}marker'],
+ ),
);
}
@@ -545,15 +590,20 @@ class LocalAlbumAssetEntityData extends i0.DataClass
implements i0.Insertable {
final String assetId;
final String albumId;
+ final bool? marker_;
const LocalAlbumAssetEntityData({
required this.assetId,
required this.albumId,
+ this.marker_,
});
@override
Map toColumns(bool nullToAbsent) {
final map = {};
map['asset_id'] = i0.Variable(assetId);
map['album_id'] = i0.Variable(albumId);
+ if (!nullToAbsent || marker_ != null) {
+ map['marker'] = i0.Variable(marker_);
+ }
return map;
}
@@ -565,6 +615,7 @@ class LocalAlbumAssetEntityData extends i0.DataClass
return LocalAlbumAssetEntityData(
assetId: serializer.fromJson(json['assetId']),
albumId: serializer.fromJson(json['albumId']),
+ marker_: serializer.fromJson(json['marker_']),
);
}
@override
@@ -573,20 +624,26 @@ class LocalAlbumAssetEntityData extends i0.DataClass
return {
'assetId': serializer.toJson(assetId),
'albumId': serializer.toJson(albumId),
+ 'marker_': serializer.toJson(marker_),
};
}
- i1.LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) =>
- i1.LocalAlbumAssetEntityData(
- assetId: assetId ?? this.assetId,
- albumId: albumId ?? this.albumId,
- );
+ i1.LocalAlbumAssetEntityData copyWith({
+ String? assetId,
+ String? albumId,
+ i0.Value marker_ = const i0.Value.absent(),
+ }) => i1.LocalAlbumAssetEntityData(
+ assetId: assetId ?? this.assetId,
+ albumId: albumId ?? this.albumId,
+ marker_: marker_.present ? marker_.value : this.marker_,
+ );
LocalAlbumAssetEntityData copyWithCompanion(
i1.LocalAlbumAssetEntityCompanion data,
) {
return LocalAlbumAssetEntityData(
assetId: data.assetId.present ? data.assetId.value : this.assetId,
albumId: data.albumId.present ? data.albumId.value : this.albumId,
+ marker_: data.marker_.present ? data.marker_.value : this.marker_,
);
}
@@ -594,51 +651,60 @@ class LocalAlbumAssetEntityData extends i0.DataClass
String toString() {
return (StringBuffer('LocalAlbumAssetEntityData(')
..write('assetId: $assetId, ')
- ..write('albumId: $albumId')
+ ..write('albumId: $albumId, ')
+ ..write('marker_: $marker_')
..write(')'))
.toString();
}
@override
- int get hashCode => Object.hash(assetId, albumId);
+ int get hashCode => Object.hash(assetId, albumId, marker_);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.LocalAlbumAssetEntityData &&
other.assetId == this.assetId &&
- other.albumId == this.albumId);
+ other.albumId == this.albumId &&
+ other.marker_ == this.marker_);
}
class LocalAlbumAssetEntityCompanion
extends i0.UpdateCompanion {
final i0.Value assetId;
final i0.Value albumId;
+ final i0.Value marker_;
const LocalAlbumAssetEntityCompanion({
this.assetId = const i0.Value.absent(),
this.albumId = const i0.Value.absent(),
+ this.marker_ = const i0.Value.absent(),
});
LocalAlbumAssetEntityCompanion.insert({
required String assetId,
required String albumId,
+ this.marker_ = const i0.Value.absent(),
}) : assetId = i0.Value(assetId),
albumId = i0.Value(albumId);
static i0.Insertable custom({
i0.Expression? assetId,
i0.Expression? albumId,
+ i0.Expression? marker_,
}) {
return i0.RawValuesInsertable({
if (assetId != null) 'asset_id': assetId,
if (albumId != null) 'album_id': albumId,
+ if (marker_ != null) 'marker': marker_,
});
}
i1.LocalAlbumAssetEntityCompanion copyWith({
i0.Value? assetId,
i0.Value? albumId,
+ i0.Value? marker_,
}) {
return i1.LocalAlbumAssetEntityCompanion(
assetId: assetId ?? this.assetId,
albumId: albumId ?? this.albumId,
+ marker_: marker_ ?? this.marker_,
);
}
@@ -651,6 +717,9 @@ class LocalAlbumAssetEntityCompanion
if (albumId.present) {
map['album_id'] = i0.Variable(albumId.value);
}
+ if (marker_.present) {
+ map['marker'] = i0.Variable(marker_.value);
+ }
return map;
}
@@ -658,7 +727,8 @@ class LocalAlbumAssetEntityCompanion
String toString() {
return (StringBuffer('LocalAlbumAssetEntityCompanion(')
..write('assetId: $assetId, ')
- ..write('albumId: $albumId')
+ ..write('albumId: $albumId, ')
+ ..write('marker_: $marker_')
..write(')'))
.toString();
}
diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart
index f04ed27779..65d26d9747 100644
--- a/mobile/lib/infrastructure/repositories/db.repository.dart
+++ b/mobile/lib/infrastructure/repositories/db.repository.dart
@@ -93,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
- int get schemaVersion => 10;
+ int get schemaVersion => 11;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -156,6 +156,9 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
await m.alterTable(TableMigration(v10.userEntity));
},
+ from10To11: (m, v11) async {
+ await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
+ },
),
);
diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart
index be6d53d5a8..7910d9fcee 100644
--- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart
+++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart
@@ -4270,6 +4270,395 @@ i1.GeneratedColumn _column_94(String aliasedName) =>
true,
type: i1.DriftSqlType.string,
);
+
+final class Schema11 extends i0.VersionedSchema {
+ Schema11({required super.database}) : super(version: 11);
+ @override
+ late final List entities = [
+ userEntity,
+ remoteAssetEntity,
+ stackEntity,
+ localAssetEntity,
+ remoteAlbumEntity,
+ localAlbumEntity,
+ localAlbumAssetEntity,
+ idxLocalAssetChecksum,
+ idxRemoteAssetOwnerChecksum,
+ uQRemoteAssetsOwnerChecksum,
+ uQRemoteAssetsOwnerLibraryChecksum,
+ idxRemoteAssetChecksum,
+ authUserEntity,
+ userMetadataEntity,
+ partnerEntity,
+ remoteExifEntity,
+ remoteAlbumAssetEntity,
+ remoteAlbumUserEntity,
+ memoryEntity,
+ memoryAssetEntity,
+ personEntity,
+ assetFaceEntity,
+ storeEntity,
+ idxLatLng,
+ ];
+ late final Shape20 userEntity = Shape20(
+ source: i0.VersionedTable(
+ entityName: 'user_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_1,
+ _column_3,
+ _column_84,
+ _column_85,
+ _column_91,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape17 remoteAssetEntity = Shape17(
+ source: i0.VersionedTable(
+ entityName: 'remote_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_1,
+ _column_8,
+ _column_9,
+ _column_5,
+ _column_10,
+ _column_11,
+ _column_12,
+ _column_0,
+ _column_13,
+ _column_14,
+ _column_15,
+ _column_16,
+ _column_17,
+ _column_18,
+ _column_19,
+ _column_20,
+ _column_21,
+ _column_86,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape3 stackEntity = Shape3(
+ source: i0.VersionedTable(
+ entityName: 'stack_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape2 localAssetEntity = Shape2(
+ source: i0.VersionedTable(
+ entityName: 'local_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_1,
+ _column_8,
+ _column_9,
+ _column_5,
+ _column_10,
+ _column_11,
+ _column_12,
+ _column_0,
+ _column_22,
+ _column_14,
+ _column_23,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape9 remoteAlbumEntity = Shape9(
+ source: i0.VersionedTable(
+ entityName: 'remote_album_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_1,
+ _column_56,
+ _column_9,
+ _column_5,
+ _column_15,
+ _column_57,
+ _column_58,
+ _column_59,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape19 localAlbumEntity = Shape19(
+ source: i0.VersionedTable(
+ entityName: 'local_album_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_1,
+ _column_5,
+ _column_31,
+ _column_32,
+ _column_90,
+ _column_33,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape22 localAlbumAssetEntity = Shape22(
+ source: i0.VersionedTable(
+ entityName: 'local_album_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
+ columns: [_column_34, _column_35, _column_33],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ 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 idxRemoteAssetOwnerChecksum = i1.Index(
+ 'idx_remote_asset_owner_checksum',
+ 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
+ );
+ 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)',
+ );
+ late final Shape21 authUserEntity = Shape21(
+ source: i0.VersionedTable(
+ entityName: 'auth_user_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_1,
+ _column_3,
+ _column_2,
+ _column_84,
+ _column_85,
+ _column_92,
+ _column_93,
+ _column_7,
+ _column_94,
+ ],
+ 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_25, _column_26, _column_27],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape5 partnerEntity = Shape5(
+ source: i0.VersionedTable(
+ entityName: 'partner_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
+ columns: [_column_28, _column_29, _column_30],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape8 remoteExifEntity = Shape8(
+ source: i0.VersionedTable(
+ entityName: 'remote_exif_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id)'],
+ columns: [
+ _column_36,
+ _column_37,
+ _column_38,
+ _column_39,
+ _column_40,
+ _column_41,
+ _column_11,
+ _column_10,
+ _column_42,
+ _column_43,
+ _column_44,
+ _column_45,
+ _column_46,
+ _column_47,
+ _column_48,
+ _column_49,
+ _column_50,
+ _column_51,
+ _column_52,
+ _column_53,
+ _column_54,
+ _column_55,
+ ],
+ 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_36, _column_60],
+ 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_60, _column_25, _column_61],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape11 memoryEntity = Shape11(
+ source: i0.VersionedTable(
+ entityName: 'memory_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_9,
+ _column_5,
+ _column_18,
+ _column_15,
+ _column_8,
+ _column_62,
+ _column_63,
+ _column_64,
+ _column_65,
+ _column_66,
+ _column_67,
+ ],
+ 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_36, _column_68],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape14 personEntity = Shape14(
+ source: i0.VersionedTable(
+ entityName: 'person_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_9,
+ _column_5,
+ _column_15,
+ _column_1,
+ _column_69,
+ _column_71,
+ _column_72,
+ _column_73,
+ _column_74,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape15 assetFaceEntity = Shape15(
+ source: i0.VersionedTable(
+ entityName: 'asset_face_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_36,
+ _column_76,
+ _column_77,
+ _column_78,
+ _column_79,
+ _column_80,
+ _column_81,
+ _column_82,
+ _column_83,
+ ],
+ 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_87, _column_88, _column_89],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ final i1.Index idxLatLng = i1.Index(
+ 'idx_lat_lng',
+ 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
+ );
+}
+
+class Shape22 extends i0.VersionedTable {
+ Shape22({required super.source, required super.alias}) : super.aliased();
+ i1.GeneratedColumn get assetId =>
+ columnsByName['asset_id']! as i1.GeneratedColumn;
+ i1.GeneratedColumn get albumId =>
+ columnsByName['album_id']! as i1.GeneratedColumn;
+ i1.GeneratedColumn get marker_ =>
+ columnsByName['marker']! as i1.GeneratedColumn;
+}
+
i0.MigrationStepWithVersion migrationSteps({
required Future Function(i1.Migrator m, Schema2 schema) from1To2,
required Future Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -4280,6 +4669,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future Function(i1.Migrator m, Schema8 schema) from7To8,
required Future Function(i1.Migrator m, Schema9 schema) from8To9,
required Future Function(i1.Migrator m, Schema10 schema) from9To10,
+ required Future Function(i1.Migrator m, Schema11 schema) from10To11,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -4328,6 +4718,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from9To10(migrator, schema);
return 10;
+ case 10:
+ final schema = Schema11(database: database);
+ final migrator = i1.Migrator(database, schema);
+ await from10To11(migrator, schema);
+ return 11;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -4344,6 +4739,7 @@ i1.OnUpgrade stepByStep({
required Future Function(i1.Migrator m, Schema8 schema) from7To8,
required Future Function(i1.Migrator m, Schema9 schema) from8To9,
required Future Function(i1.Migrator m, Schema10 schema) from9To10,
+ required Future Function(i1.Migrator m, Schema11 schema) from10To11,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -4355,5 +4751,6 @@ i1.OnUpgrade stepByStep({
from7To8: from7To8,
from8To9: from8To9,
from9To10: from9To10,
+ from10To11: from10To11,
),
);
diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart
index b97884c27f..e4bff24879 100644
--- a/mobile/lib/infrastructure/repositories/local_album.repository.dart
+++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart
@@ -72,17 +72,33 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return Future.value();
}
- final deleteSmt = _db.localAssetEntity.delete();
- deleteSmt.where((localAsset) {
- final subQuery = _db.localAlbumAssetEntity.selectOnly()
- ..addColumns([_db.localAlbumAssetEntity.assetId])
- ..join([innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id))]);
- subQuery.where(
- _db.localAlbumEntity.id.equals(albumId) & _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep),
- );
- return localAsset.id.isInQuery(subQuery);
+ return _db.transaction(() async {
+ await _db.managers.localAlbumAssetEntity
+ .filter((row) => row.albumId.id.equals(albumId))
+ .update((album) => album(marker_: const Value(true)));
+
+ await _db.batch((batch) {
+ for (final assetId in assetIdsToKeep) {
+ batch.update(
+ _db.localAlbumAssetEntity,
+ const LocalAlbumAssetEntityCompanion(marker_: Value(null)),
+ where: (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
+ );
+ }
+ });
+
+ final query = _db.localAssetEntity.delete()
+ ..where(
+ (row) => row.id.isInQuery(
+ _db.localAlbumAssetEntity.selectOnly()
+ ..addColumns([_db.localAlbumAssetEntity.assetId])
+ ..where(
+ _db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAlbumAssetEntity.marker_.isNotNull(),
+ ),
+ ),
+ );
+ await query.go();
});
- await deleteSmt.go();
}
Future upsert(
@@ -198,10 +214,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
// List
await _db.batch((batch) async {
assetAlbums.cast>().forEach((assetId, albumIds) {
- batch.deleteWhere(
- _db.localAlbumAssetEntity,
- (f) => f.albumId.isNotIn(albumIds.cast().nonNulls) & f.assetId.equals(assetId),
- );
+ for (final albumId in albumIds.cast().nonNulls) {
+ batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId));
+ }
});
});
await _db.batch((batch) async {
@@ -288,12 +303,14 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return transaction(() async {
if (assetsToUnLink.isNotEmpty) {
- await _db.batch(
- (batch) => batch.deleteWhere(
- _db.localAlbumAssetEntity,
- (f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId),
- ),
- );
+ await _db.batch((batch) {
+ for (final assetId in assetsToUnLink) {
+ batch.deleteWhere(
+ _db.localAlbumAssetEntity,
+ (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
+ );
+ }
+ });
}
await _deleteAssets(assetsToDelete);
@@ -320,7 +337,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
}
return _db.batch((batch) {
- batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
+ for (final id in ids) {
+ batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id));
+ }
});
}
diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart
index 05c8e06678..2b76472c9e 100644
--- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart
+++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart
@@ -1,4 +1,3 @@
-import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -36,17 +35,17 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Stream watch(String id) => _assetSelectable(id).watchSingleOrNull();
- Future updateHashes(Iterable hashes) {
+ Future updateHashes(Map hashes) {
if (hashes.isEmpty) {
return Future.value();
}
return _db.batch((batch) async {
- for (final asset in hashes) {
+ for (final entry in hashes.entries) {
batch.update(
_db.localAssetEntity,
- LocalAssetEntityCompanion(checksum: Value(asset.checksum)),
- where: (e) => e.id.equals(asset.id),
+ LocalAssetEntityCompanion(checksum: Value(entry.value)),
+ where: (e) => e.id.equals(entry.key),
);
}
});
@@ -58,8 +57,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}
return _db.batch((batch) {
- for (final slice in ids.slices(32000)) {
- batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice));
+ for (final id in ids) {
+ batch.deleteWhere(_db.localAssetEntity, (e) => e.id.equals(id));
}
});
}
diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart
index 78b56e7436..5dfe4ac9b3 100644
--- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart
+++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart
@@ -166,8 +166,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
);
}
- Future removeAssets(String albumId, List assetIds) {
- return _db.remoteAlbumAssetEntity.deleteWhere((tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds));
+ Future removeAssets(String albumId, List assetIds) {
+ return _db.batch((batch) {
+ for (final assetId in assetIds) {
+ batch.deleteWhere(
+ _db.remoteAlbumAssetEntity,
+ (row) => row.albumId.equals(albumId) & row.assetId.equals(assetId),
+ );
+ }
+ });
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart
index 01aa10c7ad..092bb728d9 100644
--- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart
+++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart
@@ -62,12 +62,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Future> getStackChildren(RemoteAsset asset) {
- if (asset.stackId == null) {
- return Future.value([]);
+ final stackId = asset.stackId;
+ if (stackId == null) {
+ return Future.value(const []);
}
final query = _db.remoteAssetEntity.select()
- ..where((row) => row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not())
+ ..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
return query.map((row) => row.toDto()).get();
@@ -159,7 +160,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Future delete(List ids) {
- return _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(ids));
+ return _db.batch((batch) {
+ for (final id in ids) {
+ batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id));
+ }
+ });
}
Future updateLocation(List ids, LatLng location) {
@@ -198,7 +203,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
.map((row) => row.id)
.get();
- await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
+ await _db.batch((batch) {
+ for (final stackId in stackIds) {
+ batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
+ }
+ });
await _db.batch((batch) {
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
@@ -218,15 +227,21 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
Future unStack(List stackIds) {
return _db.transaction(() async {
- await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
+ await _db.batch((batch) {
+ for (final stackId in stackIds) {
+ batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
+ }
+ });
// TODO: delete this after adding foreign key on stackId
await _db.batch((batch) {
- batch.update(
- _db.remoteAssetEntity,
- const RemoteAssetEntityCompanion(stackId: Value(null)),
- where: (e) => e.stackId.isIn(stackIds),
- );
+ for (final stackId in stackIds) {
+ batch.update(
+ _db.remoteAssetEntity,
+ const RemoteAssetEntityCompanion(stackId: Value(null)),
+ where: (e) => e.stackId.equals(stackId),
+ );
+ }
});
});
}
diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart
index 3f59392bfe..cb97df72dc 100644
--- a/mobile/lib/infrastructure/repositories/search_api.repository.dart
+++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart
@@ -33,7 +33,7 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
- size: 1000,
+ size: 100,
),
);
}
diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart
index 960a84435f..f4720fb110 100644
--- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart
+++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart
@@ -93,7 +93,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteUsersV1(Iterable data) async {
try {
- await _db.userEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId)));
+ await _db.batch((batch) {
+ for (final user in data) {
+ batch.deleteWhere(_db.userEntity, (row) => row.id.equals(user.userId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: SyncUserDeleteV1', error, stack);
rethrow;
@@ -158,7 +162,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteAssetsV1(Iterable data, {String debugLabel = 'user'}) async {
try {
- await _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId)));
+ await _db.batch((batch) {
+ for (final asset in data) {
+ batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(asset.assetId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
rethrow;
@@ -243,7 +251,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteAlbumsV1(Iterable data) async {
try {
- await _db.remoteAlbumEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId)));
+ await _db.batch((batch) {
+ for (final album in data) {
+ batch.deleteWhere(_db.remoteAlbumEntity, (row) => row.id.equals(album.albumId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: deleteAlbumsV1', error, stack);
rethrow;
@@ -379,7 +391,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteMemoriesV1(Iterable data) async {
try {
- await _db.memoryEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.memoryId)));
+ await _db.batch((batch) {
+ for (final memory in data) {
+ batch.deleteWhere(_db.memoryEntity, (row) => row.id.equals(memory.memoryId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: deleteMemoriesV1', error, stack);
rethrow;
@@ -443,7 +459,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteStacksV1(Iterable data, {String debugLabel = 'user'}) async {
try {
- await _db.stackEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.stackId)));
+ await _db.batch((batch) {
+ for (final stack in data) {
+ batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stack.stackId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
rethrow;
diff --git a/mobile/lib/pages/album/album_options.page.dart b/mobile/lib/pages/album/album_options.page.dart
index e659340f26..20d4dbd325 100644
--- a/mobile/lib/pages/album/album_options.page.dart
+++ b/mobile/lib/pages/album/album_options.page.dart
@@ -169,7 +169,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
album.activityEnabled = value;
}
},
- activeColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
+ activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
dense: true,
title: Text(
"comments_and_likes",
diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart
index bf9ad43f9c..8578506ced 100644
--- a/mobile/lib/pages/backup/drift_backup.page.dart
+++ b/mobile/lib/pages/backup/drift_backup.page.dart
@@ -12,6 +12,7 @@ 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/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';
@@ -33,7 +34,14 @@ class _DriftBackupPageState extends ConsumerState {
return;
}
- ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
+ WidgetsBinding.instance.addPostFrameCallback((_) async {
+ await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
+ await ref.read(backgroundSyncProvider).syncRemote();
+
+ if (mounted) {
+ await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
+ }
+ });
}
@override
@@ -44,7 +52,6 @@ class _DriftBackupPageState extends ConsumerState {
.toList();
final backupNotifier = ref.read(driftBackupProvider.notifier);
- final backgroundManager = ref.read(backgroundSyncProvider);
Future startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
@@ -52,7 +59,6 @@ class _DriftBackupPageState extends ConsumerState {
return;
}
- await backgroundManager.syncRemote();
await backupNotifier.getBackupStatus(currentUser.id);
await backupNotifier.startBackup(currentUser.id);
}
@@ -235,11 +241,13 @@ class _BackupCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final backupCount = ref.watch(driftBackupProvider.select((p) => p.backupCount));
+ final syncStatus = ref.watch(syncStatusProvider);
return BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: backupCount.toString(),
+ isLoading: syncStatus.isRemoteSyncing,
);
}
}
@@ -250,10 +258,13 @@ class _RemainderCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
+ final syncStatus = ref.watch(syncStatusProvider);
+
return BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: remainderCount.toString(),
+ isLoading: syncStatus.isRemoteSyncing,
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
);
}
diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
index 8cfdd2db95..368341f24a 100644
--- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
+++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
@@ -5,12 +6,14 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
+import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
-import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
+import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
@@ -64,16 +67,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount));
- final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
- final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
-
- if (totalChanged && isBackupEnabled) {
- await ref.read(driftBackupProvider.notifier).cancel();
- await ref.read(driftBackupProvider.notifier).startBackup(user.id);
- }
}
@override
@@ -102,6 +95,27 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount));
+ final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
+ final backupNotifier = ref.read(driftBackupProvider.notifier);
+ final backgroundSync = ref.read(backgroundSyncProvider);
+ final nativeSync = ref.read(nativeSyncApiProvider);
+ if (totalChanged) {
+ // Waits for hashing to be cancelled before starting a new one
+ unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
+ if (isBackupEnabled) {
+ unawaited(backupNotifier.cancel().whenComplete(() => backupNotifier.startBackup(user.id)));
+ }
+ }
+
Navigator.of(context).pop();
}
},
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 c78a9d5138..8b66bb231f 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
@@ -112,7 +112,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: showMetadata.value,
onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
- activeColor: colorScheme.primary,
+ activeThumbColor: colorScheme.primary,
dense: true,
title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
);
@@ -122,7 +122,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: allowDownload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
- activeColor: colorScheme.primary,
+ activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"allow_public_user_to_download",
@@ -135,7 +135,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: allowUpload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
- activeColor: colorScheme.primary,
+ activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"allow_public_user_to_upload",
@@ -148,7 +148,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: editExpiry.value,
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
- activeColor: colorScheme.primary,
+ activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"change_expiration_time",
diff --git a/mobile/lib/platform/background_worker_api.g.dart b/mobile/lib/platform/background_worker_api.g.dart
index 9398b0a15b..af7c78fd4b 100644
--- a/mobile/lib/platform/background_worker_api.g.dart
+++ b/mobile/lib/platform/background_worker_api.g.dart
@@ -25,6 +25,57 @@ List