Compare commits

..

13 Commits

Author SHA1 Message Date
Daniel Dietzler 5a89564270 fix: strip metadata from timeline responses for shared links without exif sharing 2026-05-27 15:14:53 +02:00
Brandon Wees 2dd6b47714 fix: OCR bounding box positioning (#28568) 2026-05-27 12:01:30 +02:00
Alex 8682be4774 feat: workflow template (#28553)
* wip: confirm before existing and disable/enable save button condition

* fix: get correct workflow detail

* wip: add back workflow summary

* wip: add back json editor

* wip: step property badge

* wip: redesign card flow

* wip: redesign card flow

* redesign workflow summary

* wworkflow summary styling

* wip

* drag and drop

* list redesign

* refactor

* refactor

* remove deadcode

* refactor

* insert steps

* push down when dropped

* feat: workflow template

* simplify

* move template to manifest

* feat: hash manifest file

* fix: template column

* fix: migration

* fix: workflow lookup

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-05-26 16:47:05 -04:00
Brandon Wees dc66892ca1 fix: await sync asset v2 (#28569)
* fix: await sync asset v2

* fix: support previous server versions for edit ready events
2026-05-26 15:43:12 -04:00
Fabian Wimberger 53a24783f5 fix(ml): stabilize MIGraphX inference (#28444)
* fix: stabilize ROCm MIGraphX inference

Serialize MIGraphX session runs so lazy compiles cannot overlap within a worker.

Use a fixed face-recognition batch size for MIGraphX to avoid compiling a new program for each detected face count.

* fix(ml): increase ROCm worker timeout

* fix(ml): narrow MIGraphX compile locking

* docs: format environment variables table

* docs: apply prettier to environment variables table
2026-05-26 18:41:56 +00:00
Alex 0546bc900c chore: workflow UI (#28536)
* wip: confirm before existing and disable/enable save button condition

* fix: get correct workflow detail

* wip: add back workflow summary

* wip: add back json editor

* wip: step property badge

* wip: redesign card flow

* wip: redesign card flow

* redesign workflow summary

* wworkflow summary styling

* wip

* drag and drop

* list redesign

* refactor

* refactor

* remove deadcode

* refactor

* insert steps

* push down when dropped

* fix: query by workflow id

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-05-26 14:06:20 -04:00
Brandon Wees 7c25bcc0a7 refactor: use ControlBar UI Library component (#28567)
* refactor: use ControlBar UI Library component

* chore: ci fix

* fix: memory viewer bar

* chore: rework e2e test

* chore: more ci fixes
2026-05-26 12:03:37 -04:00
Luis Nachtigall 7905853639 fix(mobile): preserve zoom level during image loading and live photo playback (#27960)
* fix(mobile): preserve zoom level when new images load in asset viewer

* fix(mobile): use actual child size for live photo

* revert fixes

* fix(mobile): keep zoom consistent when scale boundaries change

* fix(mobile): simplify scale handling in photo_view_core.dart
2026-05-26 21:10:02 +05:30
Mees Frensel 073dcc1fbe chore(server): deprecate total field of asset search response (#28551) 2026-05-26 16:20:24 +02:00
renovate[bot] ccdaa4223c chore(deps): update github-actions (#28623) 2026-05-26 15:04:51 +02:00
Aaron Liu 5386b62dc4 chore(ml): allow insightface 1.x (#28595)
* chore(ml): allow insightface 1.x

The new insightface 1.0 release appears to have no breaking code changes nor relevant license changes ([before](https://github.com/deepinsight/insightface/blob/2a78baec428354883e0cda39c54b555a5ed8358a/README.md), [after](https://github.com/deepinsight/insightface/blob/70f3269ea628d0658c5723976944c9de414e96f8/README.md), c.f. https://github.com/immich-app/immich/blob/fd7ddfef54cdf2b6256c4fc08bc5ff3f86176775/machine-learning/README.md), and it works on my machine.

* Update uv.lock

* please excuse my incompetence
2026-05-25 12:32:50 -04:00
Ben Beckford 9733fa4872 fix(web): timeline stuttering with many assets in 1 day (#28509)
* fix(web): timeline stuttering with many assets in 1 day

* cache isInOrNearViewport per day

* skip inOrNearViewport check on first run
2026-05-24 16:03:46 -05:00
Alex 3b34c53092 feat: command for user pages (#28554) 2026-05-24 16:03:12 -05:00
112 changed files with 2560 additions and 2847 deletions
+1 -1
View File
@@ -231,7 +231,7 @@ jobs:
run: mise //mobile:codegen:pigeon
- name: Setup Ruby
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
with:
ruby-version: '3.3'
bundler-cache: true
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
category: '/language:${{matrix.language}}'
+27 -27
View File
@@ -154,33 +154,33 @@ Redis (Sentinel) URL example JSON before encoding:
## Machine Learning
| Variable | Description | Default | Containers |
| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
| Variable | Description | Default | Containers |
| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `300` (`900` if using ROCm) | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
@@ -536,7 +536,7 @@ test.describe('Timeline', () => {
force: false,
ids: [assetToTrash.id],
});
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.locator('#control-bar').getByLabel('Close').click();
await page.getByText('Trash', { exact: true }).click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
@@ -676,7 +676,7 @@ test.describe('Timeline', () => {
ids: [assetToArchive.id],
});
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.locator('#control-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
@@ -823,7 +823,7 @@ test.describe('Timeline', () => {
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.locator('#control-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Favorites').click();
await timelineUtils.waitForTimelineLoad(page);
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
+6
View File
@@ -698,6 +698,7 @@
"birthdate_saved": "Date of birth saved successfully",
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
"blurred_background": "Blurred background",
"browse_templates": "Browse templates",
"bugs_and_feature_requests": "Bugs & Feature Requests",
"build": "Build",
"build_image": "Build Image",
@@ -976,6 +977,7 @@
"downloading_asset_filename": "Downloading asset {filename}",
"downloading_from_icloud": "Downloading from iCloud",
"downloading_media": "Downloading media",
"drag_to_reorder": "Drag to reorder",
"drop_files_to_upload": "Drop files anywhere to upload",
"duplicates": "Duplicates",
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
@@ -2254,6 +2256,7 @@
"step_delete_confirm": "Are you sure you want to delete this step?",
"step_details": "Step details",
"steps": "Steps",
"steps_count": "{count, plural, one {# step} other {# steps}}",
"stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?",
@@ -2415,6 +2418,7 @@
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
"use_current_connection": "Use current connection",
"use_custom_date_range": "Use custom date range instead",
"use_template": "Use template",
"user": "User",
"user_has_been_deleted": "This user has been deleted.",
"user_id": "User ID",
@@ -2476,6 +2480,7 @@
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"when": "When",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
@@ -2488,6 +2493,7 @@
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",
"workflow_templates": "Workflow templates",
"workflow_update_success": "Workflow updated successfully",
"workflow_updated": "Workflow updated",
"workflows": "Workflows",
+6 -2
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from socket import socket
from gunicorn.arbiter import Arbiter
from pydantic import BaseModel
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from rich.console import Console
from rich.logging import RichHandler
@@ -42,6 +42,10 @@ class MaxBatchSize(BaseModel):
ocr: int | None = None
def default_worker_timeout() -> int:
return 900 if os.environ.get("DEVICE") == "rocm" else 300
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MACHINE_LEARNING_",
@@ -54,7 +58,7 @@ class Settings(BaseSettings):
model_ttl: int = 300
model_ttl_poll_s: int = 10
workers: int = 1
worker_timeout: int = 300
worker_timeout: int = Field(default_factory=default_worker_timeout)
http_keepalive_timeout_s: int = 2
test_full: bool = False
request_threads: int = os.cpu_count() or 4
@@ -89,4 +89,10 @@ class FaceRecognizer(InferenceModel):
@property
def _batch_size_default(self) -> int | None:
providers = ort.get_available_providers()
return None if self.model_format == ModelFormat.ONNX and "OpenVINOExecutionProvider" not in providers else 1
if (
self.model_format == ModelFormat.ONNX
and "MIGraphXExecutionProvider" not in providers
and "OpenVINOExecutionProvider" not in providers
):
return None
return 1
+47 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
from threading import Lock
from typing import Any
import numpy as np
@@ -12,6 +13,37 @@ from immich_ml.schemas import ModelPrecision, SessionNode
from ..config import log, settings
MigraphxInputSignature = tuple[tuple[str, str, tuple[int, ...]], ...]
_migraphx_registry_lock = Lock()
_migraphx_model_locks: dict[str, Lock] = {}
_migraphx_compiled_inputs: set[tuple[str, MigraphxInputSignature]] = set()
def _migraphx_get_model_lock(model_key: str) -> Lock:
with _migraphx_registry_lock:
lock = _migraphx_model_locks.get(model_key)
if lock is None:
lock = Lock()
_migraphx_model_locks[model_key] = lock
return lock
def _migraphx_has_compiled_input(key: tuple[str, MigraphxInputSignature]) -> bool:
with _migraphx_registry_lock:
return key in _migraphx_compiled_inputs
def _migraphx_mark_compiled_input(key: tuple[str, MigraphxInputSignature]) -> None:
with _migraphx_registry_lock:
_migraphx_compiled_inputs.add(key)
def _migraphx_input_signature(
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]],
) -> MigraphxInputSignature:
return tuple((name, str(value.dtype), tuple(value.shape)) for name, value in sorted(input_feed.items()))
class OrtSession:
session: ort.InferenceSession
@@ -48,7 +80,21 @@ class OrtSession:
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]],
run_options: Any = None,
) -> list[NDArray[np.float32]]:
outputs: list[NDArray[np.float32]] = self.session.run(output_names, input_feed, run_options)
if "MIGraphXExecutionProvider" in self.providers:
model_key = self.model_path.resolve().as_posix()
input_key = (model_key, _migraphx_input_signature(input_feed))
if not _migraphx_has_compiled_input(input_key):
model_lock = _migraphx_get_model_lock(model_key)
with model_lock:
if not _migraphx_has_compiled_input(input_key):
outputs: list[NDArray[np.float32]] = self.session.run(output_names, input_feed, run_options)
_migraphx_mark_compiled_input(input_key)
return outputs
outputs = self.session.run(output_names, input_feed, run_options)
return outputs
outputs = self.session.run(output_names, input_feed, run_options)
return outputs
@property
+1 -1
View File
@@ -10,7 +10,7 @@ dependencies = [
"fastapi>=0.95.2,<1.0",
"gunicorn>=21.1.0",
"huggingface-hub>=1.0,<2.0",
"insightface>=0.7.3,<1.0",
"insightface>=0.7.3,<2.0",
"numpy>=2.4.0,<3.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
+104
View File
@@ -35,7 +35,37 @@ from immich_ml.sessions.ort import OrtSession
from immich_ml.sessions.rknn import RknnSession, run_inference
class FakeLock:
def __init__(self) -> None:
self.enter = mock.Mock()
self.exit = mock.Mock()
def __enter__(self) -> None:
self.enter()
def __exit__(self, *args: object) -> None:
self.exit(*args)
class TestBase:
def test_sets_default_worker_timeout(self, monkeypatch: MonkeyPatch) -> None:
monkeypatch.delenv("DEVICE", raising=False)
monkeypatch.delenv("MACHINE_LEARNING_WORKER_TIMEOUT", raising=False)
assert Settings().worker_timeout == 300
def test_sets_rocm_default_worker_timeout(self, monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("DEVICE", "rocm")
monkeypatch.delenv("MACHINE_LEARNING_WORKER_TIMEOUT", raising=False)
assert Settings().worker_timeout == 900
def test_worker_timeout_env_override(self, monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("DEVICE", "rocm")
monkeypatch.setenv("MACHINE_LEARNING_WORKER_TIMEOUT", "1200")
assert Settings().worker_timeout == 1200
def test_sets_default_cache_dir(self) -> None:
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
@@ -413,6 +443,52 @@ class TestOrtSession:
assert sess_options is session.sess_options
def test_serializes_rocm_first_run_for_new_input_signature(self, mocker: MockerFixture) -> None:
lock = FakeLock()
get_model_lock = mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
mocker.patch("immich_ml.sessions.ort._migraphx_compiled_inputs", set())
mocker.patch("immich_ml.sessions.ort.Path.mkdir")
session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["MIGraphXExecutionProvider"])
input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
session.run(None, input_feed)
session.run(None, input_feed)
lock.enter.assert_called_once()
lock.exit.assert_called_once()
get_model_lock.assert_called_once()
session.session.run.assert_has_calls([mock.call(None, input_feed, None), mock.call(None, input_feed, None)])
def test_serializes_rocm_run_for_each_new_input_signature(self, mocker: MockerFixture) -> None:
lock = FakeLock()
mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
mocker.patch("immich_ml.sessions.ort._migraphx_compiled_inputs", set())
mocker.patch("immich_ml.sessions.ort.Path.mkdir")
session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["MIGraphXExecutionProvider"])
input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
new_shape_input_feed = {"input": np.random.rand(2, 3, 224, 224).astype(np.float32)}
session.run(None, input_feed)
session.run(None, new_shape_input_feed)
assert lock.enter.call_count == 2
assert lock.exit.call_count == 2
session.session.run.assert_has_calls(
[mock.call(None, input_feed, None), mock.call(None, new_shape_input_feed, None)]
)
def test_does_not_serialize_non_rocm_run(self, mocker: MockerFixture) -> None:
lock = FakeLock()
get_model_lock = mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["CPUExecutionProvider"])
input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
session.run(None, input_feed)
get_model_lock.assert_not_called()
lock.enter.assert_not_called()
session.session.run.assert_called_once_with(None, input_feed, None)
class TestAnnSession:
def test_creates_ann_session(self, ann_session: mock.Mock, info: mock.Mock) -> None:
@@ -883,6 +959,34 @@ class TestFaceRecognition:
onnx.load.assert_not_called()
onnx.save.assert_not_called()
def test_recognition_does_not_add_batch_axis_for_migraphx(
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
) -> None:
onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True)
update_dims = mocker.patch(
"immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True
)
mocker.patch("immich_ml.models.base.InferenceModel.download")
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["MIGraphXExecutionProvider", "CPUExecutionProvider"],
)
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
inputs = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))]
outputs = [SimpleNamespace(name="output.1", shape=(1, 800))]
ort_session.return_value.get_inputs.return_value = inputs
ort_session.return_value.get_outputs.return_value = outputs
face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path)
face_recognizer.load()
assert face_recognizer.batch_size == 1
update_dims.assert_not_called()
onnx.load.assert_not_called()
onnx.save.assert_not_called()
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
+1 -1
View File
@@ -1004,7 +1004,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.95.2,<1.0" },
{ name = "gunicorn", specifier = ">=21.1.0" },
{ name = "huggingface-hub", specifier = ">=1.0,<2.0" },
{ name = "insightface", specifier = ">=0.7.3,<1.0" },
{ name = "insightface", specifier = ">=0.7.3,<2.0" },
{ name = "numpy", specifier = ">=2.4.0,<3.0" },
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" },
-1
View File
@@ -73,7 +73,6 @@ run = "bash ./bin/generate-dart-sdk.sh"
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
run = [
{ task = "//:plugins" },
{ task = "//server:build" },
{ task = "//server:install" },
{ task = "//server:build" },
{ task = "//server:sync-open-api" },
@@ -89,20 +89,6 @@
<data android:mimeType="video/*" />
</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 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -1,7 +1,6 @@
package app.alextran.immich
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock
@@ -23,7 +22,6 @@ import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.viewintent.ViewIntentPlugin
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -33,11 +31,6 @@ class MainActivity : FlutterFragmentActivity() {
registerPlugins(this, flutterEngine)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
@@ -62,7 +55,6 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)
@@ -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
}
}
}
-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/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.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:
dPrint(() => "[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume();
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
break;
case AppLifecycleState.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();
}
@@ -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;
}
}
@@ -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/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
@@ -315,7 +314,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
unawaited(
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
@@ -330,8 +328,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
backgroundManager.syncRemote().then((success) => syncSuccess = success),
]);
await viewIntentHandler.flushDeferredViewIntent();
if (syncSuccess) {
await Future.wait([
backgroundManager.hashAssets().then((_) {
+35 -35
View File
@@ -9,22 +9,14 @@ 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,
}) {
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],
);
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',
@@ -34,8 +26,6 @@ Object? _extractReplyValueOrThrow(
return replyList.firstOrNull;
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -62,35 +52,50 @@ class LocalImageApi {
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
LocalImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(String assetId, {required int requestId, required int width, required int height, required bool isVideo, required bool preferEncoded, }) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
Future<Map<String, int>?> requestImage(
String assetId, {
required int requestId,
required int width,
required int height,
required bool isVideo,
required bool preferEncoded,
}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, requestId, width, height, isVideo, preferEncoded]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
assetId,
requestId,
width,
height,
isVideo,
preferEncoded,
]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return (pigeonVar_replyValue as Map<Object?, Object?>?)?.cast<String, int>();
}
Future<void> cancelRequest(int requestId) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -99,16 +104,12 @@ class LocalImageApi {
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
Future<Map<String, int>> getThumbhash(String thumbhash) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -118,11 +119,10 @@ class LocalImageApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, int>();
}
}
+126 -170
View File
@@ -9,22 +9,14 @@ 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,
}) {
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],
);
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',
@@ -45,9 +37,7 @@ bool _deepEquals(Object? a, Object? b) {
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]));
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) {
@@ -96,15 +86,7 @@ int _deepHash(Object? value) {
return value.hashCode;
}
enum PlatformAssetPlaybackStyle {
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
class PlatformAsset {
PlatformAsset({
@@ -172,7 +154,8 @@ class PlatformAsset {
}
Object encode() {
return _toList(); }
return _toList();
}
static PlatformAsset decode(Object result) {
result as List<Object?>;
@@ -203,7 +186,20 @@ class PlatformAsset {
if (identical(this, other)) {
return true;
}
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(type, other.type) && _deepEquals(createdAt, other.createdAt) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(width, other.width) && _deepEquals(height, other.height) && _deepEquals(durationMs, other.durationMs) && _deepEquals(orientation, other.orientation) && _deepEquals(isFavorite, other.isFavorite) && _deepEquals(adjustmentTime, other.adjustmentTime) && _deepEquals(latitude, other.latitude) && _deepEquals(longitude, other.longitude) && _deepEquals(playbackStyle, other.playbackStyle);
return _deepEquals(id, other.id) &&
_deepEquals(name, other.name) &&
_deepEquals(type, other.type) &&
_deepEquals(createdAt, other.createdAt) &&
_deepEquals(updatedAt, other.updatedAt) &&
_deepEquals(width, other.width) &&
_deepEquals(height, other.height) &&
_deepEquals(durationMs, other.durationMs) &&
_deepEquals(orientation, other.orientation) &&
_deepEquals(isFavorite, other.isFavorite) &&
_deepEquals(adjustmentTime, other.adjustmentTime) &&
_deepEquals(latitude, other.latitude) &&
_deepEquals(longitude, other.longitude) &&
_deepEquals(playbackStyle, other.playbackStyle);
}
@override
@@ -231,17 +227,12 @@ class PlatformAlbum {
int assetCount;
List<Object?> _toList() {
return <Object?>[
id,
name,
updatedAt,
isCloud,
assetCount,
];
return <Object?>[id, name, updatedAt, isCloud, assetCount];
}
Object encode() {
return _toList(); }
return _toList();
}
static PlatformAlbum decode(Object result) {
result as List<Object?>;
@@ -263,7 +254,11 @@ class PlatformAlbum {
if (identical(this, other)) {
return true;
}
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(isCloud, other.isCloud) && _deepEquals(assetCount, other.assetCount);
return _deepEquals(id, other.id) &&
_deepEquals(name, other.name) &&
_deepEquals(updatedAt, other.updatedAt) &&
_deepEquals(isCloud, other.isCloud) &&
_deepEquals(assetCount, other.assetCount);
}
@override
@@ -272,12 +267,7 @@ class PlatformAlbum {
}
class SyncDelta {
SyncDelta({
required this.hasChanges,
required this.updates,
required this.deletes,
required this.assetAlbums,
});
SyncDelta({required this.hasChanges, required this.updates, required this.deletes, required this.assetAlbums});
bool hasChanges;
@@ -288,16 +278,12 @@ class SyncDelta {
Map<String, List<String>> assetAlbums;
List<Object?> _toList() {
return <Object?>[
hasChanges,
updates,
deletes,
assetAlbums,
];
return <Object?>[hasChanges, updates, deletes, assetAlbums];
}
Object encode() {
return _toList(); }
return _toList();
}
static SyncDelta decode(Object result) {
result as List<Object?>;
@@ -318,7 +304,10 @@ class SyncDelta {
if (identical(this, other)) {
return true;
}
return _deepEquals(hasChanges, other.hasChanges) && _deepEquals(updates, other.updates) && _deepEquals(deletes, other.deletes) && _deepEquals(assetAlbums, other.assetAlbums);
return _deepEquals(hasChanges, other.hasChanges) &&
_deepEquals(updates, other.updates) &&
_deepEquals(deletes, other.deletes) &&
_deepEquals(assetAlbums, other.assetAlbums);
}
@override
@@ -327,11 +316,7 @@ class SyncDelta {
}
class HashResult {
HashResult({
required this.assetId,
this.error,
this.hash,
});
HashResult({required this.assetId, this.error, this.hash});
String assetId;
@@ -340,23 +325,16 @@ class HashResult {
String? hash;
List<Object?> _toList() {
return <Object?>[
assetId,
error,
hash,
];
return <Object?>[assetId, error, hash];
}
Object encode() {
return _toList(); }
return _toList();
}
static HashResult decode(Object result) {
result as List<Object?>;
return HashResult(
assetId: result[0]! as String,
error: result[1] as String?,
hash: result[2] as String?,
);
return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?);
}
@override
@@ -377,11 +355,7 @@ class HashResult {
}
class CloudIdResult {
CloudIdResult({
required this.assetId,
this.error,
this.cloudId,
});
CloudIdResult({required this.assetId, this.error, this.cloudId});
String assetId;
@@ -390,23 +364,16 @@ class CloudIdResult {
String? cloudId;
List<Object?> _toList() {
return <Object?>[
assetId,
error,
cloudId,
];
return <Object?>[assetId, error, cloudId];
}
Object encode() {
return _toList(); }
return _toList();
}
static CloudIdResult decode(Object result) {
result as List<Object?>;
return CloudIdResult(
assetId: result[0]! as String,
error: result[1] as String?,
cloudId: result[2] as String?,
);
return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?);
}
@override
@@ -418,7 +385,9 @@ class CloudIdResult {
if (identical(this, other)) {
return true;
}
return _deepEquals(assetId, other.assetId) && _deepEquals(error, other.error) && _deepEquals(cloudId, other.cloudId);
return _deepEquals(assetId, other.assetId) &&
_deepEquals(error, other.error) &&
_deepEquals(cloudId, other.cloudId);
}
@override
@@ -426,7 +395,6 @@ class CloudIdResult {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -434,22 +402,22 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is PlatformAssetPlaybackStyle) {
} else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
} else if (value is PlatformAsset) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is PlatformAlbum) {
} else if (value is PlatformAlbum) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is SyncDelta) {
} else if (value is SyncDelta) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is HashResult) {
} else if (value is HashResult) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
} else if (value is CloudIdResult) {
buffer.putUint8(134);
writeValue(buffer, value.encode());
} else {
@@ -484,8 +452,8 @@ class NativeSyncApi {
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -493,7 +461,8 @@ class NativeSyncApi {
final String pigeonVar_messageChannelSuffix;
Future<bool> shouldFullSync() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -503,16 +472,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<SyncDelta> getMediaChanges() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -522,16 +491,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as SyncDelta;
}
Future<void> checkpointSync() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -540,16 +509,12 @@ class NativeSyncApi {
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,
)
;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
Future<void> clearSyncCheckpoint() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -558,16 +523,12 @@ class NativeSyncApi {
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,
)
;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
Future<List<String>> getAssetIdsForAlbum(String albumId) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -577,16 +538,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
}
Future<List<PlatformAlbum>> getAlbums() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -596,16 +557,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAlbum>();
}
Future<int> getAssetsCountSince(String albumId, int timestamp) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -615,16 +576,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as int;
}
Future<List<PlatformAsset>> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -634,16 +595,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAsset>();
}
Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -653,16 +614,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>();
}
Future<void> cancelHashing() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -671,16 +632,12 @@ class NativeSyncApi {
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,
)
;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -690,16 +647,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
}
Future<bool> restoreFromTrashById(String mediaId, int type) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -709,16 +666,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -728,11 +685,10 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
}
}
-208
View File
@@ -1,208 +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?;
}
}
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.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/infrastructure/action.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_ui/immich_ui.dart';
@@ -30,11 +26,7 @@ class UploadActionButton extends ConsumerWidget {
}
final isTimeline = source == ActionSource.timeline;
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
List<LocalAsset>? assets;
var isUploadDialogOpen = false;
var wasUploadCancelled = false;
Future<void>? uploadDialogFuture;
if (source == ActionSource.timeline) {
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
@@ -43,50 +35,22 @@ class UploadActionButton extends ConsumerWidget {
}
ref.read(multiSelectProvider.notifier).reset();
} else {
isUploadDialogOpen = true;
uploadDialogFuture =
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => _UploadProgressDialog(
onCancel: () {
wasUploadCancelled = true;
},
),
).whenComplete(() {
isUploadDialogOpen = false;
});
unawaited(uploadDialogFuture);
unawaited(
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => const _UploadProgressDialog(),
),
);
}
var success = false;
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;
}
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
if (!isTimeline && context.mounted && isUploadDialogOpen) {
if (!isTimeline && context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
if (context.mounted && !success && !wasUploadCancelled) {
if (context.mounted && !result.success) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
@@ -109,9 +73,7 @@ class UploadActionButton extends ConsumerWidget {
}
class _UploadProgressDialog extends ConsumerWidget {
final VoidCallback onCancel;
const _UploadProgressDialog({required this.onCancel});
const _UploadProgressDialog();
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -141,8 +103,7 @@ class _UploadProgressDialog extends ConsumerWidget {
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
onCancel();
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context).pop();
},
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/infrastructure/metadata.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/photo_view/photo_view.dart';
@@ -324,16 +323,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent,
required bool isPlayingMotionVideo,
required String? localFilePath,
}) {
final size = context.sizeData;
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView(
key: Key(asset.heroTag),
index: widget.index,
imageProvider: imageProvider,
imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
gaplessPlayback: true,
@@ -380,9 +377,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
child: NativeVideoViewer(
key: _NativeVideoViewerKey(asset.heroTag),
asset: asset,
localFilePath: localFilePath,
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));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final asset = _asset;
if (asset == null) {
@@ -422,8 +421,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_scrollController.snapPosition.snapOffset = _snapOffset;
}
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
return Stack(
children: [
SingleChildScrollView(
@@ -443,7 +440,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
: null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
localFilePath: viewIntentFilePath,
),
),
IgnorePointer(
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.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 {
final BaseAsset asset;
final String? localFilePath;
final bool isCurrent;
final bool showControls;
final Widget image;
@@ -28,7 +26,6 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
const NativeVideoViewer({
super.key,
required this.asset,
this.localFilePath,
required this.image,
this.isCurrent = false,
this.showControls = true,
@@ -109,19 +106,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
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) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
@@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:async/async.dart';
@@ -147,17 +146,10 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
}
ImageProvider getFullImageProvider(
BaseAsset asset, {
Size size = const Size(1080, 1920),
bool edited = true,
String? localFilePath,
}) {
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
// Create new provider and cache it
final ImageProvider provider;
if (localFilePath != null) {
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
} else {
@@ -1,101 +1,101 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.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:logging/logging.dart';
import 'package:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
ref.watch(appRouterProvider),
ref.read(foregroundUploadServiceProvider),
ref.read(shareIntentServiceProvider),
)),
);
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
final AppRouter router;
final ForegroundUploadService _foregroundUploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
void init() {
_shareIntentService.onSharedMedia = onSharedMedia;
_shareIntentService.init();
}
void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments();
addAttachments(attachments);
router.push(ShareIntentRoute(attachments: attachments));
}
void addAttachments(List<ShareIntentAttachment> attachments) {
if (attachments.isEmpty) {
return;
}
state = [...state, ...attachments];
}
void removeAttachment(ShareIntentAttachment attachment) {
final updatedState = state.where((element) => element != attachment).toList();
if (updatedState.length != state.length) {
state = updatedState;
}
}
void clearAttachments() {
if (state.isEmpty) {
return;
}
state = [];
}
Future<void> uploadAll(List<File> files) async {
for (final file in files) {
final fileId = p.hash(file.path).toString();
_updateStatus(fileId, UploadStatus.running);
}
await _foregroundUploadService.uploadShareIntent(
files,
onProgress: (fileId, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
_updateProgress(fileId, progress);
},
onSuccess: (fileId, _) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
},
onError: (fileId, errorMessage) {
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
_updateStatus(fileId, UploadStatus.failed);
},
);
}
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id)
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
else
attachment,
];
}
void _updateProgress(String fileId, double progress) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
];
}
}
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.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:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
ref.watch(appRouterProvider),
ref.read(foregroundUploadServiceProvider),
ref.read(shareIntentServiceProvider),
)),
);
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
final AppRouter router;
final ForegroundUploadService _foregroundUploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
void init() {
_shareIntentService.onSharedMedia = onSharedMedia;
_shareIntentService.init();
}
void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments();
addAttachments(attachments);
router.push(ShareIntentRoute(attachments: attachments));
}
void addAttachments(List<ShareIntentAttachment> attachments) {
if (attachments.isEmpty) {
return;
}
state = [...state, ...attachments];
}
void removeAttachment(ShareIntentAttachment attachment) {
final updatedState = state.where((element) => element != attachment).toList();
if (updatedState.length != state.length) {
state = updatedState;
}
}
void clearAttachments() {
if (state.isEmpty) {
return;
}
state = [];
}
Future<void> uploadAll(List<File> files) async {
for (final file in files) {
final fileId = p.hash(file.path).toString();
_updateStatus(fileId, UploadStatus.running);
}
await _foregroundUploadService.uploadShareIntent(
files,
onProgress: (fileId, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
_updateProgress(fileId, progress);
},
onSuccess: (fileId) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
},
onError: (fileId, errorMessage) {
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
_updateStatus(fileId, UploadStatus.failed);
},
);
}
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id)
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
else
attachment,
];
}
void _updateProgress(String fileId, double progress) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
];
}
}
@@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.da
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -21,6 +22,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -31,12 +33,11 @@ class ActionResult {
final int count;
final bool success;
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
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> {
@@ -490,14 +491,10 @@ class ActionNotifier extends Notifier<void> {
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
if (assetsToUpload.isEmpty) {
return const ActionResult(count: 0, success: false, error: 'No assets to upload');
}
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
final remoteAssetIds = <String>[];
// Initialize progress for all assets
for (final asset in assetsToUpload) {
@@ -514,7 +511,6 @@ class ActionNotifier extends Notifier<void> {
progressNotifier.setProgress(localAssetId, progress);
},
onSuccess: (localAssetId, remoteAssetId) {
remoteAssetIds.add(remoteAssetId);
progressNotifier.remove(localAssetId);
},
onError: (localAssetId, errorMessage) {
@@ -522,14 +518,7 @@ class ActionNotifier extends Notifier<void> {
},
),
);
final uploadedCount = remoteAssetIds.length;
final success = uploadedCount == assetsToUpload.length;
return ActionResult(
count: assetsToUpload.length,
success: success,
error: success ? null : 'Uploaded $uploadedCount/${assetsToUpload.length} assets successfully',
remoteAssetIds: remoteAssetIds,
);
return ActionResult(count: assetsToUpload.length, success: true);
} catch (error, stack) {
_logger.severe('Failed manually upload assets', error, stack);
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
@@ -549,14 +538,22 @@ class ActionNotifier extends Notifier<void> {
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
}
final completer = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
return eventAsset?.id == ids.first;
}, const Duration(seconds: 10));
Future<void> editReady;
if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) {
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) {
final eventAsset = SyncAssetV2.fromJson(data["asset"]);
return eventAsset?.id == ids.first;
}, const Duration(seconds: 10));
} else {
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
return eventAsset?.id == ids.first;
}, const Duration(seconds: 10));
}
try {
await _service.applyEdits(ids.first, edits);
await completer;
await editReady;
return const ActionResult(count: 1, success: true);
} catch (error, stack) {
_logger.severe('Failed to apply edits to assets', error, stack);
@@ -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;
}
}
@@ -151,7 +151,7 @@ class ForegroundUploadService {
List<File> files, {
Completer<void>? cancelToken,
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,
}) async {
if (files.isEmpty) {
@@ -171,7 +171,7 @@ class ForegroundUploadService {
);
if (result.isSuccess) {
onSuccess?.call(fileId, result.remoteAssetId!);
onSuccess?.call(fileId);
} else if (!result.isCancelled && result.errorMessage != null) {
onError?.call(fileId, result.errorMessage!);
}
@@ -1,110 +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,69 +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 DriftLocalAssetRepository localAssetRepository,
required TimelineFactory timelineFactory,
}) : _localAssetRepository = localAssetRepository,
_timelineFactory = 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,
);
}
}
@@ -21,7 +21,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -183,11 +182,9 @@ class LoginForm extends HookConsumerWidget {
Future<void> handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
await viewIntentHandler.flushDeferredViewIntent();
await backgroundManager.hashAssets();
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
@@ -262,7 +259,7 @@ class LoginForm extends HookConsumerWidget {
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.router.replaceAll([const TabShellRoute()]));
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
} catch (error) {
@@ -349,7 +346,7 @@ class LoginForm extends HookConsumerWidget {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(context.router.replaceAll([const TabShellRoute()]));
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
} catch (error, stack) {
@@ -139,8 +139,6 @@ class PhotoViewCoreState extends State<PhotoViewCore>
PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes;
late ScaleBoundaries cachedScaleBoundaries = widget.scaleBoundaries;
void handleScaleAnimation() {
scale = _scaleAnimation!.value;
}
@@ -303,7 +301,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
controller.scaleAnimationBuilder(_animateControllerScale);
controller.rotationAnimationBuilder(_animateControllerRotation);
cachedScaleBoundaries = widget.scaleBoundaries;
_updateScaleBoundaries();
_scaleAnimationController = AnimationController(vsync: this)
..addListener(handleScaleAnimation)
@@ -334,14 +332,29 @@ class PhotoViewCoreState extends State<PhotoViewCore>
widget.onTapDown?.call(context, details, controller.value);
}
@override
Widget build(BuildContext context) {
// Check if we need a recalc on the scale
if (widget.scaleBoundaries != cachedScaleBoundaries) {
markNeedsScaleRecalc = true;
cachedScaleBoundaries = widget.scaleBoundaries;
void _updateScaleBoundaries() {
final prev = controller.scaleBoundaries;
if (prev == widget.scaleBoundaries) {
return;
}
if (prev != null && controller.scale != null && prev.initialScale > 0) {
final ratio = widget.scaleBoundaries.initialScale / prev.initialScale;
controller.setScaleInvisibly(controller.scale! * ratio);
} else {
markNeedsScaleRecalc = true;
}
controller.scaleBoundaries = widget.scaleBoundaries;
}
@override
void didUpdateWidget(PhotoViewCore oldWidget) {
super.didUpdateWidget(oldWidget);
_updateScaleBoundaries();
}
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: controller.outputStateStream,
initialData: controller.prevValue,
@@ -145,7 +145,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
_lastStack = null;
_didLoadSynchronously = synchronousCall;
widget.controller.scaleBoundaries = scaleBoundaries;
}
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
+1 -2
View File
@@ -29,8 +29,7 @@ run = [
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
"dart run pigeon --input pigeon/connectivity_api.dart",
"dart run pigeon --input pigeon/network_api.dart",
"dart run pigeon --input pigeon/view_intent_api.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart",
]
[tasks."codegen:translation"]
+3
View File
@@ -206,6 +206,7 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
*PluginsApi* | [**searchPluginTemplates**](doc//PluginsApi.md#searchplugintemplates) | **GET** /plugins/templates | Retrieve workflow templates
*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
@@ -491,6 +492,8 @@ Class | Method | HTTP request | Description
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTemplateResponseDto](doc//PluginTemplateResponseDto.md)
- [PluginTemplateStepResponseDto](doc//PluginTemplateStepResponseDto.md)
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)
- [QueueCommand](doc//QueueCommand.md)
+2
View File
@@ -237,6 +237,8 @@ part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart';
part 'model/plugin_method_response_dto.dart';
part 'model/plugin_response_dto.dart';
part 'model/plugin_template_response_dto.dart';
part 'model/plugin_template_step_response_dto.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';
part 'model/queue_command.dart';
+51
View File
@@ -204,6 +204,57 @@ class PluginsApi {
return null;
}
/// Retrieve workflow templates
///
/// Retrieve workflow templates provided by installed plugins
///
/// Note: This method returns the HTTP [Response].
Future<Response> searchPluginTemplatesWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/templates';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Retrieve workflow templates
///
/// Retrieve workflow templates provided by installed plugins
Future<List<PluginTemplateResponseDto>?> searchPluginTemplates() async {
final response = await searchPluginTemplatesWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTemplateResponseDto>') as List)
.cast<PluginTemplateResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all plugins
///
/// Retrieve a list of plugins available to the authenticated user.
+4
View File
@@ -520,6 +520,10 @@ class ApiClient {
return PluginMethodResponseDto.fromJson(value);
case 'PluginResponseDto':
return PluginResponseDto.fromJson(value);
case 'PluginTemplateResponseDto':
return PluginTemplateResponseDto.fromJson(value);
case 'PluginTemplateStepResponseDto':
return PluginTemplateStepResponseDto.fromJson(value);
case 'PurchaseResponse':
return PurchaseResponse.fromJson(value);
case 'PurchaseUpdate':
+135
View File
@@ -0,0 +1,135 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginTemplateResponseDto {
/// Returns a new [PluginTemplateResponseDto] instance.
PluginTemplateResponseDto({
required this.description,
required this.key,
this.steps = const [],
required this.title,
required this.trigger,
});
/// Template description
String description;
/// Template key (unique across all templates)
String key;
/// Workflow steps
List<PluginTemplateStepResponseDto> steps;
/// Template title
String title;
WorkflowTrigger trigger;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
other.description == description &&
other.key == key &&
_deepEquality.equals(other.steps, steps) &&
other.title == title &&
other.trigger == trigger;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(description.hashCode) +
(key.hashCode) +
(steps.hashCode) +
(title.hashCode) +
(trigger.hashCode);
@override
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'description'] = this.description;
json[r'key'] = this.key;
json[r'steps'] = this.steps;
json[r'title'] = this.title;
json[r'trigger'] = this.trigger;
return json;
}
/// Returns a new [PluginTemplateResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTemplateResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTemplateResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTemplateResponseDto(
description: mapValueOfType<String>(json, r'description')!,
key: mapValueOfType<String>(json, r'key')!,
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
title: mapValueOfType<String>(json, r'title')!,
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
);
}
return null;
}
static List<PluginTemplateResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTemplateResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTemplateResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTemplateResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTemplateResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTemplateResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTemplateResponseDto-objects as value to a dart map
static Map<String, List<PluginTemplateResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTemplateResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTemplateResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'description',
'key',
'steps',
'title',
'trigger',
};
}
@@ -0,0 +1,131 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginTemplateStepResponseDto {
/// Returns a new [PluginTemplateStepResponseDto] instance.
PluginTemplateStepResponseDto({
this.config = const {},
this.enabled,
required this.method,
});
/// Step configuration
Map<String, Object>? config;
/// Whether the step is enabled
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? enabled;
/// Step plugin method
String method;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateStepResponseDto &&
_deepEquality.equals(other.config, config) &&
other.enabled == enabled &&
other.method == method;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(config == null ? 0 : config!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(method.hashCode);
@override
String toString() => 'PluginTemplateStepResponseDto[config=$config, enabled=$enabled, method=$method]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.config != null) {
json[r'config'] = this.config;
} else {
// json[r'config'] = null;
}
if (this.enabled != null) {
json[r'enabled'] = this.enabled;
} else {
// json[r'enabled'] = null;
}
json[r'method'] = this.method;
return json;
}
/// Returns a new [PluginTemplateStepResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTemplateStepResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTemplateStepResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTemplateStepResponseDto(
config: mapCastOfType<String, Object>(json, r'config'),
enabled: mapValueOfType<bool>(json, r'enabled'),
method: mapValueOfType<String>(json, r'method')!,
);
}
return null;
}
static List<PluginTemplateStepResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTemplateStepResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTemplateStepResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTemplateStepResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTemplateStepResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTemplateStepResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTemplateStepResponseDto-objects as value to a dart map
static Map<String, List<PluginTemplateStepResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTemplateStepResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTemplateStepResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'config',
'method',
};
}
@@ -276,8 +276,6 @@ class TimeBucketAssetResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'city',
'country',
'createdAt',
'duration',
'fileCreatedAt',
+2 -1
View File
@@ -5,7 +5,8 @@ import 'package:pigeon/pigeon.dart';
dartOut: 'lib/platform/local_image_api.g.dart',
swiftOut: 'ios/Runner/Images/LocalImages.g.swift',
swiftOptions: SwiftOptions(includeErrorClass: false),
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
kotlinOut:
'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
-24
View File
@@ -1,24 +0,0 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/view_intent_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.viewintent'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
class ViewIntentPayload {
final String? path;
final String mimeType;
final String? localAssetId;
const ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
}
@HostApi()
abstract class ViewIntentHostApi {
@async
ViewIntentPayload? consumeViewIntent();
}
@@ -1,261 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.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/user.service.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.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:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:mocktail/mocktail.dart';
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {}
class MockAppRouter extends Mock implements AppRouter {}
class MockAuthService extends Mock implements AuthService {}
class MockApiService extends Mock implements ApiService {}
class MockUserService extends Mock implements UserService {}
class MockSecureStorageService extends Mock implements SecureStorageService {}
class MockWidgetService extends Mock implements WidgetService {}
class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
class FakeTimelineService extends Fake implements TimelineService {}
class TestViewIntentService extends ViewIntentService {
ViewIntentPayload? consumedAttachment;
int cleanupStaleTempFilesCalls = 0;
int cleanupManagedTempFileCalls = 0;
final List<String> managedTempPaths = [];
TestViewIntentService() : super(MockViewIntentHostApi());
@override
Future<ViewIntentPayload?> consumeViewIntent() async => consumedAttachment;
@override
Future<void> cleanupStaleTempFiles() async {
cleanupStaleTempFilesCalls++;
}
@override
Future<void> cleanupManagedTempFile() async {
cleanupManagedTempFileCalls++;
}
@override
Future<void> setManagedTempFilePath(String path) async {
managedTempPaths.add(path);
}
}
class TestAuthNotifier extends AuthNotifier {
TestAuthNotifier(Ref ref, AuthState initial)
: super(
MockAuthService(),
MockApiService(),
MockUserService(),
MockSecureStorageService(),
MockWidgetService(),
ref,
) {
state = initial;
}
void setAuthenticated(bool isAuthenticated) {
state = state.copyWith(isAuthenticated: isAuthenticated);
}
}
final _handlerProvider = Provider<AndroidViewIntentHandler>((ref) => AndroidViewIntentHandler(ref));
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late TestViewIntentService viewIntentService;
late MockViewIntentAssetResolver resolver;
late MockAppRouter router;
late TestAuthNotifier authNotifier;
late ProviderContainer container;
late AndroidViewIntentHandler handler;
late ViewIntentPayload payload;
late LocalAsset deepLinkAsset;
late TimelineService deepLinkTimelineService;
setUpAll(() {
registerFallbackValue(FakePageRouteInfo());
registerFallbackValue(<PageRouteInfo<dynamic>>[]);
registerFallbackValue(FakeTimelineService());
registerFallbackValue(
ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'),
);
});
setUp(() async {
viewIntentService = TestViewIntentService();
resolver = MockViewIntentAssetResolver();
router = MockAppRouter();
payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1');
deepLinkAsset = _localAsset(id: 'local-1');
deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink);
when(() => router.replaceAll(any())).thenAnswer((_) async {});
container = ProviderContainer(
overrides: [
viewIntentServiceProvider.overrideWithValue(viewIntentService),
viewIntentAssetResolverProvider.overrideWithValue(resolver),
appRouterProvider.overrideWithValue(router),
authProvider.overrideWith((ref) {
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
return authNotifier;
}),
],
);
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
handler = container.read(_handlerProvider);
addTearDown(() async {
await deepLinkTimelineService.dispose();
container.dispose();
});
});
test('handle defers unauthenticated attachment', () async {
authNotifier.setAuthenticated(false);
await handler.handle(payload);
expect(container.read(viewIntentPendingProvider), payload);
verifyNever(() => resolver.resolve(any()));
});
testWidgets('flushDeferredViewIntent consumes the pending attachment and routes the viewer', (tester) async {
authNotifier.setAuthenticated(false);
container.read(viewIntentPendingProvider.notifier).defer(payload);
authNotifier.setAuthenticated(true);
when(() => resolver.resolve(payload)).thenAnswer((_) async {
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
});
unawaited(handler.flushDeferredViewIntent());
await tester.pump();
await tester.pump();
await tester.idle();
expect(container.read(viewIntentPendingProvider), isNull);
verify(() => resolver.resolve(payload)).called(1);
});
test('flushDeferredViewIntent does nothing when there is no pending attachment', () async {
await handler.flushDeferredViewIntent();
verifyNever(() => resolver.resolve(any()));
});
test('onAppResumed cleans stale temp files when no attachment is present', () async {
viewIntentService.consumedAttachment = null;
await handler.onAppResumed();
expect(viewIntentService.cleanupStaleTempFilesCalls, 1);
verifyNever(() => resolver.resolve(any()));
});
test('onAppResumed does not clean stale temp files while pending attachment exists', () async {
viewIntentService.consumedAttachment = null;
container.read(viewIntentPendingProvider.notifier).defer(payload);
await handler.onAppResumed();
expect(viewIntentService.cleanupStaleTempFilesCalls, 0);
verifyNever(() => resolver.resolve(any()));
});
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
viewIntentService.consumedAttachment = payload;
when(() => resolver.resolve(payload)).thenAnswer(
(_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService),
);
unawaited(handler.onAppResumed());
await tester.pump();
await tester.pump();
await tester.pump();
await tester.idle();
verify(() => resolver.resolve(payload)).called(1);
// Routes the user to [TabShell, AssetViewer] so back-press lands on the
// main timeline mirrors the home-screen widget navigation pattern.
final captured = verify(() => router.replaceAll(captureAny())).captured;
expect(captured, hasLength(1));
final routes = captured.single as List<PageRouteInfo<dynamic>>;
expect(routes, hasLength(2));
expect(routes[0].routeName, TabShellRoute.name);
expect(routes[1].routeName, AssetViewerRoute.name);
});
}
AuthState _authState({required bool isAuthenticated}) {
return AuthState(
deviceId: 'device-1',
userId: 'user-1',
userEmail: 'user@example.com',
isAuthenticated: isAuthenticated,
name: 'User',
isAdmin: false,
profileImagePath: '',
);
}
LocalAsset _localAsset({required String id}) {
return LocalAsset(
id: id,
name: '$id.jpg',
checksum: 'checksum-1',
type: AssetType.image,
createdAt: DateTime(2026, 4, 20),
updatedAt: DateTime(2026, 4, 20),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
return TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
origin: origin,
));
}
Future<TimelineService> _createReadyTimelineService(List<BaseAsset> assets, TimelineOrigin origin) async {
final timelineService = _timelineServiceFromAssets(assets, origin);
// Spin a few async ticks so the internal bucket subscription has populated
// the buffer before tests start asserting against totalAssets.
for (var i = 0; i < 20 && timelineService.totalAssets != assets.length; i++) {
await Future<void>.delayed(Duration.zero);
}
return timelineService;
}
@@ -1,64 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
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_pending.provider.dart';
void main() {
late DateTime now;
late ProviderContainer container;
final attachment = ViewIntentPayload(
path: '/tmp/file.jpg',
mimeType: 'image/jpeg',
localAssetId: '42',
);
setUp(() {
now = DateTime(2026, 4, 17, 12);
container = ProviderContainer(
overrides: [viewIntentNowProvider.overrideWithValue(() => now)],
);
addTearDown(container.dispose);
});
test('defer stores pending attachment', () {
container.read(viewIntentPendingProvider.notifier).defer(attachment);
expect(container.read(viewIntentPendingProvider), attachment);
});
test('takeIfFresh returns pending attachment once', () {
container.read(viewIntentPendingProvider.notifier).defer(attachment);
final first = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
final second = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
expect(first, attachment);
expect(second, isNull);
});
test('takeIfFresh drops expired attachment', () {
container.read(viewIntentPendingProvider.notifier).defer(attachment);
now = now.add(const Duration(minutes: 11));
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
expect(result, isNull);
expect(container.read(viewIntentPendingProvider), isNull);
});
test('newer deferred attachment replaces older one', () {
final newerAttachment = ViewIntentPayload(
path: '/tmp/file-2.jpg',
mimeType: 'image/jpeg',
localAssetId: '43',
);
container.read(viewIntentPendingProvider.notifier).defer(attachment);
container.read(viewIntentPendingProvider.notifier).defer(newerAttachment);
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
expect(result, newerAttachment);
});
}
@@ -1,123 +0,0 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.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/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:mocktail/mocktail.dart';
import '../infrastructure/repository.mock.dart';
class MockTimelineFactory extends Mock implements TimelineFactory {}
void main() {
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockTimelineFactory timelineFactory;
late List<TimelineService> createdTimelineServices;
late ProviderContainer container;
setUp(() {
mockLocalAssetRepository = MockDriftLocalAssetRepository();
timelineFactory = MockTimelineFactory();
createdTimelineServices = [];
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
final timelineService = _timelineServiceFromAssets(assets, TimelineOrigin.deepLink);
createdTimelineServices.add(timelineService);
return timelineService;
});
container = ProviderContainer(
overrides: [
localAssetRepository.overrideWith((ref) => mockLocalAssetRepository),
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
],
);
addTearDown(() async {
for (final timelineService in createdTimelineServices) {
await timelineService.dispose();
}
container.dispose();
});
});
test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async {
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1');
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
expect(result.asset, equals(localAsset));
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed');
});
test('returns transient asset with temp file path when localAssetId has no DB row', () async {
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null);
final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg'));
expect(result.asset, isA<LocalAsset>());
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, '/tmp/incoming.jpg');
});
test('returns transient asset for path-only attachment', () async {
final result = await _resolve(
container,
_payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'),
);
expect(result.asset, isA<LocalAsset>());
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, '/tmp/incoming.webp');
final asset = result.asset as LocalAsset;
expect(asset.localId, startsWith('-'));
expect(asset.name, 'incoming.webp');
expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated);
});
test('throws when neither localAssetId nor path is provided', () async {
await expectLater(
_resolve(container, _payload(localAssetId: null, path: null)),
throwsA(isA<StateError>()),
);
});
}
Future<ViewIntentResolvedAsset> _resolve(ProviderContainer container, ViewIntentPayload payload) {
return container.read(viewIntentAssetResolverProvider).resolve(payload);
}
ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) {
return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId);
}
LocalAsset _localAsset({required String id, String? checksum}) {
return LocalAsset(
id: id,
name: '$id.jpg',
checksum: checksum,
type: AssetType.image,
createdAt: DateTime(2026, 4, 20),
updatedAt: DateTime(2026, 4, 20),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
return TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
origin: origin,
));
}
@@ -1,119 +0,0 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:mocktail/mocktail.dart';
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
void main() {
late MockViewIntentHostApi hostApi;
late ViewIntentService service;
late Directory tempRoot;
late Directory cacheDir;
final attachment = ViewIntentPayload(
path: '/tmp/file.jpg',
mimeType: 'image/jpeg',
localAssetId: '42',
);
setUp(() {
hostApi = MockViewIntentHostApi();
tempRoot = Directory.systemTemp.createTempSync('view-intent-root');
cacheDir = Directory('${tempRoot.path}/cache')..createSync();
service = ViewIntentService(hostApi, temporaryDirectory: () async => cacheDir);
});
tearDown(() async {
clearInteractions(hostApi);
if (await tempRoot.exists()) {
await tempRoot.delete(recursive: true);
}
});
test('consumeViewIntent returns null when no attachment', () async {
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null);
final result = await service.consumeViewIntent();
expect(result, isNull);
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('consumeViewIntent returns attachment when present', () async {
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment);
final result = await service.consumeViewIntent();
expect(result, attachment);
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('consumeViewIntent swallows host api errors', () async {
when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom'));
final result = await service.consumeViewIntent();
expect(result, isNull);
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('setManagedTempFilePath cleans previous managed temp file', () async {
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
await service.setManagedTempFilePath(firstFile.path);
await service.setManagedTempFilePath(secondFile.path);
expect(await firstFile.exists(), isFalse);
expect(await secondFile.exists(), isTrue);
await service.cleanupManagedTempFile();
expect(await secondFile.exists(), isFalse);
});
test('cleanupTempFile defers deletion while an upload is active', () async {
final tempFile = File('${cacheDir.path}/view_intent_in_flight.jpg')..writeAsStringSync('bytes');
service.markUploadActive(tempFile.path);
await service.cleanupTempFile(tempFile.path);
expect(await tempFile.exists(), isTrue, reason: 'active uploads block cleanup');
await service.markUploadInactive(tempFile.path);
expect(await tempFile.exists(), isFalse);
});
test('cleanupTempFile ignores non-managed paths', () async {
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
await service.cleanupTempFile(nonManagedFile.path);
expect(await nonManagedFile.exists(), isTrue);
});
test('cleanupStaleTempFiles removes view-intent temp files and keeps unrelated files', () async {
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
final unrelatedFile = File('${cacheDir.path}/plain_file.jpg')..writeAsStringSync('plain');
await service.cleanupStaleTempFiles();
expect(await firstFile.exists(), isFalse);
expect(await secondFile.exists(), isFalse);
expect(await unrelatedFile.exists(), isTrue);
});
test('cleanupStaleTempFiles skips paths with active uploads', () async {
final stale = File('${cacheDir.path}/view_intent_stale.jpg')..writeAsStringSync('stale');
final active = File('${cacheDir.path}/view_intent_active.jpg')..writeAsStringSync('active');
service.markUploadActive(active.path);
await service.cleanupStaleTempFiles();
expect(await stale.exists(), isFalse);
expect(await active.exists(), isTrue);
});
}
+110 -3
View File
@@ -8818,6 +8818,50 @@
"x-immich-permission": "plugin.read"
}
},
"/plugins/templates": {
"get": {
"description": "Retrieve workflow templates provided by installed plugins",
"operationId": "searchPluginTemplates",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PluginTemplateResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Retrieve workflow templates",
"tags": [
"Plugins"
],
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Added"
}
],
"x-immich-permission": "plugin.read"
}
},
"/plugins/{id}": {
"get": {
"description": "Retrieve information about a specific plugin by its ID.",
@@ -20131,6 +20175,64 @@
],
"type": "object"
},
"PluginTemplateResponseDto": {
"properties": {
"description": {
"description": "Template description",
"type": "string"
},
"key": {
"description": "Template key (unique across all templates)",
"type": "string"
},
"steps": {
"description": "Workflow steps",
"items": {
"$ref": "#/components/schemas/PluginTemplateStepResponseDto"
},
"type": "array"
},
"title": {
"description": "Template title",
"type": "string"
},
"trigger": {
"$ref": "#/components/schemas/WorkflowTrigger",
"description": "Workflow trigger"
}
},
"required": [
"description",
"key",
"steps",
"title",
"trigger"
],
"type": "object"
},
"PluginTemplateStepResponseDto": {
"properties": {
"config": {
"additionalProperties": {},
"description": "Step configuration",
"nullable": true,
"type": "object"
},
"enabled": {
"description": "Whether the step is enabled",
"type": "boolean"
},
"method": {
"description": "Step plugin method",
"type": "string"
}
},
"required": [
"config",
"method"
],
"type": "object"
},
"PurchaseResponse": {
"properties": {
"hideBuyButtonUntil": {
@@ -20793,7 +20895,14 @@
"description": "Total number of matching assets",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
"type": "integer",
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Deprecated"
}
],
"x-immich-state": "Deprecated"
}
},
"required": [
@@ -25215,8 +25324,6 @@
}
},
"required": [
"city",
"country",
"createdAt",
"duration",
"fileCreatedAt",
+30
View File
@@ -5,6 +5,36 @@
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasmPath": "dist/plugin.wasm",
"templates": [
{
"name": "auto-archive-screenshots",
"title": "Auto-archive screenshots",
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
"trigger": "AssetCreate",
"steps": [
{
"method": "immich-plugin-core#assetFileFilter",
"config": {
"pattern": "screenshot",
"matchType": "contains",
"caseSensitive": false
}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
},
{
"method": "immich-plugin-core#assetArchive",
"config": {
"inverse": false
}
}
]
}
],
"methods": [
{
"name": "assetFileFilter",
+5
View File
@@ -100,6 +100,11 @@ export const assetTrash = () => {
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
if (config.albumIds.length === 0) {
// noop
return {};
}
if (config.albumIds.length === 1) {
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
return {};
+35 -2
View File
@@ -1514,6 +1514,28 @@ export type PluginResponseDto = {
/** Plugin version */
version: string;
};
export type PluginTemplateStepResponseDto = {
/** Step configuration */
config: {
[key: string]: any;
} | null;
/** Whether the step is enabled */
enabled?: boolean;
/** Step plugin method */
method: string;
};
export type PluginTemplateResponseDto = {
/** Template description */
description: string;
/** Template key (unique across all templates) */
key: string;
/** Workflow steps */
steps: PluginTemplateStepResponseDto[];
/** Template title */
title: string;
/** Workflow trigger */
trigger: WorkflowTrigger;
};
export type QueueResponseDto = {
/** Whether the queue is paused */
isPaused: boolean;
@@ -2590,9 +2612,9 @@ export type TagUpdateDto = {
};
export type TimeBucketAssetResponseDto = {
/** Array of city names extracted from EXIF GPS data */
city: (string | null)[];
city?: (string | null)[];
/** Array of country names extracted from EXIF GPS data */
country: (string | null)[];
country?: (string | null)[];
/** Array of UTC timestamps when each asset was originally uploaded to Immich */
createdAt: string[];
/** Array of video/gif durations in milliseconds (null for static images) */
@@ -5242,6 +5264,17 @@ export function searchPluginMethods({ description, enabled, id, name, pluginName
...opts
}));
}
/**
* Retrieve workflow templates
*/
export function searchPluginTemplates(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginTemplateResponseDto[];
}>("/plugins/templates", {
...opts
}));
}
/**
* Retrieve a plugin
*/
+5 -5
View File
@@ -758,8 +758,8 @@ importers:
specifier: workspace:*
version: link:../packages/sdk
'@immich/ui':
specifier: ^0.77.0
version: 0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
specifier: ^0.79.0
version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0
version: 0.4.0
@@ -3204,8 +3204,8 @@ packages:
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
hasBin: true
'@immich/ui@0.77.3':
resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==}
'@immich/ui@0.79.0':
resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
peerDependencies:
'@sveltejs/kit': ^2.13.0
svelte: ^5.0.0
@@ -15879,7 +15879,7 @@ snapshots:
pg-connection-string: 2.13.0
postgres: 3.4.9
'@immich/ui@0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
'@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
dependencies:
'@internationalized/date': 3.12.1
'@mdi/js': 7.4.47
@@ -6,6 +6,7 @@ import {
PluginMethodSearchDto,
PluginResponseDto,
PluginSearchDto,
PluginTemplateResponseDto,
} from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
@@ -39,6 +40,17 @@ export class PluginController {
return this.service.searchMethods(dto);
}
@Get('templates')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'Retrieve workflow templates',
description: 'Retrieve workflow templates provided by installed plugins',
history: HistoryBuilder.v3(),
})
searchPluginTemplates(): Promise<PluginTemplateResponseDto[]> {
return this.service.searchTemplates();
}
@Get(':id')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
+27 -1
View File
@@ -1,6 +1,6 @@
import { createZodDto } from 'nestjs-zod';
import { JsonSchemaSchema } from 'src/dtos/json-schema.dto';
import { WorkflowTypeSchema } from 'src/enum';
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import z from 'zod';
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
@@ -23,6 +23,24 @@ const PluginManifestMethodSchema = z
})
.meta({ id: 'PluginManifestMethodDto' });
const PluginManifestTemplateStepSchema = z
.object({
method: z.string().min(1).describe('Step plugin method (pluginName#methodName)'),
config: z.record(z.string(), z.unknown()).nullable().optional().describe('Step configuration'),
enabled: z.boolean().optional().describe('Whether the step is enabled'),
})
.meta({ id: 'PluginManifestTemplateStepDto' });
const PluginManifestTemplateSchema = z
.object({
name: z.string().min(1).describe('Template name (must be unique within the manifest)'),
title: z.string().min(1).describe('Template title'),
description: z.string().min(1).describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
})
.meta({ id: 'PluginManifestTemplateDto' });
const PluginManifestSchema = z
.object({
name: z
@@ -39,6 +57,14 @@ const PluginManifestSchema = z
wasmPath: z.string().min(1).describe('WASM file path'),
author: z.string().min(1).describe('Plugin author'),
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
templates: z
.array(PluginManifestTemplateSchema)
.optional()
.default([])
.refine((templates) => new Set(templates.map((t) => t.name)).size === templates.length, {
error: 'Template names must be unique within the manifest',
})
.describe('Workflow templates'),
})
.meta({ id: 'PluginManifestDto' });
+48 -3
View File
@@ -1,7 +1,7 @@
import { createZodDto } from 'nestjs-zod';
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
import { asMethodString } from 'src/utils/workflow';
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
import { asPluginKey } from 'src/utils/workflow';
import z from 'zod';
const PluginSearchSchema = z
@@ -43,6 +43,24 @@ const PluginResponseSchema = z
})
.meta({ id: 'PluginResponseDto' });
const PluginTemplateStepResponseSchema = z
.object({
method: z.string().describe('Step plugin method'),
config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'),
enabled: z.boolean().optional().describe('Whether the step is enabled'),
})
.meta({ id: 'PluginTemplateStepResponseDto' });
const PluginTemplateResponseSchema = z
.object({
key: z.string().describe('Template key (unique across all templates)'),
title: z.string().describe('Template title'),
description: z.string().describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
})
.meta({ id: 'PluginTemplateResponseDto' });
const PluginMethodSearchSchema = z
.object({
id: z.uuidv4().optional().describe('Plugin method ID'),
@@ -61,6 +79,33 @@ export class PluginSearchDto extends createZodDto(PluginSearchSchema) {}
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {}
export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {}
export class PluginTemplateResponseDto extends createZodDto(PluginTemplateResponseSchema) {}
export type PluginTemplate = {
name: string;
title: string;
description: string;
trigger: WorkflowTrigger;
steps: Array<{
method: string;
config?: Record<string, unknown> | null;
enabled?: boolean;
}>;
};
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
return {
key: asPluginKey({ pluginName: plugin.name, name: template.name }),
title: template.title,
description: template.description,
trigger: template.trigger,
steps: template.steps.map((step) => ({
method: step.method,
config: step.config ?? null,
enabled: step.enabled,
})),
};
};
type Plugin = {
id: string;
@@ -101,7 +146,7 @@ export function mapPlugin(plugin: Plugin): PluginResponseDto {
export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => {
return {
key: asMethodString({ pluginName: method.pluginName, methodName: method.name }),
key: asPluginKey({ pluginName: method.pluginName, name: method.name }),
name: method.name,
title: method.title,
hostFunctions: method.hostFunctions,
+5 -1
View File
@@ -186,7 +186,11 @@ const SearchAlbumResponseSchema = z
const SearchAssetResponseSchema = z
.object({
total: z.int().min(0).describe('Total number of matching assets'),
total: z
.int()
.min(0)
.describe('Total number of matching assets')
.meta(new HistoryBuilder().deprecated('v3.0.0').getExtensions()),
count: z.int().min(0).describe('Number of assets in this page'),
items: z.array(AssetResponseSchema),
facets: z.array(SearchFacetResponseSchema),
+2 -2
View File
@@ -107,8 +107,8 @@ const TimeBucketAssetResponseSchema = z
livePhotoVideoId: z
.array(z.string().nullable())
.describe('Array of live photo video asset IDs (null for non-live photos)'),
city: z.array(z.string().nullable()).describe('Array of city names extracted from EXIF GPS data'),
country: z.array(z.string().nullable()).describe('Array of country names extracted from EXIF GPS data'),
city: z.array(z.string().nullable()).optional().describe('Array of city names extracted from EXIF GPS data'),
country: z.array(z.string().nullable()).optional().describe('Array of country names extracted from EXIF GPS data'),
latitude: z
.array(z.number().nullable())
.optional()
+4 -4
View File
@@ -384,8 +384,6 @@ with
asset."fileCreatedAt" at time zone 'utc' as "fileCreatedAt",
asset."createdAt" at time zone 'utc' as "createdAt",
encode("asset"."thumbhash", 'base64') as "thumbhash",
"asset_exif"."city",
"asset_exif"."country",
"asset_exif"."projectionType",
coalesce(
case
@@ -398,6 +396,8 @@ with
end,
1
) as "ratio",
"asset_exif"."city",
"asset_exif"."country",
"stack"
from
"asset"
@@ -432,8 +432,6 @@ with
),
"agg" as (
select
coalesce(array_agg("city"), '{}') as "city",
coalesce(array_agg("country"), '{}') as "country",
coalesce(array_agg("duration"), '{}') as "duration",
coalesce(array_agg("id"), '{}') as "id",
coalesce(array_agg("visibility"), '{}') as "visibility",
@@ -449,6 +447,8 @@ with
coalesce(array_agg("ratio"), '{}') as "ratio",
coalesce(array_agg("status"), '{}') as "status",
coalesce(array_agg("thumbhash"), '{}') as "thumbhash",
coalesce(array_agg("city"), '{}') as "city",
coalesce(array_agg("country"), '{}') as "country",
coalesce(json_agg("stack"), '[]') as "stack"
from
"cte"
+39
View File
@@ -35,6 +35,7 @@ select
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
(
select
coalesce(json_agg(agg), '[]')
@@ -60,6 +61,42 @@ from
order by
"plugin"."name"
-- PluginRepository.getByHash
select
"plugin"."id",
"plugin"."name",
"plugin"."title",
"plugin"."description",
"plugin"."author",
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"plugin_method"."name",
"plugin_method"."title",
"plugin_method"."description",
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
"plugin_method"
where
"plugin_method"."pluginId" = "plugin"."id"
) as agg
) as "methods"
from
"plugin"
where
"plugin"."sha256hash" = $1
-- PluginRepository.getByName
select
"plugin"."id",
@@ -70,6 +107,7 @@ select
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
(
select
coalesce(json_agg(agg), '[]')
@@ -105,6 +143,7 @@ select
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
(
select
coalesce(json_agg(agg), '[]')
+1 -1
View File
@@ -134,7 +134,7 @@ from
"cte"
where
"cte"."distance" <= $4
commit
rollback
-- SearchRepository.searchPlaces
select
+9 -4
View File
@@ -786,8 +786,6 @@ export class AssetRepository {
sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
sql`asset."createdAt" at time zone 'utc'`.as('createdAt'),
eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'),
'asset_exif.city',
'asset_exif.country',
'asset_exif.projectionType',
eb.fn
.coalesce(
@@ -801,6 +799,9 @@ export class AssetRepository {
)
.as('ratio'),
])
.$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) =>
qb.select(['asset_exif.city', 'asset_exif.country']),
)
.$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude']))
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility == undefined, withDefaultVisibility)
@@ -875,8 +876,6 @@ export class AssetRepository {
qb
.selectFrom('cte')
.select((eb) => [
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'),
eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'),
eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'),
@@ -894,6 +893,12 @@ export class AssetRepository {
eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
])
.$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) =>
qb.select((eb) => [
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
]),
)
.$if(!!options.withCoordinates, (qb) =>
qb.select((eb) => [
eb.fn.coalesce(eb.fn('array_agg', ['latitude']), sql.lit('{}')).as('latitude'),
@@ -274,6 +274,7 @@ export class DatabaseRepository {
columns: { ignoreExtra: true },
functions: { ignoreExtra: false },
parameters: { ignoreExtra: true },
extensions: { ignoreExtra: true },
});
return drift;
@@ -81,6 +81,7 @@ export class PluginRepository {
'plugin.version',
'plugin.createdAt',
'plugin.updatedAt',
'plugin.templates',
jsonArrayFrom(
eb
.selectFrom('plugin_method')
@@ -102,6 +103,11 @@ export class PluginRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.STRING] })
getByHash(hash: Buffer) {
return this.queryBuilder().where('plugin.sha256hash', '=', hash).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.STRING] })
getByName(name: string) {
return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst();
@@ -151,6 +157,8 @@ export class PluginRepository {
author: eb.ref('excluded.author'),
version: eb.ref('excluded.version'),
wasmBytes: eb.ref('excluded.wasmBytes'),
templates: eb.ref('excluded.templates'),
sha256hash: eb.ref('excluded.sha256hash'),
})),
)
.returning(['id', 'name'])
@@ -47,6 +47,7 @@ export class WorkflowRepository {
@GenerateSql({ params: [DummyValue.UUID] })
search(dto: WorkflowSearchDto & { ownerId?: string }) {
return this.queryBuilder()
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin" ADD "templates" jsonb NOT NULL DEFAULT '[]';`.execute(db);
await sql`ALTER TABLE "plugin" ADD "sha256hash" bytea NOT NULL DEFAULT decode('20464b37ad726d03d878d38d873c40a52d1fdfb754feda956ebb464afd689e2f', 'hex');`.execute(db);
await sql`ALTER TABLE "plugin" ALTER COLUMN "sha256hash" DROP DEFAULT;`.execute(db);
await sql`ALTER TABLE "plugin" ALTER COLUMN "templates" DROP DEFAULT;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin" DROP COLUMN "templates";`.execute(db);
await sql`ALTER TABLE "plugin" DROP COLUMN "sha256hash";`.execute(db);
}
+7
View File
@@ -8,6 +8,7 @@ import {
Unique,
UpdateDateColumn,
} from '@immich/sql-tools';
import { PluginTemplate } from 'src/dtos/plugin.dto';
@Unique({ columns: ['name', 'version'] })
@Table('plugin')
@@ -36,6 +37,12 @@ export class PluginTable {
@Column({ type: 'bytea' })
wasmBytes!: Buffer;
@Column({ type: 'jsonb' })
templates!: PluginTemplate[];
@Column({ type: 'bytea' })
sha256hash!: Buffer;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
+7
View File
@@ -2,10 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import {
mapMethod,
mapPlugin,
mapTemplate,
PluginMethodResponseDto,
PluginMethodSearchDto,
PluginResponseDto,
PluginSearchDto,
PluginTemplateResponseDto,
} from 'src/dtos/plugin.dto';
import { BaseService } from 'src/services/base.service';
import { isMethodCompatible } from 'src/utils/workflow';
@@ -31,4 +33,9 @@ export class PluginService extends BaseService {
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
.map((method) => mapMethod(method));
}
async searchTemplates(): Promise<PluginTemplateResponseDto[]> {
const plugins = await this.pluginRepository.search();
return plugins.flatMap((plugin) => plugin.templates.map((template) => mapTemplate(plugin, template)));
}
}
+4
View File
@@ -66,6 +66,10 @@ export class TimelineService extends BaseService {
await this.requireAccess({ auth, permission: Permission.TagRead, ids: [dto.tagId] });
}
if (auth.sharedLink && !auth.sharedLink.showExif) {
dto.withCoordinates = false;
}
if (dto.withPartners) {
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
@@ -11,6 +11,7 @@ import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import {
BootstrapEventPriority,
DatabaseLock,
ImmichEnvironment,
ImmichWorker,
JobName,
JobStatus,
@@ -43,8 +44,8 @@ export class WorkflowExecutionService extends BaseService {
// TODO avoid importing plugins in each worker
// Can this use system metadata similar to geocoding?
const { resourcePaths, plugins } = this.configRepository.getEnv();
await this.importFolder(resourcePaths.corePlugin, { force: true });
const { environment, resourcePaths, plugins } = this.configRepository.getEnv();
await this.importFolder(resourcePaths.corePlugin, { force: environment === ImmichEnvironment.Development });
if (plugins.external.allow && plugins.external.installFolder) {
await this.importFolders(plugins.external.installFolder);
@@ -166,7 +167,19 @@ export class WorkflowExecutionService extends BaseService {
private async importFolder(folder: string, options?: { force?: boolean }) {
try {
const manifestPath = join(folder, 'manifest.json');
const dto = await this.storageRepository.readJsonFile(manifestPath);
const bytes = await this.storageRepository.readFile(manifestPath);
const contents = bytes.toString('utf8');
const sha256hash = this.cryptoRepository.hashSha256(contents) as Buffer;
if (!options?.force) {
const match = await this.pluginRepository.getByHash(sha256hash);
if (match) {
this.logger.log(`Plugin up to date (name=${match.name}@${match.version}, hash=${sha256hash.toString('hex')}`);
return;
}
}
const dto = JSON.parse(contents);
const result = PluginManifestDto.schema.safeParse(dto);
if (!result.success) {
const issues = result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n');
@@ -176,22 +189,21 @@ export class WorkflowExecutionService extends BaseService {
const manifest = result.data;
const existing = await this.pluginRepository.getByName(manifest.name);
if (existing && existing.version === manifest.version && options?.force !== true) {
return;
}
const wasmPath = `${folder}/${manifest.wasmPath}`;
const wasmBytes = await this.storageRepository.readFile(wasmPath);
const plugin = await this.pluginRepository.upsert(
{
// NOTE: new properties here need to be added to the on conflict clause in the repository
enabled: true,
name: manifest.name,
title: manifest.title,
description: manifest.description,
author: manifest.author,
version: manifest.version,
templates: manifest.templates,
wasmBytes,
sha256hash,
},
manifest.methods,
);
+2 -2
View File
@@ -50,8 +50,8 @@ export const resolveMethod = (methods: PluginMethodSearchResponse[], method: str
return methods.find((method) => method.pluginName === pluginName && method.name === methodName);
};
export const asMethodString = (method: { pluginName: string; methodName: string }) => {
return `${method.pluginName}#${method.methodName}`;
export const asPluginKey = (method: { pluginName: string; name: string }) => {
return `${method.pluginName}#${method.name}`;
};
const METHOD_REGEX = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\s]+)$/;
@@ -11,6 +11,7 @@ import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const wasmBytes = Buffer.from('some-wasm-binary-data');
const sha256hash = Buffer.from('some-manifest-hash');
const setup = (db?: Kysely<DB>) => {
return newMediumService(PluginService, {
@@ -46,7 +47,9 @@ describe(PluginService.name, () => {
description: 'A test plugin',
author: 'Test Author',
version: '1.0.0',
templates: [],
wasmBytes,
sha256hash,
},
[],
);
@@ -75,7 +78,9 @@ describe(PluginService.name, () => {
description: 'A plugin with multiple methods',
author: 'Test Author',
version: '1.0.0',
templates: [],
wasmBytes,
sha256hash,
},
[
{
@@ -130,7 +135,9 @@ describe(PluginService.name, () => {
description: 'First plugin',
author: 'Author 1',
version: '1.0.0',
templates: [],
wasmBytes,
sha256hash,
},
[
{
@@ -150,7 +157,9 @@ describe(PluginService.name, () => {
description: 'Second plugin',
author: 'Author 2',
version: '2.0.0',
templates: [],
wasmBytes,
sha256hash,
},
[
{
@@ -183,7 +192,9 @@ describe(PluginService.name, () => {
description: 'Plugin with multiple methods',
author: 'Test Author',
version: '1.0.0',
templates: [],
wasmBytes,
sha256hash,
},
[
{
@@ -242,6 +253,8 @@ describe(PluginService.name, () => {
description: 'A single plugin',
author: 'Test Author',
version: '1.0.0',
templates: [],
sha256hash,
wasmBytes,
},
[
@@ -1,10 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { Kysely } from 'kysely';
import { AssetVisibility } from 'src/enum';
import { AssetVisibility, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { DB } from 'src/schema';
import { TimelineService } from 'src/services/timeline.service';
import { newMediumService } from 'test/medium.factory';
@@ -207,4 +208,32 @@ describe(TimelineService.name, () => {
expect(response2).toEqual(expect.objectContaining({ id: [asset2.id, asset1.id], isFavorite: [true, false] }));
});
});
it('should strip geodata metadata if shared link without exif', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({
ownerId: user.id,
localDateTime: new Date('1970-02-12'),
deletedAt: new Date(),
});
const { album } = await ctx.newAlbum({ ownerId: user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const { id: sharedLinkId } = await sharedLinkRepo.create({
allowUpload: false,
key: Buffer.from('123'),
type: SharedLinkType.Album,
userId: user.id,
albumId: album.id,
});
await ctx.newExif({ assetId: asset.id, city: 'Austin', country: 'USA' });
const auth = factory.auth({ sharedLink: { id: sharedLinkId, showExif: false } });
const rawResponse = await sut.getTimeBucket(auth, { albumId: album.id, timeBucket: '1970-02-01', isTrashed: true });
const response = JSON.parse(rawResponse);
expect(response).not.toEqual(expect.objectContaining({ city: expect.any(Array), country: expect.any(Array) }));
});
});
@@ -21,6 +21,7 @@ const setup = (db?: Kysely<DB>) => {
};
const wasmBytes = Buffer.from('random-wasm-bytes');
const sha256hash = Buffer.from('some-manifest-hash');
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
@@ -41,7 +42,9 @@ describe(WorkflowService.name, () => {
description: 'A test core plugin for workflow tests',
author: 'Test Author',
version: '1.0.0',
templates: [],
wasmBytes,
sha256hash,
},
[
{
+1 -1
View File
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.77.0",
"@immich/ui": "^0.79.0",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0",
+145 -1
View File
@@ -1,17 +1,35 @@
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
import {
mdiAccountMultipleOutline,
mdiAccountOutline,
mdiArchiveArrowDownOutline,
mdiBookshelf,
mdiCog,
mdiContentDuplicate,
mdiCrosshairsGps,
mdiFolderOutline,
mdiHeartOutline,
mdiImageAlbum,
mdiImageMultipleOutline,
mdiImageSizeSelectLarge,
mdiKeyboard,
mdiLink,
mdiLockOutline,
mdiMagnify,
mdiMapOutline,
mdiServer,
mdiStateMachine,
mdiSync,
mdiTagMultipleOutline,
mdiThemeLightDark,
mdiToolboxOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { copyToClipboard } from '$lib/utils';
@@ -49,7 +67,133 @@ export const getPagesProvider = ($t: MessageFormatter) => {
},
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
return defaultProvider({ name: $t('page'), actions: adminPages });
const userPages: ActionItem[] = [
{
title: $t('photos'),
icon: mdiImageMultipleOutline,
onAction: () => goto(Route.photos()),
},
{
title: $t('explore'),
icon: mdiMagnify,
onAction: () => goto(Route.explore()),
$if: () => authManager.authenticated && featureFlagsManager.value.search,
},
{
title: $t('map'),
icon: mdiMapOutline,
onAction: () => goto(Route.map()),
$if: () => authManager.authenticated && featureFlagsManager.value.map,
},
{
title: $t('people'),
description: $t('people_feature_description'),
icon: mdiAccountOutline,
onAction: () => goto(Route.people()),
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
},
{
title: $t('shared_links'),
icon: mdiLink,
onAction: () => goto(Route.sharedLinks()),
$if: () => authManager.authenticated && authManager.preferences.sharedLinks.enabled,
},
{
title: $t('recently_added'),
icon: mdiMagnify,
onAction: () => goto(Route.recentlyAdded()),
$if: () => authManager.authenticated,
},
{
title: $t('sharing'),
icon: mdiAccountMultipleOutline,
onAction: () => goto(Route.sharing()),
$if: () => authManager.authenticated,
},
{
title: $t('favorites'),
icon: mdiHeartOutline,
onAction: () => goto(Route.favorites()),
$if: () => authManager.authenticated,
},
{
title: $t('albums'),
description: $t('albums_feature_description'),
icon: mdiImageAlbum,
onAction: () => goto(Route.albums()),
$if: () => authManager.authenticated,
},
{
title: $t('tags'),
description: $t('tag_feature_description'),
icon: mdiTagMultipleOutline,
onAction: () => goto(Route.tags()),
$if: () => authManager.authenticated && authManager.preferences.tags.enabled,
},
{
title: $t('folders'),
description: $t('folders_feature_description'),
icon: mdiFolderOutline,
onAction: () => goto(Route.folders()),
$if: () => authManager.authenticated && authManager.preferences.folders.enabled,
},
{
title: $t('utilities'),
icon: mdiToolboxOutline,
onAction: () => goto(Route.utilities()),
$if: () => authManager.authenticated,
},
{
title: $t('archive'),
icon: mdiArchiveArrowDownOutline,
onAction: () => goto(Route.archive()),
$if: () => authManager.authenticated,
},
{
title: $t('locked_folder'),
icon: mdiLockOutline,
onAction: () => goto(Route.locked()),
$if: () => authManager.authenticated,
},
{
title: $t('trash'),
icon: mdiTrashCanOutline,
onAction: () => goto(Route.trash()),
$if: () => authManager.authenticated && featureFlagsManager.value.trash,
},
{
title: $t('admin.user_settings'),
icon: mdiCog,
onAction: () => goto(Route.userSettings()),
$if: () => authManager.authenticated,
},
].map((route) => ({ $if: () => authManager.authenticated, ...route }));
const utilityPages: ActionItem[] = [
{
title: $t('review_duplicates'),
icon: mdiContentDuplicate,
onAction: () => goto(Route.duplicatesUtility()),
},
{
title: $t('review_large_files'),
icon: mdiImageSizeSelectLarge,
onAction: () => goto(Route.largeFileUtility()),
},
{
title: $t('manage_geolocation'),
icon: mdiCrosshairsGps,
onAction: () => goto(Route.geolocationUtility()),
},
{
title: $t('workflows'),
icon: mdiStateMachine,
onAction: () => goto(Route.workflows()),
},
].map((route) => ({ ...route, $if: () => authManager.authenticated }));
return defaultProvider({ name: $t('page'), actions: [...userPages, ...utilityPages, ...adminPages] });
};
const getMyImmichLink = () => {
+89 -74
View File
@@ -149,29 +149,35 @@
return { width: 1, height: 1 };
});
const { insetInlineStart, top, rasterWidth, rasterHeight, rasterScale } = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
if (maxRasterPixels === 0) {
const { insetInlineStart, top, displayWidth, displayHeight, rasterWidth, rasterHeight, rasterScale } = $derived.by(
() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
if (maxRasterPixels === 0) {
return {
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
displayWidth: width + 'px',
displayHeight: height + 'px',
rasterWidth: width + 'px',
rasterHeight: height + 'px',
rasterScale: 1,
};
}
const nativeRatio = imageDimensions.width / width;
const budgetRatio = Math.sqrt(maxRasterPixels / Math.max(width * height, 1));
const rasterRatio = Math.max(1, Math.min(nativeRatio, budgetRatio));
return {
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
rasterWidth: width + 'px',
rasterHeight: height + 'px',
rasterScale: 1,
displayWidth: width + 'px',
displayHeight: height + 'px',
rasterWidth: width * rasterRatio + 'px',
rasterHeight: height * rasterRatio + 'px',
rasterScale: 1 / rasterRatio,
};
}
const nativeRatio = imageDimensions.width / width;
const budgetRatio = Math.sqrt(maxRasterPixels / Math.max(width * height, 1));
const rasterRatio = Math.max(1, Math.min(nativeRatio, budgetRatio));
return {
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
rasterWidth: width * rasterRatio + 'px',
rasterHeight: height * rasterRatio + 'px',
rasterScale: 1 / rasterRatio,
};
});
},
);
const { status } = $derived(adaptiveImageLoader);
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
@@ -216,69 +222,78 @@
{@render backdrop?.()}
<div
class="pointer-events-none absolute"
class="pointer-events-none absolute overflow-hidden"
style:inset-inline-start={insetInlineStart}
style:top
style:width={rasterWidth}
style:height={rasterHeight}
style:transform="scale({rasterScale})"
style:transform-origin="0 0"
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
style:width={displayWidth}
style:height={displayHeight}
>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<Thumbhash base64ThumbHash={asset.thumbhash} class="absolute size-full" />
{:else if show.spinner}
<DelayedLoadingSpinner />
<div
style:width={rasterWidth}
style:height={rasterHeight}
style:transform="scale({rasterScale})"
style:transform-origin="0 0"
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
width={rasterWidth}
height={rasterHeight}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<Thumbhash base64ThumbHash={asset.thumbhash} class="absolute size-full" />
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
width={rasterWidth}
height={rasterHeight}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
</div>
{#if overlays}
<div class="pointer-events-none absolute inset-0">
{@render overlays()}
</div>
{/if}
</div>
</div>
+1 -14
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import Image from '$lib/components/Image.svelte';
import type { AdaptiveImageLoader, ImageQuality } from '$lib/utils/adaptive-image-loader.svelte';
import type { Snippet } from 'svelte';
type Props = {
adaptiveImageLoader: AdaptiveImageLoader;
@@ -12,20 +11,9 @@
ref?: HTMLImageElement;
width: string;
height: string;
overlays?: Snippet;
};
let {
adaptiveImageLoader,
quality,
src,
alt = '',
role,
ref = $bindable(),
width,
height,
overlays,
}: Props = $props();
let { adaptiveImageLoader, quality, src, alt = '', role, ref = $bindable(), width, height }: Props = $props();
</script>
{#key adaptiveImageLoader}
@@ -42,6 +30,5 @@
draggable={false}
data-testid={quality}
/>
{@render overlays?.()}
</div>
{/key}
@@ -103,7 +103,7 @@
{/if}
</AssetSelectControlBar>
{:else}
<ControlAppBar showBackButton={false}>
<ControlAppBar>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
@@ -91,7 +91,7 @@
</div>
</main>
<header>
<ControlAppBar showBackButton={false}>
<ControlAppBar>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant="inline" />
@@ -18,7 +18,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton, Logo, toastManager } from '@immich/ui';
import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
import { mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
@@ -97,7 +97,7 @@
{/if}
</AssetSelectControlBar>
{:else}
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}>
<ControlAppBar>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
@@ -1,97 +1,49 @@
<script lang="ts">
import { browser } from '$app/environment';
import { IconButton } from '@immich/ui';
import { ControlBar, ControlBarContent, ControlBarHeader, ControlBarOverflow, ControlBarTitle } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
interface Props {
showBackButton?: boolean;
backIcon?: string;
tailwindClasses?: string;
forceDark?: boolean;
multiRow?: boolean;
class?: ClassValue;
onClose?: () => void;
title?: Snippet | string;
leading?: Snippet;
children?: Snippet;
trailing?: Snippet;
}
let {
showBackButton = true,
backIcon = mdiClose,
tailwindClasses = '',
forceDark = false,
multiRow = false,
onClose = () => {},
leading,
children,
trailing,
}: Props = $props();
let appBarBorder = $state('border border-subtle');
const onScroll = () => {
if (window.scrollY > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
if (forceDark) {
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'border border-subtle';
}
};
onMount(() => {
if (browser) {
document.addEventListener('scroll', onScroll, { passive: true });
}
});
onDestroy(() => {
if (browser) {
document.removeEventListener('scroll', onScroll);
}
});
let { backIcon = mdiClose, class: className = '', onClose, title, leading, children, trailing }: Props = $props();
</script>
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent">
<nav
id="asset-selection-app-bar"
class={[
'grid',
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]',
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]',
'justify-between lg:grid-cols-[25%_50%_25%]',
appBarBorder,
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0',
tailwindClasses,
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray',
]}
>
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}">
{#if showBackButton}
<IconButton
aria-label={$t('close')}
onclick={onClose}
color="secondary"
shape="round"
variant="ghost"
icon={backIcon}
size="large"
/>
{/if}
{@render leading?.()}
</div>
<div class="absolute top-0 w-full bg-transparent p-2" id="control-bar">
<ControlBar closeIcon={backIcon} {onClose} shape="round" class={className}>
{#if title || leading}
<ControlBarHeader>
{#if title}
<ControlBarTitle>
{#if typeof title === 'string'}
{title}
{:else}
{@render title()}
{/if}
</ControlBarTitle>
{/if}
{@render leading?.()}
</ControlBarHeader>
{/if}
<div class="w-full">
{@render children?.()}
</div>
{#if children}
<ControlBarContent>
{@render children()}
</ControlBarContent>
{/if}
<div class="me-4 flex place-items-center gap-1 justify-self-end max-[350px]:me-0 max-[350px]:gap-0">
{@render trailing?.()}
</div>
</nav>
{#if trailing}
<ControlBarOverflow>
{@render trailing()}
</ControlBarOverflow>
{/if}
</ControlBar>
</div>
@@ -1,6 +1,5 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
@@ -31,11 +30,14 @@
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport));
const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport));
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
{#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
@@ -7,19 +7,18 @@
type Props = {
children?: Snippet;
forceDark?: boolean;
};
let { children, forceDark }: Props = $props();
let { children }: Props = $props();
const onClose = () => assetMultiSelectManager.clear();
const assets = $derived(assetMultiSelectManager.assets);
</script>
<ControlAppBar {onClose} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
<ControlAppBar {onClose} backIcon={mdiClose}>
{#snippet leading()}
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-primary'}">
<div class="font-medium text-primary">
<p class="block sm:hidden">{assets.length}</p>
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
</div>
@@ -127,7 +127,7 @@ export class EditManager {
try {
// Setup the websocket listener before sending the edit request
const editCompleted = waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000);
const editCompleted = waitForWebsocketEvent('AssetEditReadyV2', (event) => event.asset.id === assetId, 10_000);
await (edits.length === 0
? removeAssetEdits({ id: assetId })
+13 -1
View File
@@ -1,8 +1,10 @@
import {
getWorkflowTriggers,
searchPluginMethods,
searchPluginTemplates,
WorkflowTrigger,
type PluginMethodResponseDto,
type PluginTemplateResponseDto,
type WorkflowTriggerResponseDto,
} from '@immich/sdk';
import { t } from 'svelte-i18n';
@@ -16,6 +18,7 @@ class PluginManager {
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
#methods = $state<PluginMethodResponseDto[]>([]);
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
#templates = $state<PluginTemplateResponseDto[]>([]);
constructor() {
eventManager.on({
@@ -33,6 +36,10 @@ class PluginManager {
return this.#triggers;
}
get templates() {
return this.#templates;
}
ready() {
return this.initialize();
}
@@ -70,7 +77,11 @@ class PluginManager {
}
private async load() {
const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]);
const [methods, triggers, templates] = await Promise.all([
searchPluginMethods({}),
getWorkflowTriggers(),
searchPluginTemplates(),
]);
this.#methods = methods;
for (const method of this.#methods) {
@@ -78,6 +89,7 @@ class PluginManager {
}
this.#triggers = triggers;
this.#templates = templates;
}
}
@@ -17,13 +17,13 @@ export class TimelineDay {
height = $state(0);
width = $state(0);
isInOrNearViewport = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.isInOrNearViewport));
#top: number = $state(0);
#start: number = $state(0);
#row = $state(0);
#col = $state(0);
#deferredLayout = false;
#lastInOrNearViewport = -1;
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
this.index = index;
@@ -154,4 +154,13 @@ export class TimelineDay {
get absoluteTimelineDayTop() {
return this.timelineMonth.top + this.#top;
}
get isInOrNearViewport() {
if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) {
return true;
}
this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport);
return this.#lastInOrNearViewport !== -1;
}
}
@@ -179,8 +179,8 @@ export class TimelineMonth {
);
const timelineAsset: TimelineAsset = {
city: bucketAssets.city[i],
country: bucketAssets.country[i],
city: bucketAssets.city?.[i] ?? null,
country: bucketAssets.country?.[i] ?? null,
duration: bucketAssets.duration[i],
id: bucketAssets.id[i],
visibility: bucketAssets.visibility[i],
+1 -1
View File
@@ -12,7 +12,7 @@
const { trigger, selectedKey, onClose }: Props = $props();
</script>
<BasicModal title={$t('add_step')} {onClose}>
<BasicModal title={$t('add_step')} {onClose} size="medium">
{#await searchPluginMethods({ trigger })}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
@@ -38,7 +38,7 @@
</script>
{#if method}
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="medium">
<div class="flex items-center justify-between gap-2">
<div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text>
@@ -35,7 +35,7 @@
</script>
{#if method}
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="medium">
<div class="flex items-center justify-between gap-2">
<div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text>
@@ -0,0 +1,66 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { type PluginTemplateResponseDto } from '@immich/sdk';
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
import { mdiFlashOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
onClose: () => void;
};
const { onClose }: Props = $props();
let selected = $state<PluginTemplateResponseDto>();
const onSubmit = async () => {
if (!selected) {
return;
}
const success = await handleCreateWorkflow({
trigger: selected.trigger,
steps: selected.steps,
name: selected.title,
description: selected.description,
enabled: false,
});
if (success) {
onClose();
}
};
const isSelected = (template: PluginTemplateResponseDto) => selected?.key === template.key;
</script>
<FormModal
title={$t('workflow_templates')}
{onClose}
{onSubmit}
disabled={!selected}
size="medium"
submitText={$t('use_template')}
>
<div class="flex flex-col gap-2">
{#each pluginManager.templates as template (template.key)}
<ListButton
selected={isSelected(template)}
onclick={() => (selected = isSelected(template) ? undefined : template)}
>
<div class="flex w-full items-center gap-3 text-start">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary"
>
<Icon icon={mdiFlashOutline} size="18" />
</div>
<div class="min-w-0 grow">
<Text fontWeight="medium">{template.title}</Text>
<Text size="tiny" color="muted">{template.description}</Text>
</div>
</div>
</ListButton>
{/each}
</div>
</FormModal>
@@ -17,7 +17,7 @@
const onSubmit = () => onClose(selected);
</script>
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="medium">
<div class="flex flex-col gap-2">
{#each pluginManager.triggers as item (item.trigger)}
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
+12 -3
View File
@@ -10,10 +10,11 @@ import {
type WorkflowUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
import { mdiCodeJson, mdiDelete, mdiFileDocumentMultipleOutline, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
import { Route } from '$lib/route';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@@ -33,7 +34,13 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
}),
};
return { Create };
const UseTemplate: ActionItem = {
title: $t('browse_templates'),
icon: mdiFileDocumentMultipleOutline,
onAction: () => modalManager.show(WorkflowTemplatePicker, {}),
};
return { Create, UseTemplate };
};
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
@@ -72,14 +79,16 @@ export const getWorkflowShowSchemaAction = (
onAction: onToggle,
});
const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
const $t = await getFormatter();
try {
const response = await createWorkflow({ workflowCreateDto: dto });
eventManager.emit('WorkflowCreate', response);
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_create'));
return false;
}
};
+2 -2
View File
@@ -5,7 +5,7 @@ import {
type NotificationDto,
type ServerVersionResponseDto,
type SyncAssetEditV1,
type SyncAssetV1,
type SyncAssetV2,
} from '@immich/sdk';
import { io, type Socket } from 'socket.io-client';
import { get, writable } from 'svelte/store';
@@ -41,7 +41,7 @@ export interface Events {
AppRestartV1: (event: AppRestartEvent) => void;
MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void;
AssetEditReadyV1: (data: { asset: SyncAssetV1; edit: SyncAssetEditV1[] }) => void;
AssetEditReadyV2: (data: { asset: SyncAssetV2; edit: SyncAssetEditV1[] }) => void;
}
const websocket: Socket<Events> = io({

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