Compare commits

..

1 Commits

Author SHA1 Message Date
Min Idzelis a29dc703b6 refactor(web): expose scaled image dimensions from AdaptiveImage
Change-Id: Iae105fb749525739ba8df5b944a73ea66a6a6964
2026-06-03 03:56:29 +00:00
296 changed files with 4345 additions and 20980 deletions
-2
View File
@@ -94,7 +94,6 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Create the Keystore - name: Create the Keystore
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
@@ -220,7 +219,6 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Install Flutter dependencies - name: Install Flutter dependencies
working-directory: ./mobile working-directory: ./mobile
-34
View File
@@ -4,7 +4,6 @@ on:
pull_request: pull_request:
paths: paths:
- 'open-api/**' - 'open-api/**'
- 'mobile/lib/utils/openapi_patching.dart'
- '.github/workflows/check-openapi.yml' - '.github/workflows/check-openapi.yml'
concurrency: concurrency:
@@ -30,36 +29,3 @@ jobs:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json
fail-on: ERR fail-on: ERR
check-mobile-patches:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ github.token }}
working_directory: ./mobile
- name: Get packages
working-directory: ./mobile
run: flutter pub get
- name: Fetch base spec from main
run: |
curl -fsSL \
"https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json" \
-o /tmp/base-spec.json
- name: Check newly-required fields have a backward-compat patch
working-directory: ./mobile
env:
OPENAPI_BASE_SPEC: /tmp/base-spec.json
OPENAPI_REVISION_SPEC: ../open-api/immich-openapi-specs.json
run: flutter test test/openapi_patches_coverage.dart
-1
View File
@@ -64,7 +64,6 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
-1
View File
@@ -560,7 +560,6 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2 uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
+1 -1
View File
@@ -112,7 +112,7 @@ services:
traefik.enable: true traefik.enable: true
# increase readingTimeouts for the entrypoint used here # increase readingTimeouts for the entrypoint used here
traefik.http.routers.immich.entrypoints: websecure traefik.http.routers.immich.entrypoints: websecure
traefik.http.routers.immich.rule: Host(`immich.example.com`) traefik.http.routers.immich.rule: Host(`immich.your-domain.com`)
traefik.http.services.immich.loadbalancer.server.port: 2283 traefik.http.services.immich.loadbalancer.server.port: 2283
``` ```
+1 -1
View File
@@ -90,7 +90,7 @@ immich-admin list-users
[ [
{ {
id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53', id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53',
email: 'immich@example.com', email: 'immich@example.com.com',
name: 'Immich Admin', name: 'Immich Admin',
storageLabel: 'admin', storageLabel: 'admin',
externalPath: null, externalPath: null,
+1 -1
View File
@@ -17,7 +17,7 @@ services:
ports: ports:
- "8888:80" - "8888:80"
environment: environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com PGADMIN_DEFAULT_EMAIL: user-name@domain-name.com
PGADMIN_DEFAULT_PASSWORD: strong-password PGADMIN_DEFAULT_PASSWORD: strong-password
volumes: volumes:
- pgadmin-data:/var/lib/pgadmin - pgadmin-data:/var/lib/pgadmin
@@ -259,6 +259,17 @@ describe('/search', () => {
assets: [assetHeic], assets: [assetHeic],
}), }),
}, },
{
should: "should search city ('')",
deferred: () => ({
dto: {
city: '',
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
}),
},
{ {
should: 'should search city (null)', should: 'should search city (null)',
deferred: () => ({ deferred: () => ({
@@ -280,6 +291,18 @@ describe('/search', () => {
assets: [assetDensity], assets: [assetDensity],
}), }),
}, },
{
should: "should search state ('')",
deferred: () => ({
dto: {
state: '',
visibility: AssetVisibility.Timeline,
withExif: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
},
{ {
should: 'should search state (null)', should: 'should search state (null)',
deferred: () => ({ deferred: () => ({
@@ -301,6 +324,17 @@ describe('/search', () => {
assets: [assetFalcon], assets: [assetFalcon],
}), }),
}, },
{
should: "should search country ('')",
deferred: () => ({
dto: {
country: '',
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
}),
},
{ {
should: 'should search country (null)', should: 'should search country (null)',
deferred: () => ({ deferred: () => ({
-3
View File
@@ -699,7 +699,6 @@
"backup_settings_subtitle": "Manage upload settings", "backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details", "backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward", "backward": "Backward",
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
"biometric_auth_enabled": "Biometric authentication enabled", "biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication", "biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available", "biometric_no_options": "No biometric options available",
@@ -1688,10 +1687,8 @@
"not_available": "N/A", "not_available": "N/A",
"not_in_any_album": "Not in any album", "not_in_any_album": "Not in any album",
"not_selected": "Not selected", "not_selected": "Not selected",
"not_set": "Not set",
"notes": "Notes", "notes": "Notes",
"nothing_here_yet": "Nothing here yet", "nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_enable_button": "Enable Notifications",
-16
View File
@@ -816,10 +816,6 @@ class TestFaceRecognition:
def test_recognition(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None: def test_recognition(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None:
mocker.patch.object(FaceRecognizer, "load") mocker.patch.object(FaceRecognizer, "load")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
face_recognizer = FaceRecognizer("buffalo_s", min_score=0.0, cache_dir="test_cache") face_recognizer = FaceRecognizer("buffalo_s", min_score=0.0, cache_dir="test_cache")
num_faces = 2 num_faces = 2
@@ -864,10 +860,6 @@ class TestFaceRecognition:
) )
mocker.patch("immich_ml.models.base.InferenceModel.download") mocker.patch("immich_ml.models.base.InferenceModel.download")
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
ort_session.return_value.get_inputs.return_value = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))] ort_session.return_value.get_inputs.return_value = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))]
ort_session.return_value.get_outputs.return_value = [SimpleNamespace(name="output.1", shape=(1, 800))] ort_session.return_value.get_outputs.return_value = [SimpleNamespace(name="output.1", shape=(1, 800))]
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
@@ -902,10 +894,6 @@ class TestFaceRecognition:
) )
mocker.patch("immich_ml.models.base.InferenceModel.download") mocker.patch("immich_ml.models.base.InferenceModel.download")
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))] inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))]
@@ -1008,10 +996,6 @@ class TestFaceRecognition:
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None: def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2)) mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache") recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
+93 -28
View File
@@ -1,5 +1,74 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.0"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
checksum = "blake3:fb03aa5d9790205c948922ec3f0751c16e4575b09d6ae9dd4fbeb664a69f0e00"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.0-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.0-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.0-stable.zip"
[[tools.flutter]]
version = "3.41.9-stable"
backend = "asdf:flutter"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
[[tools."github:extism/cli"]] [[tools."github:extism/cli"]]
version = "1.6.3" version = "1.6.3"
backend = "github:extism/cli" backend = "github:extism/cli"
@@ -156,6 +225,30 @@ checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz" url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833" url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]] [[tools.node]]
version = "24.15.0" version = "24.15.0"
backend = "core:node" backend = "core:node"
@@ -228,34 +321,6 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.
version = "10.33.4" version = "10.33.4"
backend = "aqua:pnpm/pnpm" backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:7aae186a04e1ffaa0047d43cd07d68a98dec303304f28be52234ba955d26c671"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-arm64"
[tools.pnpm."platforms.macos-x64"]
checksum = "sha256:3b0c97b9f794cdda293949a8ee0e0151ca08f512f4a832408386221c7c73eec6"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-x64"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:3268b2f29defe0dce8a3a26c0ef01488f0d4aa4872923173186ef618ab7d68ef"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-win-x64.exe"
[[tools.terragrunt]] [[tools.terragrunt]]
version = "1.0.3" version = "1.0.3"
backend = "aqua:gruntwork-io/terragrunt" backend = "aqua:gruntwork-io/terragrunt"
+14
View File
@@ -16,14 +16,28 @@ config_roots = [
[tools] [tools]
node = "24.15.0" node = "24.15.0"
"aqua:flutter/flutter" = "3.44.0"
pnpm = "10.33.4" pnpm = "10.33.4"
terragrunt = "1.0.3" terragrunt = "1.0.3"
opentofu = "1.11.6" opentofu = "1.11.6"
java = "21.0.2"
"npm:oazapfts" = "7.5.0" "npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3" "github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124" "github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "1.6.0" "github:extism/js-pdk" = "1.6.0"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"
bin = "dcm"
postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
[tools."github:CQLabs/homebrew-dcm".platforms]
linux-x64 = { asset_pattern = "dcm-linux-x64-release.zip" }
linux-arm64 = { asset_pattern = "dcm-linux-arm-release.zip" }
macos-x64 = { asset_pattern = "dcm-macos-x64-release.zip" }
macos-arm64 = { asset_pattern = "dcm-macos-arm-release.zip" }
windows-x64 = { asset_pattern = "dcm-windows-release.zip" }
[tools."github:jellyfin/jellyfin-ffmpeg"] [tools."github:jellyfin/jellyfin-ffmpeg"]
version = "7.1.3-6" version = "7.1.3-6"
@@ -89,20 +89,6 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
</intent-filter> </intent-filter>
<!-- Allow Immich to act as an image viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="image/*" />
</intent-filter>
<!-- Allow Immich to act as a video viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="video/*" />
</intent-filter>
<!-- immich:// URL scheme handling --> <!-- immich:// URL scheme handling -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@@ -1,7 +1,6 @@
package app.alextran.immich package app.alextran.immich
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Build import android.os.Build
import android.os.ext.SdkExtensions import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock import app.alextran.immich.background.BackgroundEngineLock
@@ -23,7 +22,6 @@ import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26 import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30 import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.viewintent.ViewIntentPlugin
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@@ -33,11 +31,6 @@ class MainActivity : FlutterFragmentActivity() {
registerPlugins(this, flutterEngine) registerPlugins(this, flutterEngine)
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
companion object { companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx) HttpClientManager.initialize(ctx)
@@ -62,7 +55,6 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl) flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl) flutterEngine.plugins.add(permissionApiImpl)
@@ -47,44 +47,18 @@ class FlutterError (
override val message: String? = null, override val message: String? = null,
val details: Any? = null val details: Any? = null
) : RuntimeException() ) : RuntimeException()
enum class PermissionStatus(val raw: Int) {
GRANTED(0),
DENIED(1),
PERMANENTLY_DENIED(2);
companion object {
fun ofRaw(raw: Int): PermissionStatus? {
return values().firstOrNull { it.raw == raw }
}
}
}
private open class PermissionApiPigeonCodec : StandardMessageCodec() { private open class PermissionApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) { return super.readValueOfType(type, buffer)
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
PermissionStatus.ofRaw(it.toInt())
}
}
else -> super.readValueOfType(type, buffer)
}
} }
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) { super.writeValue(stream, value)
is PermissionStatus -> {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
else -> super.writeValue(stream, value)
}
} }
} }
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PermissionApi { interface PermissionApi {
fun isIgnoringBatteryOptimizations(): PermissionStatus
fun hasManageMediaPermission(): Boolean fun hasManageMediaPermission(): Boolean
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
@@ -98,21 +72,6 @@ interface PermissionApi {
@JvmOverloads @JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") { fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.isIgnoringBatteryOptimizations())
} catch (exception: Throwable) {
PermissionApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
@@ -1,26 +1,13 @@
package app.alextran.immich.permission package app.alextran.immich.permission
import android.content.Context import android.content.Context
import android.os.PowerManager
import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.ImmichPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware { class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
private val ctx: Context = context.applicationContext
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context) private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
private val powerManager =
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
return PermissionStatus.GRANTED
}
return PermissionStatus.DENIED
}
override fun hasManageMediaPermission(): Boolean = override fun hasManageMediaPermission(): Boolean =
manageMediaPermissionDelegate.hasManageMediaPermission() manageMediaPermissionDelegate.hasManageMediaPermission()
@@ -542,17 +542,16 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi { interface NativeSyncApi {
fun shouldFullSync(callback: (Result<Boolean>) -> Unit) fun shouldFullSync(): Boolean
fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) fun getMediaChanges(): SyncDelta
fun checkpointSync() fun checkpointSync()
fun clearSyncCheckpoint() fun clearSyncCheckpoint()
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit) fun getAssetIdsForAlbum(albumId: String): List<String>
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit) fun getAlbums(): List<PlatformAlbum>
fun getAssetsCountSince(albumId: String, timestamp: Long): Long fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit) fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing() fun cancelHashing()
fun cancelSync()
fun getTrashedAssets(): Map<String, List<PlatformAsset>> fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
@@ -571,33 +570,27 @@ interface NativeSyncApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
api.shouldFullSync{ result: Result<Boolean> -> val wrapped: List<Any?> = try {
val error = result.exceptionOrNull() listOf(api.shouldFullSync())
if (error != null) { } catch (exception: Throwable) {
reply.reply(MessagesPigeonUtils.wrapError(error)) MessagesPigeonUtils.wrapError(exception)
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
} }
reply.reply(wrapped)
} }
} else { } else {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
api.getMediaChanges{ result: Result<SyncDelta> -> val wrapped: List<Any?> = try {
val error = result.exceptionOrNull() listOf(api.getMediaChanges())
if (error != null) { } catch (exception: Throwable) {
reply.reply(MessagesPigeonUtils.wrapError(error)) MessagesPigeonUtils.wrapError(exception)
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
} }
reply.reply(wrapped)
} }
} else { } else {
channel.setMessageHandler(null) channel.setMessageHandler(null)
@@ -636,38 +629,32 @@ interface NativeSyncApi {
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
val albumIdArg = args[0] as String val albumIdArg = args[0] as String
api.getAssetIdsForAlbum(albumIdArg) { result: Result<List<String>> -> val wrapped: List<Any?> = try {
val error = result.exceptionOrNull() listOf(api.getAssetIdsForAlbum(albumIdArg))
if (error != null) { } catch (exception: Throwable) {
reply.reply(MessagesPigeonUtils.wrapError(error)) MessagesPigeonUtils.wrapError(exception)
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
} }
reply.reply(wrapped)
} }
} else { } else {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
api.getAlbums{ result: Result<List<PlatformAlbum>> -> val wrapped: List<Any?> = try {
val error = result.exceptionOrNull() listOf(api.getAlbums())
if (error != null) { } catch (exception: Throwable) {
reply.reply(MessagesPigeonUtils.wrapError(error)) MessagesPigeonUtils.wrapError(exception)
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
} }
reply.reply(wrapped)
} }
} else { } else {
channel.setMessageHandler(null) channel.setMessageHandler(null)
@@ -692,21 +679,18 @@ interface NativeSyncApi {
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
val albumIdArg = args[0] as String val albumIdArg = args[0] as String
val updatedTimeCondArg = args[1] as Long? val updatedTimeCondArg = args[1] as Long?
api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg) { result: Result<List<PlatformAsset>> -> val wrapped: List<Any?> = try {
val error = result.exceptionOrNull() listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
if (error != null) { } catch (exception: Throwable) {
reply.reply(MessagesPigeonUtils.wrapError(error)) MessagesPigeonUtils.wrapError(exception)
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
} }
reply.reply(wrapped)
} }
} else { } else {
channel.setMessageHandler(null) channel.setMessageHandler(null)
@@ -749,22 +733,6 @@ interface NativeSyncApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.cancelSync()
listOf(null)
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
@@ -4,11 +4,7 @@ import android.content.Context
class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi { class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi {
override fun shouldFullSync(callback: (Result<Boolean>) -> Unit) { override fun shouldFullSync(): Boolean {
runSync(callback) { shouldFullSync() }
}
private fun shouldFullSync(): Boolean {
return true return true
} }
@@ -22,11 +18,7 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
// No-op for Android 10 and below // No-op for Android 10 and below
} }
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) { override fun getMediaChanges(): SyncDelta {
runSync(callback) { getMediaChanges() }
}
private fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.") throw IllegalStateException("Method not supported on this Android version.")
} }
@@ -7,8 +7,6 @@ import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.annotation.RequiresExtension import androidx.annotation.RequiresExtension
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@@ -37,11 +35,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
} }
} }
override fun shouldFullSync(callback: (Result<Boolean>) -> Unit) { override fun shouldFullSync(): Boolean =
runSync(callback) { shouldFullSync() }
}
private fun shouldFullSync(): Boolean =
MediaStore.getVersion(ctx) != prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null) MediaStore.getVersion(ctx) != prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null)
override fun checkpointSync() { override fun checkpointSync() {
@@ -55,11 +49,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
} }
} }
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) { override fun getMediaChanges(): SyncDelta {
runSync(callback) { getMediaChanges() }
}
private suspend fun getMediaChanges(): SyncDelta {
val genMap = getSavedGenerationMap() val genMap = getSavedGenerationMap()
val currentVolumes = MediaStore.getExternalVolumeNames(ctx) val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
val changed = mutableListOf<PlatformAsset>() val changed = mutableListOf<PlatformAsset>()
@@ -68,7 +58,6 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
var hasChanges = genMap.keys != currentVolumes var hasChanges = genMap.keys != currentVolumes
for (volume in currentVolumes) { for (volume in currentVolumes) {
currentCoroutineContext().ensureActive()
val currentGen = MediaStore.getGeneration(ctx, volume) val currentGen = MediaStore.getGeneration(ctx, volume)
val storedGen = genMap[volume] ?: 0 val storedGen = genMap[volume] ?: 0
if (currentGen <= storedGen) { if (currentGen <= storedGen) {
@@ -45,14 +45,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
private var hashTask: Job? = null private var hashTask: Job? = null
private var syncJob: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx) private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object { companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16 private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS) private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED" private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
private const val SYNC_CANCELLED_CODE = "SYNC_CANCELLED"
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+ // MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT // https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
@@ -297,11 +295,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
return PlatformAssetPlaybackStyle.IMAGE return PlatformAssetPlaybackStyle.IMAGE
} }
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit) { fun getAlbums(): List<PlatformAlbum> {
runSync(callback) { getAlbums() }
}
private suspend fun getAlbums(): List<PlatformAlbum> {
val albums = mutableListOf<PlatformAlbum>() val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>() val albumsCount = mutableMapOf<String, Int>()
@@ -328,7 +322,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED) cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
currentCoroutineContext().ensureActive()
val id = cursor.getString(bucketIdColumn) val id = cursor.getString(bucketIdColumn)
val count = albumsCount.getOrDefault(id, 0) val count = albumsCount.getOrDefault(id, 0)
@@ -349,11 +342,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
.sortedBy { it.id } .sortedBy { it.id }
} }
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit) { fun getAssetIdsForAlbum(albumId: String): List<String> {
runSync(callback) { getAssetIdsForAlbum(albumId) }
}
private fun getAssetIdsForAlbum(albumId: String): List<String> {
val projection = arrayOf(MediaStore.MediaColumns._ID) val projection = arrayOf(MediaStore.MediaColumns._ID)
return getCursor( return getCursor(
@@ -377,11 +366,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)?.use { cursor -> cursor.count.toLong() } ?: 0L )?.use { cursor -> cursor.count.toLong() } ?: 0L
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) { fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
}
private fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION" var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS) val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
@@ -466,24 +451,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
hashTask = null hashTask = null
} }
fun cancelSync() {
syncJob?.cancel()
syncJob = null
}
protected fun <T> runSync(callback: (Result<T>) -> Unit, work: suspend () -> T) {
syncJob?.cancel()
syncJob = CoroutineScope(Dispatchers.IO).launch {
try {
completeWhenActive(callback, Result.success(work()))
} catch (e: CancellationException) {
completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
} catch (e: Exception) {
completeWhenActive(callback, Result.failure(e))
}
}
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) { fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) } mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
} }
@@ -1,292 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.viewintent
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object ViewIntentPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun doubleEquals(a: Double, b: Double): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
}
fun floatEquals(a: Float, b: Float): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
}
fun doubleHash(d: Double): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (d == 0.0) 0.0 else d
val bits = java.lang.Double.doubleToLongBits(normalized)
return (bits xor (bits ushr 32)).toInt()
}
fun floatHash(f: Float): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (f == 0.0f) 0.0f else f
return java.lang.Float.floatToIntBits(normalized)
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a === b) {
return true
}
if (a == null || b == null) {
return false
}
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!doubleEquals(a[i], b[i])) return false
}
return true
}
if (a is FloatArray && b is FloatArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!floatEquals(a[i], b[i])) return false
}
return true
}
if (a is Array<*> && b is Array<*>) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!deepEquals(a[i], b[i])) return false
}
return true
}
if (a is List<*> && b is List<*>) {
if (a.size != b.size) return false
val iterA = a.iterator()
val iterB = b.iterator()
while (iterA.hasNext() && iterB.hasNext()) {
if (!deepEquals(iterA.next(), iterB.next())) return false
}
return true
}
if (a is Map<*, *> && b is Map<*, *>) {
if (a.size != b.size) return false
for (entry in a) {
val key = entry.key
var found = false
for (bEntry in b) {
if (deepEquals(key, bEntry.key)) {
if (deepEquals(entry.value, bEntry.value)) {
found = true
break
} else {
return false
}
}
}
if (!found) return false
}
return true
}
if (a is Double && b is Double) {
return doubleEquals(a, b)
}
if (a is Float && b is Float) {
return floatEquals(a, b)
}
return a == b
}
fun deepHash(value: Any?): Int {
return when (value) {
null -> 0
is ByteArray -> value.contentHashCode()
is IntArray -> value.contentHashCode()
is LongArray -> value.contentHashCode()
is DoubleArray -> {
var result = 1
for (item in value) {
result = 31 * result + doubleHash(item)
}
result
}
is FloatArray -> {
var result = 1
for (item in value) {
result = 31 * result + floatHash(item)
}
result
}
is Array<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is List<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is Map<*, *> -> {
var result = 0
for (entry in value) {
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
}
result
}
is Double -> doubleHash(value)
is Float -> floatHash(value)
else -> value.hashCode()
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
/** Generated class from Pigeon that represents data sent in messages. */
data class ViewIntentPayload (
val path: String? = null,
val mimeType: String,
val localAssetId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ViewIntentPayload {
val path = pigeonVar_list[0] as String?
val mimeType = pigeonVar_list[1] as String
val localAssetId = pigeonVar_list[2] as String?
return ViewIntentPayload(path, mimeType, localAssetId)
}
}
fun toList(): List<Any?> {
return listOf(
path,
mimeType,
localAssetId,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as ViewIntentPayload
return ViewIntentPigeonUtils.deepEquals(this.path, other.path) && ViewIntentPigeonUtils.deepEquals(this.mimeType, other.mimeType) && ViewIntentPigeonUtils.deepEquals(this.localAssetId, other.localAssetId)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.path)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.mimeType)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.localAssetId)
return result
}
}
private open class ViewIntentPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ViewIntentPayload.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is ViewIntentPayload -> {
stream.write(129)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ViewIntentHostApi {
fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit)
companion object {
/** The codec used by ViewIntentHostApi. */
val codec: MessageCodec<Any?> by lazy {
ViewIntentPigeonCodec()
}
/** Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.consumeViewIntent{ result: Result<ViewIntentPayload?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ViewIntentPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ViewIntentPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -1,201 +0,0 @@
package app.alextran.immich.viewintent
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
private const val TAG = "ViewIntentPlugin"
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
private var context: Context? = null
private var activity: Activity? = null
private var unconsumedIntent: Intent? = null
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
ViewIntentHostApi.setUp(binding.binaryMessenger, this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
ViewIntentHostApi.setUp(binding.binaryMessenger, null)
ioScope.cancel()
context = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
unconsumedIntent = binding.activity.intent
binding.addOnNewIntentListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onNewIntent(intent: Intent): Boolean {
unconsumedIntent = intent
return false
}
override fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit) {
val context = context ?: run {
callback(Result.success(null))
return
}
val intent = unconsumedIntent ?: activity?.intent
if (intent?.action != Intent.ACTION_VIEW) {
callback(Result.success(null))
return
}
val uri = intent.data
if (uri == null) {
callback(Result.success(null))
return
}
ioScope.launch {
try {
val mimeType = context.contentResolver.getType(uri) ?: intent.type
if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
callback(Result.success(null))
return@launch
}
val localAssetId = extractLocalAssetId(context, uri, mimeType)
val tempFilePath = if (localAssetId == null) {
copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
callback(Result.success(null))
return@launch
}
} else {
null
}
val payload = ViewIntentPayload(
path = tempFilePath,
mimeType = mimeType,
localAssetId = localAssetId,
)
consumeViewIntent(intent)
callback(Result.success(payload))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
private fun consumeViewIntent(currentIntent: Intent) {
unconsumedIntent = Intent(currentIntent).apply {
action = null
data = null
type = null
}
activity?.intent = unconsumedIntent
}
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
return tryExtractDocumentLocalAssetId(context, uri)
?: tryParseContentUriId(uri)
?: resolveLocalIdByNameAndSize(context, uri, mimeType)
}
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
return try {
if (!DocumentsContract.isDocumentUri(context, uri)) return null
val docId = DocumentsContract.getDocumentId(uri)
if (docId.isBlank() || docId.startsWith("raw:")) return null
docId.substringAfter(':', docId).toLongOrNull()?.toString()
} catch (e: Exception) {
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
null
}
}
private fun tryParseContentUriId(uri: Uri): String? {
val id = uri.lastPathSegment?.toLongOrNull() ?: return null
return if (id >= 0) id.toString() else null
}
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
return try {
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: return null
tempFile
} catch (_: Exception) {
null
}
}
private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
val (displayName, size) =
try {
context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
if (name.isNullOrBlank() || bytes < 0) return null
name to bytes
} ?: return null
} catch (_: Exception) {
return null
}
val tableUri = when {
mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
else -> return null
}
return try {
context.contentResolver
.query(
tableUri,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
arrayOf(displayName, size.toString()),
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
if (idIndex < 0) return null
cursor.getLong(idIndex).toString()
}
} catch (_: Exception) {
null
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,154 +0,0 @@
import 'dart:async';
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:integration_test/integration_test.dart';
import 'package:openapi/api.dart';
import 'test_utils/fake_immich_server.dart';
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// These tests do real I/O without pumping a widget tree, so disable the fake async clock
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
late Drift drift;
late FakeImmichServer server;
setUpAll(() async {
await app.initApp();
(drift, _) = await Bootstrap.initDomain();
});
setUp(() async {
await workerManagerPatch.init(dynamicSpawning: true);
server = await FakeImmichServer.start();
await ApiService().resolveAndSetEndpoint(server.endpoint);
await drift.delete(drift.userEntity).go();
await Store.delete(StoreKey.syncMigrationStatus);
});
tearDown(() async {
await workerManagerPatch.dispose();
await server.close();
await Store.delete(StoreKey.serverEndpoint);
await Store.delete(StoreKey.syncMigrationStatus);
});
void sendUser(SyncStream stream, String id, String name) {
stream.send(
type: SyncEntityType.userV1.value,
data: SyncUserV1(
id: id,
name: name,
email: '$id@test.com',
hasProfileImage: false,
deletedAt: null,
profileChangedAt: DateTime.utc(2025),
).toJson(),
ack: id,
);
}
Future<bool> dbReadable() async {
try {
await drift.customSelect('SELECT 1').get().timeout(const Duration(seconds: 5));
return true;
} catch (_) {
return false;
}
}
Future<int> userCount() async => (await drift.select(drift.userEntity).get()).length;
// Starts a remote sync and resolves once its /sync/stream request is open.
Future<(Future<bool>, SyncStream)> startSync() async {
final sync = BackgroundSyncManager().syncRemote();
final stream = await server.streamOpened.timeout(
const Duration(seconds: 30),
onTimeout: () => fail('sync isolate never opened /sync/stream'),
);
return (sync, stream);
}
testWidgets('a full sync ingests streamed events into the shared DB', (tester) async {
expect(await userCount(), 0);
final (sync, stream) = await startSync();
sendUser(stream, 'u1', 'Alice');
sendUser(stream, 'u2', 'Bob');
await stream.close();
final result = await sync.timeout(
const Duration(seconds: 30),
onTimeout: () => fail('sync did not complete after the stream ended'),
);
expect(result, isTrue);
expect(await userCount(), 2);
expect(server.ackRequests, greaterThan(0));
});
testWidgets('disposing the pool during an in-flight sync drains promptly', (tester) async {
final (sync, _) = await startSync();
final sw = Stopwatch()..start();
await workerManagerPatch.dispose().timeout(
const Duration(seconds: 15),
onTimeout: () => fail('dispose() hung — worker did not drain and exit'),
);
expect(sw.elapsed, lessThan(const Duration(seconds: 10)), reason: 'abort-driven, not socket-timeout bound');
expect(await sync.timeout(const Duration(seconds: 5), onTimeout: () => false), isFalse);
});
testWidgets('tearing down a worker blocked mid-write leaves the DB usable', (tester) async {
final (sync, stream) = await startSync();
// Hold an exclusive write transaction so the worker's write is blocked. The lock is taken only
// after the stream opens to avoid blocking the worker's own startup DB reads.
final releaseTxn = Completer<void>();
final txnHeld = Completer<void>();
final txn = drift.transaction(() async {
await drift.into(drift.userEntity).insert(
UserEntityCompanion.insert(
id: 'holder',
name: 'holder',
email: 'holder@test.com',
hasProfileImage: const Value(false),
profileChangedAt: Value(DateTime.utc(2025)),
),
);
txnHeld.complete();
await releaseTxn.future;
});
await txnHeld.future;
sendUser(stream, 'u1', 'Alice');
await stream.close();
// dispose() can only finish once the worker unwinds, which is blocked on the
// lock — so start it, release the lock, then await completion.
final disposed = workerManagerPatch.dispose();
releaseTxn.complete();
await txn;
await disposed.timeout(
const Duration(seconds: 15),
onTimeout: () => fail('dispose() hung after releasing the write lock'),
);
await sync.timeout(const Duration(seconds: 5), onTimeout: () => false);
expect(await dbReadable(), isTrue);
final users = await drift.select(drift.userEntity).get();
expect(users.map((u) => u.id), contains('holder'));
});
}
@@ -1,115 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// A dummy localhost server that implements only the endpoints that remote-sync touches.
class FakeImmichServer {
FakeImmichServer._(this._server, this.version);
final HttpServer _server;
final (int, int, int) version;
final Completer<SyncStream> _streamOpened = Completer<SyncStream>();
int ackRequests = 0;
String get endpoint => 'http://${_server.address.host}:${_server.port}/api';
/// Resolves when the sync isolate opens `POST /sync/stream`.
Future<SyncStream> get streamOpened => _streamOpened.future;
static Future<FakeImmichServer> start({(int, int, int) version = (3, 0, 0)}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = FakeImmichServer._(server, version);
fake._listen();
return fake;
}
void _listen() {
// A connection torn down mid-write during teardown is expected
_server.listen((request) => unawaited(_route(request).catchError((_) {})));
}
Future<void> _route(HttpRequest request) async {
final method = request.method;
final path = request.uri.path;
if (method == 'GET' && path == '/api/server/ping') {
return _respondJson(request, {'res': 'pong'});
}
if (method == 'GET' && path == '/api/server/version') {
final (major, minor, patch) = version;
return _respondJson(request, {'major': major, 'minor': minor, 'patch': patch});
}
if (path == '/api/sync/ack') {
if (method != 'DELETE') {
ackRequests++;
}
return _respondEmpty(request);
}
if (method == 'POST' && path == '/api/sync/stream') {
return _openSyncStream(request);
}
return _respondEmpty(request, status: HttpStatus.notFound);
}
Future<void> _openSyncStream(HttpRequest request) async {
await request.drain<void>();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType('application', 'jsonlines+json')
..contentLength = -1 // chunked: stays open to stream incrementally
..bufferOutput = false;
// Flush headers so the client's send() resolves and enters its read loop.
await request.response.flush();
if (!_streamOpened.isCompleted) {
_streamOpened.complete(SyncStream._(request.response));
}
}
Future<void> _respondJson(HttpRequest request, Object body) async {
await request.drain<void>();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(jsonEncode(body));
await request.response.close();
}
Future<void> _respondEmpty(HttpRequest request, {int status = HttpStatus.ok}) async {
await request.drain<void>();
request.response.statusCode = status;
await request.response.close();
}
Future<void> close() async {
if (_streamOpened.isCompleted) {
await (await _streamOpened.future).close();
}
await _server.close(force: true);
}
}
/// Handle to the open `/sync/stream` response: push jsonlines events, then end.
class SyncStream {
SyncStream._(this._response);
final HttpResponse _response;
bool _closed = false;
/// [data] should be a Sync*V1 DTO's `toJson()` so the parser's `fromJson` round-trips it.
void send({required String type, required Object data, required String ack}) {
if (_closed) {
return;
}
_response.write('${jsonEncode({'type': type, 'data': data, 'ack': ack})}\n');
}
Future<void> close() async {
if (_closed) {
return;
}
_closed = true;
await _response.close();
}
}
@@ -121,8 +121,8 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
/** /**
* Cancels the currently running background task, either due to timeout or external request. * Cancels the currently running background task, either due to timeout or external request.
* Only tears down the engine after Dart confirms it's drained. If Dart overruns iOS's grace window, * Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
* the expiration handler still calls setTaskCompleted and iOS suspends us. * the completion handler is eventually called even if Flutter doesn't respond.
*/ */
func close() { func close() {
if isComplete { if isComplete {
@@ -132,6 +132,12 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
flutterApi?.cancel { result in flutterApi?.cancel { result in
self.complete(success: false) self.complete(success: false)
} }
// Fallback safety mechanism: ensure completion is called within 2 seconds
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
self.complete(success: false)
}
} }
+1 -81
View File
@@ -11,24 +11,6 @@ import Foundation
#error("Unsupported platform.") #error("Unsupported platform.")
#endif #endif
/// Error class for passing custom error details to Dart side.
final class PigeonError: Error {
let code: String
let message: String?
let details: Sendable?
init(code: String, message: String?, details: Sendable?) {
self.code = code
self.message = message
self.details = details
}
var localizedDescription: String {
return
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
}
}
private func wrapResult(_ result: Any?) -> [Any?] { private func wrapResult(_ result: Any?) -> [Any?] {
return [result] return [result]
} }
@@ -64,57 +46,8 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
return value as! T? return value as! T?
} }
enum PermissionStatus: Int {
case granted = 0
case denied = 1
case permanentlyDenied = 2
}
private class PermissionApiPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return PermissionStatus(rawValue: enumResultAsInt)
}
return nil
default:
return super.readValue(ofType: type)
}
}
}
private class PermissionApiPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? PermissionStatus {
super.writeByte(129)
super.writeValue(value.rawValue)
} else {
super.writeValue(value)
}
}
}
private class PermissionApiPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return PermissionApiPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return PermissionApiPigeonCodecWriter(data: data)
}
}
class PermissionApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = PermissionApiPigeonCodec(readerWriter: PermissionApiPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol PermissionApi { protocol PermissionApi {
func isIgnoringBatteryOptimizations() throws -> PermissionStatus
func hasManageMediaPermission() throws -> Bool func hasManageMediaPermission() throws -> Bool
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
@@ -122,23 +55,10 @@ protocol PermissionApi {
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class PermissionApiSetup { class PermissionApiSetup {
static var codec: FlutterStandardMessageCodec { PermissionApiPigeonCodec.shared } static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. /// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") { static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let isIgnoringBatteryOptimizationsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
isIgnoringBatteryOptimizationsChannel.setMessageHandler { _, reply in
do {
let result = try api.isIgnoringBatteryOptimizations()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
isIgnoringBatteryOptimizationsChannel.setMessageHandler(nil)
}
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api { if let api = api {
hasManageMediaPermissionChannel.setMessageHandler { _, reply in hasManageMediaPermissionChannel.setMessageHandler { _, reply in
@@ -1,10 +1,6 @@
import Foundation import Foundation
class PermissionApiImpl: PermissionApi { class PermissionApiImpl: PermissionApi {
func isIgnoringBatteryOptimizations() throws -> PermissionStatus {
return PermissionStatus.granted;
}
func hasManageMediaPermission() throws -> Bool { func hasManageMediaPermission() throws -> Bool {
return false return false
} }
+42 -58
View File
@@ -526,17 +526,16 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi { protocol NativeSyncApi {
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void) func shouldFullSync() throws -> Bool
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void) func getMediaChanges() throws -> SyncDelta
func checkpointSync() throws func checkpointSync() throws
func clearSyncCheckpoint() throws func clearSyncCheckpoint() throws
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void) func getAssetIdsForAlbum(albumId: String) throws -> [String]
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void) func getAlbums() throws -> [PlatformAlbum]
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void) func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws func cancelHashing() throws
func cancelSync() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]] func getTrashedAssets() throws -> [String: [PlatformAsset]]
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
@@ -556,28 +555,26 @@ class NativeSyncApiSetup {
let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api { if let api = api {
shouldFullSyncChannel.setMessageHandler { _, reply in shouldFullSyncChannel.setMessageHandler { _, reply in
api.shouldFullSync { result in do {
switch result { let result = try api.shouldFullSync()
case .success(let res): reply(wrapResult(result))
reply(wrapResult(res)) } catch {
case .failure(let error): reply(wrapError(error))
reply(wrapError(error))
}
} }
} }
} else { } else {
shouldFullSyncChannel.setMessageHandler(nil) shouldFullSyncChannel.setMessageHandler(nil)
} }
let getMediaChangesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let getMediaChangesChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api { if let api = api {
getMediaChangesChannel.setMessageHandler { _, reply in getMediaChangesChannel.setMessageHandler { _, reply in
api.getMediaChanges { result in do {
switch result { let result = try api.getMediaChanges()
case .success(let res): reply(wrapResult(result))
reply(wrapResult(res)) } catch {
case .failure(let error): reply(wrapError(error))
reply(wrapError(error))
}
} }
} }
} else { } else {
@@ -609,33 +606,33 @@ class NativeSyncApiSetup {
} else { } else {
clearSyncCheckpointChannel.setMessageHandler(nil) clearSyncCheckpointChannel.setMessageHandler(nil)
} }
let getAssetIdsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let getAssetIdsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api { if let api = api {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?] let args = message as! [Any?]
let albumIdArg = args[0] as! String let albumIdArg = args[0] as! String
api.getAssetIdsForAlbum(albumId: albumIdArg) { result in do {
switch result { let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
case .success(let res): reply(wrapResult(result))
reply(wrapResult(res)) } catch {
case .failure(let error): reply(wrapError(error))
reply(wrapError(error))
}
} }
} }
} else { } else {
getAssetIdsForAlbumChannel.setMessageHandler(nil) getAssetIdsForAlbumChannel.setMessageHandler(nil)
} }
let getAlbumsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let getAlbumsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api { if let api = api {
getAlbumsChannel.setMessageHandler { _, reply in getAlbumsChannel.setMessageHandler { _, reply in
api.getAlbums { result in do {
switch result { let result = try api.getAlbums()
case .success(let res): reply(wrapResult(result))
reply(wrapResult(res)) } catch {
case .failure(let error): reply(wrapError(error))
reply(wrapError(error))
}
} }
} }
} else { } else {
@@ -659,19 +656,19 @@ class NativeSyncApiSetup {
} else { } else {
getAssetsCountSinceChannel.setMessageHandler(nil) getAssetsCountSinceChannel.setMessageHandler(nil)
} }
let getAssetsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let getAssetsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api { if let api = api {
getAssetsForAlbumChannel.setMessageHandler { message, reply in getAssetsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?] let args = message as! [Any?]
let albumIdArg = args[0] as! String let albumIdArg = args[0] as! String
let updatedTimeCondArg: Int64? = nilOrValue(args[1]) let updatedTimeCondArg: Int64? = nilOrValue(args[1])
api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) { result in do {
switch result { let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
case .success(let res): reply(wrapResult(result))
reply(wrapResult(res)) } catch {
case .failure(let error): reply(wrapError(error))
reply(wrapError(error))
}
} }
} }
} else { } else {
@@ -710,19 +707,6 @@ class NativeSyncApiSetup {
} else { } else {
cancelHashingChannel.setMessageHandler(nil) cancelHashingChannel.setMessageHandler(nil)
} }
let cancelSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelSyncChannel.setMessageHandler { _, reply in
do {
try api.cancelSync()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelSyncChannel.setMessageHandler(nil)
}
let getTrashedAssetsChannel = taskQueue == nil let getTrashedAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+45 -102
View File
@@ -39,9 +39,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
private static let hashCancelledCode = "HASH_CANCELLED" private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil)) private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
private var syncTask: Task<Void?, Error>?
private static let syncCancelledCode = "SYNC_CANCELLED"
private static let syncCancelled = PigeonError(code: syncCancelledCode, message: "Sync cancelled", details: nil)
init(with defaults: UserDefaults = .standard) { init(with defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
@@ -74,11 +71,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
} }
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void) { func shouldFullSync() -> Bool {
runSync(completion) { $0.shouldFullSync() }
}
private func shouldFullSync() -> Bool {
guard #available(iOS 16, *), guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized, PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
let storedToken = getChangeToken() else { let storedToken = getChangeToken() else {
@@ -94,17 +87,12 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return false return false
} }
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void) { func getAlbums() throws -> [PlatformAlbum] {
runSync(completion) { try $0.getAlbums() }
}
private func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = [] var albums: [PlatformAlbum] = []
for type in albumTypes { albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count { for i in 0..<collections.count {
try Task.checkCancellation()
let album = collections.object(at: i) let album = collections.object(at: i)
// Ignore recovered album // Ignore recovered album
@@ -138,11 +126,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return albums.sorted { $0.id < $1.id } return albums.sorted { $0.id < $1.id }
} }
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void) { func getMediaChanges() throws -> SyncDelta {
runSync(completion) { try $0.getMediaChanges() }
}
private func getMediaChanges() throws -> SyncDelta {
guard #available(iOS 16, *) else { guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
} }
@@ -162,49 +146,51 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
} }
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) do {
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = []
for change in changes {
try Task.checkCancellation()
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) var updatedAssets: Set<AssetWrapper> = []
deletedAssets.formUnion(details.deletedLocalIdentifiers) var deletedAssets: Set<String> = []
if (updated.isEmpty) { continue } for change in changes {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count {
let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
let predicate = PlatformAsset( deletedAssets.formUnion(details.deletedLocalIdentifiers)
id: asset.localIdentifier,
name: "", if (updated.isEmpty) { continue }
type: 0,
durationMs: 0, let options = PHFetchOptions()
orientation: 0, options.includeHiddenAssets = false
isFavorite: false, let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
playbackStyle: .unknown for i in 0..<result.count {
) let asset = result.object(at: i)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue // Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(
id: asset.localIdentifier,
name: "",
type: 0,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: .unknown
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
}
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
} }
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
} }
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
} }
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
} }
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] { private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
guard !assets.isEmpty else { guard !assets.isEmpty else {
return [:] return [:]
@@ -227,11 +213,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return albumAssets return albumAssets
} }
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void) { func getAssetIdsForAlbum(albumId: String) throws -> [String] {
runSync(completion) { try $0.getAssetIdsForAlbum(albumId: albumId) }
}
private func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else { guard let album = collections.firstObject else {
return [] return []
@@ -241,14 +223,9 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
let options = PHFetchOptions() let options = PHFetchOptions()
options.includeHiddenAssets = false options.includeHiddenAssets = false
let assets = getAssetsFromAlbum(in: album, options: options) let assets = getAssetsFromAlbum(in: album, options: options)
assets.enumerateObjects { (asset, _, stop) in assets.enumerateObjects { (asset, _, _) in
if Task.isCancelled {
stop.pointee = true
return
}
ids.append(asset.localIdentifier) ids.append(asset.localIdentifier)
} }
try Task.checkCancellation()
return ids return ids
} }
@@ -266,11 +243,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return Int64(assets.count) return Int64(assets.count)
} }
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void) { func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
runSync(completion) { try $0.getAssetsForAlbum(albumId: albumId, updatedTimeCond: updatedTimeCond) }
}
private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else { guard let album = collections.firstObject else {
return [] return []
@@ -289,14 +262,9 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
} }
var assets: [PlatformAsset] = [] var assets: [PlatformAsset] = []
result.enumerateObjects { (asset, _, stop) in result.enumerateObjects { (asset, _, _) in
if Task.isCancelled {
stop.pointee = true
return
}
assets.append(asset.toPlatformAsset()) assets.append(asset.toPlatformAsset())
} }
try Task.checkCancellation()
return assets return assets
} }
@@ -356,31 +324,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
hashTask = nil hashTask = nil
} }
func cancelSync() {
syncTask?.cancel()
syncTask = nil
}
private func runSync<T>(
_ completion: @escaping (Result<T, Error>) -> Void,
_ work: @escaping (NativeSyncApiImpl) throws -> T
) {
syncTask?.cancel()
syncTask = Task { [weak self] in
guard let self else { return nil }
let result: Result<T, Error>
do {
result = .success(try work(self))
} catch is CancellationError {
result = .failure(Self.syncCancelled)
} catch {
result = .failure(error)
}
self.completeWhenActive(for: completion, with: result)
return nil
}
}
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? { private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef { class RequestRef {
var id: PHAssetResourceDataRequestID? var id: PHAssetResourceDataRequestID?
@@ -15,7 +15,6 @@ import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/option.dart';
const defaultConfig = AppConfig(); const defaultConfig = AppConfig();
@@ -131,8 +130,6 @@ class AppConfig {
.mapIncludeArchived => map.includeArchived, .mapIncludeArchived => map.includeArchived,
.mapThemeMode => map.themeMode, .mapThemeMode => map.themeMode,
.mapWithPartners => map.withPartners, .mapWithPartners => map.withPartners,
.mapCustomFrom => map.customFrom,
.mapCustomTo => map.customTo,
.cleanupKeepFavorites => cleanup.keepFavorites, .cleanupKeepFavorites => cleanup.keepFavorites,
.cleanupKeepMediaType => cleanup.keepMediaType, .cleanupKeepMediaType => cleanup.keepMediaType,
.cleanupKeepAlbumIds => cleanup.keepAlbumIds, .cleanupKeepAlbumIds => cleanup.keepAlbumIds,
@@ -184,8 +181,6 @@ class AppConfig {
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)), .mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)), .mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)), .mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: value as Option<DateTime>)),
.mapCustomTo => copyWith(map: map.copyWith(customTo: value as Option<DateTime>)),
.cleanupKeepFavorites => copyWith(cleanup: cleanup.copyWith(keepFavorites: value as bool)), .cleanupKeepFavorites => copyWith(cleanup: cleanup.copyWith(keepFavorites: value as bool)),
.cleanupKeepMediaType => copyWith(cleanup: cleanup.copyWith(keepMediaType: value as AssetKeepType)), .cleanupKeepMediaType => copyWith(cleanup: cleanup.copyWith(keepMediaType: value as AssetKeepType)),
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)), .cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/option.dart';
class MapConfig { class MapConfig {
final int relativeDays; final int relativeDays;
@@ -7,8 +6,6 @@ class MapConfig {
final bool includeArchived; final bool includeArchived;
final ThemeMode themeMode; final ThemeMode themeMode;
final bool withPartners; final bool withPartners;
final Option<DateTime> customFrom;
final Option<DateTime> customTo;
const MapConfig({ const MapConfig({
this.relativeDays = 0, this.relativeDays = 0,
@@ -16,8 +13,6 @@ class MapConfig {
this.includeArchived = false, this.includeArchived = false,
this.themeMode = ThemeMode.system, this.themeMode = ThemeMode.system,
this.withPartners = false, this.withPartners = false,
this.customFrom = const Option.none(),
this.customTo = const Option.none(),
}); });
MapConfig copyWith({ MapConfig copyWith({
@@ -26,16 +21,12 @@ class MapConfig {
bool? includeArchived, bool? includeArchived,
ThemeMode? themeMode, ThemeMode? themeMode,
bool? withPartners, bool? withPartners,
Option<DateTime>? customFrom,
Option<DateTime>? customTo,
}) => MapConfig( }) => MapConfig(
relativeDays: relativeDays ?? this.relativeDays, relativeDays: relativeDays ?? this.relativeDays,
favoritesOnly: favoritesOnly ?? this.favoritesOnly, favoritesOnly: favoritesOnly ?? this.favoritesOnly,
includeArchived: includeArchived ?? this.includeArchived, includeArchived: includeArchived ?? this.includeArchived,
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,
withPartners: withPartners ?? this.withPartners, withPartners: withPartners ?? this.withPartners,
customFrom: customFrom ?? this.customFrom,
customTo: customTo ?? this.customTo,
); );
@override @override
@@ -46,15 +37,12 @@ class MapConfig {
other.favoritesOnly == favoritesOnly && other.favoritesOnly == favoritesOnly &&
other.includeArchived == includeArchived && other.includeArchived == includeArchived &&
other.themeMode == themeMode && other.themeMode == themeMode &&
other.withPartners == withPartners && other.withPartners == withPartners);
other.customFrom == customFrom &&
other.customTo == customTo);
@override @override
int get hashCode => int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners);
Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo);
@override @override
String toString() => String toString() =>
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)'; 'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)';
} }
@@ -6,7 +6,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/option.dart';
enum SettingsKey<T extends Object> { enum SettingsKey<T extends Object> {
// Theme // Theme
@@ -59,8 +58,6 @@ enum SettingsKey<T extends Object> {
mapIncludeArchived<bool>(), mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)), mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(), mapWithPartners<bool>(),
mapCustomFrom<Option<DateTime>>(codec: _OptionCodec(_DateTimeCodec())),
mapCustomTo<Option<DateTime>>(codec: _OptionCodec(_DateTimeCodec())),
// Cleanup // Cleanup
cleanupKeepFavorites<bool>(), cleanupKeepFavorites<bool>(),
@@ -132,30 +129,6 @@ final class _DateTimeCodec extends _SettingsCodec<DateTime> {
DateTime decode(String raw) => DateTime.parse(raw); DateTime decode(String raw) => DateTime.parse(raw);
} }
final class _OptionCodec<T extends Object> extends _SettingsCodec<Option<T>> {
final _SettingsCodec<T> _inner;
const _OptionCodec(this._inner);
@override
String encode(Option<T> value) => switch (value) {
Some(:final value) => _inner.encode(value),
None() => '',
};
@override
Option<T> decode(String raw) {
if (raw.isEmpty) {
return const Option.none();
}
try {
return Option.some(_inner.decode(raw));
} on FormatException {
return const Option.none();
}
}
}
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> { final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
final _SettingsCodec<K> _keyCodec; final _SettingsCodec<K> _keyCodec;
final _SettingsCodec<V> _valueCodec; final _SettingsCodec<V> _valueCodec;
@@ -1,15 +0,0 @@
import 'package:immich_mobile/utils/option.dart';
class TimeRange {
final Option<DateTime> from;
final Option<DateTime> to;
const TimeRange({this.from = const None(), this.to = const None()});
TimeRange copyWith({Option<DateTime>? from, Option<DateTime>? to}) {
return TimeRange(from: from ?? this.from, to: to ?? this.to);
}
TimeRange clearFrom() => TimeRange(to: to);
TimeRange clearTo() => TimeRange(from: from);
}
@@ -113,35 +113,9 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@override @override
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async { Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s'); final hashTimeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
final sw = Stopwatch()..start(); final backupTimeout = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
try { return _backgroundLoop(hashTimeout: hashTimeout, backupTimeout: backupTimeout, debugLabel: 'iOS background upload');
final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
final sync = _ref?.read(backgroundSyncProvider);
if (sync == null) {
return;
}
// Run sync local, sync remote, hash and backup concurrently so the bg
// refresh task (20s budget) can make progress on all four instead of
// racing them sequentially. Phases are independent at the data layer:
// hash and handle_backup read drift state and tolerate stale reads
// (server-side dedup catches the rare race). The single budget caps the
// whole batch; no phase needs its own timeout.
final all = Future.wait<dynamic>([sync.syncLocal(), sync.syncRemote(), sync.hashAssets(), _handleBackup()]);
if (budget != null) {
await all.timeout(budget, onTimeout: () => <dynamic>[]);
} else {
await all;
}
} catch (error, stack) {
_logger.severe("Failed to complete iOS background upload", error, stack);
} finally {
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
await _cleanup();
}
} }
Future<void> _backgroundLoop({ Future<void> _backgroundLoop({
@@ -214,14 +188,20 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (!_cancellationToken.isCompleted) { if (!_cancellationToken.isCompleted) {
_cancellationToken.complete(); _cancellationToken.complete();
} }
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManagerPatch.dispose().catchError((_) async {
// Discard any errors on the dispose call
return;
}),
LogService.I.dispose(),
Store.dispose(),
// Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it. backgroundSyncManager?.cancel(),
await Future.wait([ _drift.optimize(allTables: true),
if (backgroundSyncManager != null) backgroundSyncManager.cancel(), ];
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
]); await Future.wait(cleanupFutures.nonNulls);
await workerManagerPatch.dispose().catchError((_) async {});
await Future.wait([LogService.I.dispose(), Store.dispose(), _drift.optimize(allTables: true)]);
await _drift.close(); await _drift.close();
await _driftLogger.close(); await _driftLogger.close();
+4 -10
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.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/album/local_album.model.dart';
@@ -19,7 +17,7 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final Completer<void>? _cancellation; final bool Function()? _cancelChecker;
final _log = Logger('HashService'); final _log = Logger('HashService');
HashService({ HashService({
@@ -27,15 +25,11 @@ class HashService {
required this._localAssetRepository, required this._localAssetRepository,
required this._trashedLocalAssetRepository, required this._trashedLocalAssetRepository,
required this._nativeSyncApi, required this._nativeSyncApi,
this._cancellation, this._cancelChecker,
int? batchSize, int? batchSize,
}) : _batchSize = batchSize ?? kBatchHashFileLimit { }) : _batchSize = batchSize ?? kBatchHashFileLimit;
// Stop the in-flight native hash call promptly on cancellation; the loops
// below also observe [isCancelled] to bail between batches.
_cancellation?.future.then((_) => _nativeSyncApi.cancelHashing().onError(_log.warning));
}
bool get isCancelled => _cancellation?.isCompleted ?? false; bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async { Future<void> hashAssets() async {
_log.info("Starting hashing of assets"); _log.info("Starting hashing of assets");
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
@@ -18,8 +17,6 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
const String _kSyncCancelledCode = "SYNC_CANCELLED";
class LocalSyncService { class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository; final DriftLocalAlbumRepository _localAlbumRepository;
// ignore: unused_field // ignore: unused_field
@@ -28,7 +25,6 @@ class LocalSyncService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository; final IPermissionRepository _permissionRepository;
final Completer<void>? _cancellation;
final Logger _log = Logger("DeviceSyncService"); final Logger _log = Logger("DeviceSyncService");
LocalSyncService({ LocalSyncService({
@@ -38,12 +34,7 @@ class LocalSyncService {
required this._trashedLocalAssetRepository, required this._trashedLocalAssetRepository,
required this._assetMediaRepository, required this._assetMediaRepository,
required this._permissionRepository, required this._permissionRepository,
this._cancellation, });
}) {
_cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
}
bool get _isCancelled => _cancellation?.isCompleted ?? false;
Future<void> sync({bool full = false}) async { Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
@@ -90,10 +81,6 @@ class LocalSyncService {
// detect album deletions from the native side // detect album deletions from the native side
if (CurrentPlatform.isAndroid) { if (CurrentPlatform.isAndroid) {
for (final album in dbAlbums) { for (final album in dbAlbums) {
if (_isCancelled) {
_log.warning("Local sync cancelled. Stopped processing albums.");
return;
}
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id); final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds); await _localAlbumRepository.syncDeletes(album.id, deviceIds);
} }
@@ -104,10 +91,6 @@ class LocalSyncService {
// does not include changes for cloud albums. // does not include changes for cloud albums.
final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums(); final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums();
for (final album in cloudAlbums) { for (final album in cloudAlbums) {
if (_isCancelled) {
_log.warning("Local sync cancelled. Stopped processing cloud albums.");
return;
}
final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id); final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id);
if (dbAlbum == null) { if (dbAlbum == null) {
_log.warning("Cloud album ${album.name} not found in local database. Skipping sync."); _log.warning("Cloud album ${album.name} not found in local database. Skipping sync.");
@@ -119,12 +102,6 @@ class LocalSyncService {
await _mapIosCloudIds(newAssets); await _mapIosCloudIds(newAssets);
} }
await _nativeSyncApi.checkpointSync(); await _nativeSyncApi.checkpointSync();
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Local sync cancelled");
} else {
_log.severe("Error performing device sync", e, s);
}
} catch (e, s) { } catch (e, s) {
_log.severe("Error performing device sync", e, s); _log.severe("Error performing device sync", e, s);
} finally { } finally {
@@ -152,21 +129,12 @@ class LocalSyncService {
await _nativeSyncApi.checkpointSync(); await _nativeSyncApi.checkpointSync();
stopwatch.stop(); stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms"); _log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Full device sync cancelled");
} else {
_log.severe("Error performing full device sync", e, s);
}
} catch (e, s) { } catch (e, s) {
_log.severe("Error performing full device sync", e, s); _log.severe("Error performing full device sync", e, s);
} }
} }
Future<void> addAlbum(LocalAlbum album) async { Future<void> addAlbum(LocalAlbum album) async {
if (_isCancelled) {
return;
}
try { try {
_log.fine("Adding device album ${album.name}"); _log.fine("Adding device album ${album.name}");
@@ -194,9 +162,6 @@ class LocalSyncService {
// The deviceAlbum is ignored since we are going to refresh it anyways // The deviceAlbum is ignored since we are going to refresh it anyways
FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async { FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
if (_isCancelled) {
return false;
}
try { try {
_log.fine("Syncing device album ${dbAlbum.name}"); _log.fine("Syncing device album ${dbAlbum.name}");
+3 -9
View File
@@ -112,16 +112,10 @@ class LogService {
return _flushBuffer(); return _flushBuffer();
} }
Future<void> dispose() async { Future<void> dispose() {
_flushTimer?.cancel(); _flushTimer?.cancel();
_flushTimer = null; _logSubscription.cancel();
await _logSubscription.cancel(); return _flushBuffer();
await _flushBuffer();
// Allow a subsequent init() (e.g. when a worker isolate is reused) to
// create a fresh instance instead of returning this disposed one.
if (identical(_instance, this)) {
_instance = null;
}
} }
Future<void> _flushBuffer() async { Future<void> _flushBuffer() async {
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:openapi/api.dart' show Optional;
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -138,7 +137,7 @@ class RemoteAlbumService {
Future<RemoteAlbum> updateAlbum( Future<RemoteAlbum> updateAlbum(
String albumId, { String albumId, {
String? name, String? name,
Optional<String?> description = const Optional.absent(), String? description,
String? thumbnailAssetId, String? thumbnailAssetId,
bool? isActivityEnabled, bool? isActivityEnabled,
AlbumAssetOrder? order, AlbumAssetOrder? order,
@@ -54,13 +54,7 @@ class StoreService {
/// Disposes the store and cancels the subscription. To reuse the store call init() again /// Disposes the store and cancels the subscription. To reuse the store call init() again
Future<void> dispose() async { Future<void> dispose() async {
await _storeUpdateSubscription?.cancel(); await _storeUpdateSubscription?.cancel();
_storeUpdateSubscription = null;
_cache.clear(); _cache.clear();
// Allow a subsequent init() (e.g. when a worker isolate is reused) to
// create a fresh instance instead of returning this disposed one.
if (identical(_instance, this)) {
_instance = null;
}
} }
/// Returns the cached value for [key], or `null` /// Returns the cached value for [key], or `null`
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
@@ -7,7 +5,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
@@ -19,7 +16,6 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(remoteAlbumRepository), ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider), ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider), ref.watch(storeServiceProvider),
cancellation: ref.watch(cancellationProvider),
), ),
); );
@@ -28,15 +24,13 @@ class SyncLinkedAlbumService {
final DriftRemoteAlbumRepository _remoteAlbumRepository; final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository; final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService; final StoreService _storeService;
final Completer<void>? _cancellation;
SyncLinkedAlbumService( SyncLinkedAlbumService(
this._localAlbumRepository, this._localAlbumRepository,
this._remoteAlbumRepository, this._remoteAlbumRepository,
this._albumApiRepository, this._albumApiRepository,
this._storeService, { this._storeService,
this._cancellation, );
});
final _log = Logger("SyncLinkedAlbumService"); final _log = Logger("SyncLinkedAlbumService");
@@ -61,11 +55,7 @@ class SyncLinkedAlbumService {
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId); final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
_log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}"); _log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}");
if (assetIds.isNotEmpty) { if (assetIds.isNotEmpty) {
final album = await _albumApiRepository.addAssets( final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
remoteAlbum.id,
assetIds,
abortTrigger: _cancellation?.future,
);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added); await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
} }
}), }),
@@ -38,7 +38,7 @@ class SyncStreamService {
final IPermissionRepository _permissionRepository; final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository; final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api; final ApiService _api;
final Completer<void>? _cancellation; final bool Function()? _cancelChecker;
SyncStreamService({ SyncStreamService({
required this._syncApiRepository, required this._syncApiRepository,
@@ -49,10 +49,10 @@ class SyncStreamService {
required this._permissionRepository, required this._permissionRepository,
required this._syncMigrationRepository, required this._syncMigrationRepository,
required this._api, required this._api,
this._cancellation, this._cancelChecker,
}); });
bool get isCancelled => _cancellation?.isCompleted ?? false; bool get isCancelled => _cancelChecker?.call() ?? false;
Future<bool> sync() async { Future<bool> sync() async {
_logger.info("Remote sync request for user"); _logger.info("Remote sync request for user");
@@ -80,15 +80,10 @@ class SyncStreamService {
_handleEvents, _handleEvents,
serverVersion: serverSemVer, serverVersion: serverSemVer,
onReset: () => shouldReset = true, onReset: () => shouldReset = true,
abortSignal: _cancellation?.future,
); );
if (shouldReset) { if (shouldReset) {
_logger.info("Resetting sync state as requested by server"); _logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges( await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
_handleEvents,
serverVersion: serverSemVer,
abortSignal: _cancellation?.future,
);
} }
previousLength = migrations.length; previousLength = migrations.length;
@@ -323,7 +318,7 @@ class SyncStreamService {
} }
Future<void> handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) async { Future<void> handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty || isCancelled) { if (batchData.isEmpty) {
return; return;
} }
@@ -366,7 +361,7 @@ class SyncStreamService {
} }
Future<void> handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) async { Future<void> handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) async {
if (batchData.isEmpty || isCancelled) { if (batchData.isEmpty) {
return; return;
} }
@@ -409,9 +404,6 @@ class SyncStreamService {
} }
Future<void> handleWsAssetEditReadyV1(dynamic data) async { Future<void> handleWsAssetEditReadyV1(dynamic data) async {
if (isCancelled) {
return;
}
_logger.info('Processing AssetEditReadyV1 event'); _logger.info('Processing AssetEditReadyV1 event');
try { try {
@@ -452,9 +444,6 @@ class SyncStreamService {
} }
Future<void> handleWsAssetEditReadyV2(dynamic data) async { Future<void> handleWsAssetEditReadyV2(dynamic data) async {
if (isCancelled) {
return;
}
_logger.info('Processing AssetEditReadyV2 event'); _logger.info('Processing AssetEditReadyV2 event');
try { try {
+41 -15
View File
@@ -50,28 +50,54 @@ class BackgroundSyncManager {
}); });
Future<void> cancel() async { Future<void> cancel() async {
final tasks = [ final futures = <Future>[];
_syncTask,
_syncWebsocketTask, if (_syncTask != null) {
_cloudIdSyncTask, futures.add(_syncTask!.future);
_linkedAlbumSyncTask,
_deviceAlbumSyncTask,
_hashTask,
];
final futures = [
for (final task in tasks)
if (task != null) task.future,
];
for (final task in tasks) {
task?.cancel();
} }
_syncTask?.cancel();
_syncTask = null; _syncTask = null;
if (_syncWebsocketTask != null) {
futures.add(_syncWebsocketTask!.future);
}
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null; _syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null; _cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future);
}
_linkedAlbumSyncTask?.cancel();
_linkedAlbumSyncTask = null; _linkedAlbumSyncTask = null;
_deviceAlbumSyncTask = null;
try {
await Future.wait(futures);
} on CanceledError {
// Ignore cancellation errors
}
}
Future<void> cancelLocal() async {
final futures = <Future>[];
if (_hashTask != null) {
futures.add(_hashTask!.future);
}
_hashTask?.cancel();
_hashTask = null; _hashTask = null;
if (_deviceAlbumSyncTask != null) {
futures.add(_deviceAlbumSyncTask!.future);
}
_deviceAlbumSyncTask?.cancel();
_deviceAlbumSyncTask = null;
try { try {
await Future.wait(futures); await Future.wait(futures);
} on CanceledError { } on CanceledError {
+7 -31
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
@@ -11,7 +9,6 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -54,10 +51,9 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
} }
final assetApi = ref.read(apiServiceProvider).assetsApi; final assetApi = ref.read(apiServiceProvider).assetsApi;
final cancellation = ref.read(cancellationProvider);
// Process cloud IDs in paginated batches // Process cloud IDs in paginated batches
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger, cancellation); await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
} }
Future<void> _processCloudIdMappingsInBatches( Future<void> _processCloudIdMappingsInBatches(
@@ -66,17 +62,12 @@ Future<void> _processCloudIdMappingsInBatches(
AssetsApi assetsApi, AssetsApi assetsApi,
bool canBulkUpdate, bool canBulkUpdate,
Logger logger, Logger logger,
Completer<void> cancellation,
) async { ) async {
const pageSize = 20000; const pageSize = 20000;
String? lastLocalId; String? lastLocalId;
final seenRemoteAssetIds = <String>{}; final seenRemoteAssetIds = <String>{};
while (true) { while (true) {
if (cancellation.isCompleted) {
logger.warning('Cloud ID migration cancelled. Stopping batch processing.');
break;
}
final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId); final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
if (mappings.isEmpty) { if (mappings.isEmpty) {
break; break;
@@ -107,9 +98,9 @@ Future<void> _processCloudIdMappingsInBatches(
if (items.isNotEmpty) { if (items.isNotEmpty) {
if (canBulkUpdate) { if (canBulkUpdate) {
await _bulkUpdateCloudIds(assetsApi, items, cancellation.future); await _bulkUpdateCloudIds(assetsApi, items);
} else { } else {
await _sequentialUpdateCloudIds(assetsApi, items, cancellation); await _sequentialUpdateCloudIds(assetsApi, items);
} }
} }
@@ -120,35 +111,20 @@ Future<void> _processCloudIdMappingsInBatches(
} }
} }
Future<void> _sequentialUpdateCloudIds( Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
AssetsApi assetsApi,
List<AssetMetadataBulkUpsertItemDto> items,
Completer<void> cancellation,
) async {
for (final item in items) { for (final item in items) {
if (cancellation.isCompleted) {
break;
}
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value); final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
try { try {
await assetsApi.updateAssetMetadata( await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
item.assetId,
AssetMetadataUpsertDto(items: [upsertItem]),
abortTrigger: cancellation.future,
);
} catch (error, stack) { } catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack); Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack);
} }
} }
} }
Future<void> _bulkUpdateCloudIds( Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
AssetsApi assetsApi,
List<AssetMetadataBulkUpsertItemDto> items,
Future<void> abortTrigger,
) async {
try { try {
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items), abortTrigger: abortTrigger); await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
} catch (error, stack) { } catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack); Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
} }
+5 -5
View File
@@ -18,11 +18,11 @@ extension DTOToAsset on api.AssetResponseDto {
height: height?.toInt(), height: height?.toInt(),
width: width?.toInt(), width: width?.toInt(),
isFavorite: isFavorite, isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId.orElse(null), livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash, thumbHash: thumbhash,
localId: null, localId: null,
type: type.toAssetType(), type: type.toAssetType(),
stackId: stack.orElse(null)?.id, stackId: stack?.id,
isEdited: isEdited, isEdited: isEdited,
); );
} }
@@ -41,13 +41,13 @@ extension DTOToAsset on api.AssetResponseDto {
height: height?.toInt(), height: height?.toInt(),
width: width?.toInt(), width: width?.toInt(),
isFavorite: isFavorite, isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId.orElse(null), livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash, thumbHash: thumbhash,
localId: null, localId: null,
type: type.toAssetType(), type: type.toAssetType(),
stackId: stack.orElse(null)?.id, stackId: stack?.id,
isEdited: isEdited, isEdited: isEdited,
exifInfo: exifInfo.orElse(null) != null ? ExifDtoConverter.fromDto(exifInfo.orElse(null)!) : const ExifInfo(), exifInfo: exifInfo != null ? ExifDtoConverter.fromDto(exifInfo!) : const ExifInfo(),
); );
} }
} }
@@ -6,7 +6,6 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity(); const LocalAssetEntity();
@@ -1348,7 +1348,3 @@ i0.Index get idxLocalAssetCloudId => i0.Index(
'idx_local_asset_cloud_id', 'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)', 'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
); );
i0.Index get idxLocalAssetCreatedAt => i0.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
@@ -98,7 +98,7 @@ class Drift extends $Drift {
} }
@override @override
int get schemaVersion => 28; int get schemaVersion => 27;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -279,9 +279,6 @@ class Drift extends $Drift {
from26To27: (m, v27) async { from26To27: (m, v27) async {
await customStatement('ALTER TABLE metadata RENAME TO settings'); await customStatement('ALTER TABLE metadata RENAME TO settings');
}, },
from27To28: (m, v28) async {
await m.createIndex(v28.idxLocalAssetCreatedAt);
},
), ),
); );
@@ -112,7 +112,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i7.idxLocalAlbumAssetAlbumAsset, i7.idxLocalAlbumAssetAlbumAsset,
i4.idxLocalAssetChecksum, i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId, i4.idxLocalAssetCloudId,
i4.idxLocalAssetCreatedAt,
i3.idxStackPrimaryAssetId, i3.idxStackPrimaryAssetId,
i2.uQRemoteAssetsOwnerChecksum, i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum, i2.uQRemoteAssetsOwnerLibraryChecksum,
@@ -14083,554 +14083,6 @@ final class Schema27 extends i0.VersionedSchema {
); );
} }
final class Schema28 extends i0.VersionedSchema {
Schema28({required super.database}) : super(version: 28);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_211, _column_115],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
}
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -14658,7 +14110,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25, required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26, required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27, required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -14792,11 +14243,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from26To27(migrator, schema); await from26To27(migrator, schema);
return 27; return 27;
case 27:
final schema = Schema28(database: database);
final migrator = i1.Migrator(database, schema);
await from27To28(migrator, schema);
return 28;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -14830,7 +14276,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25, required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26, required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27, required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -14859,6 +14304,5 @@ i1.OnUpgrade stepByStep({
from24To25: from24To25, from24To25: from24To25,
from25To26: from25To26, from25To26: from25To26,
from26To27: from26To27, from26To27: from26To27,
from27To28: from27To28,
), ),
); );
@@ -241,7 +241,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)), innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
]) ])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAssetEntity.checksum.isNull()) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAssetEntity.checksum.isNull())
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]); ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
} }
@@ -27,18 +27,7 @@ class DriftMapRepository extends DriftDatabaseRepository {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true); condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
} }
final timeRange = options.timeRange; if (options.relativeDays != 0) {
final hasCustomRange = timeRange.from.isSome || timeRange.to.isSome;
if (hasCustomRange) {
timeRange.from.ifPresent((from) {
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from);
});
timeRange.to.ifPresent((to) {
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to);
});
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate); condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
} }
@@ -20,64 +20,50 @@ class SearchApiRepository extends ApiRepository {
(filter.assetId != null && filter.assetId!.isNotEmpty)) { (filter.assetId != null && filter.assetId!.isNotEmpty)) {
return _api.searchSmart( return _api.searchSmart(
SmartSearchDto( SmartSearchDto(
query: filter.context == null ? const Optional.absent() : Optional.present(filter.context!), query: filter.context,
queryAssetId: filter.assetId == null ? const Optional.absent() : Optional.present(filter.assetId!), queryAssetId: filter.assetId,
language: filter.language == null ? const Optional.absent() : Optional.present(filter.language!), language: filter.language,
country: filter.location.country == null country: filter.location.country,
? const Optional.absent() state: filter.location.state,
: Optional.present(filter.location.country!), city: filter.location.city,
state: filter.location.state == null ? const Optional.absent() : Optional.present(filter.location.state!), make: filter.camera.make,
city: filter.location.city == null ? const Optional.absent() : Optional.present(filter.location.city!), model: filter.camera.model,
make: filter.camera.make == null ? const Optional.absent() : Optional.present(filter.camera.make!), takenAfter: filter.date.takenAfter,
model: filter.camera.model == null ? const Optional.absent() : Optional.present(filter.camera.model!), takenBefore: filter.date.takenBefore,
takenAfter: filter.date.takenAfter == null visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
? const Optional.absent() rating: filter.rating.rating,
: Optional.present(filter.date.takenAfter!), isFavorite: filter.display.isFavorite ? true : null,
takenBefore: filter.date.takenBefore == null isNotInAlbum: filter.display.isNotInAlbum ? true : null,
? const Optional.absent() personIds: filter.people.map((e) => e.id).toList(),
: Optional.present(filter.date.takenBefore!), tagIds: filter.tagIds,
visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline), type: type,
rating: filter.rating.rating == null ? const Optional.absent() : Optional.present(filter.rating.rating!), page: page,
isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(), size: 100,
isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(),
personIds: Optional.present(filter.people.map((e) => e.id).toList()),
tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds!),
type: type == null ? const Optional.absent() : Optional.present(type),
page: Optional.present(page),
size: const Optional.present(100),
), ),
); );
} }
return _api.searchAssets( return _api.searchAssets(
MetadataSearchDto( MetadataSearchDto(
originalFileName: filter.filename != null && filter.filename!.isNotEmpty originalFileName: filter.filename != null && filter.filename!.isNotEmpty ? filter.filename : null,
? Optional.present(filter.filename!) country: filter.location.country,
: const Optional.absent(), description: filter.description != null && filter.description!.isNotEmpty ? filter.description : null,
country: filter.location.country == null ? const Optional.absent() : Optional.present(filter.location.country!), ocr: filter.ocr != null && filter.ocr!.isNotEmpty ? filter.ocr : null,
description: filter.description != null && filter.description!.isNotEmpty state: filter.location.state,
? Optional.present(filter.description!) city: filter.location.city,
: const Optional.absent(), make: filter.camera.make,
ocr: filter.ocr != null && filter.ocr!.isNotEmpty ? Optional.present(filter.ocr!) : const Optional.absent(), model: filter.camera.model,
state: filter.location.state == null ? const Optional.absent() : Optional.present(filter.location.state!), takenAfter: filter.date.takenAfter,
city: filter.location.city == null ? const Optional.absent() : Optional.present(filter.location.city!), takenBefore: filter.date.takenBefore,
make: filter.camera.make == null ? const Optional.absent() : Optional.present(filter.camera.make!), visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
model: filter.camera.model == null ? const Optional.absent() : Optional.present(filter.camera.model!), rating: filter.rating.rating,
takenAfter: filter.date.takenAfter == null isFavorite: filter.display.isFavorite ? true : null,
? const Optional.absent() isNotInAlbum: filter.display.isNotInAlbum ? true : null,
: Optional.present(filter.date.takenAfter!), personIds: filter.people.map((e) => e.id).toList(),
takenBefore: filter.date.takenBefore == null tagIds: filter.tagIds,
? const Optional.absent() type: type,
: Optional.present(filter.date.takenBefore!), page: page,
visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline), size: 1000,
rating: filter.rating.rating == null ? const Optional.absent() : Optional.present(filter.rating.rating!),
isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(),
isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(),
personIds: Optional.present(filter.people.map((e) => e.id).toList()),
tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds!),
type: type == null ? const Optional.absent() : Optional.present(type),
page: Optional.present(page),
size: const Optional.present(1000),
), ),
); );
} }
@@ -20,7 +20,7 @@ class SyncApiRepository {
} }
Future<void> deleteSyncAck(List<SyncEntityType> types) { Future<void> deleteSyncAck(List<SyncEntityType> types) {
return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: Optional.present(types))); return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: types));
} }
Future<void> streamChanges( Future<void> streamChanges(
@@ -29,7 +29,6 @@ class SyncApiRepository {
Function()? onReset, Function()? onReset,
int batchSize = kSyncEventBatchSize, int batchSize = kSyncEventBatchSize,
http.Client? httpClient, http.Client? httpClient,
Future<void>? abortSignal,
}) async { }) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final client = httpClient ?? NetworkRepository.client; final client = httpClient ?? NetworkRepository.client;
@@ -37,7 +36,7 @@ class SyncApiRepository {
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
final request = http.AbortableRequest('POST', Uri.parse(endpoint), abortTrigger: abortSignal); final request = http.Request('POST', Uri.parse(endpoint));
request.headers.addAll(headers); request.headers.addAll(headers);
request.body = jsonEncode( request.body = jsonEncode(
SyncStreamDto( SyncStreamDto(
@@ -91,7 +91,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
email: Value(user.email), email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage), hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt), profileChangedAt: Value(user.profileChangedAt),
avatarColor: Value(user.avatarColor.orElse(null)?.toAvatarColor() ?? AvatarColor.primary), avatarColor: Value(user.avatarColor?.toAvatarColor() ?? AvatarColor.primary),
isAdmin: Value(user.isAdmin), isAdmin: Value(user.isAdmin),
pinCode: Value(user.pinCode), pinCode: Value(user.pinCode),
quotaSizeInBytes: Value(user.quotaSizeInBytes ?? 0), quotaSizeInBytes: Value(user.quotaSizeInBytes ?? 0),
@@ -133,7 +133,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
email: Value(user.email), email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage), hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt), profileChangedAt: Value(user.profileChangedAt),
avatarColor: Value(user.avatarColor.orElse(null)?.toAvatarColor() ?? AvatarColor.primary), avatarColor: Value(user.avatarColor?.toAvatarColor() ?? AvatarColor.primary),
); );
batch.insert(_db.userEntity, companion.copyWith(id: Value(user.id)), onConflict: DoUpdate((_) => companion)); batch.insert(_db.userEntity, companion.copyWith(id: Value(user.id)), onConflict: DoUpdate((_) => companion));
@@ -5,7 +5,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
@@ -22,7 +21,6 @@ class TimelineMapOptions {
final bool includeArchived; final bool includeArchived;
final bool withPartners; final bool withPartners;
final int relativeDays; final int relativeDays;
final TimeRange timeRange;
const TimelineMapOptions({ const TimelineMapOptions({
required this.bounds, required this.bounds,
@@ -30,7 +28,6 @@ class TimelineMapOptions {
this.includeArchived = false, this.includeArchived = false,
this.withPartners = false, this.withPartners = false,
this.relativeDays = 0, this.relativeDays = 0,
this.timeRange = const TimeRange(),
}); });
} }
@@ -556,21 +553,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true)); query.where(_db.remoteAssetEntity.isFavorite.equals(true));
} }
final timeRange = options.timeRange; if (options.relativeDays != 0) {
final hasCustomRange = timeRange.from.isSome || timeRange.to.isSome;
if (hasCustomRange) {
timeRange.from.ifPresent((from) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
});
timeRange.to.ifPresent((to) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
});
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
} }
@@ -611,21 +595,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true)); query.where(_db.remoteAssetEntity.isFavorite.equals(true));
} }
final timeRange = options.timeRange; if (options.relativeDays != 0) {
final hasCustomRange = timeRange.from.isSome || timeRange.to.isSome;
if (hasCustomRange) {
timeRange.from.ifPresent((from) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
});
timeRange.to.ifPresent((to) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
});
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
} }
@@ -5,24 +5,24 @@ import 'package:openapi/api.dart';
abstract final class ExifDtoConverter { abstract final class ExifDtoConverter {
static ExifInfo fromDto(ExifResponseDto dto) { static ExifInfo fromDto(ExifResponseDto dto) {
return ExifInfo( return ExifInfo(
fileSize: dto.fileSizeInByte.orElse(null), fileSize: dto.fileSizeInByte,
description: dto.description.orElse(null), description: dto.description,
orientation: dto.orientation.orElse(null), orientation: dto.orientation,
timeZone: dto.timeZone.orElse(null), timeZone: dto.timeZone,
dateTimeOriginal: dto.dateTimeOriginal.orElse(null), dateTimeOriginal: dto.dateTimeOriginal,
isFlipped: isOrientationFlipped(dto.orientation.orElse(null)), isFlipped: isOrientationFlipped(dto.orientation),
latitude: dto.latitude.orElse(null)?.toDouble(), latitude: dto.latitude?.toDouble(),
longitude: dto.longitude.orElse(null)?.toDouble(), longitude: dto.longitude?.toDouble(),
city: dto.city.orElse(null), city: dto.city,
state: dto.state.orElse(null), state: dto.state,
country: dto.country.orElse(null), country: dto.country,
make: dto.make.orElse(null), make: dto.make,
model: dto.model.orElse(null), model: dto.model,
lens: dto.lensModel.orElse(null), lens: dto.lensModel,
f: dto.fNumber.orElse(null)?.toDouble(), f: dto.fNumber?.toDouble(),
mm: dto.focalLength.orElse(null)?.toDouble(), mm: dto.focalLength?.toDouble(),
iso: dto.iso.orElse(null)?.toInt(), iso: dto.iso?.toInt(),
exposureSeconds: exposureTimeToSeconds(dto.exposureTime.orElse(null)), exposureSeconds: exposureTimeToSeconds(dto.exposureTime),
); );
} }
@@ -40,7 +40,7 @@ abstract final class UserConverter {
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
avatarColor: dto.avatarColor.toAvatarColor(), avatarColor: dto.avatarColor.toAvatarColor(),
memoryEnabled: false, memoryEnabled: false,
inTimeline: dto.inTimeline.orElse(null) ?? false, inTimeline: dto.inTimeline ?? false,
isPartnerSharedBy: false, isPartnerSharedBy: false,
isPartnerSharedWith: false, isPartnerSharedWith: false,
profileChangedAt: dto.profileChangedAt, profileChangedAt: dto.profileChangedAt,
-3
View File
@@ -24,7 +24,6 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
@@ -129,7 +128,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
dPrint(() => "[APP STATE] resumed"); dPrint(() => "[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume(); ref.read(appStateProvider.notifier).handleAppResume();
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
break; break;
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
dPrint(() => "[APP STATE] inactive"); dPrint(() => "[APP STATE] inactive");
@@ -235,7 +233,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
} }
}); });
ref.read(viewIntentHandlerProvider).init();
ref.read(shareIntentUploadProvider.notifier).init(); ref.read(shareIntentUploadProvider.notifier).init();
} }
@@ -73,10 +73,10 @@ class SharedLink {
slug = dto.slug, slug = dto.slug,
type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual, type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual,
title = dto.type == SharedLinkType.ALBUM title = dto.type == SharedLinkType.ALBUM
? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE" ? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
: "INDIVIDUAL SHARE", : "INDIVIDUAL SHARE",
thumbAssetId = dto.type == SharedLinkType.ALBUM thumbAssetId = dto.type == SharedLinkType.ALBUM
? dto.album.orElse(null)?.albumThumbnailAssetId ? dto.album?.albumThumbnailAssetId
: dto.assets.isNotEmpty : dto.assets.isNotEmpty
? dto.assets[0].id ? dto.assets[0].id
: null; : null;
@@ -1,35 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:path/path.dart';
extension ViewIntentPayloadX on ViewIntentPayload {
String get fileName {
final resolvedPath = path;
if (resolvedPath != null && resolvedPath.isNotEmpty) {
return basename(resolvedPath);
}
return localAssetId ?? 'view_intent_asset';
}
bool get isImage => mimeType.toLowerCase().startsWith('image/');
bool get isVideo => mimeType.toLowerCase().startsWith('video/');
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
final normalizedMimeType = mimeType.toLowerCase();
if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') {
return AssetPlaybackStyle.imageAnimated;
}
final normalizedPath = path?.toLowerCase();
if (normalizedPath != null && (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp'))) {
return AssetPlaybackStyle.imageAnimated;
}
return AssetPlaybackStyle.image;
}
}
+5 -138
View File
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/generated/translations.g.dart';
@@ -16,16 +15,11 @@ 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/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.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/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage() @RoutePage()
@@ -168,7 +162,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
), ),
), ),
}, },
const _BackupFooter(), TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text("view_details".t(context: context)),
),
], ],
], ],
), ),
@@ -179,137 +177,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
} }
} }
class _BackupFooter extends ConsumerStatefulWidget {
const _BackupFooter();
@override
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
}
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
unawaited(ref.read(batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
}
}
void showPermissionsDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Text(context.t.notification_permission_dialog_content),
actions: [
ImmichTextButton(
labelText: context.t.cancel,
variant: .ghost,
expanded: false,
onPressed: () => ContextHelper(ctx).pop(),
),
ImmichTextButton(
labelText: context.t.settings,
variant: .ghost,
expanded: false,
onPressed: () {
ContextHelper(context).pop();
openAppSettings();
},
),
],
),
);
}
void showBatteryOptimizationInfo() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(context.t.backup_controller_page_background_battery_info_title),
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
actions: [
ImmichTextButton(
labelText: context.t.backup_controller_page_background_battery_info_link,
variant: .ghost,
expanded: false,
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
),
ImmichTextButton(
labelText: context.t.backup_controller_page_background_battery_info_ok,
variant: .ghost,
expanded: false,
onPressed: () => ContextHelper(ctx).pop(),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final isBackupEnabled = ref.watch(appConfigProvider.select((config) => config.backup.enabled));
final notificationStatus = ref.watch(notificationPermissionProvider);
final batteryOptimizationStatus = ref.watch(batteryOptimizationProvider).valueOrNull;
return Column(
children: [
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
if (notificationStatus != PermissionStatus.granted)
TextButton.icon(
iconAlignment: .end,
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.notification_backup_reliability,
textAlign: TextAlign.left,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: () {
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
if (p == PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
});
},
),
if (notificationStatus != PermissionStatus.granted && batteryOptimizationStatus != PermissionStatus.granted)
const Divider(indent: 32, endIndent: 32),
if (batteryOptimizationStatus != PermissionStatus.granted)
TextButton.icon(
iconAlignment: .end,
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.battery_optimization_backup_reliability,
textAlign: TextAlign.left,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: showBatteryOptimizationInfo,
),
],
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text(context.t.view_details),
),
],
);
}
}
class _BackupAlbumSelectionCard extends ConsumerWidget { class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard(); const _BackupAlbumSelectionCard();
@@ -17,7 +17,6 @@ import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/theme/color_scheme.dart'; import 'package:immich_mobile/theme/color_scheme.dart';
@@ -315,7 +314,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final wsProvider = ref.read(websocketProvider.notifier); final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier); final backupProvider = ref.read(driftBackupProvider.notifier);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
unawaited( unawaited(
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then( ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
@@ -330,8 +328,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
backgroundManager.syncRemote().then((success) => syncSuccess = success), backgroundManager.syncRemote().then((success) => syncSuccess = success),
]); ]);
await viewIntentHandler.flushDeferredViewIntent();
if (syncSuccess) { if (syncSuccess) {
await Future.wait([ await Future.wait([
backgroundManager.hashAssets().then((_) { backgroundManager.hashAssets().then((_) {
@@ -11,7 +11,6 @@ import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart'; import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/services/shared_link.service.dart'; import 'package:immich_mobile/services/shared_link.service.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -366,10 +365,11 @@ class SharedLinkEditPage extends HookConsumerWidget {
bool? download; bool? download;
bool? upload; bool? upload;
bool? meta; bool? meta;
var password = const Optional<String?>.absent(); String? desc;
var description = const Optional<String?>.absent(); String? password;
String? slug; String? slug;
var expiry = const Optional<DateTime?>.absent(); DateTime? expiry;
bool? changeExpiry;
if (allowDownload.value != existingLink!.allowDownload) { if (allowDownload.value != existingLink!.allowDownload) {
download = allowDownload.value; download = allowDownload.value;
@@ -383,16 +383,12 @@ class SharedLinkEditPage extends HookConsumerWidget {
meta = showMetadata.value; meta = showMetadata.value;
} }
if (descriptionController.text != (existingLink!.description ?? '')) { if (descriptionController.text != existingLink!.description) {
description = descriptionController.text.isEmpty desc = descriptionController.text;
? const Optional.present(null)
: Optional.present(descriptionController.text);
} }
if (passwordController.text != (existingLink!.password ?? '')) { if (passwordController.text != existingLink!.password) {
password = passwordController.text.isEmpty password = passwordController.text;
? const Optional.present(null)
: Optional.present(passwordController.text);
} }
if (slugController.text != (existingLink!.slug ?? "")) { if (slugController.text != (existingLink!.slug ?? "")) {
@@ -403,7 +399,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
final newExpiry = expiryAfter.value; final newExpiry = expiryAfter.value;
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) { if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
expiry = newExpiry == null ? const Optional.present(null) : Optional.present(newExpiry.toUtc()); expiry = newExpiry;
changeExpiry = true;
} }
await ref await ref
@@ -413,10 +410,11 @@ class SharedLinkEditPage extends HookConsumerWidget {
showMeta: meta, showMeta: meta,
allowDownload: download, allowDownload: download,
allowUpload: upload, allowUpload: upload,
description: description, description: desc,
password: password, password: password,
slug: slug, slug: slug,
expiresAt: expiry, expiresAt: expiry?.toUtc(),
changeExpiry: changeExpiry,
); );
if (!context.mounted) { if (!context.mounted) {
return; return;
-14
View File
@@ -635,20 +635,6 @@ class NativeSyncApi {
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true); _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
} }
Future<void> cancelSync() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async { Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
final pigeonVar_channelName = final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
-27
View File
@@ -26,8 +26,6 @@ Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName,
return replyList.firstOrNull; return replyList.firstOrNull;
} }
enum PermissionStatus { granted, denied, permanentlyDenied }
class _PigeonCodec extends StandardMessageCodec { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@@ -35,9 +33,6 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) { if (value is int) {
buffer.putUint8(4); buffer.putUint8(4);
buffer.putInt64(value); buffer.putInt64(value);
} else if (value is PermissionStatus) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else { } else {
super.writeValue(buffer, value); super.writeValue(buffer, value);
} }
@@ -46,9 +41,6 @@ class _PigeonCodec extends StandardMessageCodec {
@override @override
Object? readValueOfType(int type, ReadBuffer buffer) { Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) { switch (type) {
case 129:
final value = readValue(buffer) as int?;
return value == null ? null : PermissionStatus.values[value];
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);
} }
@@ -68,25 +60,6 @@ class PermissionApi {
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as PermissionStatus;
}
Future<bool> hasManageMediaPermission() async { Future<bool> hasManageMediaPermission() async {
final pigeonVar_channelName = final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
-191
View File
@@ -1,191 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
}
if (a is double && b is double) {
if (a.isNaN && b.isNaN) {
return true;
}
return a == b;
}
if (a is List && b is List) {
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
if (a.length != b.length) {
return false;
}
for (final MapEntry<Object?, Object?> entryA in a.entries) {
bool found = false;
for (final MapEntry<Object?, Object?> entryB in b.entries) {
if (_deepEquals(entryA.key, entryB.key)) {
if (_deepEquals(entryA.value, entryB.value)) {
found = true;
break;
} else {
return false;
}
}
}
if (!found) {
return false;
}
}
return true;
}
return a == b;
}
int _deepHash(Object? value) {
if (value is List) {
return Object.hashAll(value.map(_deepHash));
}
if (value is Map) {
int result = 0;
for (final MapEntry<Object?, Object?> entry in value.entries) {
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
}
return result;
}
if (value is double && value.isNaN) {
// Normalize NaN to a consistent hash.
return 0x7FF8000000000000.hashCode;
}
if (value is double && value == 0.0) {
// Normalize -0.0 to 0.0 so they have the same hash code.
return 0.0.hashCode;
}
return value.hashCode;
}
class ViewIntentPayload {
ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
String? path;
String mimeType;
String? localAssetId;
List<Object?> _toList() {
return <Object?>[path, mimeType, localAssetId];
}
Object encode() {
return _toList();
}
static ViewIntentPayload decode(Object result) {
result as List<Object?>;
return ViewIntentPayload(
path: result[0] as String?,
mimeType: result[1]! as String,
localAssetId: result[2] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! ViewIntentPayload || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(path, other.path) &&
_deepEquals(mimeType, other.mimeType) &&
_deepEquals(localAssetId, other.localAssetId);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is ViewIntentPayload) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
return ViewIntentPayload.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
class ViewIntentHostApi {
/// Constructor for [ViewIntentHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
ViewIntentHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<ViewIntentPayload?> consumeViewIntent() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as ViewIntentPayload?;
}
}
@@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
import 'package:openapi/api.dart' show Optional;
@RoutePage() @RoutePage()
class RemoteAlbumPage extends ConsumerStatefulWidget { class RemoteAlbumPage extends ConsumerStatefulWidget {
@@ -248,13 +247,10 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
try { try {
final newTitle = titleController.text.trim(); final newTitle = titleController.text.trim();
final newDescription = descriptionController.text.trim(); final newDescription = descriptionController.text.trim();
final description = newDescription.isEmpty
? const Optional<String?>.present(null)
: Optional<String?>.present(newDescription);
await ref await ref
.read(remoteAlbumProvider.notifier) .read(remoteAlbumProvider.notifier)
.updateAlbum(widget.album.id, name: newTitle, description: description); .updateAlbum(widget.album.id, name: newTitle, description: newDescription);
if (mounted) { if (mounted) {
Navigator.of( Navigator.of(
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
@@ -11,9 +10,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart'; import 'package:immich_ui/immich_ui.dart';
@@ -30,11 +26,7 @@ class UploadActionButton extends ConsumerWidget {
} }
final isTimeline = source == ActionSource.timeline; final isTimeline = source == ActionSource.timeline;
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
List<LocalAsset>? assets; List<LocalAsset>? assets;
var isUploadDialogOpen = false;
var wasUploadCancelled = false;
Future<void>? uploadDialogFuture;
if (source == ActionSource.timeline) { if (source == ActionSource.timeline) {
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList(); assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
@@ -43,50 +35,22 @@ class UploadActionButton extends ConsumerWidget {
} }
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
} else { } else {
isUploadDialogOpen = true; unawaited(
uploadDialogFuture = showDialog(
showDialog<void>( context: context,
context: context, barrierDismissible: false,
barrierDismissible: false, builder: (dialogContext) => const _UploadProgressDialog(),
builder: (dialogContext) => _UploadProgressDialog( ),
onCancel: () { );
wasUploadCancelled = true;
},
),
).whenComplete(() {
isUploadDialogOpen = false;
});
unawaited(uploadDialogFuture);
} }
var success = false; final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
if (!isTimeline && viewerIntentFilePath != null) {
final viewIntentService = ref.read(viewIntentServiceProvider);
viewIntentService.markUploadActive(viewerIntentFilePath);
var hasError = false;
try {
await ref
.read(foregroundUploadServiceProvider)
.uploadShareIntent(
[File(viewerIntentFilePath)],
onError: (_, _) {
hasError = true;
},
);
} finally {
await viewIntentService.markUploadInactive(viewerIntentFilePath);
}
success = !hasError;
} else {
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
success = result.success;
}
if (!isTimeline && context.mounted && isUploadDialogOpen) { if (!isTimeline && context.mounted) {
Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context, rootNavigator: true).pop();
} }
if (context.mounted && !success && !wasUploadCancelled) { if (context.mounted && !result.success) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'scaffold_body_error_occurred'.t(context: context), msg: 'scaffold_body_error_occurred'.t(context: context),
@@ -109,9 +73,7 @@ class UploadActionButton extends ConsumerWidget {
} }
class _UploadProgressDialog extends ConsumerWidget { class _UploadProgressDialog extends ConsumerWidget {
final VoidCallback onCancel; const _UploadProgressDialog();
const _UploadProgressDialog({required this.onCancel});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -141,8 +103,7 @@ class _UploadProgressDialog extends ConsumerWidget {
onPressed: () { onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete(); ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null; ref.read(manualUploadCancelTokenProvider.notifier).state = null;
onCancel(); Navigator.of(context).pop();
Navigator.of(context, rootNavigator: true).pop();
}, },
labelText: 'cancel'.t(context: context), labelText: 'cancel'.t(context: context),
), ),
@@ -21,7 +21,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -324,16 +323,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required PhotoViewHeroAttributes? heroAttributes, required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent, required bool isCurrent,
required bool isPlayingMotionVideo, required bool isPlayingMotionVideo,
required String? localFilePath,
}) { }) {
final size = context.sizeData; final size = context.sizeData;
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
if (asset.isImage && !isPlayingMotionVideo) { if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView( return PhotoView(
key: Key(asset.heroTag), key: Key(asset.heroTag),
index: widget.index, index: widget.index,
imageProvider: imageProvider, imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes, heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
gaplessPlayback: true, gaplessPlayback: true,
@@ -380,9 +377,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
child: NativeVideoViewer( child: NativeVideoViewer(
key: _NativeVideoViewerKey(asset.heroTag), key: _NativeVideoViewerKey(asset.heroTag),
asset: asset, asset: asset,
localFilePath: localFilePath,
isCurrent: isCurrent, isCurrent: isCurrent,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), image: Image(
image: getFullImageProvider(asset, size: size),
fit: BoxFit.contain,
alignment: Alignment.center,
),
), ),
); );
} }
@@ -393,7 +393,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final asset = _asset; final asset = _asset;
if (asset == null) { if (asset == null) {
@@ -422,8 +421,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_scrollController.snapPosition.snapOffset = _snapOffset; _scrollController.snapPosition.snapOffset = _snapOffset;
} }
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
return Stack( return Stack(
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
@@ -443,7 +440,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
: null, : null,
isCurrent: isCurrent, isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo, isPlayingMotionVideo: isPlayingMotionVideo,
localFilePath: viewIntentFilePath,
), ),
), ),
IgnorePointer( IgnorePointer(
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -20,7 +19,6 @@ import 'package:native_video_player/native_video_player.dart';
class NativeVideoViewer extends ConsumerStatefulWidget { class NativeVideoViewer extends ConsumerStatefulWidget {
final BaseAsset asset; final BaseAsset asset;
final String? localFilePath;
final bool isCurrent; final bool isCurrent;
final bool showControls; final bool showControls;
final Widget image; final Widget image;
@@ -28,7 +26,6 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
const NativeVideoViewer({ const NativeVideoViewer({
super.key, super.key,
required this.asset, required this.asset,
this.localFilePath,
required this.image, required this.image,
this.isCurrent = false, this.isCurrent = false,
this.showControls = true, this.showControls = true,
@@ -109,19 +106,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
} }
try { try {
final localFilePath = widget.localFilePath;
if (localFilePath != null) {
final file = File(localFilePath);
if (!await file.exists()) {
throw Exception('No file found for the video');
}
return VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
}
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id); final file = await StorageRepository().getFileForAsset(id);
@@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:async/async.dart'; import 'package:async/async.dart';
@@ -147,17 +146,10 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
} }
} }
ImageProvider getFullImageProvider( ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
BaseAsset asset, {
Size size = const Size(1080, 1920),
bool edited = true,
String? localFilePath,
}) {
// Create new provider and cache it // Create new provider and cache it
final ImageProvider provider; final ImageProvider provider;
if (localFilePath != null) { if (_shouldUseLocalAsset(asset)) {
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
} else { } else {
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart'; import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
@@ -16,7 +15,6 @@ class MapState {
final bool includeArchived; final bool includeArchived;
final bool withPartners; final bool withPartners;
final int relativeDays; final int relativeDays;
final TimeRange timeRange;
const MapState({ const MapState({
this.themeMode = ThemeMode.system, this.themeMode = ThemeMode.system,
@@ -25,7 +23,6 @@ class MapState {
this.includeArchived = false, this.includeArchived = false,
this.withPartners = false, this.withPartners = false,
this.relativeDays = 0, this.relativeDays = 0,
this.timeRange = const TimeRange(),
}); });
@override @override
@@ -43,7 +40,6 @@ class MapState {
bool? includeArchived, bool? includeArchived,
bool? withPartners, bool? withPartners,
int? relativeDays, int? relativeDays,
TimeRange? timeRange,
}) { }) {
return MapState( return MapState(
bounds: bounds ?? this.bounds, bounds: bounds ?? this.bounds,
@@ -52,7 +48,6 @@ class MapState {
includeArchived: includeArchived ?? this.includeArchived, includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners, withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays, relativeDays: relativeDays ?? this.relativeDays,
timeRange: timeRange ?? this.timeRange,
); );
} }
@@ -62,7 +57,6 @@ class MapState {
includeArchived: includeArchived, includeArchived: includeArchived,
withPartners: withPartners, withPartners: withPartners,
relativeDays: relativeDays, relativeDays: relativeDays,
timeRange: timeRange,
); );
} }
@@ -109,13 +103,6 @@ class MapStateNotifier extends Notifier<MapState> {
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
void setTimeRange(TimeRange range) {
ref.read(settingsProvider).write(.mapCustomFrom, range.from);
ref.read(settingsProvider).write(.mapCustomTo, range.to);
state = state.copyWith(timeRange: range);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
@override @override
MapState build() { MapState build() {
final mapConfig = ref.read(appConfigProvider.select((config) => config.map)); final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
@@ -124,9 +111,8 @@ class MapStateNotifier extends Notifier<MapState> {
onlyFavorites: mapConfig.favoritesOnly, onlyFavorites: mapConfig.favoritesOnly,
includeArchived: mapConfig.includeArchived, includeArchived: mapConfig.includeArchived,
withPartners: mapConfig.withPartners, withPartners: mapConfig.withPartners,
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
relativeDays: mapConfig.relativeDays, relativeDays: mapConfig.relativeDays,
timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo), bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
); );
} }
} }
@@ -1,39 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends ConsumerStatefulWidget { class DriftMapSettingsSheet extends HookConsumerWidget {
const DriftMapSettingsSheet({super.key}); const DriftMapSettingsSheet({super.key});
@override @override
ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState(); Widget build(BuildContext context, WidgetRef ref) {
}
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
late bool useCustomRange;
@override
void initState() {
super.initState();
final mapState = ref.read(mapStateProvider);
final timeRange = mapState.timeRange;
useCustomRange = timeRange.from.isSome || timeRange.to.isSome;
}
@override
Widget build(BuildContext context) {
final mapState = ref.watch(mapStateProvider); final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet( return DraggableScrollableSheet(
expand: false, expand: false,
initialChildSize: useCustomRange ? 0.7 : 0.6, initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView( builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController, controller: scrollController,
child: Card( child: Card(
@@ -65,41 +47,10 @@ class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
selected: mapState.withPartners, selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners), onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
), ),
if (useCustomRange) ...[ MapTimeDropDown(
MapTimeRange( relativeTime: mapState.relativeDays,
timeRange: mapState.timeRange, onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
onChanged: (range) { ),
ref.read(mapStateProvider.notifier).setTimeRange(range);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = false;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text(context.t.remove_custom_date_range),
),
),
] else ...[
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = true;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text(context.t.use_custom_date_range),
),
),
],
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),
@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -1,101 +1,101 @@
import 'dart:io'; import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/share_intent_service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>( final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier( ((ref) => ShareIntentUploadStateNotifier(
ref.watch(appRouterProvider), ref.watch(appRouterProvider),
ref.read(foregroundUploadServiceProvider), ref.read(foregroundUploadServiceProvider),
ref.read(shareIntentServiceProvider), ref.read(shareIntentServiceProvider),
)), )),
); );
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> { class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
final AppRouter router; final AppRouter router;
final ForegroundUploadService _foregroundUploadService; final ForegroundUploadService _foregroundUploadService;
final ShareIntentService _shareIntentService; final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier'); final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]); ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
void init() { void init() {
_shareIntentService.onSharedMedia = onSharedMedia; _shareIntentService.onSharedMedia = onSharedMedia;
_shareIntentService.init(); _shareIntentService.init();
} }
void onSharedMedia(List<ShareIntentAttachment> attachments) { void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute"); router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments(); clearAttachments();
addAttachments(attachments); addAttachments(attachments);
router.push(ShareIntentRoute(attachments: attachments)); router.push(ShareIntentRoute(attachments: attachments));
} }
void addAttachments(List<ShareIntentAttachment> attachments) { void addAttachments(List<ShareIntentAttachment> attachments) {
if (attachments.isEmpty) { if (attachments.isEmpty) {
return; return;
} }
state = [...state, ...attachments]; state = [...state, ...attachments];
} }
void removeAttachment(ShareIntentAttachment attachment) { void removeAttachment(ShareIntentAttachment attachment) {
final updatedState = state.where((element) => element != attachment).toList(); final updatedState = state.where((element) => element != attachment).toList();
if (updatedState.length != state.length) { if (updatedState.length != state.length) {
state = updatedState; state = updatedState;
} }
} }
void clearAttachments() { void clearAttachments() {
if (state.isEmpty) { if (state.isEmpty) {
return; return;
} }
state = []; state = [];
} }
Future<void> uploadAll(List<File> files) async { Future<void> uploadAll(List<File> files) async {
for (final file in files) { for (final file in files) {
final fileId = p.hash(file.path).toString(); final fileId = p.hash(file.path).toString();
_updateStatus(fileId, UploadStatus.running); _updateStatus(fileId, UploadStatus.running);
} }
await _foregroundUploadService.uploadShareIntent( await _foregroundUploadService.uploadShareIntent(
files, files,
onProgress: (fileId, bytes, totalBytes) { onProgress: (fileId, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
_updateProgress(fileId, progress); _updateProgress(fileId, progress);
}, },
onSuccess: (fileId, _) { onSuccess: (fileId) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0); _updateStatus(fileId, UploadStatus.complete, progress: 1.0);
}, },
onError: (fileId, errorMessage) { onError: (fileId, errorMessage) {
_logger.warning("Upload failed for file: $fileId, error: $errorMessage"); _logger.warning("Upload failed for file: $fileId, error: $errorMessage");
_updateStatus(fileId, UploadStatus.failed); _updateStatus(fileId, UploadStatus.failed);
}, },
); );
} }
void _updateStatus(String fileId, UploadStatus status, {double? progress}) { void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
final id = int.parse(fileId); final id = int.parse(fileId);
state = [ state = [
for (final attachment in state) for (final attachment in state)
if (attachment.id == id) if (attachment.id == id)
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress) attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
else else
attachment, attachment,
]; ];
} }
void _updateProgress(String fileId, double progress) { void _updateProgress(String fileId, double progress) {
final id = int.parse(fileId); final id = int.parse(fileId);
state = [ state = [
for (final attachment in state) for (final attachment in state)
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment, if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
]; ];
} }
} }
@@ -36,12 +36,11 @@ class ActionResult {
final int count; final int count;
final bool success; final bool success;
final String? error; final String? error;
final List<String> remoteAssetIds;
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []}); const ActionResult({required this.count, required this.success, this.error});
@override @override
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)'; String toString() => 'ActionResult(count: $count, success: $success, error: $error)';
} }
class ActionNotifier extends Notifier<void> { class ActionNotifier extends Notifier<void> {
@@ -555,14 +554,10 @@ class ActionNotifier extends Notifier<void> {
final uploadedAssetIds = <String>{}; final uploadedAssetIds = <String>{};
final failedAssetIds = <String>{}; final failedAssetIds = <String>{};
final postUploadTasks = <Future<void>>[]; final postUploadTasks = <Future<void>>[];
if (assetsToUpload.isEmpty) {
return const ActionResult(count: 0, success: false, error: 'No assets to upload');
}
final progressNotifier = ref.read(assetUploadProgressProvider.notifier); final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = Completer<void>(); final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
final remoteAssetIds = <String>[];
// Initialize progress for all assets // Initialize progress for all assets
for (final asset in assetsToUpload) { for (final asset in assetsToUpload) {
@@ -579,7 +574,6 @@ class ActionNotifier extends Notifier<void> {
progressNotifier.setProgress(localAssetId, progress); progressNotifier.setProgress(localAssetId, progress);
}, },
onSuccess: (localAssetId, remoteAssetId) { onSuccess: (localAssetId, remoteAssetId) {
remoteAssetIds.add(remoteAssetId);
progressNotifier.remove(localAssetId); progressNotifier.remove(localAssetId);
uploadedAssetIds.add(localAssetId); uploadedAssetIds.add(localAssetId);
final asset = assetById[localAssetId]; final asset = assetById[localAssetId];
@@ -1,9 +1,8 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Holds the isolate's cancellation signal. /// Provider holding a boolean function that returns true when cancellation is requested.
final cancellationProvider = Provider<Completer<void>>( /// A computation running in the isolate uses the function to implement cooperative cancellation.
final cancellationProvider = Provider<bool Function()>(
// This will be overridden in the isolate's container. // This will be overridden in the isolate's container.
// Throwing ensures it's not used without an override. // Throwing ensures it's not used without an override.
(ref) => throw UnimplementedError( (ref) => throw UnimplementedError(
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:openapi/api.dart' show Optional;
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart'; import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -154,7 +153,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
Future<RemoteAlbum?> updateAlbum( Future<RemoteAlbum?> updateAlbum(
String albumId, { String albumId, {
String? name, String? name,
Optional<String?> description = const Optional.absent(), String? description,
String? thumbnailAssetId, String? thumbnailAssetId,
bool? isActivityEnabled, bool? isActivityEnabled,
AlbumAssetOrder? order, AlbumAssetOrder? order,
@@ -26,7 +26,7 @@ final syncStreamServiceProvider = Provider(
permissionRepository: ref.watch(permissionRepositoryProvider), permissionRepository: ref.watch(permissionRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider), syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider), api: ref.watch(apiServiceProvider),
cancellation: ref.watch(cancellationProvider), cancelChecker: ref.watch(cancellationProvider),
), ),
); );
@@ -42,7 +42,6 @@ final localSyncServiceProvider = Provider(
assetMediaRepository: ref.watch(assetMediaRepositoryProvider), assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider), permissionRepository: ref.watch(permissionRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider),
cancellation: ref.watch(cancellationProvider),
), ),
); );
@@ -52,6 +51,5 @@ final hashServiceProvider = Provider(
localAssetRepository: ref.watch(localAssetRepository), localAssetRepository: ref.watch(localAssetRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
cancellation: ref.watch(cancellationProvider),
), ),
); );
@@ -1,9 +1,6 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/permission_api.g.dart' as pm;
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> { class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
@@ -42,26 +39,3 @@ class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
final notificationPermissionProvider = StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>( final notificationPermissionProvider = StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>(
(ref) => NotificationPermissionNotifier(), (ref) => NotificationPermissionNotifier(),
); );
final batteryOptimizationProvider = AsyncNotifierProvider<BatteryOptimizationNotifier, PermissionStatus>(
BatteryOptimizationNotifier.new,
);
class BatteryOptimizationNotifier extends AsyncNotifier<PermissionStatus> {
Future<PermissionStatus> getBatteryOptimizationPermission() async {
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations().then((p) => p.toStatus());
state = AsyncValue.data(isIgnoring);
return isIgnoring;
}
@override
FutureOr<PermissionStatus> build() => getBatteryOptimizationPermission();
}
extension on pm.PermissionStatus {
PermissionStatus toStatus() => switch (this) {
pm.PermissionStatus.granted => PermissionStatus.granted,
pm.PermissionStatus.denied => PermissionStatus.denied,
pm.PermissionStatus.permanentlyDenied => PermissionStatus.permanentlyDenied,
};
}
@@ -29,7 +29,7 @@ final getAllPlacesProvider = FutureProvider.autoDispose<List<SearchCuratedConten
} }
final curatedContent = assetPlaces final curatedContent = assetPlaces
.map((data) => SearchCuratedContent(label: data.exifInfo.orElse(null)!.city.orElse(null)!, id: data.id)) .map((data) => SearchCuratedContent(label: data.exifInfo!.city!, id: data.id))
.toList(); .toList();
return curatedContent; return curatedContent;
@@ -1,31 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ViewIntentFilePathNotifier extends Notifier<String?> {
@override
String? build() => null;
void setPath(String path) {
if (state == path) {
return;
}
state = path;
}
void clear() {
if (state == null) {
return;
}
state = null;
}
void clearIfMatch(String path) {
if (state != path) {
return;
}
state = null;
}
}
final viewIntentFilePathProvider = NotifierProvider<ViewIntentFilePathNotifier, String?>(
ViewIntentFilePathNotifier.new,
);
@@ -1,23 +0,0 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_stub.dart';
abstract class ViewIntentHandler {
void init();
Future<void> onAppResumed();
Future<void> flushDeferredViewIntent();
Future<void> handle(ViewIntentPayload attachment);
}
final viewIntentHandlerProvider = Provider<ViewIntentHandler>((ref) {
if (Platform.isAndroid) {
return AndroidViewIntentHandler(ref);
}
return const StubViewIntentHandler();
});
@@ -1,103 +0,0 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:logging/logging.dart';
class AndroidViewIntentHandler implements ViewIntentHandler {
final Ref _ref;
final ViewIntentService _viewIntentService;
final ViewIntentAssetResolver _viewIntentAssetResolver;
final AppRouter _router;
static final Logger _logger = Logger('ViewIntentHandler');
AndroidViewIntentHandler(Ref ref)
: _ref = ref,
_viewIntentService = ref.read(viewIntentServiceProvider),
_viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider),
_router = ref.watch(appRouterProvider);
@override
void init() {
// Covers cold start from a view intent before the first lifecycle "resumed".
unawaited(onAppResumed());
}
@override
Future<void> onAppResumed() => _checkForViewIntent();
@override
Future<void> flushDeferredViewIntent() => _flushPending();
Future<void> _checkForViewIntent() async {
final attachment = await _viewIntentService.consumeViewIntent();
if (attachment != null) {
await handle(attachment);
return;
}
if (_ref.read(viewIntentPendingProvider) == null) {
await _viewIntentService.cleanupStaleTempFiles();
}
}
Future<void> _flushPending() async {
final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
_logger.info('flushPending, pendingAttachment:$pendingAttachment');
if (pendingAttachment != null) {
await handle(pendingAttachment);
}
}
@override
Future<void> handle(ViewIntentPayload attachment) async {
_logger.info(
'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
);
if (!_ref.read(authProvider).isAuthenticated) {
_ref.read(viewIntentPendingProvider.notifier).defer(attachment);
return;
}
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
await _openAssetViewer(
resolvedAsset.asset,
resolvedAsset.timelineService,
viewIntentFilePath: resolvedAsset.viewIntentFilePath,
);
}
Future<void> _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async {
final notifier = _ref.read(assetViewerProvider.notifier);
notifier.reset();
if (asset.isVideo) {
notifier.setControls(false);
}
notifier.setAsset(asset);
if (viewIntentFilePath != null) {
_ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
} else {
_ref.read(viewIntentFilePathProvider.notifier).clear();
unawaited(_viewIntentService.cleanupManagedTempFile());
}
await _router.replaceAll([
const TabShellRoute(),
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
]);
}
}
@@ -1,18 +0,0 @@
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
class StubViewIntentHandler implements ViewIntentHandler {
const StubViewIntentHandler();
@override
void init() {}
@override
Future<void> onAppResumed() async {}
@override
Future<void> flushDeferredViewIntent() async {}
@override
Future<void> handle(ViewIntentPayload attachment) async {}
}
@@ -1,39 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
final viewIntentNowProvider = Provider<DateTime Function()>((ref) => DateTime.now);
final viewIntentPendingProvider = NotifierProvider<ViewIntentPendingNotifier, ViewIntentPayload?>(
ViewIntentPendingNotifier.new,
);
class ViewIntentPendingNotifier extends Notifier<ViewIntentPayload?> {
static const _ttl = Duration(minutes: 10);
DateTime? _deferredAt;
@override
ViewIntentPayload? build() => null;
void defer(ViewIntentPayload attachment) {
_deferredAt = ref.read(viewIntentNowProvider)();
state = attachment;
}
ViewIntentPayload? takeIfFresh() {
final attachment = state;
final deferredAt = _deferredAt;
state = null;
_deferredAt = null;
if (attachment == null) {
return null;
}
if (deferredAt != null && ref.read(viewIntentNowProvider)().difference(deferredAt) > _ttl) {
return null;
}
return attachment;
}
}
@@ -23,8 +23,8 @@ class ActivityApiRepository extends ApiRepository {
final dto = ActivityCreateDto( final dto = ActivityCreateDto(
albumId: albumId, albumId: albumId,
type: type == ActivityType.comment ? ReactionType.comment : ReactionType.like, type: type == ActivityType.comment ? ReactionType.comment : ReactionType.like,
assetId: assetId == null ? const Optional.absent() : Optional.present(assetId), assetId: assetId,
comment: comment == null ? const Optional.absent() : Optional.present(comment), comment: comment,
); );
final response = await checkNull(_api.createActivity(dto)); final response = await checkNull(_api.createActivity(dto));
return _toActivity(response); return _toActivity(response);
@@ -45,6 +45,6 @@ class ActivityApiRepository extends ApiRepository {
type: dto.type == ReactionType.comment ? ActivityType.comment : ActivityType.like, type: dto.type == ReactionType.comment ? ActivityType.comment : ActivityType.like,
user: UserConverter.fromSimpleUserDto(dto.user), user: UserConverter.fromSimpleUserDto(dto.user),
assetId: dto.assetId, assetId: dto.assetId,
comment: dto.comment.orElse(null), comment: dto.comment,
); );
} }
@@ -24,7 +24,7 @@ class AssetApiRepository extends ApiRepository {
AssetApiRepository(this._api, this._stacksApi, this._trashApi); AssetApiRepository(this._api, this._stacksApi, this._trashApi);
Future<void> delete(List<String> ids, bool force) async { Future<void> delete(List<String> ids, bool force) async {
return _api.deleteAssets(AssetBulkDeleteDto(ids: ids, force: Optional.present(force))); return _api.deleteAssets(AssetBulkDeleteDto(ids: ids, force: force));
} }
Future<void> restoreTrash(List<String> ids) async { Future<void> restoreTrash(List<String> ids) async {
@@ -42,27 +42,19 @@ class AssetApiRepository extends ApiRepository {
} }
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async { Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility)))); return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility)));
} }
Future<void> updateFavorite(List<String> ids, bool isFavorite) async { Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite))); return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: isFavorite));
} }
Future<void> updateLocation(List<String> ids, LatLng location) async { Future<void> updateLocation(List<String> ids, LatLng location) async {
return _api.updateAssets( return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
AssetBulkUpdateDto(
ids: ids,
latitude: Optional.present(location.latitude),
longitude: Optional.present(location.longitude),
),
);
} }
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async { Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
return _api.updateAssets( return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: dateTime.toIso8601String()));
AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime.toIso8601String())),
);
} }
Future<StackResponse> stack(List<String> ids) async { Future<StackResponse> stack(List<String> ids) async {
@@ -90,15 +82,15 @@ class AssetApiRepository extends ApiRepository {
final response = await checkNull(_api.getAssetInfo(assetId)); final response = await checkNull(_api.getAssetInfo(assetId));
// we need to get the MIME of the thumbnail once that gets added to the API // we need to get the MIME of the thumbnail once that gets added to the API
return response.originalMimeType.orElse(null); return response.originalMimeType;
} }
Future<void> updateDescription(String assetId, String description) { Future<void> updateDescription(String assetId, String description) {
return _api.updateAsset(assetId, UpdateAssetDto(description: Optional.present(description))); return _api.updateAsset(assetId, UpdateAssetDto(description: description));
} }
Future<void> updateRating(String assetId, int rating) { Future<void> updateRating(String assetId, int rating) {
return _api.updateAsset(assetId, UpdateAssetDto(rating: Optional.present(rating))); return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
} }
Future<AssetEditsResponseDto?> editAsset(String assetId, List<AssetEdit> edits) { Future<AssetEditsResponseDto?> editAsset(String assetId, List<AssetEdit> edits) {
@@ -13,7 +13,7 @@ class AuthApiRepository extends ApiRepository {
AuthApiRepository(this._apiService); AuthApiRepository(this._apiService);
Future<void> changePassword(String newPassword) async { Future<void> changePassword(String newPassword) async {
await _apiService.usersApi.updateMyUser(UserUpdateMeDto(password: Optional.present(newPassword))); await _apiService.usersApi.updateMyUser(UserUpdateMeDto(password: newPassword));
} }
Future<LoginResponse> login(String email, String password) async { Future<LoginResponse> login(String email, String password) async {
@@ -46,7 +46,7 @@ class AuthApiRepository extends ApiRepository {
Future<bool> unlockPinCode(String pinCode) async { Future<bool> unlockPinCode(String pinCode) async {
try { try {
await _apiService.authenticationApi.unlockAuthSession(SessionUnlockDto(pinCode: Optional.present(pinCode))); await _apiService.authenticationApi.unlockAuthSession(SessionUnlockDto(pinCode: pinCode));
return true; return true;
} catch (_) { } catch (_) {
return false; return false;
@@ -22,13 +22,7 @@ class DriftAlbumApiRepository extends ApiRepository {
String? description, String? description,
}) async { }) async {
final responseDto = await checkNull( final responseDto = await checkNull(
_api.createAlbum( _api.createAlbum(CreateAlbumDto(albumName: name, description: description, assetIds: assetIds.toList())),
CreateAlbumDto(
albumName: name,
description: description == null ? const Optional.absent() : Optional.present(description),
assetIds: Optional.present(assetIds.toList()),
),
),
); );
return responseDto.toRemoteAlbum(owner); return responseDto.toRemoteAlbum(owner);
@@ -47,14 +41,8 @@ class DriftAlbumApiRepository extends ApiRepository {
return (removed: removed, failed: failed); return (removed: removed, failed: failed);
} }
Future<({List<String> added, List<String> failed})> addAssets( Future<({List<String> added, List<String> failed})> addAssets(String albumId, Iterable<String> assetIds) async {
String albumId, final response = await checkNull(_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList())));
Iterable<String> assetIds, {
Future<void>? abortTrigger,
}) async {
final response = await checkNull(
_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList()), abortTrigger: abortTrigger),
);
final List<String> added = [], failed = []; final List<String> added = [], failed = [];
for (final dto in response) { for (final dto in response) {
if (dto.success) { if (dto.success) {
@@ -71,7 +59,7 @@ class DriftAlbumApiRepository extends ApiRepository {
String albumId, String albumId,
UserDto owner, { UserDto owner, {
String? name, String? name,
Optional<String?> description = const Optional.absent(), String? description,
String? thumbnailAssetId, String? thumbnailAssetId,
bool? isActivityEnabled, bool? isActivityEnabled,
AlbumAssetOrder? order, AlbumAssetOrder? order,
@@ -85,13 +73,11 @@ class DriftAlbumApiRepository extends ApiRepository {
_api.updateAlbumInfo( _api.updateAlbumInfo(
albumId, albumId,
UpdateAlbumDto( UpdateAlbumDto(
albumName: name == null ? const Optional.absent() : Optional.present(name), albumName: name,
description: description, description: description,
albumThumbnailAssetId: thumbnailAssetId == null albumThumbnailAssetId: thumbnailAssetId,
? const Optional.absent() isActivityEnabled: isActivityEnabled,
: Optional.present(thumbnailAssetId), order: apiOrder,
isActivityEnabled: isActivityEnabled == null ? const Optional.absent() : Optional.present(isActivityEnabled),
order: apiOrder == null ? const Optional.absent() : Optional.present(apiOrder),
), ),
), ),
); );
@@ -113,9 +99,7 @@ class DriftAlbumApiRepository extends ApiRepository {
} }
Future<bool> setActivityStatus(String albumId, bool isEnabled) async { Future<bool> setActivityStatus(String albumId, bool isEnabled) async {
final response = await checkNull( final response = await checkNull(_api.updateAlbumInfo(albumId, UpdateAlbumDto(isActivityEnabled: isEnabled)));
_api.updateAlbumInfo(albumId, UpdateAlbumDto(isActivityEnabled: Optional.present(isEnabled))),
);
return response.isActivityEnabled; return response.isActivityEnabled;
} }
} }
@@ -132,7 +116,7 @@ extension on AlbumResponseDto {
updatedAt: updatedAt, updatedAt: updatedAt,
thumbnailAssetId: albumThumbnailAssetId, thumbnailAssetId: albumThumbnailAssetId,
isActivityEnabled: isActivityEnabled, isActivityEnabled: isActivityEnabled,
order: order.orElse(null) == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc, order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: assetCount, assetCount: assetCount,
isShared: albumUsers.length > 2, isShared: albumUsers.length > 2,
); );
@@ -16,7 +16,7 @@ class PartnerApiRepository extends ApiRepository {
Future<List<UserDto>> getAll(Direction direction) async { Future<List<UserDto>> getAll(Direction direction) async {
final response = await checkNull( final response = await checkNull(
_api.getPartners(direction == Direction.sharedByMe ? PartnerDirection.sharedBy : PartnerDirection.sharedWith), _api.getPartners(direction == Direction.sharedByMe ? PartnerDirection.by : PartnerDirection.with_),
); );
return response.map(UserConverter.fromPartnerDto).toList(); return response.map(UserConverter.fromPartnerDto).toList();
} }
@@ -18,10 +18,7 @@ class PersonApiRepository extends ApiRepository {
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async { Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day);
final dto = PersonUpdateDto( final dto = PersonUpdateDto(name: name, birthDate: birthdayUtc);
name: name == null ? const Optional.absent() : Optional.present(name),
birthDate: birthdayUtc == null ? const Optional.absent() : Optional.present(birthdayUtc),
);
final response = await checkNull(_api.updatePerson(id, dto)); final response = await checkNull(_api.updatePerson(id, dto));
return _toPerson(response); return _toPerson(response);
} }
@@ -15,13 +15,7 @@ class SessionsAPIRepository extends ApiRepository {
Future<SessionCreateResponse> createSession(String deviceType, String deviceOS, {int? duration}) async { Future<SessionCreateResponse> createSession(String deviceType, String deviceOS, {int? duration}) async {
final dto = await checkNull( final dto = await checkNull(
_api.createSession( _api.createSession(SessionCreateDto(deviceType: deviceType, deviceOS: deviceOS, duration: duration)),
SessionCreateDto(
deviceType: Optional.present(deviceType),
deviceOS: Optional.present(deviceOS),
duration: duration == null ? const Optional.absent() : Optional.present(duration),
),
),
); );
return SessionCreateResponse( return SessionCreateResponse(
@@ -29,7 +23,7 @@ class SessionsAPIRepository extends ApiRepository {
current: dto.current, current: dto.current,
deviceType: deviceType, deviceType: deviceType,
deviceOS: deviceOS, deviceOS: deviceOS,
expiresAt: dto.expiresAt.orElse(null), expiresAt: dto.expiresAt,
createdAt: dto.createdAt, createdAt: dto.createdAt,
updatedAt: dto.updatedAt, updatedAt: dto.updatedAt,
token: dto.token, token: dto.token,
+1 -1
View File
@@ -55,7 +55,7 @@ class LockedGuard extends AutoRouteGuard {
return; return;
} }
await _apiService.authenticationApi.unlockAuthSession(SessionUnlockDto(pinCode: Optional.present(securePinCode))); await _apiService.authenticationApi.unlockAuthSession(SessionUnlockDto(pinCode: securePinCode));
resolver.next(true); resolver.next(true);
} on PlatformException catch (error) { } on PlatformException catch (error) {
@@ -151,7 +151,7 @@ class ForegroundUploadService {
List<File> files, { List<File> files, {
Completer<void>? cancelToken, Completer<void>? cancelToken,
void Function(String fileId, int bytes, int totalBytes)? onProgress, void Function(String fileId, int bytes, int totalBytes)? onProgress,
void Function(String fileId, String remoteAssetId)? onSuccess, void Function(String fileId)? onSuccess,
void Function(String fileId, String errorMessage)? onError, void Function(String fileId, String errorMessage)? onError,
}) async { }) async {
if (files.isEmpty) { if (files.isEmpty) {
@@ -171,7 +171,7 @@ class ForegroundUploadService {
); );
if (result.isSuccess) { if (result.isSuccess) {
onSuccess?.call(fileId, result.remoteAssetId!); onSuccess?.call(fileId);
} else if (!result.isCancelled && result.errorMessage != null) { } else if (!result.isCancelled && result.errorMessage != null) {
onError?.call(fileId, result.errorMessage!); onError?.call(fileId, result.errorMessage!);
} }
+2 -6
View File
@@ -18,11 +18,7 @@ class OAuthService {
log.info("Starting OAuth flow with redirect URI: $redirectUri"); log.info("Starting OAuth flow with redirect URI: $redirectUri");
final dto = await _apiService.oAuthApi.startOAuth( final dto = await _apiService.oAuthApi.startOAuth(
OAuthConfigDto( OAuthConfigDto(redirectUri: redirectUri, state: state, codeChallenge: codeChallenge),
redirectUri: redirectUri,
state: Optional.present(state),
codeChallenge: Optional.present(codeChallenge),
),
); );
final authUrl = dto?.url; final authUrl = dto?.url;
@@ -41,7 +37,7 @@ class OAuthService {
} }
return await _apiService.oAuthApi.finishOAuth( return await _apiService.oAuthApi.finishOAuth(
OAuthCallbackDto(url: result, state: Optional.present(state), codeVerifier: Optional.present(codeVerifier)), OAuthCallbackDto(url: result, state: state, codeVerifier: codeVerifier),
); );
} }
} }
+27 -25
View File
@@ -48,26 +48,26 @@ class SharedLinkService {
if (type == SharedLinkType.ALBUM) { if (type == SharedLinkType.ALBUM) {
dto = SharedLinkCreateDto( dto = SharedLinkCreateDto(
type: type, type: type,
albumId: albumId == null ? const Optional.absent() : Optional.present(albumId), albumId: albumId,
showMetadata: Optional.present(showMeta), showMetadata: showMeta,
allowDownload: Optional.present(allowDownload), allowDownload: allowDownload,
allowUpload: Optional.present(allowUpload), allowUpload: allowUpload,
expiresAt: expiresAt == null ? const Optional.absent() : Optional.present(expiresAt), expiresAt: expiresAt,
description: description == null ? const Optional.absent() : Optional.present(description), description: description,
password: password == null ? const Optional.absent() : Optional.present(password), password: password,
slug: slug == null ? const Optional.absent() : Optional.present(slug), slug: slug,
); );
} else if (assetIds != null) { } else if (assetIds != null) {
dto = SharedLinkCreateDto( dto = SharedLinkCreateDto(
type: type, type: type,
showMetadata: Optional.present(showMeta), showMetadata: showMeta,
allowDownload: Optional.present(allowDownload), allowDownload: allowDownload,
allowUpload: Optional.present(allowUpload), allowUpload: allowUpload,
expiresAt: expiresAt == null ? const Optional.absent() : Optional.present(expiresAt), expiresAt: expiresAt,
description: description == null ? const Optional.absent() : Optional.present(description), description: description,
password: password == null ? const Optional.absent() : Optional.present(password), password: password,
slug: slug == null ? const Optional.absent() : Optional.present(slug), slug: slug,
assetIds: Optional.present(assetIds), assetIds: assetIds,
); );
} }
@@ -88,22 +88,24 @@ class SharedLinkService {
required bool? showMeta, required bool? showMeta,
required bool? allowDownload, required bool? allowDownload,
required bool? allowUpload, required bool? allowUpload,
Optional<String?> password = const Optional.absent(), bool? changeExpiry = false,
Optional<String?> description = const Optional.absent(), String? description,
String? password,
String? slug, String? slug,
Optional<DateTime?> expiresAt = const Optional.absent(), DateTime? expiresAt,
}) async { }) async {
try { try {
final responseDto = await _apiService.sharedLinksApi.updateSharedLink( final responseDto = await _apiService.sharedLinksApi.updateSharedLink(
id, id,
SharedLinkEditDto( SharedLinkEditDto(
showMetadata: showMeta == null ? const Optional.absent() : Optional.present(showMeta), showMetadata: showMeta,
allowDownload: allowDownload == null ? const Optional.absent() : Optional.present(allowDownload), allowDownload: allowDownload,
allowUpload: allowUpload == null ? const Optional.absent() : Optional.present(allowUpload), allowUpload: allowUpload,
password: password,
description: description,
expiresAt: expiresAt, expiresAt: expiresAt,
slug: slug == null ? const Optional.absent() : Optional.present(slug), description: description,
password: password,
slug: slug,
changeExpiryTime: changeExpiry,
), ),
); );
if (responseDto != null) { if (responseDto != null) {
@@ -1,108 +0,0 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi()));
class ViewIntentService {
final ViewIntentHostApi _viewIntentHostApi;
final Future<Directory> Function() _temporaryDirectory;
String? _managedTempFilePath;
final Set<String> _activeUploadPaths = {};
ViewIntentService(this._viewIntentHostApi, {Future<Directory> Function()? temporaryDirectory})
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
Future<ViewIntentPayload?> consumeViewIntent() async {
try {
return await _viewIntentHostApi.consumeViewIntent();
} catch (_) {
// Ignore errors - view intent might not be present
return null;
}
}
Future<void> setManagedTempFilePath(String path) async {
final previous = _managedTempFilePath;
if (previous == path) {
return;
}
_managedTempFilePath = path;
if (previous != null) {
await cleanupTempFile(previous);
}
}
Future<void> cleanupManagedTempFile() async {
final path = _managedTempFilePath;
_managedTempFilePath = null;
if (path != null) {
await cleanupTempFile(path);
}
}
Future<void> cleanupManagedTempFileIfCurrent(String path) async {
if (_managedTempFilePath == path) {
_managedTempFilePath = null;
}
await cleanupTempFile(path);
}
Future<void> cleanupTempFile(String path) async {
if (!_isManagedTempFile(path)) {
return;
}
if (_activeUploadPaths.contains(path)) {
return;
}
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {
// Best-effort cleanup only.
}
}
Future<void> cleanupStaleTempFiles() async {
try {
final tempDirectory = await _temporaryDirectory();
await for (final entity in tempDirectory.list()) {
if (entity is! File) {
continue;
}
final path = entity.path;
if (!_isManagedTempFile(path) || path == _managedTempFilePath || _activeUploadPaths.contains(path)) {
continue;
}
await entity.delete();
}
} catch (_) {
// Best-effort cleanup only.
}
}
void markUploadActive(String path) {
_activeUploadPaths.add(path);
}
Future<void> markUploadInactive(String path) async {
if (!_activeUploadPaths.remove(path)) {
return;
}
if (_managedTempFilePath != path) {
await cleanupTempFile(path);
}
}
bool _isManagedTempFile(String path) {
return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache';
}
}
@@ -1,65 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class ViewIntentResolvedAsset {
final BaseAsset asset;
final TimelineService timelineService;
final String? viewIntentFilePath;
const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath});
}
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
(ref) => ViewIntentAssetResolver(
localAssetRepository: ref.read(localAssetRepository),
timelineFactory: ref.read(timelineFactoryProvider),
),
);
class ViewIntentAssetResolver {
final DriftLocalAssetRepository _localAssetRepository;
final TimelineFactory _timelineFactory;
static final Logger _logger = Logger('ViewIntentAssetResolver');
const ViewIntentAssetResolver({required this._localAssetRepository, required this._timelineFactory});
Future<ViewIntentResolvedAsset> resolve(ViewIntentPayload attachment) async {
final localAssetId = attachment.localAssetId;
final path = attachment.path;
_logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}');
if (localAssetId == null && path == null) {
throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.');
}
final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null;
final asset = localAsset ?? _toTransientAsset(attachment);
return ViewIntentResolvedAsset(
asset: asset,
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
viewIntentFilePath: localAsset == null ? path : null,
);
}
LocalAsset _toTransientAsset(ViewIntentPayload attachment) {
final now = DateTime.now();
return LocalAsset(
id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}',
name: attachment.fileName,
type: attachment.isVideo ? AssetType.video : AssetType.image,
createdAt: now,
updatedAt: now,
isEdited: false,
playbackStyle: attachment.playbackStyle,
);
}
}

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