Compare commits

..

14 Commits

Author SHA1 Message Date
midzelis 6fe63b70a6 feat(web): animate zoom toggle with cubicOut easing 2026-03-07 14:11:31 +00:00
Snowknight26 6e9a425592 fix(web): asset viewer showing wrong viewer type when hovering on stack thumbnails (#26741) 2026-03-06 21:17:11 +01:00
Thomas 6012d22d98 fix(mobile): incorrect asset dimensions in search (#26725)
Search results use a different provider than the main timeline, and they
appear appear to have diverged a bit. This means that assets can
sometimes look wrong or different in search compared to the main
timeline or albums.
2026-03-05 21:58:58 -06:00
Min Idzelis abfcffb423 feat(web): toggle zoom on double-click in photo viewer (#26732) 2026-03-05 21:58:09 -06:00
Min Idzelis ec7246b86f refactor(web): add --font-sans CSS variable for primary font (#26730) 2026-03-06 03:00:37 +00:00
Brandon Wees 9597f8c37f feat(mobile): SyncAssetEditV1 (#26518)
* feat(mobile): SyncAssetEditV1

* fix: websocket handling

* fix: server version requirement

* fix: revert pubspec changes
2026-03-05 13:03:59 -06:00
Alex 7b0deb1fd3 fix: playback style migration (#26718) 2026-03-05 12:51:58 -06:00
Thomas 5ab05e57fa fix(mobile): inconsistent asset details background (#26634)
The background of the photo view does not extend below the height of the
viewport, and so the asset details fade in over black with the photo
view, and the standard surface colour scheme of the scaffold for the
rest. This leads to a janky animation. We can't change the background of
the scaffold to black, as it in turn makes the iOS bouncing scroll
physics cut off incorrectly. The best fix is to remove background
decoration from the photo view, and defer to the parent to colour the
background.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-05 17:45:21 +00:00
Marvin M ba3f114625 chore: always use Package Imports (#26630)
* chore: always_use_package_imports

* fix: lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-05 11:18:01 -06:00
Mees Frensel 9b642633c1 fix(server): clarify transcoding bitrate policy (#26711) 2026-03-05 12:13:29 -05:00
Mert a05c8c6087 feat(mobile): use shared native client (#25942)
* use shared client in dart

fix android

* websocket integration

platform-side headers

update comment

consistent platform check

tweak websocket handling

support streaming

* redundant logging

* fix proguard

* formatting

* handle onProgress

* support videos on ios

* inline return

* improved ios impl

* cleanup

* sync stopForegroundBackup

* voidify

* future already completed

* stream request on android

* outdated ios ws code

* use `choosePrivateKeyAlias`

* return result

* formatting

* update tests

* redundant check

* handle custom headers

* move completer outside of state

* persist auth

* dispose old socket

* use group id for cookies

* redundant headers

* cache global ref

* handle network switching

* handle basic auth

* apply custom headers immediately

* video player update

* fix

* persist url

* potential logout fix

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-05 11:04:45 -06:00
Mert 35a521c6ec fix(ml): batch size setting (#26524) 2026-03-05 12:01:47 -05:00
Snowknight26 09fabb36b6 fix(web): video stealing focus when it plays again when looping (#26704) 2026-03-05 15:41:27 +00:00
Daniel Dietzler c259fee309 chore: cleanup vscode settings (#26709) 2026-03-05 08:12:59 -05:00
128 changed files with 12064 additions and 2698 deletions
+7 -17
View File
@@ -1,8 +1,7 @@
{ {
"[css]": { "[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true
"editor.tabSize": 2
}, },
"[dart]": { "[dart]": {
"editor.defaultFormatter": "Dart-Code.dart-code", "editor.defaultFormatter": "Dart-Code.dart-code",
@@ -15,49 +14,40 @@
}, },
"[javascript]": { "[javascript]": {
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit", "source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit" "source.removeUnusedImports": "explicit"
}, },
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true
"editor.tabSize": 2
}, },
"[json]": { "[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true
"editor.tabSize": 2
}, },
"[jsonc]": { "[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true
"editor.tabSize": 2
}, },
"[svelte]": { "[svelte]": {
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit", "source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit" "source.removeUnusedImports": "explicit"
}, },
"editor.defaultFormatter": "svelte.svelte-vscode", "editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true
"editor.tabSize": 2
}, },
"[typescript]": { "[typescript]": {
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit", "source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit" "source.removeUnusedImports": "explicit"
}, },
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true
"editor.tabSize": 2
}, },
"cSpell.words": ["immich"], "cSpell.words": ["immich"],
"css.lint.unknownAtRules": "ignore", "css.lint.unknownAtRules": "ignore",
"editor.bracketPairColorization.enabled": true, "editor.bracketPairColorization.enabled": true,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.guides.bracketPairs": "active",
"eslint.useFlatConfig": true, "eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "svelte"], "eslint.validate": ["javascript", "typescript", "svelte"],
"eslint.workingDirectories": [ "eslint.workingDirectories": [
@@ -91,6 +81,6 @@
"tailwindCSS.experimental.configFile": { "tailwindCSS.experimental.configFile": {
"web/src/app.css": "web/src/**" "web/src/app.css": "web/src/**"
}, },
"typescript.preferences.importModuleSpecifier": "non-relative", "js/ts.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10 "vitest.maximumConfigs": 10
} }
@@ -166,6 +166,8 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) 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__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__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` | 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_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_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
+19 -27
View File
@@ -1,7 +1,10 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test'; import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => { test.describe('Photo Viewer', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetMediaResponseDto; let asset: AssetMediaResponseDto;
@@ -23,42 +26,31 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => { test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); const box = await imageLocator(page).boundingBox();
const original = page.getByTestId('original').filter({ visible: true }); expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await expect(thumbnail).toHaveAttribute('src', /thumbnail/); await page.mouse.move(x + width / 2, y + height / 2);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);
await expect(original).toBeInViewport(); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
await expect(original).toHaveAttribute('src', /original/);
}); });
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`); await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); const box = await imageLocator(page).boundingBox();
const original = page.getByTestId('original').filter({ visible: true }); expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await expect(thumbnail).toHaveAttribute('src', /thumbnail/); await page.mouse.move(x + width / 2, y + height / 2);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);
await expect(original).toHaveAttribute('src', /fullsize/); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
}); });
test('reloads photo when checksum changes', async ({ page }) => { test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); const initialSrc = await imageLocator(page).getAttribute('src');
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const initialSrc = await thumbnail.getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id); await utils.replaceAsset(admin.accessToken, asset.id);
await expect(preview).not.toHaveAttribute('src', initialSrc!); await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
}); });
}); });
+2 -2
View File
@@ -99,13 +99,13 @@ export const setupTimelineMockApiRoutes = async (
}); });
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => { await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail|fullsize)/; const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern); const match = request.url().match(pattern);
if (!match?.groups) { if (!match?.groups) {
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`); throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
} }
if (match.groups.size === 'preview' || match.groups.size === 'fullsize') { if (match.groups.size === 'preview') {
if (!route.request().serviceWorker()) { if (!route.request().serviceWorker()) {
return route.continue(); return route.continue();
} }
@@ -73,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer'); await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]'); const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
await expect(viewerBrokenAsset).toBeVisible(); await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
@@ -6,7 +6,6 @@ import {
generateTimelineData, generateTimelineData,
TimelineAssetConfig, TimelineAssetConfig,
TimelineData, TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline'; } from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network'; import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network'; import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -54,7 +53,7 @@ test.describe('search gallery-viewer', () => {
assets: { assets: {
total: searchAssets.length, total: searchAssets.length,
count: searchAssets.length, count: searchAssets.length,
items: searchAssets.map((asset) => toAssetResponseDto(asset)), items: searchAssets,
facets: [], facets: [],
nextPage: null, nextPage: null,
}, },
+6 -4
View File
@@ -163,11 +163,13 @@ export const assetViewerUtils = {
return page.locator('#immich-asset-viewer'); return page.locator('#immich-asset-viewer');
}, },
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) { async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`;
await page await page
.getByTestId('preview') .locator(
.and(page.locator(`[src="${previewUrl}"]`)) `img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
.or(page.locator(`video[poster="${previewUrl}"]`)) )
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.waitFor(); .waitFor();
}, },
async expectActiveAssetToBe(page: Page, assetId: string) { async expectActiveAssetToBe(page: Page, assetId: string) {
+1 -1
View File
@@ -411,7 +411,7 @@
"transcoding_tone_mapping": "Tone-mapping", "transcoding_tone_mapping": "Tone-mapping",
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.", "transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
"transcoding_transcode_policy": "Transcode policy", "transcoding_transcode_policy": "Transcode policy",
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).", "transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos and videos with a pixel format other than YUV 4:2:0 will always be transcoded (except if transcoding is disabled).",
"transcoding_two_pass_encoding": "Two-pass encoding", "transcoding_two_pass_encoding": "Two-pass encoding",
"transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.", "transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
"transcoding_video_codec": "Video codec", "transcoding_video_codec": "Video codec",
+4 -1
View File
@@ -48,8 +48,11 @@ class PreloadModelData(BaseModel):
class MaxBatchSize(BaseModel): class MaxBatchSize(BaseModel):
ocr_fallback: str | None = os.getenv("MACHINE_LEARNING_MAX_BATCH_SIZE__TEXT_RECOGNITION", None)
if ocr_fallback is not None:
os.environ["MACHINE_LEARNING_MAX_BATCH_SIZE__OCR"] = ocr_fallback
facial_recognition: int | None = None facial_recognition: int | None = None
text_recognition: int | None = None ocr: int | None = None
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -29,7 +29,7 @@ class FaceRecognizer(InferenceModel):
def __init__(self, model_name: str, **model_kwargs: Any) -> None: def __init__(self, model_name: str, **model_kwargs: Any) -> None:
super().__init__(model_name, **model_kwargs) super().__init__(model_name, **model_kwargs)
max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None max_batch_size = settings.max_batch_size and settings.max_batch_size.facial_recognition
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
def _load(self) -> ModelSession: def _load(self) -> ModelSession:
@@ -22,7 +22,7 @@ class TextDetector(InferenceModel):
depends = [] depends = []
identity = (ModelType.DETECTION, ModelTask.OCR) identity = (ModelType.DETECTION, ModelTask.OCR)
def __init__(self, model_name: str, **model_kwargs: Any) -> None: def __init__(self, model_name: str, min_score: float = 0.5, **model_kwargs: Any) -> None:
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX) super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
self.max_resolution = 736 self.max_resolution = 736
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32) self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
@@ -33,7 +33,7 @@ class TextDetector(InferenceModel):
} }
self.postprocess = DBPostProcess( self.postprocess = DBPostProcess(
thresh=0.3, thresh=0.3,
box_thresh=model_kwargs.get("minScore", 0.5), box_thresh=model_kwargs.get("minScore", min_score),
max_candidates=1000, max_candidates=1000,
unclip_ratio=1.6, unclip_ratio=1.6,
use_dilation=True, use_dilation=True,
@@ -24,9 +24,9 @@ class TextRecognizer(InferenceModel):
depends = [(ModelType.DETECTION, ModelTask.OCR)] depends = [(ModelType.DETECTION, ModelTask.OCR)]
identity = (ModelType.RECOGNITION, ModelTask.OCR) identity = (ModelType.RECOGNITION, ModelTask.OCR)
def __init__(self, model_name: str, **model_kwargs: Any) -> None: def __init__(self, model_name: str, min_score: float = 0.9, **model_kwargs: Any) -> None:
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
self.min_score = model_kwargs.get("minScore", 0.9) self.min_score = model_kwargs.get("minScore", min_score)
self._empty: TextRecognitionOutput = { self._empty: TextRecognitionOutput = {
"box": np.empty(0, dtype=np.float32), "box": np.empty(0, dtype=np.float32),
"boxScore": np.empty(0, dtype=np.float32), "boxScore": np.empty(0, dtype=np.float32),
@@ -57,10 +57,11 @@ class TextRecognizer(InferenceModel):
def _load(self) -> ModelSession: def _load(self) -> ModelSession:
# TODO: support other runtimes # TODO: support other runtimes
session = OrtSession(self.model_path) session = OrtSession(self.model_path)
max_batch_size = settings.max_batch_size and settings.max_batch_size.ocr
self.model = RapidTextRecognizer( self.model = RapidTextRecognizer(
OcrOptions( OcrOptions(
session=session.session, session=session.session,
rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6, rec_batch_num=max_batch_size if max_batch_size else 6,
rec_img_shape=(3, 48, 320), rec_img_shape=(3, 48, 320),
lang_type=self.language, lang_type=self.language,
) )
+76 -1
View File
@@ -18,7 +18,7 @@ from PIL import Image
from pytest import MonkeyPatch from pytest import MonkeyPatch
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from immich_ml.config import Settings, settings from immich_ml.config import MaxBatchSize, Settings, settings
from immich_ml.main import load, preload_models from immich_ml.main import load, preload_models
from immich_ml.models.base import InferenceModel from immich_ml.models.base import InferenceModel
from immich_ml.models.cache import ModelCache from immich_ml.models.cache import ModelCache
@@ -26,6 +26,9 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn
from immich_ml.models.clip.visual import OpenClipVisualEncoder from immich_ml.models.clip.visual import OpenClipVisualEncoder
from immich_ml.models.facial_recognition.detection import FaceDetector from immich_ml.models.facial_recognition.detection import FaceDetector
from immich_ml.models.facial_recognition.recognition import FaceRecognizer from immich_ml.models.facial_recognition.recognition import FaceRecognizer
from immich_ml.models.ocr.detection import TextDetector
from immich_ml.models.ocr.recognition import TextRecognizer
from immich_ml.models.ocr.schemas import OcrOptions
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
from immich_ml.sessions.ann import AnnSession from immich_ml.sessions.ann import AnnSession
from immich_ml.sessions.ort import OrtSession from immich_ml.sessions.ort import OrtSession
@@ -855,6 +858,78 @@ class TestFaceRecognition:
onnx.load.assert_not_called() onnx.load.assert_not_called()
onnx.save.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))
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
assert recognizer.batch_size == 2
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
assert recognizer.batch_size is None
class TestOcr:
def test_set_det_min_score(self, path: mock.Mock) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
text_detector = TextDetector("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
assert text_detector.postprocess.box_thresh == 0.8
def test_set_rec_min_score(self, path: mock.Mock) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
text_recognizer = TextRecognizer("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
assert text_recognizer.min_score == 0.8
def test_set_rec_set_default_max_batch_size(
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
mocker.patch("immich_ml.models.base.InferenceModel.download")
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
)
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
mocker.patch("immich_ml.models.base.InferenceModel.download")
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=4))
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
)
def test_ignore_other_custom_max_batch_size(
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
) -> None:
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
mocker.patch("immich_ml.models.base.InferenceModel.download")
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=3))
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
)
@pytest.mark.asyncio @pytest.mark.asyncio
class TestCache: class TestCache:
+1
View File
@@ -33,6 +33,7 @@ linter:
require_trailing_commas: true require_trailing_commas: true
unrelated_type_equality_checks: true unrelated_type_equality_checks: true
prefer_const_constructors: true prefer_const_constructors: true
always_use_package_imports: true
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options
+1
View File
@@ -81,6 +81,7 @@ android {
release { release {
signingConfig signingConfigs.release signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
} }
namespace 'app.alextran.immich' namespace 'app.alextran.immich'
+8
View File
@@ -37,3 +37,11 @@
# Keep all widget model classes and their fields for Gson # Keep all widget model classes and their fields for Gson
-keep class app.alextran.immich.widget.model.** { *; } -keep class app.alextran.immich.widget.model.** { *; }
##---------------Begin: proguard configuration for ok_http JNI ----------
# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI
# string-based reflection (JClass.forName), which R8 cannot trace.
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
-keep class com.example.ok_http.** { *; }
##---------------End: proguard configuration for ok_http JNI ----------
@@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy(
memcpy((void *) destAddress, (char *) src + offset, length); memcpy((void *) destAddress, (char *) src + offset, length);
} }
} }
/**
* Creates a JNI global reference to the given object and returns its address.
* The caller is responsible for deleting the global reference when it's no longer needed.
*/
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
if (obj == NULL) {
return 0;
}
jobject globalRef = (*env)->NewGlobalRef(env, obj);
return (jlong) globalRef;
}
@@ -23,6 +23,9 @@ object NativeBuffer {
@JvmStatic @JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int) external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
@JvmStatic
external fun createGlobalRef(obj: Any): Long
} }
class NativeByteBuffer(initialCapacity: Int) { class NativeByteBuffer(initialCapacity: Int) {
@@ -1,11 +1,18 @@
package app.alextran.immich.core package app.alextran.immich.core
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import androidx.core.content.edit
import app.alextran.immich.BuildConfig import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer
import okhttp3.Cache import okhttp3.Cache
import okhttp3.ConnectionPool import okhttp3.ConnectionPool
import okhttp3.Dispatcher import okhttp3.Dispatcher
import okhttp3.Headers
import okhttp3.Credentials
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.net.Socket import java.net.Socket
@@ -20,8 +27,12 @@ import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509KeyManager import javax.net.ssl.X509KeyManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
const val CERT_ALIAS = "client_cert"
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}" const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
private const val CERT_ALIAS = "client_cert"
private const val PREFS_NAME = "immich.ssl"
private const val PREFS_CERT_ALIAS = "immich.client_cert"
private const val PREFS_HEADERS = "immich.request_headers"
private const val PREFS_SERVER_URL = "immich.server_url"
/** /**
* Manages a shared OkHttpClient with SSL configuration support. * Manages a shared OkHttpClient with SSL configuration support.
@@ -36,22 +47,56 @@ object HttpClientManager {
private val clientChangedListeners = mutableListOf<() -> Unit>() private val clientChangedListeners = mutableListOf<() -> Unit>()
private lateinit var client: OkHttpClient private lateinit var client: OkHttpClient
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS) var keyChainAlias: String? = null
private set
var headers: Headers = Headers.headersOf()
private set
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
fun initialize(context: Context) { fun initialize(context: Context) {
if (initialized) return if (initialized) return
synchronized(this) { synchronized(this) {
if (initialized) return if (initialized) return
appContext = context.applicationContext
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
if (savedHeaders != null) {
val json = JSONObject(savedHeaders)
val builder = Headers.Builder()
for (key in json.keys()) {
builder.add(key, json.getString(key))
}
headers = builder.build()
}
val cacheDir = File(File(context.cacheDir, "okhttp"), "api") val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir) client = build(cacheDir)
initialized = true initialized = true
} }
} }
fun setKeyChainAlias(alias: String) {
synchronized(this) {
val wasMtls = isMtls
keyChainAlias = alias
prefs.edit { putString(PREFS_CERT_ALIAS, alias) }
if (wasMtls != isMtls) {
clientChangedListeners.forEach { it() }
}
}
}
fun setKeyEntry(clientData: ByteArray, password: CharArray) { fun setKeyEntry(clientData: ByteArray, password: CharArray) {
synchronized(this) { synchronized(this) {
val wasMtls = isMtls val wasMtls = isMtls
@@ -63,7 +108,7 @@ object HttpClientManager {
val key = tmpKeyStore.getKey(tmpAlias, password) val key = tmpKeyStore.getKey(tmpAlias, password)
val chain = tmpKeyStore.getCertificateChain(tmpAlias) val chain = tmpKeyStore.getCertificateChain(tmpAlias)
if (wasMtls) { if (keyStore.containsAlias(CERT_ALIAS)) {
keyStore.deleteEntry(CERT_ALIAS) keyStore.deleteEntry(CERT_ALIAS)
} }
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain) keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
@@ -75,24 +120,58 @@ object HttpClientManager {
fun deleteKeyEntry() { fun deleteKeyEntry() {
synchronized(this) { synchronized(this) {
if (!isMtls) { val wasMtls = isMtls
return
if (keyChainAlias != null) {
keyChainAlias = null
prefs.edit { remove(PREFS_CERT_ALIAS) }
} }
keyStore.deleteEntry(CERT_ALIAS) keyStore.deleteEntry(CERT_ALIAS)
if (wasMtls) {
clientChangedListeners.forEach { it() } clientChangedListeners.forEach { it() }
} }
} }
}
private var clientGlobalRef: Long = 0L
@JvmStatic @JvmStatic
fun getClient(): OkHttpClient { fun getClient(): OkHttpClient {
return client return client
} }
fun getClientPointer(): Long {
if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client)
}
return clientGlobalRef
}
fun addClientChangedListener(listener: () -> Unit) { fun addClientChangedListener(listener: () -> Unit) {
synchronized(this) { clientChangedListeners.add(listener) } synchronized(this) { clientChangedListeners.add(listener) }
} }
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>) {
synchronized(this) {
val builder = Headers.Builder()
headerMap.forEach { (key, value) -> builder[key] = value }
val newHeaders = builder.build()
val headersChanged = headers != newHeaders
val newUrl = serverUrls.firstOrNull()
val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null)
if (!headersChanged && !urlChanged) return
headers = newHeaders
prefs.edit {
if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString())
if (urlChanged) {
if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL)
}
}
}
}
private fun build(cacheDir: File): OkHttpClient { private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool( val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS, maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -109,8 +188,16 @@ object HttpClientManager {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor { chain -> .addInterceptor {
chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build()) val request = it.request()
val builder = request.newBuilder()
builder.header("User-Agent", USER_AGENT)
headers.forEach { (key, value) -> builder.header(key, value) }
val url = request.url
if (url.username.isNotEmpty()) {
builder.header("Authorization", Credentials.basic(url.username, url.password))
}
it.proceed(builder.build())
} }
.connectionPool(connectionPool) .connectionPool(connectionPool)
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
@@ -119,23 +206,39 @@ object HttpClientManager {
.build() .build()
} }
// Reads from the key store rather than taking a snapshot at initialization time /**
* Resolves client certificates dynamically at TLS handshake time.
* Checks the system KeyChain alias first, then falls back to the app's private KeyStore.
*/
private class DynamicKeyManager : X509KeyManager { private class DynamicKeyManager : X509KeyManager {
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? = override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? {
if (isMtls) arrayOf(CERT_ALIAS) else null val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null
return arrayOf(alias)
}
override fun chooseClientAlias( override fun chooseClientAlias(
keyTypes: Array<String>, keyTypes: Array<String>,
issuers: Array<Principal>?, issuers: Array<Principal>?,
socket: Socket? socket: Socket?
): String? = ): String? {
if (isMtls) CERT_ALIAS else null keyChainAlias?.let { return it }
if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS
return null
}
override fun getCertificateChain(alias: String): Array<X509Certificate>? = override fun getCertificateChain(alias: String): Array<X509Certificate>? {
keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() if (alias == keyChainAlias) {
return KeyChain.getCertificateChain(appContext, alias)
}
return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
}
override fun getPrivateKey(alias: String): PrivateKey? = override fun getPrivateKey(alias: String): PrivateKey? {
keyStore.getKey(alias, null) as? PrivateKey if (alias == keyChainAlias) {
return KeyChain.getPrivateKey(appContext, alias)
}
return keyStore.getKey(alias, null) as? PrivateKey
}
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? = override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
null null
@@ -180,8 +180,11 @@ private open class NetworkPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NetworkApi { interface NetworkApi {
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit)
fun removeCertificate(callback: (Result<Unit>) -> Unit) fun removeCertificate(callback: (Result<Unit>) -> Unit)
fun hasCertificate(): Boolean
fun getClientPointer(): Long
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>)
companion object { companion object {
/** The codec used by NetworkApi. */ /** The codec used by NetworkApi. */
@@ -217,13 +220,12 @@ interface NetworkApi {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
val promptTextArg = args[0] as ClientCertPrompt val promptTextArg = args[0] as ClientCertPrompt
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> -> api.selectCertificate(promptTextArg) { result: Result<Unit> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error)) reply.reply(NetworkPigeonUtils.wrapError(error))
} else { } else {
val data = result.getOrNull() reply.reply(NetworkPigeonUtils.wrapResult(null))
reply.reply(NetworkPigeonUtils.wrapResult(data))
} }
} }
} }
@@ -248,6 +250,55 @@ interface NetworkApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasCertificate())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getClientPointer())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val headersArg = args[0] as Map<String, String>
val serverUrlsArg = args[1] as List<String>
val wrapped: List<Any?> = try {
api.setRequestHeaders(headersArg, serverUrlsArg)
listOf(null)
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }
@@ -2,20 +2,9 @@ package app.alextran.immich.core
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.OperationCanceledException import android.os.OperationCanceledException
import android.text.InputType import android.security.KeyChain
import android.view.ContextThemeWrapper import app.alextran.immich.NativeBuffer
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -24,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
private var networkApi: NetworkApiImpl? = null private var networkApi: NetworkApiImpl? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
networkApi = NetworkApiImpl(binding.applicationContext) networkApi = NetworkApiImpl()
NetworkApi.setUp(binding.binaryMessenger, networkApi) NetworkApi.setUp(binding.binaryMessenger, networkApi)
} }
@@ -34,48 +23,24 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
} }
override fun onAttachedToActivity(binding: ActivityPluginBinding) { override fun onAttachedToActivity(binding: ActivityPluginBinding) {
networkApi?.onAttachedToActivity(binding) networkApi?.activity = binding.activity
} }
override fun onDetachedFromActivityForConfigChanges() { override fun onDetachedFromActivityForConfigChanges() {
networkApi?.onDetachedFromActivityForConfigChanges() networkApi?.activity = null
} }
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
networkApi?.onReattachedToActivityForConfigChanges(binding) networkApi?.activity = binding.activity
} }
override fun onDetachedFromActivity() { override fun onDetachedFromActivity() {
networkApi?.onDetachedFromActivity() networkApi?.activity = null
} }
} }
private class NetworkApiImpl(private val context: Context) : NetworkApi { private class NetworkApiImpl() : NetworkApi {
private var activity: Activity? = null var activity: Activity? = null
private var pendingCallback: ((Result<ClientCertData>) -> Unit)? = null
private var filePicker: ActivityResultLauncher<Array<String>>? = null
private var promptText: ClientCertPrompt? = null
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
(binding.activity as? ComponentActivity)?.let { componentActivity ->
filePicker = componentActivity.registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) }
}
}
fun onDetachedFromActivityForConfigChanges() {
activity = null
}
fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}
fun onDetachedFromActivity() {
activity = null
}
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) { override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
try { try {
@@ -86,11 +51,19 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi {
} }
} }
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) { override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit) {
val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity"))) val currentActivity = activity
pendingCallback = callback ?: return callback(Result.failure(IllegalStateException("No activity")))
this.promptText = promptText
picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file")) val onAlias = { alias: String? ->
if (alias != null) {
HttpClientManager.setKeyChainAlias(alias)
callback(Result.success(Unit))
} else {
callback(Result.failure(OperationCanceledException()))
}
}
KeyChain.choosePrivateKeyAlias(currentActivity, onAlias, null, null, null, null)
} }
override fun removeCertificate(callback: (Result<Unit>) -> Unit) { override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
@@ -98,62 +71,15 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi {
callback(Result.success(Unit)) callback(Result.success(Unit))
} }
private fun handlePickedFile(uri: Uri) { override fun hasCertificate(): Boolean {
val callback = pendingCallback ?: return return HttpClientManager.isMtls
pendingCallback = null
try {
val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalStateException("Could not read file")
val activity = activity ?: throw IllegalStateException("No activity")
promptForPassword(activity) { password ->
promptText = null
if (password == null) {
callback(Result.failure(OperationCanceledException()))
return@promptForPassword
}
try {
HttpClientManager.setKeyEntry(data, password.toCharArray())
callback(Result.success(ClientCertData(data, password)))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
} catch (e: Exception) {
callback(Result.failure(e))
}
} }
private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) { override fun getClientPointer(): Long {
val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog) return HttpClientManager.getClientPointer()
val density = activity.resources.displayMetrics.density
val horizontalPadding = (24 * density).toInt()
val textInputLayout = TextInputLayout(themedContext).apply {
hint = "Password"
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
setMargins(horizontalPadding, 0, horizontalPadding, 0)
}
} }
val editText = TextInputEditText(textInputLayout.context).apply { override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>) {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD HttpClientManager.setRequestHeaders(headers, serverUrls)
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}
textInputLayout.addView(editText)
val container = FrameLayout(themedContext).apply { addView(textInputLayout) }
val text = promptText!!
MaterialAlertDialogBuilder(themedContext)
.setTitle(text.title)
.setMessage(text.message)
.setView(container)
.setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) }
.setNegativeButton(text.cancel) { _, _ -> callback(null) }
.setOnCancelListener { callback(null) }
.show()
} }
} }
@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface RemoteImageApi { interface RemoteImageApi {
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit) fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long) fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit) fun clearCache(callback: (Result<Long>) -> Unit)
@@ -66,10 +66,9 @@ interface RemoteImageApi {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
val urlArg = args[0] as String val urlArg = args[0] as String
val headersArg = args[1] as Map<String, String> val requestIdArg = args[1] as Long
val requestIdArg = args[2] as Long val preferEncodedArg = args[2] as Boolean
val preferEncodedArg = args[3] as Boolean api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error)) reply.reply(RemoteImagesPigeonUtils.wrapError(error))
@@ -15,6 +15,8 @@ import okhttp3.Callback
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
import org.chromium.net.CronetException import org.chromium.net.CronetException
import org.chromium.net.UrlRequest import org.chromium.net.UrlRequest
@@ -49,7 +51,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun requestImage( override fun requestImage(
url: String, url: String,
headers: Map<String, String>,
requestId: Long, requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android @Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit callback: (Result<Map<String, Long>?>) -> Unit
@@ -59,7 +60,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
ImageFetcherManager.fetch( ImageFetcherManager.fetch(
url, url,
headers,
signal, signal,
onSuccess = { buffer -> onSuccess = { buffer ->
requestMap.remove(requestId) requestMap.remove(requestId)
@@ -120,12 +120,11 @@ private object ImageFetcherManager {
fun fetch( fun fetch(
url: String, url: String,
headers: Map<String, String>,
signal: CancellationSignal, signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit, onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
) { ) {
fetcher.fetch(url, headers, signal, onSuccess, onFailure) fetcher.fetch(url, signal, onSuccess, onFailure)
} }
fun clearCache(onCleared: (Result<Long>) -> Unit) { fun clearCache(onCleared: (Result<Long>) -> Unit) {
@@ -152,7 +151,6 @@ private object ImageFetcherManager {
private sealed interface ImageFetcher { private sealed interface ImageFetcher {
fun fetch( fun fetch(
url: String, url: String,
headers: Map<String, String>,
signal: CancellationSignal, signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit, onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
@@ -179,7 +177,6 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
override fun fetch( override fun fetch(
url: String, url: String,
headers: Map<String, String>,
signal: CancellationSignal, signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit, onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
@@ -194,7 +191,12 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
val callback = FetchCallback(onSuccess, onFailure, ::onComplete) val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
}
}
val request = requestBuilder.build() val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel) signal.setOnCancelListener(request::cancel)
request.start() request.start()
@@ -391,7 +393,6 @@ private class OkHttpImageFetcher private constructor(
override fun fetch( override fun fetch(
url: String, url: String,
headers: Map<String, String>,
signal: CancellationSignal, signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit, onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
@@ -404,7 +405,6 @@ private class OkHttpImageFetcher private constructor(
} }
val requestBuilder = Request.Builder().url(url) val requestBuilder = Request.Builder().url(url)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val call = client.newCall(requestBuilder.build()) val call = client.newCall(requestBuilder.build())
signal.setOnCancelListener(call::cancel) signal.setOnCancelListener(call::cancel)
File diff suppressed because one or more lines are too long
+48 -3
View File
@@ -221,8 +221,11 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NetworkApi { protocol NetworkApi {
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void) func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, Error>) -> Void) func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, Error>) -> Void)
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void) func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
func hasCertificate() throws -> Bool
func getClientPointer() throws -> Int64
func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -255,8 +258,8 @@ class NetworkApiSetup {
let promptTextArg = args[0] as! ClientCertPrompt let promptTextArg = args[0] as! ClientCertPrompt
api.selectCertificate(promptText: promptTextArg) { result in api.selectCertificate(promptText: promptTextArg) { result in
switch result { switch result {
case .success(let res): case .success:
reply(wrapResult(res)) reply(wrapResult(nil))
case .failure(let error): case .failure(let error):
reply(wrapError(error)) reply(wrapError(error))
} }
@@ -280,5 +283,47 @@ class NetworkApiSetup {
} else { } else {
removeCertificateChannel.setMessageHandler(nil) removeCertificateChannel.setMessageHandler(nil)
} }
let hasCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasCertificateChannel.setMessageHandler { _, reply in
do {
let result = try api.hasCertificate()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
hasCertificateChannel.setMessageHandler(nil)
}
let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getClientPointerChannel.setMessageHandler { _, reply in
do {
let result = try api.getClientPointer()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getClientPointerChannel.setMessageHandler(nil)
}
let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
setRequestHeadersChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let headersArg = args[0] as! [String: String]
let serverUrlsArg = args[1] as! [String]
do {
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
setRequestHeadersChannel.setMessageHandler(nil)
}
} }
} }
+61 -5
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import UniformTypeIdentifiers import UniformTypeIdentifiers
import native_video_player
enum ImportError: Error { enum ImportError: Error {
case noFile case noFile
@@ -16,15 +17,26 @@ class NetworkApiImpl: NetworkApi {
self.viewController = viewController self.viewController = viewController
} }
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, any Error>) -> Void) { func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, any Error>) -> Void) {
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
self?.activeImporter = nil self?.activeImporter = nil
completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) }) completion(result)
}, viewController: viewController) }, viewController: viewController)
activeImporter = importer activeImporter = importer
importer.load() importer.load()
} }
func hasCertificate() throws -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecReturnRef as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
return status == errSecSuccess
}
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) { func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
let status = clearCerts() let status = clearCerts()
if status == errSecSuccess || status == errSecItemNotFound { if status == errSecSuccess || status == errSecItemNotFound {
@@ -40,14 +52,58 @@ class NetworkApiImpl: NetworkApi {
} }
completion(.failure(ImportError.keychainError(status))) completion(.failure(ImportError.keychainError(status)))
} }
func getClientPointer() throws -> Int64 {
let pointer = URLSessionManager.shared.sessionPointer
return Int64(Int(bitPattern: pointer))
}
func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws {
var headers = headers
if let token = headers.removeValue(forKey: "x-immich-user-token") {
for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https")
let cookies: [(String, String, Bool)] = [
("immich_access_token", token, true),
("immich_is_authenticated", "true", false),
("immich_auth_type", "password", true),
]
let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60)
for (name, value, httpOnly) in cookies {
var properties: [HTTPCookiePropertyKey: Any] = [
.name: name,
.value: value,
.domain: domain,
.path: "/",
.expires: expiry,
]
if isSecure { properties[.secure] = "TRUE" }
if httpOnly { properties[.init("HttpOnly")] = "TRUE" }
if let cookie = HTTPCookie(properties: properties) {
URLSessionManager.cookieStorage.setCookie(cookie)
}
}
}
}
if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) {
UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY)
}
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart
}
}
} }
private class CertImporter: NSObject, UIDocumentPickerDelegate { private class CertImporter: NSObject, UIDocumentPickerDelegate {
private let promptText: ClientCertPrompt private let promptText: ClientCertPrompt
private var completion: ((Result<(Data, String), Error>) -> Void) private var completion: ((Result<Void, Error>) -> Void)
private weak var viewController: UIViewController? private weak var viewController: UIViewController?
init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) { init(promptText: ClientCertPrompt, completion: (@escaping (Result<Void, Error>) -> Void), viewController: UIViewController?) {
self.promptText = promptText self.promptText = promptText
self.completion = completion self.completion = completion
self.viewController = viewController self.viewController = viewController
@@ -81,7 +137,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate {
} }
await URLSessionManager.shared.session.flush() await URLSessionManager.shared.session.flush()
self.completion(.success((data, password))) self.completion(.success(()))
} catch { } catch {
completion(.failure(error)) completion(.failure(error))
} }
+74 -21
View File
@@ -1,49 +1,77 @@
import Foundation import Foundation
import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers"
let SERVER_URL_KEY = "immich.server_url"
let APP_GROUP = "group.app.immich.share"
extension UserDefaults {
static let group = UserDefaults(suiteName: APP_GROUP)!
}
/// Manages a shared URLSession with SSL configuration support. /// Manages a shared URLSession with SSL configuration support.
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
class URLSessionManager: NSObject { class URLSessionManager: NSObject {
static let shared = URLSessionManager() static let shared = URLSessionManager()
let session: URLSession private(set) var session: URLSession
private let configuration = { let delegate: URLSessionManagerDelegate
let config = URLSessionConfiguration.default private static let cacheDir: URL = {
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first! .first!
.appendingPathComponent("api", isDirectory: true) .appendingPathComponent("api", isDirectory: true)
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
config.urlCache = URLCache( }()
private static let urlCache = URLCache(
memoryCapacity: 0, memoryCapacity: 0,
diskCapacity: 1024 * 1024 * 1024, diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir directory: cacheDir
) )
private static let userAgent: String = {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
return "Immich_iOS_\(version)"
}()
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
var sessionPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(session).toOpaque()
}
private override init() {
delegate = URLSessionManagerDelegate()
session = Self.buildSession(delegate: delegate)
super.init()
}
func recreateSession() {
session = Self.buildSession(delegate: delegate)
}
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
let config = URLSessionConfiguration.default
config.urlCache = urlCache
config.httpCookieStorage = cookieStorage
config.httpMaximumConnectionsPerHost = 64 config.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60 config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300 config.timeoutIntervalForResource = 300
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] headers["User-Agent"] = headers["User-Agent"] ?? userAgent
config.httpAdditionalHeaders = headers
return config return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}()
private override init() {
session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil)
super.init()
} }
} }
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
func urlSession( func urlSession(
_ session: URLSession, _ session: URLSession,
didReceive challenge: URLAuthenticationChallenge, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) { ) {
handleChallenge(challenge, completionHandler: completionHandler) handleChallenge(session, challenge, completionHandler)
} }
func urlSession( func urlSession(
@@ -52,20 +80,24 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
didReceive challenge: URLAuthenticationChallenge, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) { ) {
handleChallenge(challenge, completionHandler: completionHandler) handleChallenge(session, challenge, completionHandler, task: task)
} }
func handleChallenge( func handleChallenge(
_ session: URLSession,
_ challenge: URLAuthenticationChallenge, _ challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void _ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void,
task: URLSessionTask? = nil
) { ) {
switch challenge.protectionSpace.authenticationMethod { switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler) case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler)
case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler)
default: completionHandler(.performDefaultHandling, nil) default: completionHandler(.performDefaultHandling, nil)
} }
} }
private func handleClientCertificate( private func handleClientCertificate(
_ session: URLSession,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) { ) {
let query: [String: Any] = [ let query: [String: Any] = [
@@ -80,8 +112,29 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
let credential = URLCredential(identity: identity as! SecIdentity, let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil, certificates: nil,
persistence: .forSession) persistence: .forSession)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
return completion(.useCredential, credential) return completion(.useCredential, credential)
} }
completion(.performDefaultHandling, nil) completion(.performDefaultHandling, nil)
} }
private func handleBasicAuth(
_ session: URLSession,
task: URLSessionTask?,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let url = task?.originalRequest?.url,
let user = url.user,
let password = url.password
else {
return completion(.performDefaultHandling, nil)
}
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
let credential = URLCredential(user: user, password: password, persistence: .forSession)
completion(.useCredential, credential)
}
} }
@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi { protocol RemoteImageApi {
func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void) func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
} }
@@ -86,10 +86,9 @@ class RemoteImageApiSetup {
requestImageChannel.setMessageHandler { message, reply in requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?] let args = message as! [Any?]
let urlArg = args[0] as! String let urlArg = args[0] as! String
let headersArg = args[1] as! [String: String] let requestIdArg = args[1] as! Int64
let requestIdArg = args[2] as! Int64 let preferEncodedArg = args[2] as! Bool
let preferEncodedArg = args[3] as! Bool api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
switch result { switch result {
case .success(let res): case .success(let res):
reply(wrapResult(res)) reply(wrapResult(res))
@@ -33,12 +33,9 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
kCGImageSourceCreateThumbnailFromImageAlways: true kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary ] as CFDictionary
func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!) var urlRequest = URLRequest(url: URL(string: url)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad urlRequest.cachePolicy = .returnCacheDataElseLoad
for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error) Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
@@ -0,0 +1,21 @@
import "package:openapi/api.dart" as api show AssetEditAction;
enum AssetEditAction { rotate, crop, mirror, other }
extension AssetEditActionExtension on AssetEditAction {
api.AssetEditAction? toDto() {
return switch (this) {
AssetEditAction.rotate => api.AssetEditAction.rotate,
AssetEditAction.crop => api.AssetEditAction.crop,
AssetEditAction.mirror => api.AssetEditAction.mirror,
AssetEditAction.other => null,
};
}
}
class AssetEdit {
final AssetEditAction action;
final Map<String, dynamic> parameters;
const AssetEdit({required this.action, required this.parameters});
}
@@ -3,7 +3,6 @@ import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
@@ -28,7 +27,6 @@ import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart'; import 'package:immich_mobile/wm_executor.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -64,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift; final Drift _drift;
final DriftLogger _driftLogger; final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi; final BackgroundWorkerBgHostApi _backgroundHostApi;
final CancellationToken _cancellationToken = CancellationToken(); final _cancellationToken = Completer<void>();
final Logger _logger = Logger('BackgroundWorkerBgService'); final Logger _logger = Logger('BackgroundWorkerBgService');
bool _isCleanedUp = false; bool _isCleanedUp = false;
@@ -88,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> init() async { Future<void> init() async {
try { try {
HttpSSLOptions.apply();
await Future.wait( await Future.wait(
[ [
loadTranslations(), loadTranslations(),
@@ -198,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_ref?.dispose(); _ref?.dispose();
_ref = null; _ref = null;
_cancellationToken.cancel(); _cancellationToken.complete();
_logger.info("Cleaning up background worker"); _logger.info("Cleaning up background worker");
final cleanupFutures = [ final cleanupFutures = [
@@ -70,13 +70,14 @@ extension on AssetResponseDto {
_ => AssetVisibility.timeline, _ => AssetVisibility.timeline,
}, },
durationInSeconds: duration.toDuration()?.inSeconds ?? 0, durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(), height: height?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(), width: width?.toInt(),
isFavorite: isFavorite, isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId, livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash, thumbHash: thumbhash,
localId: null, localId: null,
type: type.toAssetType(), type: type.toAssetType(),
stackId: stack?.id,
isEdited: isEdited, isEdited: isEdited,
); );
} }
@@ -205,6 +205,10 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsV1(data.cast()); return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1: case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast()); return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetEditV1:
return _syncStreamRepository.updateAssetEditsV1(data.cast());
case SyncEntityType.assetEditDeleteV1:
return _syncStreamRepository.deleteAssetEditsV1(data.cast());
case SyncEntityType.assetMetadataV1: case SyncEntityType.assetMetadataV1:
return _syncStreamRepository.updateAssetsMetadataV1(data.cast()); return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
case SyncEntityType.assetMetadataDeleteV1: case SyncEntityType.assetMetadataDeleteV1:
@@ -336,39 +340,43 @@ class SyncStreamService {
} }
} }
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async { Future<void> handleWsAssetEditReadyV1(dynamic data) async {
if (batchData.isEmpty) return; _logger.info('Processing AssetEditReadyV1 event');
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
final List<SyncAssetV1> assets = [];
try { try {
for (final data in batchData) {
if (data is! Map<String, dynamic>) { if (data is! Map<String, dynamic>) {
continue; throw ArgumentError("Invalid data format for AssetEditReadyV1 event");
} }
final payload = data; final payload = data;
final assetData = payload['asset'];
if (assetData == null) { if (payload['asset'] == null) {
continue; throw ArgumentError("Missing 'asset' field in AssetEditReadyV1 event data");
} }
final asset = SyncAssetV1.fromJson(assetData); final asset = SyncAssetV1.fromJson(payload['asset']);
if (asset == null) {
if (asset != null) { throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV1 event data");
assets.add(asset);
}
} }
if (assets.isNotEmpty) { List<SyncAssetEditV1> assetEdits = [];
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
_logger.info('Successfully processed ${assets.length} edited assets'); // Edits are only send on v2.6.0+
if (payload['edit'] != null && payload['edit'] is List<dynamic>) {
assetEdits = (payload['edit'] as List<dynamic>)
.map((e) => SyncAssetEditV1.fromJson(e))
.whereType<SyncAssetEditV1>()
.toList();
} }
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
_logger.info(
'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits',
);
} catch (error, stackTrace) { } catch (error, stackTrace) {
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace); _logger.severe("Error processing AssetEditReadyV1 websocket event", error, stackTrace);
} }
} }
+4 -4
View File
@@ -196,11 +196,11 @@ class BackgroundSyncManager {
}); });
} }
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) { Future<void> syncWebsocketEdit(dynamic data) {
if (_syncWebsocketTask != null) { if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future; return _syncWebsocketTask!.future;
} }
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData); _syncWebsocketTask = _handleWsAssetEditReadyV1(data);
return _syncWebsocketTask!.whenComplete(() { return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null; _syncWebsocketTask = null;
}); });
@@ -242,7 +242,7 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
debugLabel: 'websocket-batch', debugLabel: 'websocket-batch',
); );
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle( Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData), computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
debugLabel: 'websocket-edit', debugLabel: 'websocket-edit',
); );
@@ -0,0 +1,33 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)')
class AssetEditEntity extends Table with DriftDefaultsMixin {
const AssetEditEntity();
TextColumn get id => text()();
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
IntColumn get action => intEnum<AssetEditAction>()();
BlobColumn get parameters => blob().map(editParameterConverter)();
IntColumn get sequence => integer()();
@override
Set<Column> get primaryKey => {id};
}
final JsonTypeConverter2<Map<String, Object?>, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb(
fromJson: (json) => json as Map<String, Object?>,
);
extension AssetEditEntityDataDomainEx on AssetEditEntityData {
AssetEdit toDto() {
return AssetEdit(action: action, parameters: parameters);
}
}
@@ -0,0 +1,752 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i1;
import 'package:immich_mobile/domain/models/asset_edit.model.dart' as i2;
import 'dart:typed_data' as i3;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i5;
import 'package:drift/internal/modular.dart' as i6;
typedef $$AssetEditEntityTableCreateCompanionBuilder =
i1.AssetEditEntityCompanion Function({
required String id,
required String assetId,
required i2.AssetEditAction action,
required Map<String, Object?> parameters,
required int sequence,
});
typedef $$AssetEditEntityTableUpdateCompanionBuilder =
i1.AssetEditEntityCompanion Function({
i0.Value<String> id,
i0.Value<String> assetId,
i0.Value<i2.AssetEditAction> action,
i0.Value<Map<String, Object?>> parameters,
i0.Value<int> sequence,
});
final class $$AssetEditEntityTableReferences
extends
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$AssetEditEntityTable,
i1.AssetEditEntityData
> {
$$AssetEditEntityTableReferences(
super.$_db,
super.$_table,
super.$_typedResult,
);
static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$AssetEditEntityTable>('asset_edit_entity')
.assetId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
i5.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
final manager = i5
.$$RemoteAssetEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer(
$_db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
}
class $$AssetEditEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnWithTypeConverterFilters<i2.AssetEditAction, i2.AssetEditAction, int>
get action => $composableBuilder(
column: $table.action,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnWithTypeConverterFilters<
Map<String, Object?>,
Map<String, Object>,
i3.Uint8List
>
get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnFilters<int> get sequence => $composableBuilder(
column: $table.sequence,
builder: (column) => i0.ColumnFilters(column),
);
i5.$$RemoteAssetEntityTableFilterComposer get assetId {
final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get action => $composableBuilder(
column: $table.action,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<i3.Uint8List> get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get sequence => $composableBuilder(
column: $table.sequence,
builder: (column) => i0.ColumnOrderings(column),
);
i5.$$RemoteAssetEntityTableOrderingComposer get assetId {
final i5.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int> get action =>
$composableBuilder(column: $table.action, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<Map<String, Object?>, i3.Uint8List>
get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => column,
);
i0.GeneratedColumn<int> get sequence =>
$composableBuilder(column: $table.sequence, builder: (column) => column);
i5.$$RemoteAssetEntityTableAnnotationComposer get assetId {
final i5.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$AssetEditEntityTable,
i1.AssetEditEntityData,
i1.$$AssetEditEntityTableFilterComposer,
i1.$$AssetEditEntityTableOrderingComposer,
i1.$$AssetEditEntityTableAnnotationComposer,
$$AssetEditEntityTableCreateCompanionBuilder,
$$AssetEditEntityTableUpdateCompanionBuilder,
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
i1.AssetEditEntityData,
i0.PrefetchHooks Function({bool assetId})
> {
$$AssetEditEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$AssetEditEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$AssetEditEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$AssetEditEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () => i1
.$$AssetEditEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> id = const i0.Value.absent(),
i0.Value<String> assetId = const i0.Value.absent(),
i0.Value<i2.AssetEditAction> action = const i0.Value.absent(),
i0.Value<Map<String, Object?>> parameters =
const i0.Value.absent(),
i0.Value<int> sequence = const i0.Value.absent(),
}) => i1.AssetEditEntityCompanion(
id: id,
assetId: assetId,
action: action,
parameters: parameters,
sequence: sequence,
),
createCompanionCallback:
({
required String id,
required String assetId,
required i2.AssetEditAction action,
required Map<String, Object?> parameters,
required int sequence,
}) => i1.AssetEditEntityCompanion.insert(
id: id,
assetId: assetId,
action: action,
parameters: parameters,
sequence: sequence,
),
withReferenceMapper: (p0) => p0
.map(
(e) => (
e.readTable(table),
i1.$$AssetEditEntityTableReferences(db, table, e),
),
)
.toList(),
prefetchHooksCallback: ({assetId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins:
<
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic
>
>(state) {
if (assetId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.assetId,
referencedTable: i1
.$$AssetEditEntityTableReferences
._assetIdTable(db),
referencedColumn: i1
.$$AssetEditEntityTableReferences
._assetIdTable(db)
.id,
)
as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
),
);
}
typedef $$AssetEditEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$AssetEditEntityTable,
i1.AssetEditEntityData,
i1.$$AssetEditEntityTableFilterComposer,
i1.$$AssetEditEntityTableOrderingComposer,
i1.$$AssetEditEntityTableAnnotationComposer,
$$AssetEditEntityTableCreateCompanionBuilder,
$$AssetEditEntityTableUpdateCompanionBuilder,
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
i1.AssetEditEntityData,
i0.PrefetchHooks Function({bool assetId})
>;
i0.Index get idxAssetEditAssetId => i0.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
class $AssetEditEntityTable extends i4.AssetEditEntity
with i0.TableInfo<$AssetEditEntityTable, i1.AssetEditEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$AssetEditEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
@override
late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
'id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
'assetId',
);
@override
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
'asset_id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
),
);
@override
late final i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int>
action =
i0.GeneratedColumn<int>(
'action',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
).withConverter<i2.AssetEditAction>(
i1.$AssetEditEntityTable.$converteraction,
);
@override
late final i0.GeneratedColumnWithTypeConverter<
Map<String, Object?>,
i3.Uint8List
>
parameters =
i0.GeneratedColumn<i3.Uint8List>(
'parameters',
aliasedName,
false,
type: i0.DriftSqlType.blob,
requiredDuringInsert: true,
).withConverter<Map<String, Object?>>(
i1.$AssetEditEntityTable.$converterparameters,
);
static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta(
'sequence',
);
@override
late final i0.GeneratedColumn<int> sequence = i0.GeneratedColumn<int>(
'sequence',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<i0.GeneratedColumn> get $columns => [
id,
assetId,
action,
parameters,
sequence,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'asset_edit_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.AssetEditEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('asset_id')) {
context.handle(
_assetIdMeta,
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
);
} else if (isInserting) {
context.missing(_assetIdMeta);
}
if (data.containsKey('sequence')) {
context.handle(
_sequenceMeta,
sequence.isAcceptableOrUnknown(data['sequence']!, _sequenceMeta),
);
} else if (isInserting) {
context.missing(_sequenceMeta);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.AssetEditEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.AssetEditEntityData(
id: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
assetId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}asset_id'],
)!,
action: i1.$AssetEditEntityTable.$converteraction.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}action'],
)!,
),
parameters: i1.$AssetEditEntityTable.$converterparameters.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.blob,
data['${effectivePrefix}parameters'],
)!,
),
sequence: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}sequence'],
)!,
);
}
@override
$AssetEditEntityTable createAlias(String alias) {
return $AssetEditEntityTable(attachedDatabase, alias);
}
static i0.JsonTypeConverter2<i2.AssetEditAction, int, int> $converteraction =
const i0.EnumIndexConverter<i2.AssetEditAction>(
i2.AssetEditAction.values,
);
static i0.JsonTypeConverter2<Map<String, Object?>, i3.Uint8List, Object?>
$converterparameters = i4.editParameterConverter;
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class AssetEditEntityData extends i0.DataClass
implements i0.Insertable<i1.AssetEditEntityData> {
final String id;
final String assetId;
final i2.AssetEditAction action;
final Map<String, Object?> parameters;
final int sequence;
const AssetEditEntityData({
required this.id,
required this.assetId,
required this.action,
required this.parameters,
required this.sequence,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<String>(id);
map['asset_id'] = i0.Variable<String>(assetId);
{
map['action'] = i0.Variable<int>(
i1.$AssetEditEntityTable.$converteraction.toSql(action),
);
}
{
map['parameters'] = i0.Variable<i3.Uint8List>(
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters),
);
}
map['sequence'] = i0.Variable<int>(sequence);
return map;
}
factory AssetEditEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return AssetEditEntityData(
id: serializer.fromJson<String>(json['id']),
assetId: serializer.fromJson<String>(json['assetId']),
action: i1.$AssetEditEntityTable.$converteraction.fromJson(
serializer.fromJson<int>(json['action']),
),
parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson(
serializer.fromJson<Object?>(json['parameters']),
),
sequence: serializer.fromJson<int>(json['sequence']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'assetId': serializer.toJson<String>(assetId),
'action': serializer.toJson<int>(
i1.$AssetEditEntityTable.$converteraction.toJson(action),
),
'parameters': serializer.toJson<Object?>(
i1.$AssetEditEntityTable.$converterparameters.toJson(parameters),
),
'sequence': serializer.toJson<int>(sequence),
};
}
i1.AssetEditEntityData copyWith({
String? id,
String? assetId,
i2.AssetEditAction? action,
Map<String, Object?>? parameters,
int? sequence,
}) => i1.AssetEditEntityData(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
action: action ?? this.action,
parameters: parameters ?? this.parameters,
sequence: sequence ?? this.sequence,
);
AssetEditEntityData copyWithCompanion(i1.AssetEditEntityCompanion data) {
return AssetEditEntityData(
id: data.id.present ? data.id.value : this.id,
assetId: data.assetId.present ? data.assetId.value : this.assetId,
action: data.action.present ? data.action.value : this.action,
parameters: data.parameters.present
? data.parameters.value
: this.parameters,
sequence: data.sequence.present ? data.sequence.value : this.sequence,
);
}
@override
String toString() {
return (StringBuffer('AssetEditEntityData(')
..write('id: $id, ')
..write('assetId: $assetId, ')
..write('action: $action, ')
..write('parameters: $parameters, ')
..write('sequence: $sequence')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, assetId, action, parameters, sequence);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.AssetEditEntityData &&
other.id == this.id &&
other.assetId == this.assetId &&
other.action == this.action &&
other.parameters == this.parameters &&
other.sequence == this.sequence);
}
class AssetEditEntityCompanion
extends i0.UpdateCompanion<i1.AssetEditEntityData> {
final i0.Value<String> id;
final i0.Value<String> assetId;
final i0.Value<i2.AssetEditAction> action;
final i0.Value<Map<String, Object?>> parameters;
final i0.Value<int> sequence;
const AssetEditEntityCompanion({
this.id = const i0.Value.absent(),
this.assetId = const i0.Value.absent(),
this.action = const i0.Value.absent(),
this.parameters = const i0.Value.absent(),
this.sequence = const i0.Value.absent(),
});
AssetEditEntityCompanion.insert({
required String id,
required String assetId,
required i2.AssetEditAction action,
required Map<String, Object?> parameters,
required int sequence,
}) : id = i0.Value(id),
assetId = i0.Value(assetId),
action = i0.Value(action),
parameters = i0.Value(parameters),
sequence = i0.Value(sequence);
static i0.Insertable<i1.AssetEditEntityData> custom({
i0.Expression<String>? id,
i0.Expression<String>? assetId,
i0.Expression<int>? action,
i0.Expression<i3.Uint8List>? parameters,
i0.Expression<int>? sequence,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (assetId != null) 'asset_id': assetId,
if (action != null) 'action': action,
if (parameters != null) 'parameters': parameters,
if (sequence != null) 'sequence': sequence,
});
}
i1.AssetEditEntityCompanion copyWith({
i0.Value<String>? id,
i0.Value<String>? assetId,
i0.Value<i2.AssetEditAction>? action,
i0.Value<Map<String, Object?>>? parameters,
i0.Value<int>? sequence,
}) {
return i1.AssetEditEntityCompanion(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
action: action ?? this.action,
parameters: parameters ?? this.parameters,
sequence: sequence ?? this.sequence,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<String>(id.value);
}
if (assetId.present) {
map['asset_id'] = i0.Variable<String>(assetId.value);
}
if (action.present) {
map['action'] = i0.Variable<int>(
i1.$AssetEditEntityTable.$converteraction.toSql(action.value),
);
}
if (parameters.present) {
map['parameters'] = i0.Variable<i3.Uint8List>(
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value),
);
}
if (sequence.present) {
map['sequence'] = i0.Variable<int>(sequence.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('AssetEditEntityCompanion(')
..write('id: $id, ')
..write('assetId: $assetId, ')
..write('action: $action, ')
..write('parameters: $parameters, ')
..write('sequence: $sequence')
..write(')'))
.toString();
}
}
@@ -2,9 +2,8 @@ part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest { class RemoteImageRequest extends ImageRequest {
final String uri; final String uri;
final Map<String, String> headers;
RemoteImageRequest({required this.uri, required this.headers}); RemoteImageRequest({required this.uri});
@override @override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async { Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@@ -12,7 +11,7 @@ class RemoteImageRequest extends ImageRequest {
return null; return null;
} }
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false); final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
// Android always returns encoded data, so we need to check for both shapes of the response. // Android always returns encoded data, so we need to check for both shapes of the response.
final frame = switch (info) { final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
@@ -29,7 +28,7 @@ class RemoteImageRequest extends ImageRequest {
return null; return null;
} }
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true); final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true);
if (info == null) return null; if (info == null) return null;
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null); final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
@@ -4,6 +4,7 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
@@ -24,11 +25,10 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:isar/isar.dart' hide Index; import 'package:isar/isar.dart' hide Index;
import 'db.repository.drift.dart';
// #zoneTxn is the symbol used by Isar to mark a transaction within the current zone // #zoneTxn is the symbol used by Isar to mark a transaction within the current zone
// ref: isar/isar_common.dart // ref: isar/isar_common.dart
const Symbol _kzoneTxn = #zoneTxn; const Symbol _kzoneTxn = #zoneTxn;
@@ -66,6 +66,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
AssetFaceEntity, AssetFaceEntity,
StoreEntity, StoreEntity,
TrashedLocalAssetEntity, TrashedLocalAssetEntity,
AssetEditEntity,
], ],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
) )
@@ -97,7 +98,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 21; int get schemaVersion => 22;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -234,6 +235,10 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle); await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle); await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
}, },
from21To22: (m, v22) async {
await m.createTable(v22.assetEditEntity);
await m.createIndex(v22.idxAssetEditAssetId);
},
), ),
); );
@@ -41,9 +41,11 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i19; as i19;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
as i20; as i20;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i21; as i21;
import 'package:drift/internal/modular.dart' as i22; import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i22;
import 'package:drift/internal/modular.dart' as i23;
abstract class $Drift extends i0.GeneratedDatabase { abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e); $Drift(i0.QueryExecutor e) : super(e);
@@ -85,9 +87,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this); late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20 late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
.$TrashedLocalAssetEntityTable(this); .$TrashedLocalAssetEntityTable(this);
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer( late final i21.$AssetEditEntityTable assetEditEntity = i21
.$AssetEditEntityTable(this);
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
this, this,
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new); ).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
@override @override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables => Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>(); allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -125,6 +129,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
assetFaceEntity, assetFaceEntity,
storeEntity, storeEntity,
trashedLocalAssetEntity, trashedLocalAssetEntity,
assetEditEntity,
i10.idxPartnerSharedWithId, i10.idxPartnerSharedWithId,
i11.idxLatLng, i11.idxLatLng,
i12.idxRemoteAlbumAssetAlbumAsset, i12.idxRemoteAlbumAssetAlbumAsset,
@@ -134,6 +139,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i18.idxAssetFaceAssetId, i18.idxAssetFaceAssetId,
i20.idxTrashedLocalAssetChecksum, i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum, i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
]; ];
@override @override
i0.StreamQueryUpdateRules i0.StreamQueryUpdateRules
@@ -325,6 +331,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
), ),
result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)], result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)],
), ),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
),
]); ]);
@override @override
i0.DriftDatabaseOptions get options => i0.DriftDatabaseOptions get options =>
@@ -384,4 +397,6 @@ class $DriftManager {
_db, _db,
_db.trashedLocalAssetEntity, _db.trashedLocalAssetEntity,
); );
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
} }
@@ -9489,6 +9489,565 @@ class Shape31 extends i0.VersionedTable {
columnsByName['playback_style']! as i1.GeneratedColumn<int>; columnsByName['playback_style']! as i1.GeneratedColumn<int>;
} }
final class Schema22 extends i0.VersionedSchema {
Schema22({required super.database}) : super(version: 22);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxRemoteAlbumOwnerId,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetLocalDateTimeDay,
idxRemoteAssetLocalDateTimeMonth,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape28 remoteAssetEntity = Shape28(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
_column_101,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape30 localAssetEntity = Shape30(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_98,
_column_96,
_column_46,
_column_47,
_column_103,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
'idx_remote_album_owner_id',
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
'idx_remote_asset_local_date_time_day',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
);
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
'idx_remote_asset_local_date_time_month',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape29 assetFaceEntity = Shape29(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
_column_102,
_column_18,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape31 trashedLocalAssetEntity = Shape31(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
_column_97,
_column_103,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_36, _column_104, _column_105, _column_106],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
}
class Shape32 extends i0.VersionedTable {
Shape32({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get action =>
columnsByName['action']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<i2.Uint8List> get parameters =>
columnsByName['parameters']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<int> get sequence =>
columnsByName['sequence']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_104(String aliasedName) =>
i1.GeneratedColumn<int>(
'action',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i1.GeneratedColumn<i2.Uint8List> _column_105(String aliasedName) =>
i1.GeneratedColumn<i2.Uint8List>(
'parameters',
aliasedName,
false,
type: i1.DriftSqlType.blob,
);
i1.GeneratedColumn<int> _column_106(String aliasedName) =>
i1.GeneratedColumn<int>(
'sequence',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -9510,6 +10069,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19, required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20, required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21, required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -9613,6 +10173,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from20To21(migrator, schema); await from20To21(migrator, schema);
return 21; return 21;
case 21:
final schema = Schema22(database: database);
final migrator = i1.Migrator(database, schema);
await from21To22(migrator, schema);
return 22;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -9640,6 +10205,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19, required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20, required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21, required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -9662,5 +10228,6 @@ i1.OnUpgrade stepByStep({
from18To19: from18To19, from18To19: from18To19,
from19To20: from19To20, from19To20: from19To20,
from20To21: from20To21, from20To21: from20To21,
from21To22: from21To22,
), ),
); );
@@ -2,8 +2,7 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
import 'logger_db.repository.drift.dart';
@DriftDatabase(tables: [LogMessageEntity]) @DriftDatabase(tables: [LogMessageEntity])
class DriftLogger extends $DriftLogger implements IDatabaseRepository { class DriftLogger extends $DriftLogger implements IDatabaseRepository {
@@ -1,67 +1,55 @@
import 'dart:ffi';
import 'dart:io'; import 'dart:io';
import 'package:cronet_http/cronet_http.dart';
import 'package:cupertino_http/cupertino_http.dart'; import 'package:cupertino_http/cupertino_http.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:immich_mobile/utils/user_agent.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:path_provider/path_provider.dart'; import 'package:ok_http/ok_http.dart';
import 'package:web_socket/web_socket.dart';
class NetworkRepository { class NetworkRepository {
static late Directory _cachePath; static http.Client? _client;
static late String _userAgent; static Pointer<Void>? _clientPointer;
static final _clients = <String, http.Client>{};
static Future<void> init() { static Future<void> init() async {
return ( final clientPointer = Pointer<Void>.fromAddress(await networkApi.getClientPointer());
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath), if (clientPointer == _clientPointer) {
getUserAgentString().then((userAgent) => _userAgent = userAgent), return;
).wait; }
_clientPointer = clientPointer;
_client?.close();
if (Platform.isIOS) {
final session = URLSession.fromRawPointer(clientPointer.cast());
_client = CupertinoClient.fromSharedSession(session);
} else {
_client = OkHttpClient.fromJniGlobalRef(clientPointer);
}
} }
static void reset() { static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls) async {
Future.microtask(init); await networkApi.setRequestHeaders(headers, serverUrls);
for (final client in _clients.values) { if (Platform.isIOS) {
client.close(); await init();
}
}
// ignore: avoid-unused-parameters
static Future<WebSocket> createWebSocket(Uri uri, {Map<String, String>? headers, Iterable<String>? protocols}) {
if (Platform.isIOS) {
final session = URLSession.fromRawPointer(_clientPointer!.cast());
return CupertinoWebSocket.connectWithSession(session, uri, protocols: protocols);
} else {
return OkHttpWebSocket.connectFromJniGlobalRef(_clientPointer!, uri, protocols: protocols);
} }
_clients.clear();
} }
const NetworkRepository(); const NetworkRepository();
/// Note: when disk caching is enabled, only one client may use a given directory at a time. /// Returns a shared HTTP client that uses native SSL configuration.
/// Different isolates or engines must use different directories. ///
http.Client getHttpClient( /// On iOS: Uses SharedURLSessionManager's URLSession.
String directoryName, { /// On Android: Uses SharedHttpClientManager's OkHttpClient.
CacheMode cacheMode = CacheMode.memory, ///
int diskCapacity = 0, /// Must call [init] before using this method.
int maxConnections = 6, static http.Client get client => _client!;
int memoryCapacity = 10 << 20,
}) {
final cachedClient = _clients[directoryName];
if (cachedClient != null) {
return cachedClient;
}
final directory = Directory('${_cachePath.path}/$directoryName');
directory.createSync(recursive: true);
if (Platform.isAndroid) {
final engine = CronetEngine.build(
cacheMode: cacheMode,
cacheMaxSize: diskCapacity,
storagePath: directory.path,
userAgent: _userAgent,
);
return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true);
}
final config = URLSessionConfiguration.defaultSessionConfiguration()
..httpMaximumConnectionsPerHost = maxConnections
..cache = URLCache.withCapacity(
diskCapacity: diskCapacity,
memoryCapacity: memoryCapacity,
directory: directory.uri,
)
..httpAdditionalHeaders = {'User-Agent': _userAgent};
return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config);
}
} }
@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -32,15 +33,11 @@ class SyncApiRepository {
http.Client? httpClient, http.Client? httpClient,
}) async { }) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final client = httpClient ?? http.Client(); final client = httpClient ?? NetworkRepository.client;
final endpoint = "${_api.apiClient.basePath}/sync/stream"; final endpoint = "${_api.apiClient.basePath}/sync/stream";
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
final headerParams = <String, String>{};
await _api.applyToParams([], headerParams);
headers.addAll(headerParams);
final shouldReset = Store.get(StoreKey.shouldResetSync, false); final shouldReset = Store.get(StoreKey.shouldResetSync, false);
final request = http.Request('POST', Uri.parse(endpoint)); final request = http.Request('POST', Uri.parse(endpoint));
request.headers.addAll(headers); request.headers.addAll(headers);
@@ -51,6 +48,7 @@ class SyncApiRepository {
SyncRequestType.usersV1, SyncRequestType.usersV1,
SyncRequestType.assetsV1, SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1, SyncRequestType.assetExifsV1,
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1, SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1, SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1, SyncRequestType.partnerAssetsV1,
@@ -119,8 +117,6 @@ class SyncApiRepository {
} }
} catch (error, stack) { } catch (error, stack) {
return Future.error(error, stack); return Future.error(error, stack);
} finally {
client.close();
} }
stopwatch.stop(); stopwatch.stop();
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms"); _logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
@@ -156,6 +152,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetV1: SyncAssetV1.fromJson, SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson,
SyncEntityType.assetEditDeleteV1: SyncAssetEditDeleteV1.fromJson,
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson, SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson, SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
@@ -5,9 +5,11 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
@@ -26,8 +28,8 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey; import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey; import 'package:openapi/api.dart' hide AlbumUserRole, UserMetadataKey, AssetEditAction, AssetVisibility;
class SyncStreamRepository extends DriftDatabaseRepository { class SyncStreamRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftSyncStreamRepository'); final Logger _logger = Logger('DriftSyncStreamRepository');
@@ -58,6 +60,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.userEntity.deleteAll(); await _db.userEntity.deleteAll();
await _db.userMetadataEntity.deleteAll(); await _db.userMetadataEntity.deleteAll();
await _db.remoteAssetCloudIdEntity.deleteAll(); await _db.remoteAssetCloudIdEntity.deleteAll();
await _db.assetEditEntity.deleteAll();
}); });
await _db.customStatement('PRAGMA foreign_keys = ON'); await _db.customStatement('PRAGMA foreign_keys = ON');
}); });
@@ -322,6 +325,63 @@ class SyncStreamRepository extends DriftDatabaseRepository {
} }
} }
Future<void> updateAssetEditsV1(Iterable<SyncAssetEditV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final edit in data) {
final companion = AssetEditEntityCompanion(
id: Value(edit.id),
assetId: Value(edit.assetId),
action: Value(edit.action.toAssetEditAction()),
parameters: Value(edit.parameters as Map<String, Object?>),
sequence: Value(edit.sequence),
);
batch.insert(_db.assetEditEntity, companion, onConflict: DoUpdate((_) => companion));
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetEditsV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> replaceAssetEditsV1(String assetId, Iterable<SyncAssetEditV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId));
for (final edit in data) {
final companion = AssetEditEntityCompanion(
id: Value(edit.id),
assetId: Value(edit.assetId),
action: Value(edit.action.toAssetEditAction()),
parameters: Value(edit.parameters as Map<String, Object?>),
sequence: Value(edit.sequence),
);
batch.insert(_db.assetEditEntity, companion);
}
});
} catch (error, stack) {
_logger.severe('Error: replaceAssetEditsV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> deleteAssetEditsV1(Iterable<SyncAssetEditDeleteV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final edit in data) {
batch.deleteWhere(_db.assetEditEntity, (row) => row.id.equals(edit.editId));
}
});
} catch (error, stack) {
_logger.severe('Error: deleteAssetEditsV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async { Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try { try {
await _db.batch((batch) { await _db.batch((batch) {
@@ -798,3 +858,12 @@ extension on String {
extension on UserAvatarColor { extension on UserAvatarColor {
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value); AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value);
} }
extension on api.AssetEditAction {
AssetEditAction toAssetEditAction() => switch (this) {
api.AssetEditAction.crop => AssetEditAction.crop,
api.AssetEditAction.rotate => AssetEditAction.rotate,
api.AssetEditAction.mirror => AssetEditAction.mirror,
_ => AssetEditAction.other,
};
}
+1 -3
View File
@@ -40,7 +40,6 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/wm_executor.dart'; import 'package:immich_mobile/wm_executor.dart';
@@ -60,7 +59,6 @@ void main() async {
// Warm-up isolate pool for worker manager // Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift); await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
runApp( runApp(
ProviderScope( ProviderScope(
@@ -246,7 +244,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
@override @override
void reassemble() { void reassemble() {
if (kDebugMode) { if (kDebugMode) {
NetworkRepository.reset(); NetworkRepository.init();
} }
super.reassemble(); super.reassemble();
} }
@@ -1,6 +1,5 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first // ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
@@ -21,7 +20,6 @@ class BackUpState {
final DateTime progressInFileSpeedUpdateTime; final DateTime progressInFileSpeedUpdateTime;
final int progressInFileSpeedUpdateSentBytes; final int progressInFileSpeedUpdateSentBytes;
final double iCloudDownloadProgress; final double iCloudDownloadProgress;
final CancellationToken cancelToken;
final ServerDiskInfo serverInfo; final ServerDiskInfo serverInfo;
final bool autoBackup; final bool autoBackup;
final bool backgroundBackup; final bool backgroundBackup;
@@ -53,7 +51,6 @@ class BackUpState {
required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes, required this.progressInFileSpeedUpdateSentBytes,
required this.iCloudDownloadProgress, required this.iCloudDownloadProgress,
required this.cancelToken,
required this.serverInfo, required this.serverInfo,
required this.autoBackup, required this.autoBackup,
required this.backgroundBackup, required this.backgroundBackup,
@@ -78,7 +75,6 @@ class BackUpState {
DateTime? progressInFileSpeedUpdateTime, DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes, int? progressInFileSpeedUpdateSentBytes,
double? iCloudDownloadProgress, double? iCloudDownloadProgress,
CancellationToken? cancelToken,
ServerDiskInfo? serverInfo, ServerDiskInfo? serverInfo,
bool? autoBackup, bool? autoBackup,
bool? backgroundBackup, bool? backgroundBackup,
@@ -102,7 +98,6 @@ class BackUpState {
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo, serverInfo: serverInfo ?? this.serverInfo,
autoBackup: autoBackup ?? this.autoBackup, autoBackup: autoBackup ?? this.autoBackup,
backgroundBackup: backgroundBackup ?? this.backgroundBackup, backgroundBackup: backgroundBackup ?? this.backgroundBackup,
@@ -120,7 +115,7 @@ class BackUpState {
@override @override
String toString() { String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
} }
@override @override
@@ -137,7 +132,6 @@ class BackUpState {
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
other.iCloudDownloadProgress == iCloudDownloadProgress && other.iCloudDownloadProgress == iCloudDownloadProgress &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo && other.serverInfo == serverInfo &&
other.autoBackup == autoBackup && other.autoBackup == autoBackup &&
other.backgroundBackup == backgroundBackup && other.backgroundBackup == backgroundBackup &&
@@ -163,7 +157,6 @@ class BackUpState {
progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^
iCloudDownloadProgress.hashCode ^ iCloudDownloadProgress.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^ serverInfo.hashCode ^
autoBackup.hashCode ^ autoBackup.hashCode ^
backgroundBackup.hashCode ^ backgroundBackup.hashCode ^
@@ -1,11 +1,8 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
class ManualUploadState { class ManualUploadState {
final CancellationToken cancelToken;
// Current Backup Asset // Current Backup Asset
final CurrentUploadAsset currentUploadAsset; final CurrentUploadAsset currentUploadAsset;
final int currentAssetIndex; final int currentAssetIndex;
@@ -29,7 +26,6 @@ class ManualUploadState {
required this.progressInFileSpeeds, required this.progressInFileSpeeds,
required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes, required this.progressInFileSpeedUpdateSentBytes,
required this.cancelToken,
required this.currentUploadAsset, required this.currentUploadAsset,
required this.totalAssetsToUpload, required this.totalAssetsToUpload,
required this.currentAssetIndex, required this.currentAssetIndex,
@@ -44,7 +40,6 @@ class ManualUploadState {
List<double>? progressInFileSpeeds, List<double>? progressInFileSpeeds,
DateTime? progressInFileSpeedUpdateTime, DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes, int? progressInFileSpeedUpdateSentBytes,
CancellationToken? cancelToken,
CurrentUploadAsset? currentUploadAsset, CurrentUploadAsset? currentUploadAsset,
int? totalAssetsToUpload, int? totalAssetsToUpload,
int? successfulUploads, int? successfulUploads,
@@ -58,7 +53,6 @@ class ManualUploadState {
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds, progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
cancelToken: cancelToken ?? this.cancelToken,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset, currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload, totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload,
currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex, currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex,
@@ -69,7 +63,7 @@ class ManualUploadState {
@override @override
String toString() { String toString() {
return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)';
} }
@override @override
@@ -84,7 +78,6 @@ class ManualUploadState {
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) && collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
other.cancelToken == cancelToken &&
other.currentUploadAsset == currentUploadAsset && other.currentUploadAsset == currentUploadAsset &&
other.totalAssetsToUpload == totalAssetsToUpload && other.totalAssetsToUpload == totalAssetsToUpload &&
other.currentAssetIndex == currentAssetIndex && other.currentAssetIndex == currentAssetIndex &&
@@ -100,7 +93,6 @@ class ManualUploadState {
progressInFileSpeeds.hashCode ^ progressInFileSpeeds.hashCode ^
progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^
cancelToken.hashCode ^
currentUploadAsset.hashCode ^ currentUploadAsset.hashCode ^
totalAssetsToUpload.hashCode ^ totalAssetsToUpload.hashCode ^
currentAssetIndex.hashCode ^ currentAssetIndex.hashCode ^
@@ -96,10 +96,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
await backupNotifier.startForegroundBackup(currentUser.id); await backupNotifier.startForegroundBackup(currentUser.id);
} }
Future<void> stopBackup() async {
await backupNotifier.stopForegroundBackup();
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
@@ -136,9 +132,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
const Divider(), const Divider(),
BackupToggleButton( BackupToggleButton(
onStart: () async => await startBackup(), onStart: () async => await startBackup(),
onStop: () async { onStop: () {
syncSuccess = null; syncSuccess = null;
await stopBackup(); backupNotifier.stopForegroundBackup();
}, },
), ),
switch (error) { switch (error) {
@@ -112,16 +112,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
// Waits for hashing to be cancelled before starting a new one // Waits for hashing to be cancelled before starting a new one
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets())); unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
if (isBackupEnabled) { if (isBackupEnabled) {
backupNotifier.stopForegroundBackup();
unawaited( unawaited(
backupNotifier.stopForegroundBackup().whenComplete( backgroundSync.syncRemote().then((success) {
() => backgroundSync.syncRemote().then((success) {
if (success) { if (success) {
return backupNotifier.startForegroundBackup(user.id); return backupNotifier.startForegroundBackup(user.id);
} else { } else {
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
} }
}), }),
),
); );
} }
} }
@@ -59,16 +59,15 @@ class DriftBackupOptionsPage extends ConsumerWidget {
final backupNotifier = ref.read(driftBackupProvider.notifier); final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundSync = ref.read(backgroundSyncProvider); final backgroundSync = ref.read(backgroundSyncProvider);
backupNotifier.stopForegroundBackup();
unawaited( unawaited(
backupNotifier.stopForegroundBackup().whenComplete( backgroundSync.syncRemote().then((success) {
() => backgroundSync.syncRemote().then((success) {
if (success) { if (success) {
return backupNotifier.startForegroundBackup(currentUser.id); return backupNotifier.startForegroundBackup(currentUser.id);
} else { } else {
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
} }
}), }),
),
); );
} }
}, },
@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
class SettingsHeader { class SettingsHeader {
String key = ""; String key = "";
@@ -20,7 +21,6 @@ class HeaderSettingsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// final apiService = ref.watch(apiServiceProvider);
final headers = useState<List<SettingsHeader>>([]); final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false); final setInitialHeaders = useState(false);
@@ -75,7 +75,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
], ],
), ),
body: PopScope( body: PopScope(
onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value), onPopInvokedWithResult: (didPop, _) => saveHeaders(ref, headers.value),
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
itemCount: list.length, itemCount: list.length,
@@ -87,7 +87,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
); );
} }
saveHeaders(List<SettingsHeader> headers) { saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
final headersMap = {}; final headersMap = {};
for (var header in headers) { for (var header in headers) {
final key = header.key.trim(); final key = header.key.trim();
@@ -98,7 +98,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
} }
var encoded = jsonEncode(headersMap); var encoded = jsonEncode(headersMap);
Store.put(StoreKey.customHeaders, encoded); await Store.put(StoreKey.customHeaders, encoded);
await ref.read(apiServiceProvider).updateHeaders();
} }
} }
+1 -2
View File
@@ -7,11 +7,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/editing/edit.page.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'edit.page.dart';
/// A widget for cropping an image. /// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows /// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to crop an image and then navigate to the [EditImagePage] with the /// users to crop an image and then navigate to the [EditImagePage] with the
+81 -7
View File
@@ -179,7 +179,7 @@ class NetworkApi {
} }
} }
Future<ClientCertData> selectCertificate(ClientCertPrompt promptText) async { Future<void> selectCertificate(ClientCertPrompt promptText) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -197,13 +197,8 @@ class NetworkApi {
message: pigeonVar_replyList[1] as String?, message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2], details: pigeonVar_replyList[2],
); );
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else { } else {
return (pigeonVar_replyList[0] as ClientCertData?)!; return;
} }
} }
@@ -229,4 +224,83 @@ class NetworkApi {
return; return;
} }
} }
Future<bool> hasCertificate() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
Future<int> getClientPointer() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as int?)!;
}
}
Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
} }
+2 -12
View File
@@ -49,12 +49,7 @@ class RemoteImageApi {
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage( Future<Map<String, int>?> requestImage(String url, {required int requestId, required bool preferEncoded}) async {
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
}) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -62,12 +57,7 @@ class RemoteImageApi {
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[ final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, requestId, preferEncoded]);
url,
headers,
requestId,
preferEncoded,
]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -62,7 +61,7 @@ class DriftEditImagePage extends ConsumerWidget {
return; return;
} }
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]);
} catch (e) { } catch (e) {
ImmichToast.show( ImmichToast.show(
durationInSecond: 6, durationInSecond: 6,
@@ -101,7 +101,8 @@ class _UploadProgressDialog extends ConsumerWidget {
actions: [ actions: [
ImmichTextButton( ImmichTextButton(
onPressed: () { onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.cancel(); ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
labelText: 'cancel'.t(context: context), labelText: 'cancel'.t(context: context),
@@ -292,7 +292,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required PhotoViewHeroAttributes? heroAttributes, required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent, required bool isCurrent,
required bool isPlayingMotionVideo, required bool isPlayingMotionVideo,
required BoxDecoration backgroundDecoration,
}) { }) {
final size = context.sizeData; final size = context.sizeData;
@@ -303,7 +302,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
imageProvider: getFullImageProvider(asset, size: size), imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes, heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
backgroundDecoration: backgroundDecoration,
gaplessPlayback: true, gaplessPlayback: true,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
@@ -345,7 +343,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
tightMode: true, tightMode: true,
onPageBuild: _onPageBuild, onPageBuild: _onPageBuild,
enablePanAlways: true, enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer( child: NativeVideoViewer(
key: _NativeVideoViewerKey(asset.heroTag), key: _NativeVideoViewerKey(asset.heroTag),
asset: asset, asset: asset,
@@ -397,6 +394,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
SingleChildScrollView( SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
physics: const SnapScrollPhysics(), physics: const SnapScrollPhysics(),
child: ColoredBox(
color: _showingDetails ? Colors.black : Colors.transparent,
child: Stack( child: Stack(
children: [ children: [
SizedBox( SizedBox(
@@ -409,7 +408,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
: null, : null,
isCurrent: isCurrent, isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo, isPlayingMotionVideo: isPlayingMotionVideo,
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
), ),
), ),
IgnorePointer( IgnorePointer(
@@ -434,6 +432,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
], ],
), ),
), ),
),
if (stackChildren != null && stackChildren.isNotEmpty) if (stackChildren != null && stackChildren.isNotEmpty)
Positioned( Positioned(
left: 0, left: 0,
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -37,7 +36,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
} }
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders()); final request = this.request = RemoteImageRequest(uri: key.url);
return loadRequest(request, decode); return loadRequest(request, decode);
} }
@@ -88,10 +87,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return; return;
} }
final headers = ApiService.getRequestHeaders();
final previewRequest = request = RemoteImageRequest( final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash), uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
); );
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal); final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal); yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
@@ -105,7 +102,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return; return;
} }
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers); final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
yield* loadRequest(originalRequest, decode); yield* loadRequest(originalRequest, decode);
} }
@@ -232,7 +232,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} }
} }
Future<void> _performPause() async { Future<void> _performPause() {
if (_ref.read(authProvider).isAuthenticated) { if (_ref.read(authProvider).isAuthenticated) {
if (!Store.isBetaTimelineEnabled) { if (!Store.isBetaTimelineEnabled) {
// Do not cancel backup if manual upload is in progress // Do not cancel backup if manual upload is in progress
@@ -240,15 +240,13 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_ref.read(backupProvider.notifier).cancelBackup(); _ref.read(backupProvider.notifier).cancelBackup();
} }
} else { } else {
await _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
} }
_ref.read(websocketProvider.notifier).disconnect(); _ref.read(websocketProvider.notifier).disconnect();
} }
try { return LogService.I.flush().catchError((_) {});
await LogService.I.flush();
} catch (_) {}
} }
Future<void> handleAppDetached() async { Future<void> handleAppDetached() async {
+1
View File
@@ -124,6 +124,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<bool> saveAuthInfo({required String accessToken}) async { Future<bool> saveAuthInfo({required String accessToken}) async {
await _apiService.setAccessToken(accessToken); await _apiService.setAccessToken(accessToken);
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final customHeaders = Store.tryGet(StoreKey.customHeaders); final customHeaders = Store.tryGet(StoreKey.customHeaders);
@@ -1,4 +1,5 @@
import 'package:cancellation_token_http/http.dart'; import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Tracks per-asset upload progress. /// Tracks per-asset upload progress.
@@ -30,4 +31,4 @@ final assetUploadProgressProvider = NotifierProvider<AssetUploadProgressNotifier
AssetUploadProgressNotifier.new, AssetUploadProgressNotifier.new,
); );
final manualUploadCancelTokenProvider = StateProvider<CancellationToken?>((ref) => null); final manualUploadCancelTokenProvider = StateProvider<Completer<void>?>((ref) => null);
@@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
@@ -68,7 +68,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
progressInFileSpeeds: const [], progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0, progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
autoBackup: Store.get(StoreKey.autoBackup, false), autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: Store.get(StoreKey.backgroundBackup, false), backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true), backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
@@ -102,6 +101,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final FileMediaRepository _fileMediaRepository; final FileMediaRepository _fileMediaRepository;
final BackupAlbumService _backupAlbumService; final BackupAlbumService _backupAlbumService;
final Ref ref; final Ref ref;
Completer<void>? _cancelToken;
/// ///
/// UI INTERACTION /// UI INTERACTION
@@ -454,7 +454,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
// Perform Backup // Perform Backup
state = state.copyWith(cancelToken: CancellationToken()); _cancelToken?.complete();
_cancelToken = Completer<void>();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
@@ -465,7 +466,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await _backupService.backupAsset( await _backupService.backupAsset(
assetsWillBeBackup, assetsWillBeBackup,
state.cancelToken, _cancelToken!,
pmProgressHandler: pmProgressHandler, pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded, onSuccess: _onAssetUploaded,
onProgress: _onUploadProgress, onProgress: _onUploadProgress,
@@ -494,7 +495,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (state.backupProgress != BackUpProgressEnum.inProgress) { if (state.backupProgress != BackUpProgressEnum.inProgress) {
notifyBackgroundServiceCanRun(); notifyBackgroundServiceCanRun();
} }
state.cancelToken.cancel(); _cancelToken?.complete();
_cancelToken = null;
state = state.copyWith( state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
progressInPercentage: 0.0, progressInPercentage: 0.0,
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -109,7 +108,6 @@ class DriftBackupState {
final BackupError error; final BackupError error;
final Map<String, DriftUploadStatus> uploadItems; final Map<String, DriftUploadStatus> uploadItems;
final CancellationToken? cancelToken;
final Map<String, double> iCloudDownloadProgress; final Map<String, double> iCloudDownloadProgress;
@@ -121,7 +119,6 @@ class DriftBackupState {
required this.isSyncing, required this.isSyncing,
this.error = BackupError.none, this.error = BackupError.none,
required this.uploadItems, required this.uploadItems,
this.cancelToken,
this.iCloudDownloadProgress = const {}, this.iCloudDownloadProgress = const {},
}); });
@@ -133,7 +130,6 @@ class DriftBackupState {
bool? isSyncing, bool? isSyncing,
BackupError? error, BackupError? error,
Map<String, DriftUploadStatus>? uploadItems, Map<String, DriftUploadStatus>? uploadItems,
CancellationToken? cancelToken,
Map<String, double>? iCloudDownloadProgress, Map<String, double>? iCloudDownloadProgress,
}) { }) {
return DriftBackupState( return DriftBackupState(
@@ -144,7 +140,6 @@ class DriftBackupState {
isSyncing: isSyncing ?? this.isSyncing, isSyncing: isSyncing ?? this.isSyncing,
error: error ?? this.error, error: error ?? this.error,
uploadItems: uploadItems ?? this.uploadItems, uploadItems: uploadItems ?? this.uploadItems,
cancelToken: cancelToken ?? this.cancelToken,
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
); );
} }
@@ -153,7 +148,7 @@ class DriftBackupState {
@override @override
String toString() { String toString() {
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)'; return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, iCloudDownloadProgress: $iCloudDownloadProgress)';
} }
@override @override
@@ -168,8 +163,7 @@ class DriftBackupState {
other.isSyncing == isSyncing && other.isSyncing == isSyncing &&
other.error == error && other.error == error &&
mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) &&
mapEquals(other.uploadItems, uploadItems) && mapEquals(other.uploadItems, uploadItems);
other.cancelToken == cancelToken;
} }
@override @override
@@ -181,7 +175,6 @@ class DriftBackupState {
isSyncing.hashCode ^ isSyncing.hashCode ^
error.hashCode ^ error.hashCode ^
uploadItems.hashCode ^ uploadItems.hashCode ^
cancelToken.hashCode ^
iCloudDownloadProgress.hashCode; iCloudDownloadProgress.hashCode;
} }
} }
@@ -211,6 +204,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
final ForegroundUploadService _foregroundUploadService; final ForegroundUploadService _foregroundUploadService;
final BackgroundUploadService _backgroundUploadService; final BackgroundUploadService _backgroundUploadService;
final UploadSpeedManager _uploadSpeedManager; final UploadSpeedManager _uploadSpeedManager;
Completer<void>? _cancelToken;
final _logger = Logger("DriftBackupNotifier"); final _logger = Logger("DriftBackupNotifier");
@@ -246,7 +240,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
); );
} }
void updateError(BackupError error) async { void updateError(BackupError error) {
if (!mounted) { if (!mounted) {
_logger.warning("Skip updateError: notifier disposed"); _logger.warning("Skip updateError: notifier disposed");
return; return;
@@ -254,24 +248,23 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
state = state.copyWith(error: error); state = state.copyWith(error: error);
} }
void updateSyncing(bool isSyncing) async { void updateSyncing(bool isSyncing) {
state = state.copyWith(isSyncing: isSyncing); state = state.copyWith(isSyncing: isSyncing);
} }
Future<void> startForegroundBackup(String userId) async { Future<void> startForegroundBackup(String userId) {
// Cancel any existing backup before starting a new one // Cancel any existing backup before starting a new one
if (state.cancelToken != null) { if (_cancelToken != null) {
await stopForegroundBackup(); stopForegroundBackup();
} }
state = state.copyWith(error: BackupError.none); state = state.copyWith(error: BackupError.none);
final cancelToken = CancellationToken(); _cancelToken = Completer<void>();
state = state.copyWith(cancelToken: cancelToken);
return _foregroundUploadService.uploadCandidates( return _foregroundUploadService.uploadCandidates(
userId, userId,
cancelToken, _cancelToken!,
callbacks: UploadCallbacks( callbacks: UploadCallbacks(
onProgress: _handleForegroundBackupProgress, onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess, onSuccess: _handleForegroundBackupSuccess,
@@ -281,10 +274,11 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
); );
} }
Future<void> stopForegroundBackup() async { void stopForegroundBackup() {
state.cancelToken?.cancel(); _cancelToken?.complete();
_cancelToken = null;
_uploadSpeedManager.clear(); _uploadSpeedManager.clear();
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
} }
void _handleICloudProgress(String localAssetId, double progress) { void _handleICloudProgress(String localAssetId, double progress) {
@@ -300,7 +294,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
} }
void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) { void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) {
if (state.cancelToken == null) { if (_cancelToken == null) {
return; return;
} }
@@ -399,7 +393,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
} }
} }
final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) async { final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
if (user == null) { if (user == null) {
return []; return [];
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -50,6 +49,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final BackupService _backupService; final BackupService _backupService;
final BackupAlbumService _backupAlbumService; final BackupAlbumService _backupAlbumService;
final Ref ref; final Ref ref;
Completer<void>? _cancelToken;
ManualUploadNotifier( ManualUploadNotifier(
this._localNotificationService, this._localNotificationService,
@@ -65,7 +65,6 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
progressInFileSpeeds: const [], progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0, progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
currentUploadAsset: CurrentUploadAsset( currentUploadAsset: CurrentUploadAsset(
id: '...', id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'), fileCreatedAt: DateTime.parse('2020-10-04'),
@@ -236,7 +235,6 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
fileName: '...', fileName: '...',
fileType: '...', fileType: '...',
), ),
cancelToken: CancellationToken(),
); );
// Reset Error List // Reset Error List
ref.watch(errorBackupListProvider.notifier).empty(); ref.watch(errorBackupListProvider.notifier).empty();
@@ -252,11 +250,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
state = state.copyWith(showDetailedNotification: showDetailedNotification); state = state.copyWith(showDetailedNotification: showDetailedNotification);
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
_cancelToken?.complete();
_cancelToken = Completer<void>();
final bool ok = await ref final bool ok = await ref
.read(backupServiceProvider) .read(backupServiceProvider)
.backupAsset( .backupAsset(
uploadAssets, uploadAssets,
state.cancelToken, _cancelToken!,
pmProgressHandler: pmProgressHandler, pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded, onSuccess: _onAssetUploaded,
onProgress: _onProgress, onProgress: _onProgress,
@@ -273,14 +273,14 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
); );
// User cancelled upload // User cancelled upload
if (!ok && state.cancelToken.isCancelled) { if (!ok && _cancelToken == null) {
await _localNotificationService.showOrUpdateManualUploadStatus( await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(), "backup_manual_title".tr(),
"backup_manual_cancelled".tr(), "backup_manual_cancelled".tr(),
presentBanner: true, presentBanner: true,
); );
hasErrors = true; hasErrors = true;
} else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCancelled)) { } else if (state.successfulUploads == 0 || (!ok && _cancelToken != null)) {
await _localNotificationService.showOrUpdateManualUploadStatus( await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(), "backup_manual_title".tr(),
"failed".tr(), "failed".tr(),
@@ -324,7 +324,8 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.notifyBackgroundServiceCanRun(); _backupProvider.notifyBackgroundServiceCanRun();
} }
state.cancelToken.cancel(); _cancelToken?.complete();
_cancelToken = null;
if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
} }
-40
View File
@@ -1,40 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
import 'package:immich_mobile/services/api.service.dart';
/// Loads the codec from the URI and sends the events to the [chunkEvents] stream
///
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
/// for this wonderful implementation of their image loader
class ImageLoader {
static Future<ui.Codec> loadImageFromCache(
String uri, {
required CacheManager cache,
required ImageDecoderCallback decode,
StreamController<ImageChunkEvent>? chunkEvents,
}) async {
final headers = ApiService.getRequestHeaders();
final stream = cache.getFileStream(uri, withProgress: chunkEvents != null, headers: headers);
await for (final result in stream) {
if (result is DownloadProgress) {
// We are downloading the file, so update the [chunkEvents]
chunkEvents?.add(
ImageChunkEvent(cumulativeBytesLoaded: result.downloaded, expectedTotalBytes: result.totalSize),
);
} else if (result is FileInfo) {
// We have the file
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
return decode(buffer);
}
}
// If we get here, the image failed to load from the cache stream
throw const ImageLoadingException('Could not load image from stream');
}
}
@@ -1,25 +0,0 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class RemoteImageCacheManager extends CacheManager {
static const key = 'remoteImageCacheKey';
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
factory RemoteImageCacheManager() {
return _instance;
}
RemoteImageCacheManager._() : super(_config);
}
class RemoteThumbnailCacheManager extends CacheManager {
static const key = 'remoteThumbnailCacheKey';
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
factory RemoteThumbnailCacheManager() {
return _instance;
}
RemoteThumbnailCacheManager._() : super(_config);
}
@@ -1,13 +0,0 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider]
class ThumbnailImageCacheManager extends CacheManager {
static const key = 'thumbnailImageCacheKey';
static final ThumbnailImageCacheManager _instance = ThumbnailImageCacheManager._();
factory ThumbnailImageCacheManager() {
return _instance;
}
ThumbnailImageCacheManager._() : super(Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30)));
}
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -455,7 +454,7 @@ class ActionNotifier extends Notifier<void> {
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList(); final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
final progressNotifier = ref.read(assetUploadProgressProvider.notifier); final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = CancellationToken(); final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
// Initialize progress for all assets // Initialize progress for all assets
@@ -466,7 +465,7 @@ class ActionNotifier extends Notifier<void> {
try { try {
await _foregroundUploadService.uploadManual( await _foregroundUploadService.uploadManual(
assetsToUpload, assetsToUpload,
cancelToken, cancelToken: cancelToken,
callbacks: UploadCallbacks( callbacks: UploadCallbacks(
onProgress: (localAssetId, filename, bytes, totalBytes) { onProgress: (localAssetId, filename, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
@@ -1,11 +1,10 @@
import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/domain/services/memory.service.dart';
import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'db.provider.dart';
final driftMemoryRepositoryProvider = Provider<DriftMemoryRepository>( final driftMemoryRepositoryProvider = Provider<DriftMemoryRepository>(
(ref) => DriftMemoryRepository(ref.watch(driftProvider)), (ref) => DriftMemoryRepository(ref.watch(driftProvider)),
); );
@@ -6,11 +6,10 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'album.provider.dart';
class RemoteAlbumState { class RemoteAlbumState {
final List<RemoteAlbum> albums; final List<RemoteAlbum> albums;
+4 -13
View File
@@ -1,18 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
@@ -99,11 +98,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
if (authenticationState.isAuthenticated) { if (authenticationState.isAuthenticated) {
try { try {
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint)); final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
final headers = ApiService.getRequestHeaders();
if (endpoint.userInfo.isNotEmpty) {
headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
}
dPrint(() => "Attempting to connect to websocket"); dPrint(() => "Attempting to connect to websocket");
// Configure socket transports must be specified // Configure socket transports must be specified
Socket socket = io( Socket socket = io(
@@ -111,11 +105,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
OptionBuilder() OptionBuilder()
.setPath("${endpoint.path}/socket.io") .setPath("${endpoint.path}/socket.io")
.setTransports(['websocket']) .setTransports(['websocket'])
.setWebSocketConnector(NetworkRepository.createWebSocket)
.enableReconnection() .enableReconnection()
.enableForceNew() .enableForceNew()
.enableForceNewConnection() .enableForceNewConnection()
.enableAutoConnect() .enableAutoConnect()
.setExtraHeaders(headers)
.build(), .build(),
); );
@@ -160,12 +154,9 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchedAssetUploadReady.clear(); _batchedAssetUploadReady.clear();
var socket = state.socket?.disconnect(); state.socket?.dispose();
if (socket?.disconnected == true) {
state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges); state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges);
} }
}
void stopListenToEvent(String eventName) { void stopListenToEvent(String eventName) {
state.socket?.off(eventName); state.socket?.off(eventName);
@@ -319,7 +310,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
} }
void _handleSyncAssetEditReady(dynamic data) { void _handleSyncAssetEditReady(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditBatch([data])); unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEdit(data));
} }
void _processBatchedAssetUploadReady() { void _processBatchedAssetUploadReady() {
+40 -40
View File
@@ -3,21 +3,15 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
class UploadTaskWithFile {
final File file;
final UploadTask task;
const UploadTaskWithFile({required this.file, required this.task});
}
final uploadRepositoryProvider = Provider((ref) => UploadRepository()); final uploadRepositoryProvider = Provider((ref) => UploadRepository());
class UploadRepository { class UploadRepository {
@@ -97,26 +91,27 @@ class UploadRepository {
Future<UploadResult> uploadFile({ Future<UploadResult> uploadFile({
required File file, required File file,
required String originalFileName, required String originalFileName,
required Map<String, String> headers,
required Map<String, String> fields, required Map<String, String> fields,
required Client httpClient, required Completer<void>? cancelToken,
required CancellationToken cancelToken, void Function(int bytes, int totalBytes)? onProgress,
required void Function(int bytes, int totalBytes) onProgress,
required String logContext, required String logContext,
}) async { }) async {
final String savedEndpoint = Store.get(StoreKey.serverEndpoint); final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
final baseRequest = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
abortTrigger: cancelToken?.future,
onProgress: onProgress,
);
try { try {
final fileStream = file.openRead(); final fileStream = file.openRead();
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName);
final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress);
baseRequest.headers.addAll(headers);
baseRequest.fields.addAll(fields); baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData); baseRequest.files.add(assetRawUploadData);
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); final response = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await response.stream.bytesToString(); final responseBodyString = await response.stream.bytesToString();
if (![200, 201].contains(response.statusCode)) { if (![200, 201].contains(response.statusCode)) {
@@ -145,7 +140,7 @@ class UploadRepository {
} catch (e) { } catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response'); return UploadResult.error(errorMessage: 'Failed to parse server response');
} }
} on CancelledException { } on RequestAbortedException {
logger.warning("Upload $logContext was cancelled"); logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled(); return UploadResult.cancelled();
} catch (error, stackTrace) { } catch (error, stackTrace) {
@@ -155,6 +150,34 @@ class UploadRepository {
} }
} }
class ProgressMultipartRequest extends MultipartRequest with Abortable {
ProgressMultipartRequest(super.method, super.url, {this.abortTrigger, this.onProgress});
@override
final Future<void>? abortTrigger;
final void Function(int bytes, int totalBytes)? onProgress;
@override
ByteStream finalize() {
final byteStream = super.finalize();
if (onProgress == null) return byteStream;
final total = contentLength;
var bytes = 0;
final stream = byteStream.transform(
StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress!(bytes, total);
sink.add(data);
},
),
);
return ByteStream(stream);
}
}
class UploadResult { class UploadResult {
final bool isSuccess; final bool isSuccess;
final bool isCancelled; final bool isCancelled;
@@ -182,26 +205,3 @@ class UploadResult {
return const UploadResult(isSuccess: false, isCancelled: true); return const UploadResult(isSuccess: false, isCancelled: true);
} }
} }
class _CustomMultipartRequest extends MultipartRequest {
_CustomMultipartRequest(super.method, super.url, {required this.onProgress});
final void Function(int bytes, int totalBytes) onProgress;
@override
ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
var bytes = 0;
final t = StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return ByteStream(stream);
}
}
+35 -19
View File
@@ -3,12 +3,11 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -49,9 +48,14 @@ class ApiService implements Authentication {
String? _accessToken; String? _accessToken;
final _log = Logger("ApiService"); final _log = Logger("ApiService");
Future<void> updateHeaders() async {
await NetworkRepository.setHeaders(getRequestHeaders(), getServerUrls());
_apiClient.client = NetworkRepository.client;
}
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint, authentication: this); _apiClient = ApiClient(basePath: endpoint, authentication: this);
_setUserAgentHeader(); _apiClient.client = NetworkRepository.client;
if (_accessToken != null) { if (_accessToken != null) {
setAccessToken(_accessToken!); setAccessToken(_accessToken!);
} }
@@ -78,11 +82,6 @@ class ApiService implements Authentication {
tagsApi = TagsApi(_apiClient); tagsApi = TagsApi(_apiClient);
} }
Future<void> _setUserAgentHeader() async {
final userAgent = await getUserAgentString();
_apiClient.addDefaultHeader('User-Agent', userAgent);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async { Future<String> resolveAndSetEndpoint(String serverUrl) async {
final endpoint = await resolveEndpoint(serverUrl); final endpoint = await resolveEndpoint(serverUrl);
setEndpoint(endpoint); setEndpoint(endpoint);
@@ -136,14 +135,9 @@ class ApiService implements Authentication {
} }
Future<String> _getWellKnownEndpoint(String baseUrl) async { Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();
try { try {
var headers = {"Accept": "application/json"}; final res = await NetworkRepository.client
headers.addAll(getRequestHeaders()); .get(Uri.parse("$baseUrl/.well-known/immich"))
final res = await client
.get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers)
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
if (res.statusCode == 200) { if (res.statusCode == 200) {
@@ -185,6 +179,31 @@ class ApiService implements Authentication {
} }
} }
static List<String> getServerUrls() {
final urls = <String>[];
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null && serverUrl.isNotEmpty) {
urls.add(serverUrl);
}
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
final externalJson = Store.tryGet(StoreKey.externalEndpointList);
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = entry['url'] as String?;
if (url != null && url.isNotEmpty) urls.add(url);
}
}
return urls;
}
static Map<String, String> getRequestHeaders() { static Map<String, String> getRequestHeaders() {
var accessToken = Store.get(StoreKey.accessToken, ""); var accessToken = Store.get(StoreKey.accessToken, "");
var customHeadersStr = Store.get(StoreKey.customHeaders, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, "");
@@ -207,10 +226,7 @@ class ApiService implements Authentication {
@override @override
Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) { Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) {
return Future<void>(() { return Future.value();
var headers = ApiService.getRequestHeaders();
headerParams.addAll(headers);
});
} }
ApiClient get apiClient => _apiClient; ApiClient get apiClient => _apiClient;
+2 -13
View File
@@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
@@ -64,27 +64,16 @@ class AuthService {
} }
Future<bool> validateAuxilaryServerUrl(String url) async { Future<bool> validateAuxilaryServerUrl(String url) async {
final httpclient = HttpClient();
bool isValid = false; bool isValid = false;
try { try {
final uri = Uri.parse('$url/users/me'); final uri = Uri.parse('$url/users/me');
final request = await httpclient.getUrl(uri); final response = await NetworkRepository.client.get(uri);
// add auth token + any configured custom headers
final customHeaders = ApiService.getRequestHeaders();
customHeaders.forEach((key, value) {
request.headers.add(key, value);
});
final response = await request.close();
if (response.statusCode == 200) { if (response.statusCode == 200) {
isValid = true; isValid = true;
} }
} catch (error) { } catch (error) {
_log.severe("Error validating auxiliary endpoint", error); _log.severe("Error validating auxiliary endpoint", error);
} finally {
httpclient.close();
} }
return isValid; return isValid;
+7 -8
View File
@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate'; import 'dart:isolate';
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -30,7 +29,6 @@ import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -43,7 +41,7 @@ class BackgroundService {
static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel'); static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel');
static const notifyInterval = Duration(milliseconds: 400); static const notifyInterval = Duration(milliseconds: 400);
bool _isBackgroundInitialized = false; bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken; Completer<void>? _cancellationToken;
bool _canceledBySystem = false; bool _canceledBySystem = false;
int _wantsLockTime = 0; int _wantsLockTime = 0;
bool _hasLock = false; bool _hasLock = false;
@@ -321,7 +319,8 @@ class BackgroundService {
} }
case "systemStop": case "systemStop":
_canceledBySystem = true; _canceledBySystem = true;
_cancellationToken?.cancel(); _cancellationToken?.complete();
_cancellationToken = null;
return true; return true;
default: default:
dPrint(() => "Unknown method ${call.method}"); dPrint(() => "Unknown method ${call.method}");
@@ -341,7 +340,6 @@ class BackgroundService {
], ],
); );
HttpSSLOptions.apply();
await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken));
await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
@@ -441,7 +439,8 @@ class BackgroundService {
), ),
); );
_cancellationToken = CancellationToken(); _cancellationToken?.complete();
_cancellationToken = Completer<void>();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await backupService.backupAsset( final bool ok = await backupService.backupAsset(
@@ -455,7 +454,7 @@ class BackgroundService {
isBackground: true, isBackground: true,
); );
if (!ok && !_cancellationToken!.isCancelled) { if (!ok && !_cancellationToken!.isCompleted) {
unawaited( unawaited(
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: "backup_background_service_error_title".tr(),
@@ -467,7 +466,7 @@ class BackgroundService {
return ok; return ok;
} }
void _onAssetUploaded({bool shouldNotify = false}) async { void _onAssetUploaded({bool shouldNotify = false}) {
if (!shouldNotify) { if (!shouldNotify) {
return; return;
} }
+13 -40
View File
@@ -2,14 +2,16 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
@@ -43,7 +45,6 @@ final backupServiceProvider = Provider(
); );
class BackupService { class BackupService {
final httpClient = http.Client();
final ApiService _apiService; final ApiService _apiService;
final Logger _log = Logger("BackupService"); final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting; final AppSettingsService _appSetting;
@@ -233,7 +234,7 @@ class BackupService {
Future<bool> backupAsset( Future<bool> backupAsset(
Iterable<BackupCandidate> assets, Iterable<BackupCandidate> assets,
http.CancellationToken cancelToken, { Completer<void> cancelToken, {
bool isBackground = false, bool isBackground = false,
PMProgressHandler? pmProgressHandler, PMProgressHandler? pmProgressHandler,
required void Function(SuccessUploadAsset result) onSuccess, required void Function(SuccessUploadAsset result) onSuccess,
@@ -306,20 +307,20 @@ class BackupService {
} }
final fileStream = file.openRead(); final fileStream = file.openRead();
final assetRawUploadData = http.MultipartFile( final assetRawUploadData = MultipartFile(
"assetData", "assetData",
fileStream, fileStream,
file.lengthSync(), file.lengthSync(),
filename: originalFileName, filename: originalFileName,
); );
final baseRequest = MultipartRequest( final baseRequest = ProgressMultipartRequest(
'POST', 'POST',
Uri.parse('$savedEndpoint/assets'), Uri.parse('$savedEndpoint/assets'),
abortTrigger: cancelToken.future,
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
); );
baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.fields['deviceAssetId'] = asset.localId!; baseRequest.fields['deviceAssetId'] = asset.localId!;
baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String(); baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String();
@@ -348,7 +349,7 @@ class BackupService {
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
} }
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); final response = await NetworkRepository.client.send(baseRequest);
final responseBody = jsonDecode(await response.stream.bytesToString()); final responseBody = jsonDecode(await response.stream.bytesToString());
@@ -398,7 +399,7 @@ class BackupService {
await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]); await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]);
} }
} }
} on http.CancelledException { } on RequestAbortedException {
dPrint(() => "Backup was cancelled by the user"); dPrint(() => "Backup was cancelled by the user");
anyErrors = true; anyErrors = true;
break; break;
@@ -429,26 +430,26 @@ class BackupService {
String originalFileName, String originalFileName,
File? livePhotoVideoFile, File? livePhotoVideoFile,
MultipartRequest baseRequest, MultipartRequest baseRequest,
http.CancellationToken cancelToken, Completer cancelToken,
) async { ) async {
if (livePhotoVideoFile == null) { if (livePhotoVideoFile == null) {
return null; return null;
} }
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path)); final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path));
final fileStream = livePhotoVideoFile.openRead(); final fileStream = livePhotoVideoFile.openRead();
final livePhotoRawUploadData = http.MultipartFile( final livePhotoRawUploadData = MultipartFile(
"assetData", "assetData",
fileStream, fileStream,
livePhotoVideoFile.lengthSync(), livePhotoVideoFile.lengthSync(),
filename: livePhotoTitle, filename: livePhotoTitle,
); );
final livePhotoReq = MultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress) final livePhotoReq = ProgressMultipartRequest(baseRequest.method, baseRequest.url, abortTrigger: cancelToken.future)
..headers.addAll(baseRequest.headers) ..headers.addAll(baseRequest.headers)
..fields.addAll(baseRequest.fields); ..fields.addAll(baseRequest.fields);
livePhotoReq.files.add(livePhotoRawUploadData); livePhotoReq.files.add(livePhotoRawUploadData);
var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken); var response = await NetworkRepository.client.send(livePhotoReq);
var responseBody = jsonDecode(await response.stream.bytesToString()); var responseBody = jsonDecode(await response.stream.bytesToString());
@@ -470,31 +471,3 @@ class BackupService {
AssetType.other => "OTHER", AssetType.other => "OTHER",
}; };
} }
class MultipartRequest extends http.MultipartRequest {
/// Creates a new [MultipartRequest].
MultipartRequest(super.method, super.url, {required this.onProgress});
final void Function(int bytes, int totalBytes) onProgress;
/// Freezes all mutable fields and returns a
/// single-subscription [http.ByteStream]
/// that will emit the request body.
@override
http.ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
var bytes = 0;
final t = StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return http.ByteStream(stream);
}
}
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -19,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -82,7 +80,7 @@ class ForegroundUploadService {
/// Bulk upload of backup candidates from selected albums /// Bulk upload of backup candidates from selected albums
Future<void> uploadCandidates( Future<void> uploadCandidates(
String userId, String userId,
CancellationToken cancelToken, { Completer<void> cancelToken, {
UploadCallbacks callbacks = const UploadCallbacks(), UploadCallbacks callbacks = const UploadCallbacks(),
bool useSequentialUpload = false, bool useSequentialUpload = false,
}) async { }) async {
@@ -105,7 +103,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset); final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi; return requireWifi && !hasWifi;
}, },
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
); );
} }
} }
@@ -113,17 +111,15 @@ class ForegroundUploadService {
/// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues /// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
Future<void> _uploadSequentially({ Future<void> _uploadSequentially({
required List<LocalAsset> items, required List<LocalAsset> items,
required CancellationToken cancelToken, required Completer<void> cancelToken,
required bool hasWifi, required bool hasWifi,
required UploadCallbacks callbacks, required UploadCallbacks callbacks,
}) async { }) async {
final httpClient = Client();
await _storageRepository.clearCache(); await _storageRepository.clearCache();
shouldAbortUpload = false; shouldAbortUpload = false;
try {
for (final asset in items) { for (final asset in items) {
if (shouldAbortUpload || cancelToken.isCancelled) { if (shouldAbortUpload || cancelToken.isCompleted) {
break; break;
} }
@@ -133,17 +129,14 @@ class ForegroundUploadService {
continue; continue;
} }
await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks); await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
}
} finally {
httpClient.close();
} }
} }
/// Manually upload picked local assets /// Manually upload picked local assets
Future<void> uploadManual( Future<void> uploadManual(
List<LocalAsset> localAssets, List<LocalAsset> localAssets, {
CancellationToken cancelToken, { Completer<void>? cancelToken,
UploadCallbacks callbacks = const UploadCallbacks(), UploadCallbacks callbacks = const UploadCallbacks(),
}) async { }) async {
if (localAssets.isEmpty) { if (localAssets.isEmpty) {
@@ -153,14 +146,14 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>( await _executeWithWorkerPool<LocalAsset>(
items: localAssets, items: localAssets,
cancelToken: cancelToken, cancelToken: cancelToken,
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
); );
} }
/// Upload files from shared intent /// Upload files from shared intent
Future<void> uploadShareIntent( Future<void> uploadShareIntent(
List<File> files, { List<File> files, {
CancellationToken? cancelToken, Completer<void>? cancelToken,
void Function(String fileId, int bytes, int totalBytes)? onProgress, void Function(String fileId, int bytes, int totalBytes)? onProgress,
void Function(String fileId)? onSuccess, void Function(String fileId)? onSuccess,
void Function(String fileId, String errorMessage)? onError, void Function(String fileId, String errorMessage)? onError,
@@ -168,20 +161,16 @@ class ForegroundUploadService {
if (files.isEmpty) { if (files.isEmpty) {
return; return;
} }
final effectiveCancelToken = cancelToken ?? CancellationToken();
await _executeWithWorkerPool<File>( await _executeWithWorkerPool<File>(
items: files, items: files,
cancelToken: effectiveCancelToken, cancelToken: cancelToken,
processItem: (file, httpClient) async { processItem: (file) async {
final fileId = p.hash(file.path).toString(); final fileId = p.hash(file.path).toString();
final result = await _uploadSingleFile( final result = await _uploadSingleFile(
file, file,
deviceAssetId: fileId, deviceAssetId: fileId,
httpClient: httpClient, cancelToken: cancelToken,
cancelToken: effectiveCancelToken,
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
); );
@@ -207,22 +196,19 @@ class ForegroundUploadService {
/// [concurrentWorkers] - Number of concurrent workers (default: 3) /// [concurrentWorkers] - Number of concurrent workers (default: 3)
Future<void> _executeWithWorkerPool<T>({ Future<void> _executeWithWorkerPool<T>({
required List<T> items, required List<T> items,
required CancellationToken cancelToken, required Completer<void>? cancelToken,
required Future<void> Function(T item, Client httpClient) processItem, required Future<void> Function(T item) processItem,
bool Function(T item)? shouldSkip, bool Function(T item)? shouldSkip,
int concurrentWorkers = 3, int concurrentWorkers = 3,
}) async { }) async {
final httpClients = List.generate(concurrentWorkers, (_) => Client());
await _storageRepository.clearCache(); await _storageRepository.clearCache();
shouldAbortUpload = false; shouldAbortUpload = false;
try {
int currentIndex = 0; int currentIndex = 0;
Future<void> worker(Client httpClient) async { Future<void> worker() async {
while (true) { while (true) {
if (shouldAbortUpload || cancelToken.isCancelled) { if (shouldAbortUpload || (cancelToken != null && cancelToken.isCompleted)) {
break; break;
} }
@@ -238,27 +224,21 @@ class ForegroundUploadService {
continue; continue;
} }
await processItem(item, httpClient); await processItem(item);
} }
} }
final workerFutures = <Future<void>>[]; final workerFutures = <Future<void>>[];
for (int i = 0; i < concurrentWorkers; i++) { for (int i = 0; i < concurrentWorkers; i++) {
workerFutures.add(worker(httpClients[i])); workerFutures.add(worker());
} }
await Future.wait(workerFutures); await Future.wait(workerFutures);
} finally {
for (final client in httpClients) {
client.close();
}
}
} }
Future<void> _uploadSingleAsset( Future<void> _uploadSingleAsset(
LocalAsset asset, LocalAsset asset,
Client httpClient, Completer<void>? cancelToken, {
CancellationToken cancelToken, {
required UploadCallbacks callbacks, required UploadCallbacks callbacks,
}) async { }) async {
File? file; File? file;
@@ -343,7 +323,6 @@ class ForegroundUploadService {
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId); final deviceId = Store.get(StoreKey.deviceId);
final headers = ApiService.getRequestHeaders();
final fields = { final fields = {
'deviceAssetId': asset.localId!, 'deviceAssetId': asset.localId!,
'deviceId': deviceId, 'deviceId': deviceId,
@@ -358,15 +337,15 @@ class ForegroundUploadService {
if (entity.isLivePhoto && livePhotoFile != null) { if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile( final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile, file: livePhotoFile,
originalFileName: livePhotoTitle, originalFileName: livePhotoTitle,
headers: headers,
fields: fields, fields: fields,
httpClient: httpClient,
cancelToken: cancelToken, cancelToken: cancelToken,
onProgress: (bytes, totalBytes) => onProgress: onProgress != null
callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes), ? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
: null,
logContext: 'livePhotoVideo[${asset.localId}]', logContext: 'livePhotoVideo[${asset.localId}]',
); );
@@ -395,15 +374,15 @@ class ForegroundUploadService {
]); ]);
} }
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile( final result = await _uploadRepository.uploadFile(
file: file, file: file,
originalFileName: originalFileName, originalFileName: originalFileName,
headers: headers,
fields: fields, fields: fields,
httpClient: httpClient,
cancelToken: cancelToken, cancelToken: cancelToken,
onProgress: (bytes, totalBytes) => onProgress: onProgress != null
callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes), ? (bytes, totalBytes) => onProgress(asset.localId!, originalFileName, bytes, totalBytes)
: null,
logContext: 'asset[${asset.localId}]', logContext: 'asset[${asset.localId}]',
); );
@@ -442,8 +421,7 @@ class ForegroundUploadService {
Future<UploadResult> _uploadSingleFile( Future<UploadResult> _uploadSingleFile(
File file, { File file, {
required String deviceAssetId, required String deviceAssetId,
required Client httpClient, required Completer<void>? cancelToken,
required CancellationToken cancelToken,
void Function(int bytes, int totalBytes)? onProgress, void Function(int bytes, int totalBytes)? onProgress,
}) async { }) async {
try { try {
@@ -452,12 +430,9 @@ class ForegroundUploadService {
final fileModifiedAt = stats.modified; final fileModifiedAt = stats.modified;
final filename = p.basename(file.path); final filename = p.basename(file.path);
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final fields = { final fields = {
'deviceAssetId': deviceAssetId, 'deviceAssetId': deviceAssetId,
'deviceId': deviceId, 'deviceId': Store.get(StoreKey.deviceId),
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false', 'isFavorite': 'false',
@@ -467,11 +442,9 @@ class ForegroundUploadService {
return await _uploadRepository.uploadFile( return await _uploadRepository.uploadFile(
file: file, file: file,
originalFileName: filename, originalFileName: filename,
headers: headers,
fields: fields, fields: fields,
httpClient: httpClient,
cancelToken: cancelToken, cancelToken: cancelToken,
onProgress: onProgress ?? (_, __) {}, onProgress: onProgress,
logContext: 'shareIntent[$deviceAssetId]', logContext: 'shareIntent[$deviceAssetId]',
); );
} catch (e) { } catch (e) {
+1 -2
View File
@@ -6,12 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'api.service.dart';
final shareServiceProvider = Provider((ref) => ShareService(ref.watch(apiServiceProvider))); final shareServiceProvider = Provider((ref) => ShareService(ref.watch(apiServiceProvider)));
class ShareService { class ShareService {
+1 -2
View File
@@ -1,6 +1,5 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/utils/cache/custom_image_cache.dart';
import 'custom_image_cache.dart';
final class ImmichWidgetsBinding extends WidgetsFlutterBinding { final class ImmichWidgetsBinding extends WidgetsFlutterBinding {
@override @override
@@ -1,61 +0,0 @@
import 'dart:io';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:logging/logging.dart';
class HttpSSLCertOverride extends HttpOverrides {
static final Logger _log = Logger("HttpSSLCertOverride");
final bool _allowSelfSignedSSLCert;
final String? _serverHost;
final SSLClientCertStoreVal? _clientCert;
late final SecurityContext? _ctxWithCert;
HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost, this._clientCert) {
if (_clientCert != null) {
_ctxWithCert = SecurityContext(withTrustedRoots: true);
if (_ctxWithCert != null) {
setClientCert(_ctxWithCert, _clientCert);
} else {
_log.severe("Failed to create security context with client cert!");
}
} else {
_ctxWithCert = null;
}
}
static bool setClientCert(SecurityContext ctx, SSLClientCertStoreVal cert) {
try {
_log.info("Setting client certificate");
ctx.usePrivateKeyBytes(cert.data, password: cert.password);
ctx.useCertificateChainBytes(cert.data, password: cert.password);
} catch (e) {
_log.severe("Failed to set SSL client cert: $e");
return false;
}
return true;
}
@override
HttpClient createHttpClient(SecurityContext? context) {
if (context != null) {
if (_clientCert != null) {
setClientCert(context, _clientCert);
}
} else {
context = _ctxWithCert;
}
return super.createHttpClient(context)
..badCertificateCallback = (X509Certificate cert, String host, int port) {
if (_allowSelfSignedSSLCert) {
// Conduct server host checks if user is logged in to avoid making
// insecure SSL connections to services that are not the immich server.
if (_serverHost == null || _serverHost.contains(host)) {
return true;
}
}
_log.severe("Invalid SSL certificate for $host:$port");
return false;
};
}
}
-27
View File
@@ -1,27 +0,0 @@
import 'dart:io';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
class HttpSSLOptions {
static void apply() {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
return _apply(allowSelfSignedSSLCert);
}
static void applyFromSettings(bool newValue) => _apply(newValue);
static void _apply(bool allowSelfSignedSSLCert) {
String? serverHost;
if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) {
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
}
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
}
}
-2
View File
@@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart'; import 'package:immich_mobile/wm_executor.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart'; import 'package:worker_manager/worker_manager.dart';
@@ -54,7 +53,6 @@ Cancelable<T?> runInIsolateGentle<T>({
Logger log = Logger("IsolateLogger"); Logger log = Logger("IsolateLogger");
try { try {
HttpSSLOptions.apply();
result = await computation(ref); result = await computation(ref);
} on CanceledError { } on CanceledError {
log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}"); log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}");
+2 -1
View File
@@ -417,7 +417,8 @@ Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
} }
final trashedAssetMap = await nativeApi.getTrashedAssets(); final trashedAssetMap = await nativeApi.getTrashedAssets();
for (final assets in trashedAssetMap.values) { for (final entry in trashedAssetMap.cast<String, List<Object?>>().entries) {
final assets = entry.value.cast<PlatformAsset>();
await db.batch((batch) { await db.batch((batch) {
for (final asset in assets) { for (final asset in assets) {
batch.update( batch.update(
@@ -23,17 +23,16 @@ import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/disable_multi_select_button.dart';
import 'package:immich_mobile/widgets/asset_grid/draggable_scrollbar_custom.dart';
import 'package:immich_mobile/widgets/asset_grid/group_divider_title.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
import 'disable_multi_select_button.dart';
import 'draggable_scrollbar_custom.dart';
import 'group_divider_title.dart';
typedef ImmichAssetGridSelectionListener = void Function(bool, Set<Asset>); typedef ImmichAssetGridSelectionListener = void Function(bool, Set<Asset>);
class ImmichAssetGridView extends ConsumerStatefulWidget { class ImmichAssetGridView extends ConsumerStatefulWidget {
@@ -1,7 +1,6 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_hit_corners.dart';
import 'photo_view_hit_corners.dart';
/// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c) /// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c)
/// for the gist /// for the gist
@@ -1,9 +1,8 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import '../photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart';
import 'core/photo_view_core.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_default_widgets.dart';
import 'photo_view_default_widgets.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart';
import 'utils/photo_view_utils.dart';
class ImageWrapper extends StatefulWidget { class ImageWrapper extends StatefulWidget {
const ImageWrapper({ const ImageWrapper({
@@ -10,12 +10,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
@@ -31,15 +29,12 @@ class AdvancedSettings extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
bool isLoggedIn = ref.read(currentUserProvider) != null;
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false); final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false); final manageMediaAndroidPermission = useState(false);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
@@ -120,15 +115,8 @@ class AdvancedSettings extends HookConsumerWidget {
subtitle: "advanced_settings_prefer_remote_subtitle".tr(), subtitle: "advanced_settings_prefer_remote_subtitle".tr(),
), ),
if (!Store.isBetaTimelineEnabled) const LocalStorageSettings(), if (!Store.isBetaTimelineEnabled) const LocalStorageSettings(),
SettingsSwitchListTile(
enabled: !isLoggedIn,
valueNotifier: allowSelfSignedSSLCert,
title: "advanced_settings_self_signed_ssl_title".tr(),
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
onChanged: HttpSSLOptions.applyFromSettings,
),
const CustomProxyHeaderSettings(), const CustomProxyHeaderSettings(),
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), const SslClientCertSettings(),
if (!Store.isBetaTimelineEnabled) if (!Store.isBetaTimelineEnabled)
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useAlternatePMFilter, valueNotifier: useAlternatePMFilter,
@@ -6,11 +6,10 @@ import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_layout_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'asset_list_layout_settings.dart';
class AssetListSettings extends HookConsumerWidget { class AssetListSettings extends HookConsumerWidget {
const AssetListSettings({super.key}); const AssetListSettings({super.key});
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'video_viewer_settings.dart';
class AssetViewerSettings extends StatelessWidget { class AssetViewerSettings extends StatelessWidget {
const AssetViewerSettings({super.key}); const AssetViewerSettings({super.key});
@@ -1,18 +1,16 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
class SslClientCertSettings extends StatefulWidget { class SslClientCertSettings extends StatefulWidget {
const SslClientCertSettings({super.key, required this.isLoggedIn}); const SslClientCertSettings({super.key});
final bool isLoggedIn;
@override @override
State<StatefulWidget> createState() => _SslClientCertSettingsState(); State<StatefulWidget> createState() => _SslClientCertSettingsState();
@@ -21,9 +19,24 @@ class SslClientCertSettings extends StatefulWidget {
class _SslClientCertSettingsState extends State<SslClientCertSettings> { class _SslClientCertSettingsState extends State<SslClientCertSettings> {
final _log = Logger("SslClientCertSettings"); final _log = Logger("SslClientCertSettings");
bool isCertExist; bool isCertExist = false;
_SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null; @override
void initState() {
super.initState();
unawaited(_checkCertificate());
}
Future<void> _checkCertificate() async {
try {
final exists = await networkApi.hasCertificate();
if (mounted && exists != isCertExist) {
setState(() => isCertExist = exists);
}
} catch (e) {
_log.warning("Failed to check certificate existence", e);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -45,11 +58,8 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())), ElevatedButton(onPressed: importCert, child: Text("client_cert_import".tr())),
ElevatedButton( ElevatedButton(onPressed: !isCertExist ? null : removeCert, child: Text("remove".tr())),
onPressed: widget.isLoggedIn || !isCertExist ? null : removeCert,
child: Text("remove".tr()),
),
], ],
), ),
], ],
@@ -74,9 +84,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
cancel: "cancel".tr(), cancel: "cancel".tr(),
confirm: "confirm".tr(), confirm: "confirm".tr(),
); );
final cert = await networkApi.selectCertificate(styling); await networkApi.selectCertificate(styling);
await SSLClientCertStoreVal(cert.data, cert.password).save();
HttpSSLOptions.apply();
setState(() => isCertExist = true); setState(() => isCertExist = true);
showMessage("client_cert_import_success_msg".tr()); showMessage("client_cert_import_success_msg".tr());
} catch (e) { } catch (e) {
@@ -91,8 +99,6 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
Future<void> removeCert() async { Future<void> removeCert() async {
try { try {
await networkApi.removeCertificate(); await networkApi.removeCertificate();
await SSLClientCertStoreVal.delete();
HttpSSLOptions.apply();
setState(() => isCertExist = false); setState(() => isCertExist = false);
showMessage("client_cert_remove_msg".tr()); showMessage("client_cert_remove_msg".tr());
} catch (e) { } catch (e) {
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/icon_button.dart';
import 'package:immich_ui/src/types.dart'; import 'package:immich_ui/src/types.dart';
import 'icon_button.dart';
class ImmichCloseButton extends StatelessWidget { class ImmichCloseButton extends StatelessWidget {
final VoidCallback? onPressed; final VoidCallback? onPressed;
final ImmichVariant variant; final ImmichVariant variant;
+7 -1
View File
@@ -34,8 +34,14 @@ abstract class NetworkApi {
void addCertificate(ClientCertData clientData); void addCertificate(ClientCertData clientData);
@async @async
ClientCertData selectCertificate(ClientCertPrompt promptText); void selectCertificate(ClientCertPrompt promptText);
@async @async
void removeCertificate(); void removeCertificate();
bool hasCertificate();
int getClientPointer();
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls);
} }
+2 -8
View File
@@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart';
dartOut: 'lib/platform/remote_image_api.g.dart', dartOut: 'lib/platform/remote_image_api.g.dart',
swiftOut: 'ios/Runner/Images/RemoteImages.g.swift', swiftOut: 'ios/Runner/Images/RemoteImages.g.swift',
swiftOptions: SwiftOptions(includeErrorClass: false), swiftOptions: SwiftOptions(includeErrorClass: false),
kotlinOut: kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt',
'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false), kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false),
dartOptions: DartOptions(), dartOptions: DartOptions(),
dartPackageName: 'immich_mobile', dartPackageName: 'immich_mobile',
@@ -15,12 +14,7 @@ import 'package:pigeon/pigeon.dart';
@HostApi() @HostApi()
abstract class RemoteImageApi { abstract class RemoteImageApi {
@async @async
Map<String, int>? requestImage( Map<String, int>? requestImage(String url, {required int requestId, required bool preferEncoded});
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
});
void cancelRequest(int requestId); void cancelRequest(int requestId);
+30 -43
View File
@@ -201,22 +201,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.9.5" version: "8.9.5"
cancellation_token:
dependency: transitive
description:
name: cancellation_token
sha256: ad95acf9d4b2f3563e25dc937f63587e46a70ce534e910b65d10e115490f1027
url: "https://pub.dev"
source: hosted
version: "2.0.1"
cancellation_token_http:
dependency: "direct main"
description:
name: cancellation_token_http
sha256: "0fff478fe5153700396b3472ddf93303c219f1cb8d8e779e65b014cb9c7f0213"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
cast: cast:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -313,14 +297,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
cronet_http:
dependency: "direct main"
description:
name: cronet_http
sha256: "1fff7f26ac0c4cda97fe2a9aa082494baee4775f167c27ba45f6c8e88571e3ab"
url: "https://pub.dev"
source: hosted
version: "1.7.0"
crop_image: crop_image:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -356,11 +332,12 @@ packages:
cupertino_http: cupertino_http:
dependency: "direct main" dependency: "direct main"
description: description:
name: cupertino_http path: "pkgs/cupertino_http"
sha256: "82cbec60c90bf785a047a9525688b6dacac444e177e1d5a5876963d3c50369e8" ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
url: "https://pub.dev" resolved-ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
source: hosted url: "https://github.com/mertalev/http"
version: "2.4.0" source: git
version: "3.0.0-wip"
custom_lint: custom_lint:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -1241,8 +1218,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: e132bc3 ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
url: "https://github.com/immich-app/native_video_player" url: "https://github.com/immich-app/native_video_player"
source: git source: git
version: "1.3.1" version: "1.3.1"
@@ -1286,6 +1263,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
ok_http:
dependency: "direct main"
description:
path: "pkgs/ok_http"
ref: "549c24b0a4d3881a9a44b70f4873450d43c1c4af"
resolved-ref: "549c24b0a4d3881a9a44b70f4873450d43c1c4af"
url: "https://github.com/mertalev/http"
source: git
version: "0.1.1-wip"
openapi: openapi:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1741,19 +1727,20 @@ packages:
socket_io_client: socket_io_client:
dependency: "direct main" dependency: "direct main"
description: description:
name: socket_io_client path: "."
sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006
url: "https://pub.dev" resolved-ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006
source: hosted url: "https://github.com/mertalev/socket.io-client-dart"
version: "2.0.3+1" source: git
version: "3.1.4"
socket_io_common: socket_io_common:
dependency: transitive dependency: transitive
description: description:
name: socket_io_common name: socket_io_common
sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb" sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "3.1.1"
source_gen: source_gen:
dependency: transitive dependency: transitive
description: description:
@@ -2115,21 +2102,21 @@ packages:
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
web_socket: web_socket:
dependency: transitive dependency: "direct main"
description: description:
name: web_socket name: web_socket
sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.6" version: "1.0.1"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: transitive
description: description:
name: web_socket_channel name: web_socket_channel
sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.3"
webdriver: webdriver:
dependency: transitive dependency: transitive
description: description:
+16 -5
View File
@@ -12,7 +12,6 @@ dependencies:
async: ^2.13.0 async: ^2.13.0
auto_route: ^9.2.0 auto_route: ^9.2.0
background_downloader: ^9.3.0 background_downloader: ^9.3.0
cancellation_token_http: ^2.1.0
cast: ^2.1.0 cast: ^2.1.0
collection: ^1.19.1 collection: ^1.19.1
connectivity_plus: ^6.1.3 connectivity_plus: ^6.1.3
@@ -57,7 +56,7 @@ dependencies:
native_video_player: native_video_player:
git: git:
url: https://github.com/immich-app/native_video_player url: https://github.com/immich-app/native_video_player
ref: 'e132bc3' ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
network_info_plus: ^6.1.3 network_info_plus: ^6.1.3
octo_image: ^2.1.0 octo_image: ^2.1.0
openapi: openapi:
@@ -76,7 +75,6 @@ dependencies:
share_handler: ^0.0.25 share_handler: ^0.0.25
share_plus: ^10.1.4 share_plus: ^10.1.4
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
socket_io_client: ^2.0.3+1
stream_transform: ^2.1.1 stream_transform: ^2.1.1
thumbhash: 0.1.0+1 thumbhash: 0.1.0+1
timezone: ^0.9.4 timezone: ^0.9.4
@@ -84,8 +82,21 @@ dependencies:
uuid: ^4.5.1 uuid: ^4.5.1
wakelock_plus: ^1.3.0 wakelock_plus: ^1.3.0
worker_manager: ^7.2.7 worker_manager: ^7.2.7
cronet_http: ^1.7.0 web_socket: ^1.0.1
cupertino_http: ^2.4.0 socket_io_client:
git:
url: https://github.com/mertalev/socket.io-client-dart
ref: 'e1d813a240b5d5b7e2f141b2b605c5429b7cd006' # https://github.com/rikulo/socket.io-client-dart/pull/435
cupertino_http:
git:
url: https://github.com/mertalev/http
ref: 'a0a933358517c6d01cff37fc2a2752ee2d744a3c' # https://github.com/dart-lang/http/pull/1876
path: pkgs/cupertino_http/
ok_http:
git:
url: https://github.com/mertalev/http
ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877
path: pkgs/ok_http/
dev_dependencies: dev_dependencies:
auto_route_generator: ^9.0.0 auto_route_generator: ^9.0.0
+4
View File
@@ -24,6 +24,7 @@ import 'schema_v18.dart' as v18;
import 'schema_v19.dart' as v19; import 'schema_v19.dart' as v19;
import 'schema_v20.dart' as v20; import 'schema_v20.dart' as v20;
import 'schema_v21.dart' as v21; import 'schema_v21.dart' as v21;
import 'schema_v22.dart' as v22;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -71,6 +72,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v20.DatabaseAtV20(db); return v20.DatabaseAtV20(db);
case 21: case 21:
return v21.DatabaseAtV21(db); return v21.DatabaseAtV21(db);
case 22:
return v22.DatabaseAtV22(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@@ -98,5 +101,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
19, 19,
20, 20,
21, 21,
22,
]; ];
} }
File diff suppressed because it is too large Load Diff
@@ -54,13 +54,10 @@ void main() {
when(() => mockApiService.apiClient).thenReturn(mockApiClient); when(() => mockApiService.apiClient).thenReturn(mockApiClient);
when(() => mockApiService.syncApi).thenReturn(mockSyncApi); when(() => mockApiService.syncApi).thenReturn(mockSyncApi);
when(() => mockApiClient.basePath).thenReturn('http://demo.immich.app/api'); when(() => mockApiClient.basePath).thenReturn('http://demo.immich.app/api');
when(() => mockApiService.applyToParams(any(), any())).thenAnswer((_) async => {});
// Mock HTTP client behavior // Mock HTTP client behavior
when(() => mockHttpClient.send(any())).thenAnswer((_) async => mockStreamedResponse); when(() => mockHttpClient.send(any())).thenAnswer((_) async => mockStreamedResponse);
when(() => mockStreamedResponse.statusCode).thenReturn(200); when(() => mockStreamedResponse.statusCode).thenReturn(200);
when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream)); when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream));
when(() => mockHttpClient.close()).thenAnswer((_) => {});
sut = SyncApiRepository(mockApiService); sut = SyncApiRepository(mockApiService);
}); });
@@ -133,7 +130,6 @@ void main() {
expect(onDataCallCount, 1); expect(onDataCallCount, 1);
expect(abortWasCalledInCallback, isTrue); expect(abortWasCalledInCallback, isTrue);
expect(receivedEventsBatch1.length, testBatchSize); expect(receivedEventsBatch1.length, testBatchSize);
verify(() => mockHttpClient.close()).called(1);
}); });
test('streamChanges does not process remaining lines in finally block if aborted', () async { test('streamChanges does not process remaining lines in finally block if aborted', () async {
@@ -181,7 +177,6 @@ void main() {
expect(onDataCallCount, 1); expect(onDataCallCount, 1);
expect(abortWasCalledInCallback, isTrue); expect(abortWasCalledInCallback, isTrue);
verify(() => mockHttpClient.close()).called(1);
}); });
test('streamChanges processes remaining lines in finally block if not aborted', () async { test('streamChanges processes remaining lines in finally block if not aborted', () async {
@@ -240,7 +235,6 @@ void main() {
expect(onDataCallCount, 2); expect(onDataCallCount, 2);
expect(receivedEventsBatch1.length, testBatchSize); expect(receivedEventsBatch1.length, testBatchSize);
expect(receivedEventsBatch2.length, 1); expect(receivedEventsBatch2.length, 1);
verify(() => mockHttpClient.close()).called(1);
}); });
test('streamChanges handles stream error gracefully', () async { test('streamChanges handles stream error gracefully', () async {
@@ -265,7 +259,6 @@ void main() {
await expectLater(streamChangesFuture, throwsA(streamError)); await expectLater(streamChangesFuture, throwsA(streamError));
expect(onDataCallCount, 0); expect(onDataCallCount, 0);
verify(() => mockHttpClient.close()).called(1);
}); });
test('streamChanges throws ApiException on non-200 status code', () async { test('streamChanges throws ApiException on non-200 status code', () async {
@@ -293,6 +286,5 @@ void main() {
); );
expect(onDataCallCount, 0); expect(onDataCallCount, 0);
verify(() => mockHttpClient.close()).called(1);
}); });
} }
@@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadFiles } from 'src/types'; import { UploadFiles } from 'src/types';
import { ImmichFileResponse, sendFile, sendFileThrottled } from 'src/utils/file'; import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets) @ApiTags(ApiTag.Assets)
@@ -109,7 +109,7 @@ export class AssetMediaController {
@Res() res: Response, @Res() res: Response,
@Next() next: NextFunction, @Next() next: NextFunction,
) { ) {
await sendFileThrottled(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger, 10); await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
} }
@Put(':id/original') @Put(':id/original')
@@ -162,14 +162,7 @@ export class AssetMediaController {
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto); const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
if (viewThumbnailRes instanceof ImmichFileResponse) { if (viewThumbnailRes instanceof ImmichFileResponse) {
const size = dto.size ?? AssetMediaSize.THUMBNAIL; await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
let durationSeconds = 1;
if (size === AssetMediaSize.PREVIEW) {
durationSeconds = 4;
} else if (size === AssetMediaSize.FULLSIZE) {
durationSeconds = 4;
}
await sendFileThrottled(res, next, () => Promise.resolve(viewThumbnailRes), this.logger, durationSeconds);
} else { } else {
// viewThumbnailRes is a AssetMediaRedirectResponse // viewThumbnailRes is a AssetMediaRedirectResponse
// which redirects to the original asset or a specific size to make better use of caching // which redirects to the original asset or a specific size to make better use of caching
+16 -10
View File
@@ -2015,6 +2015,13 @@ describe(MediaService.name, () => {
); );
}); });
it('should not transcode when policy bitrate and bitrate lower than max bitrate', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '50M' } });
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '30M' } }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '30M' } });
@@ -2030,19 +2037,18 @@ describe(MediaService.name, () => {
); );
}); });
it('should transcode when max bitrate is not a number', async () => { it('should not transcode when max bitrate is not a number', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: 'foo' } }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: 'foo' } });
await sut.handleVideoConversion({ id: 'video-id' }); await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith( expect(mocks.media.transcode).not.toHaveBeenCalled();
'/original/path.ext', });
expect.any(String),
expect.objectContaining({ it('should not transcode when max bitrate is 0', async () => {
inputOptions: expect.any(Array), mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps);
outputOptions: expect.any(Array), mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '0' } });
twoPass: false, await sut.handleVideoConversion({ id: 'video-id' });
}), expect(mocks.media.transcode).not.toHaveBeenCalled();
);
}); });
it('should not scale resolution if no target resolution', async () => { it('should not scale resolution if no target resolution', async () => {

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