Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
8e04007f8b WIP 2026-03-11 00:09:54 -04:00
395 changed files with 9866 additions and 23141 deletions

View File

@@ -16,7 +16,7 @@ services:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../plugins:/build/corePlugin
- ../packages/plugin-core:/build/plugins/immich-plugin-core
immich-web:
env_file: !reset []
immich-machine-learning:

View File

@@ -131,7 +131,7 @@ jobs:
- device: rocm
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}'
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions:
contents: read

170
.github/workflows/release-pr.yml vendored Normal file
View File

@@ -0,0 +1,170 @@
name: Manage release PR
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
bump:
runs-on: ubuntu-latest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Determine release type
id: bump-type
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
with:
token: ${{ steps.generate-token.outputs.token }}
- name: Bump versions
env:
TYPE: ${{ steps.bump-type.outputs.bump }}
run: |
if [ "$TYPE" == "none" ]; then
exit 1 # TODO: Is there a cleaner way to abort the workflow?
fi
misc/release/pump-version.sh -s $TYPE -m true
- name: Manage Outline release document
id: outline
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const fs = require('fs');
const outlineKey = process.env.OUTLINE_API_KEY;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
let documentId;
let documentUrl;
let documentText;
if (!document) {
// Create new document
console.log('No existing document found. Creating new one...');
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'next',
text: notesTmpl,
collectionId: collectionId,
parentDocumentId: parentDocumentId,
publish: true
})
});
if (!createResponse.ok) {
throw new Error(`Failed to create document: ${createResponse.statusText}`);
}
const createData = await createResponse.json();
documentId = createData.data.id;
const urlId = createData.data.urlId;
documentUrl = `${baseUrl}/doc/next-${urlId}`;
documentText = createData.data.text || '';
console.log(`Created new document: ${documentUrl}`);
} else {
documentId = document.id;
const docPath = document.url;
documentUrl = `${baseUrl}${docPath}`;
documentText = document.text || '';
console.log(`Found existing document: ${documentUrl}`);
}
// Generate GitHub release notes
console.log('Generating GitHub release notes...');
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `${process.env.NEXT_VERSION}`,
});
// Combine the content
const changelog = `
# ${process.env.NEXT_VERSION}
${documentText}
${releaseNotesResponse.data.body}
---
`
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
core.setOutput('document_url', documentUrl);
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
labels: 'changelog:skip'
branch: 'release/next'
draft: true

View File

@@ -90,6 +90,8 @@ jobs:
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Build app
run: pnpm run --filter immich --filter @immich/plugin-sdk build
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
@@ -394,6 +396,8 @@ jobs:
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build plugin
run: pnpm run --filter @immich/plugin-sdk --filter @immich/plugin-core build
- name: Run medium tests
run: pnpm test:medium
if: ${{ !cancelled() }}
@@ -722,7 +726,7 @@ jobs:
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Build the app
run: pnpm --filter immich build
run: pnpm --filter immich --filter @immich/plugin-sdk build
- name: Run API generation
run: ./bin/generate-open-api.sh
working-directory: open-api
@@ -784,7 +788,7 @@ jobs:
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build the app
run: pnpm build
run: pnpm run --filter immich --filter @immich/plugin-sdk build
- name: Run existing migrations
run: pnpm migrations:run
- name: Test npm run schema:reset command works

48
.vscode/settings.json vendored
View File

@@ -1,7 +1,8 @@
{
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[dart]": {
"editor.defaultFormatter": "Dart-Code.dart-code",
@@ -18,15 +19,18 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[svelte]": {
"editor.codeActionsOnSave": {
@@ -34,7 +38,8 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[typescript]": {
"editor.codeActionsOnSave": {
@@ -42,45 +47,18 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"cSpell.words": ["immich"],
"css.lint.unknownAtRules": "ignore",
"editor.bracketPairColorization.enabled": true,
"editor.formatOnSave": true,
"eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "svelte"],
"eslint.workingDirectories": [
{ "directory": "cli", "changeProcessCWD": true },
{ "directory": "e2e", "changeProcessCWD": true },
{ "directory": "server", "changeProcessCWD": true },
{ "directory": "web", "changeProcessCWD": true }
],
"files.watcherExclude": {
"**/.jj/**": true,
"**/.git/**": true,
"**/node_modules/**": true,
"**/build/**": true,
"**/dist/**": true,
"**/.svelte-kit/**": true
},
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
},
"search.exclude": {
"**/node_modules": true,
"**/build": true,
"**/dist": true,
"**/.svelte-kit": true,
"**/open-api/typescript-sdk/src": true
},
"svelte.enable-ts-plugin": true,
"tailwindCSS.experimental.configFile": {
"web/src/app.css": "web/src/**"
},
"js/ts.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10
"typescript.preferences.importModuleSpecifier": "non-relative"
}

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.11.0",
"@types/node": "^24.10.14",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -73,7 +73,7 @@ services:
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../plugins:/build/corePlugin
- ../packages/plugin-core:/build/plugins/immich-plugin-core
env_file:
- .env
environment:
@@ -155,7 +155,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
user: '1000:1000'
security_opt:
- no-new-privileges:true

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -230,7 +230,7 @@ The default value is `ultrafast`.
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
The default value is `aac`.

View File

@@ -27,7 +27,7 @@ The default configuration looks like this:
"ffmpeg": {
"accel": "disabled",
"accelDecode": false,
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
"acceptedContainers": ["mov", "ogg", "webm"],
"acceptedVideoCodecs": ["h264"],
"bframes": -1,

View File

@@ -166,8 +166,6 @@ 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__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |

View File

@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.11.0",
"@types/node": "^24.10.14",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",

View File

@@ -1,13 +1,14 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import type { Socket } from 'socket.io-client';
import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let rawAsset: AssetMediaResponseDto;
let websocket: Socket;
test.beforeAll(async () => {
utils.initSdk();
@@ -15,11 +16,6 @@ test.describe('Photo Viewer', () => {
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
websocket = await utils.connectWebsocket(admin.accessToken);
});
test.afterAll(() => {
utils.disconnectWebsocket(websocket);
});
test.beforeEach(async ({ context, page }) => {
@@ -30,51 +26,31 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await originalResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /original/);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await fullsizeResponse;
const original = page.getByTestId('original').filter({ visible: true });
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 }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const initialSrc = await preview.getAttribute('src');
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id);
await websocketEvent;
await expect(preview).not.toHaveAttribute('src', initialSrc!);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
});
});

View File

@@ -64,9 +64,7 @@ test.describe('broken-asset responsiveness', () => {
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
(url) =>
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
async (route) => {
return route.fulfill({ status: 404 });
},
@@ -75,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();

View File

@@ -215,9 +215,8 @@ export const pageUtils = {
await page.getByText('Confirm').click();
},
async selectDay(page: Page, day: string) {
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
await section.hover();
await section.locator('.w-8').click();
await page.getByTitle(day).hover();
await page.locator('[data-group] .w-8').click();
},
async pauseTestDebug() {
console.log('NOTE: pausing test indefinately for debug');

View File

@@ -177,51 +177,40 @@ export const utils = {
},
resetDatabase: async (tables?: string[]) => {
client = await utils.connectDatabase();
try {
client = await utils.connectDatabase();
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
'stack',
'library',
'shared_link',
'person',
'album',
'asset',
'asset_face',
'activity',
'api_key',
'session',
'user',
'system_metadata',
'tag',
];
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
'stack',
'library',
'shared_link',
'person',
'album',
'asset',
'asset_face',
'activity',
'api_key',
'session',
'user',
'system_metadata',
'tag',
];
const truncateTables = tables.filter((table) => table !== 'system_metadata');
const sql: string[] = [];
const sql: string[] = [];
if (truncateTables.length > 0) {
sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`);
}
if (tables.includes('system_metadata')) {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
}
const query = sql.join('\n');
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await client.query(query);
return;
} catch (error: any) {
if (error?.code === '40P01' && attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
continue;
for (const table of tables) {
if (table === 'system_metadata') {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else {
sql.push(`DELETE FROM "${table}" CASCADE;`);
}
console.error('Failed to reset database', error);
throw error;
}
await client.query(sql.join('\n'));
} catch (error) {
console.error('Failed to reset database', error);
throw error;
}
},

View File

@@ -22,13 +22,12 @@
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
"add_path": "Add path",
"add_photos": "Add photos",
"add_step": "Add step",
"add_tag": "Add tag",
"add_to": "Add to…",
"add_to_album": "Add to album",
@@ -42,7 +41,6 @@
"add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites",
@@ -411,7 +409,7 @@
"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_transcode_policy": "Transcode policy",
"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_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
"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_video_codec": "Video codec",
@@ -805,6 +803,7 @@
"comments_are_disabled": "Comments are disabled",
"common_create_new_album": "Create new album",
"completed": "Completed",
"configuration": "Configuration",
"confirm": "Confirm",
"confirm_admin_password": "Confirm Admin Password",
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
@@ -1072,7 +1071,7 @@
"failed_to_update_notification_status": "Failed to update notification status",
"incorrect_email_or_password": "Incorrect email or password",
"library_folder_already_exists": "This import path already exists.",
"page_not_found": "Page not found",
"page_not_found": "Page not found :/",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
@@ -1583,7 +1582,6 @@
"next": "Next",
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_found": "No albums found",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
@@ -1600,7 +1598,6 @@
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
@@ -1613,6 +1610,7 @@
"no_results": "No results",
"no_results_description": "Try a synonym or more general keyword",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"no_steps": "No steps added yet",
"no_uploads_in_progress": "No uploads in progress",
"none": "None",
"not_allowed": "Not allowed",
@@ -2181,6 +2179,7 @@
"start_date_before_end_date": "Start date must be before end date",
"state": "State",
"status": "Status",
"steps": "Steps",
"stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?",
@@ -2311,7 +2310,6 @@
"unsupported_field_type": "Unsupported field type",
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated",
@@ -2402,6 +2400,7 @@
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",

View File

@@ -48,11 +48,8 @@ class PreloadModelData(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
ocr: int | None = None
text_recognition: int | None = None
class Settings(BaseSettings):

View File

@@ -29,7 +29,7 @@ class FaceRecognizer(InferenceModel):
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
super().__init__(model_name, **model_kwargs)
max_batch_size = settings.max_batch_size and settings.max_batch_size.facial_recognition
max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
def _load(self) -> ModelSession:

View File

@@ -22,7 +22,7 @@ class TextDetector(InferenceModel):
depends = []
identity = (ModelType.DETECTION, ModelTask.OCR)
def __init__(self, model_name: str, min_score: float = 0.5, **model_kwargs: Any) -> None:
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
self.max_resolution = 736
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
@@ -33,7 +33,7 @@ class TextDetector(InferenceModel):
}
self.postprocess = DBPostProcess(
thresh=0.3,
box_thresh=model_kwargs.get("minScore", min_score),
box_thresh=model_kwargs.get("minScore", 0.5),
max_candidates=1000,
unclip_ratio=1.6,
use_dilation=True,

View File

@@ -24,9 +24,9 @@ class TextRecognizer(InferenceModel):
depends = [(ModelType.DETECTION, ModelTask.OCR)]
identity = (ModelType.RECOGNITION, ModelTask.OCR)
def __init__(self, model_name: str, min_score: float = 0.9, **model_kwargs: Any) -> None:
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
self.min_score = model_kwargs.get("minScore", min_score)
self.min_score = model_kwargs.get("minScore", 0.9)
self._empty: TextRecognitionOutput = {
"box": np.empty(0, dtype=np.float32),
"boxScore": np.empty(0, dtype=np.float32),
@@ -57,11 +57,10 @@ class TextRecognizer(InferenceModel):
def _load(self) -> ModelSession:
# TODO: support other runtimes
session = OrtSession(self.model_path)
max_batch_size = settings.max_batch_size and settings.max_batch_size.ocr
self.model = RapidTextRecognizer(
OcrOptions(
session=session.session,
rec_batch_num=max_batch_size if max_batch_size else 6,
rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6,
rec_img_shape=(3, 48, 320),
lang_type=self.language,
)

View File

@@ -64,6 +64,14 @@ class OrtSession:
def _providers_default(self) -> list[str]:
available_providers = set(ort.get_available_providers())
log.debug(f"Available ORT providers: {available_providers}")
if (openvino := "OpenVINOExecutionProvider") in available_providers:
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
log.debug(f"Available OpenVINO devices: {device_ids}")
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
if not gpu_devices:
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
available_providers.remove(openvino)
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
@property
@@ -94,19 +102,12 @@ class OrtSession:
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
}
case "OpenVINOExecutionProvider":
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
# Check for available devices, preferring GPU over CPU
gpu_devices = [d for d in device_ids if d.startswith("GPU")]
if gpu_devices:
device_type = f"GPU.{settings.device_id}"
log.debug(f"OpenVINO: Using GPU device {device_type}")
else:
device_type = "CPU"
log.debug("OpenVINO: No GPU found, using CPU")
openvino_dir = self.model_path.parent / "openvino"
device = f"GPU.{settings.device_id}"
options = {
"device_type": device_type,
"device_type": device,
"precision": settings.openvino_precision.value,
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
"cache_dir": openvino_dir.as_posix(),
}
case "CoreMLExecutionProvider":
options = {
@@ -138,14 +139,12 @@ class OrtSession:
sess_options.enable_cpu_mem_arena = settings.model_arena
# avoid thread contention between models
# Set inter_op threads
if settings.model_inter_op_threads > 0:
sess_options.inter_op_num_threads = settings.model_inter_op_threads
# these defaults work well for CPU, but bottleneck GPU
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
sess_options.inter_op_num_threads = 1
# Set intra_op threads
if settings.model_intra_op_threads > 0:
sess_options.intra_op_num_threads = settings.model_intra_op_threads
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:

View File

@@ -18,7 +18,7 @@ from PIL import Image
from pytest import MonkeyPatch
from pytest_mock import MockerFixture
from immich_ml.config import MaxBatchSize, Settings, settings
from immich_ml.config import Settings, settings
from immich_ml.main import load, preload_models
from immich_ml.models.base import InferenceModel
from immich_ml.models.cache import ModelCache
@@ -26,9 +26,6 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn
from immich_ml.models.clip.visual import OpenClipVisualEncoder
from immich_ml.models.facial_recognition.detection import FaceDetector
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.sessions.ann import AnnSession
from immich_ml.sessions.ort import OrtSession
@@ -204,6 +201,13 @@ class TestOrtSession:
assert session.providers == self.OV_EP
@pytest.mark.ov_device_ids(["CPU"])
@pytest.mark.providers(OV_EP)
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], ov_device_ids: list[str]) -> None:
session = OrtSession("ViT-B-32__openai")
assert session.providers == self.CPU_EP
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
session = OrtSession("ViT-B-32__openai")
@@ -249,8 +253,7 @@ class TestOrtSession:
{"arena_extend_strategy": "kSameAsRequested"},
]
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None:
def test_sets_provider_options_for_openvino(self) -> None:
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
@@ -264,8 +267,7 @@ class TestOrtSession:
}
]
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None:
def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
@@ -280,19 +282,6 @@ class TestOrtSession:
}
]
@pytest.mark.ov_device_ids(["CPU"])
def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/model.onnx"
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
assert session.provider_options == [
{
"device_type": "CPU",
"precision": "FP32",
"cache_dir": "/cache/ViT-B-32__openai/openvino",
}
]
def test_sets_provider_options_for_cuda(self) -> None:
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
@@ -349,23 +338,6 @@ class TestOrtSession:
assert session.sess_options.inter_op_num_threads == 1
assert session.sess_options.intra_op_num_threads == 2
@pytest.mark.ov_device_ids(["CPU"])
def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/model.onnx"
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
assert session.sess_options.inter_op_num_threads == 0
assert session.sess_options.intra_op_num_threads == 0
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/model.onnx"
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
assert session.sess_options.inter_op_num_threads == 0
assert session.sess_options.intra_op_num_threads == 0
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
@@ -883,78 +855,6 @@ class TestFaceRecognition:
onnx.load.assert_not_called()
onnx.save.assert_not_called()
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
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
class TestCache:

View File

@@ -2,7 +2,7 @@ experimental_monorepo_root = true
[monorepo]
config_roots = [
"plugins",
"packages/plugin-core",
"server",
"cli",
"deployment",

View File

@@ -9,7 +9,7 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
formatter:
formatter:
page_width: 120
linter:
@@ -33,7 +33,6 @@ linter:
require_trailing_commas: true
unrelated_type_equality_checks: true
prefer_const_constructors: true
always_use_package_imports: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -3,7 +3,6 @@ plugins {
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
}
@@ -82,7 +81,6 @@ android {
release {
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
namespace 'app.alextran.immich'

View File

@@ -36,12 +36,4 @@
##---------------End: proguard configuration for Gson ----------
# Keep all widget model classes and their fields for Gson
-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 ----------
-keep class app.alextran.immich.widget.model.** { *; }

View File

@@ -36,17 +36,3 @@ Java_app_alextran_immich_NativeBuffer_copy(
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;
}

View File

@@ -23,9 +23,6 @@ object NativeBuffer {
@JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
@JvmStatic
external fun createGlobalRef(obj: Any): Long
}
class NativeByteBuffer(initialCapacity: Int) {

View File

@@ -1,23 +1,11 @@
package app.alextran.immich.core
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.NativeBuffer
import okhttp3.Cache
import okhttp3.ConnectionPool
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.Credentials
import okhttp3.Dispatcher
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.File
import java.net.Socket
@@ -32,24 +20,8 @@ import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509KeyManager
import javax.net.ssl.X509TrustManager
const val CERT_ALIAS = "client_cert"
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_URLS = "immich.server_urls"
private const val PREFS_COOKIES = "immich.cookies"
private const val COOKIE_EXPIRY_DAYS = 400L
private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
ACCESS_TOKEN("immich_access_token", httpOnly = true),
IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false),
AUTH_TYPE("immich_auth_type", httpOnly = true);
companion object {
val names = entries.map { it.cookieName }.toSet()
}
}
/**
* Manages a shared OkHttpClient with SSL configuration support.
@@ -63,71 +35,23 @@ object HttpClientManager {
private var initialized = false
private val clientChangedListeners = mutableListOf<() -> Unit>()
@JvmStatic
lateinit var client: OkHttpClient
private set
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
private lateinit var client: OkHttpClient
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
var keyChainAlias: String? = null
private set
var headers: Headers = Headers.headersOf()
private set
private val cookieJar = PersistentCookieJar()
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
val serverUrl: String? get() = if (initialized) prefs.getString(PREFS_SERVER_URLS, null)
?.let { Json.decodeFromString<List<String>>(it).firstOrNull() } else null
val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS)
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
cookieJar.init(prefs)
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
if (savedHeaders != null) {
val map = Json.decodeFromString<Map<String, String>>(savedHeaders)
val builder = Headers.Builder()
for ((key, value) in map) {
builder.add(key, value)
}
headers = builder.build()
}
val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null)
if (serverUrlsJson != null) {
cookieJar.setServerUrls(Json.decodeFromString<List<String>>(serverUrlsJson))
}
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)
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) {
synchronized(this) {
val wasMtls = isMtls
@@ -139,7 +63,7 @@ object HttpClientManager {
val key = tmpKeyStore.getKey(tmpAlias, password)
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
if (keyStore.containsAlias(CERT_ALIAS)) {
if (wasMtls) {
keyStore.deleteEntry(CERT_ALIAS)
}
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
@@ -151,78 +75,24 @@ object HttpClientManager {
fun deleteKeyEntry() {
synchronized(this) {
val wasMtls = isMtls
if (keyChainAlias != null) {
keyChainAlias = null
prefs.edit { remove(PREFS_CERT_ALIAS) }
if (!isMtls) {
return
}
keyStore.deleteEntry(CERT_ALIAS)
if (wasMtls) {
clientChangedListeners.forEach { it() }
}
clientChangedListeners.forEach { it() }
}
}
private var clientGlobalRef: Long = 0L
fun getClientPointer(): Long {
if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client)
}
return clientGlobalRef
@JvmStatic
fun getClient(): OkHttpClient {
return client
}
fun addClientChangedListener(listener: () -> Unit) {
synchronized(this) { clientChangedListeners.add(listener) }
}
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>, token: String?) {
synchronized(this) {
val builder = Headers.Builder()
headerMap.forEach { (key, value) -> builder[key] = value }
val newHeaders = builder.build()
val headersChanged = headers != newHeaders
val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null)
headers = newHeaders
cookieJar.setServerUrls(serverUrls)
if (headersChanged || urlsChanged) {
prefs.edit {
putString(PREFS_HEADERS, Json.encodeToString(headerMap))
putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls))
}
}
if (token != null) {
val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return
val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000
val values = mapOf(
AuthCookie.ACCESS_TOKEN to token,
AuthCookie.IS_AUTHENTICATED to "true",
AuthCookie.AUTH_TYPE to "password",
)
cookieJar.saveFromResponse(url, values.map { (cookie, value) ->
Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry)
.apply {
if (url.isHttps) secure()
if (cookie.httpOnly) httpOnly()
}.build()
})
}
}
}
fun loadCookieHeader(url: String): String? {
val httpUrl = url.toHttpUrlOrNull() ?: return null
return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() }
?.joinToString("; ") { "${it.name}=${it.value}" }
}
private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -239,17 +109,8 @@ object HttpClientManager {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
return OkHttpClient.Builder()
.cookieJar(cookieJar)
.addInterceptor {
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())
.addInterceptor { chain ->
chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build())
}
.connectionPool(connectionPool)
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
@@ -258,39 +119,23 @@ object HttpClientManager {
.build()
}
/**
* Resolves client certificates dynamically at TLS handshake time.
* Checks the system KeyChain alias first, then falls back to the app's private KeyStore.
*/
// Reads from the key store rather than taking a snapshot at initialization time
private class DynamicKeyManager : X509KeyManager {
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? {
val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null
return arrayOf(alias)
}
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
if (isMtls) arrayOf(CERT_ALIAS) else null
override fun chooseClientAlias(
keyTypes: Array<String>,
issuers: Array<Principal>?,
socket: Socket?
): String? {
keyChainAlias?.let { return it }
if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS
return null
}
): String? =
if (isMtls) CERT_ALIAS else null
override fun getCertificateChain(alias: String): Array<X509Certificate>? {
if (alias == keyChainAlias) {
return KeyChain.getCertificateChain(appContext, alias)
}
return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
}
override fun getCertificateChain(alias: String): Array<X509Certificate>? =
keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
override fun getPrivateKey(alias: String): PrivateKey? {
if (alias == keyChainAlias) {
return KeyChain.getPrivateKey(appContext, alias)
}
return keyStore.getKey(alias, null) as? PrivateKey
}
override fun getPrivateKey(alias: String): PrivateKey? =
keyStore.getKey(alias, null) as? PrivateKey
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
null
@@ -301,131 +146,4 @@ object HttpClientManager {
socket: Socket?
): String? = null
}
/**
* Persistent CookieJar that duplicates auth cookies across equivalent server URLs.
* When the server sets cookies for one domain, copies are created for all other known
* server domains (for URL switching between local/remote endpoints of the same server).
*/
private class PersistentCookieJar : CookieJar {
private val store = mutableListOf<Cookie>()
private var serverUrls = listOf<HttpUrl>()
private var prefs: SharedPreferences? = null
fun init(prefs: SharedPreferences) {
this.prefs = prefs
restore()
}
@Synchronized
fun setServerUrls(urls: List<String>) {
val parsed = urls.mapNotNull { it.toHttpUrlOrNull() }
if (parsed.map { it.host } == serverUrls.map { it.host }) return
serverUrls = parsed
if (syncAuthCookies()) persist()
}
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val changed = cookies.any { new ->
store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value }
}
store.removeAll { existing ->
cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
}
store.addAll(cookies)
val synced = serverUrls.any { it.host == url.host } && syncAuthCookies()
if (changed || synced) persist()
}
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val now = System.currentTimeMillis()
if (store.removeAll { it.expiresAt < now }) {
syncAuthCookies()
persist()
}
return store.filter { it.matches(url) }
}
private fun syncAuthCookies(): Boolean {
val serverHosts = serverUrls.map { it.host }.toSet()
val now = System.currentTimeMillis()
val sourceCookies = store
.filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now }
.associateBy { it.name }
if (sourceCookies.isEmpty()) {
return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts }
}
var changed = false
for (url in serverUrls) {
for ((_, source) in sourceCookies) {
if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue
store.removeAll { it.name == source.name && it.domain == url.host }
store.add(rebuildCookie(source, url))
changed = true
}
}
return changed
}
private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie {
return Cookie.Builder()
.name(source.name).value(source.value)
.domain(url.host).path("/")
.expiresAt(source.expiresAt)
.apply {
if (url.isHttps) secure()
if (source.httpOnly) httpOnly()
}
.build()
}
private fun persist() {
val p = prefs ?: return
p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) }
}
private fun restore() {
val p = prefs ?: return
val jsonStr = p.getString(PREFS_COOKIES, null) ?: return
try {
store.addAll(Json.decodeFromString<List<SerializedCookie>>(jsonStr).map { it.toCookie() })
} catch (_: Exception) {
store.clear()
}
}
}
@Serializable
private data class SerializedCookie(
val name: String,
val value: String,
val domain: String,
val path: String,
val expiresAt: Long,
val secure: Boolean,
val httpOnly: Boolean,
val hostOnly: Boolean,
) {
fun toCookie(): Cookie = Cookie.Builder()
.name(name).value(value).path(path).expiresAt(expiresAt)
.apply {
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
if (secure) secure()
if (httpOnly) httpOnly()
}
.build()
companion object {
fun from(cookie: Cookie) = SerializedCookie(
name = cookie.name, value = cookie.value, domain = cookie.domain,
path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure,
httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly,
)
}
}
}

View File

@@ -180,11 +180,8 @@ private open class NetworkPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NetworkApi {
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit)
fun removeCertificate(callback: (Result<Unit>) -> Unit)
fun hasCertificate(): Boolean
fun getClientPointer(): Long
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?)
companion object {
/** The codec used by NetworkApi. */
@@ -220,12 +217,13 @@ interface NetworkApi {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val promptTextArg = args[0] as ClientCertPrompt
api.selectCertificate(promptTextArg) { result: Result<Unit> ->
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
reply.reply(NetworkPigeonUtils.wrapResult(null))
val data = result.getOrNull()
reply.reply(NetworkPigeonUtils.wrapResult(data))
}
}
}
@@ -250,56 +248,6 @@ interface NetworkApi {
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 tokenArg = args[2] as String?
val wrapped: List<Any?> = try {
api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg)
listOf(null)
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -2,9 +2,20 @@ package app.alextran.immich.core
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.OperationCanceledException
import android.security.KeyChain
import app.alextran.immich.NativeBuffer
import android.text.InputType
import android.view.ContextThemeWrapper
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.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -13,7 +24,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
private var networkApi: NetworkApiImpl? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
networkApi = NetworkApiImpl()
networkApi = NetworkApiImpl(binding.applicationContext)
NetworkApi.setUp(binding.binaryMessenger, networkApi)
}
@@ -23,24 +34,48 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
networkApi?.activity = binding.activity
networkApi?.onAttachedToActivity(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
networkApi?.activity = null
networkApi?.onDetachedFromActivityForConfigChanges()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
networkApi?.activity = binding.activity
networkApi?.onReattachedToActivityForConfigChanges(binding)
}
override fun onDetachedFromActivity() {
networkApi?.activity = null
networkApi?.onDetachedFromActivity()
}
}
private class NetworkApiImpl : NetworkApi {
var activity: Activity? = null
private class NetworkApiImpl(private val context: Context) : NetworkApi {
private 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) {
try {
@@ -51,19 +86,11 @@ private class NetworkApiImpl : NetworkApi {
}
}
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit) {
val currentActivity = activity
?: return callback(Result.failure(IllegalStateException("No activity")))
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 selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) {
val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity")))
pendingCallback = callback
this.promptText = promptText
picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file"))
}
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
@@ -71,15 +98,62 @@ private class NetworkApiImpl : NetworkApi {
callback(Result.success(Unit))
}
override fun hasCertificate(): Boolean {
return HttpClientManager.isMtls
private fun handlePickedFile(uri: Uri) {
val callback = pendingCallback ?: return
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))
}
}
override fun getClientPointer(): Long {
return HttpClientManager.getClientPointer()
}
private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) {
val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog)
val density = activity.resources.displayMetrics.density
val horizontalPadding = (24 * density).toInt()
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) {
HttpClientManager.setRequestHeaders(headers, serverUrls, token)
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 {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
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()
}
}

View File

@@ -32,18 +32,14 @@ data class Request(
)
@RequiresApi(Build.VERSION_CODES.Q)
fun ImageDecoder.Source.decodeBitmap(
target: Size = Size(0, 0),
allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT,
colorspace: ColorSpace? = null
): Bitmap {
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = allocator
decoder.setTargetColorSpace(colorspace)
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
}
@@ -232,11 +228,7 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.createSource(resolver, uri).decodeBitmap(
target,
ImageDecoder.ALLOCATOR_SOFTWARE,
ColorSpace.get(ColorSpace.Named.SRGB)
)
ImageDecoder.createSource(resolver, uri).decodeBitmap(target)
} else {
val ref =
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()

View File

@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface RemoteImageApi {
fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit)
@@ -66,9 +66,10 @@ interface RemoteImageApi {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val urlArg = args[0] as String
val requestIdArg = args[1] as Long
val preferEncodedArg = args[2] as Boolean
api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val headersArg = args[1] as Map<String, String>
val requestIdArg = args[2] as Long
val preferEncodedArg = args[3] as Boolean
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))

View File

@@ -1,10 +1,6 @@
package app.alextran.immich.images
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import app.alextran.immich.INITIAL_BUFFER_SIZE
@@ -16,8 +12,6 @@ import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -37,21 +31,6 @@ import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
fun NativeByteBuffer.decodeBitmap(target: android.util.Size = android.util.Size(0, 0)): Bitmap {
try {
val byteBuffer = NativeBuffer.wrap(pointer, offset)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.createSource(byteBuffer).decodeBitmap(target = target)
} else {
val bytes = ByteArray(offset)
byteBuffer.get(bytes)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IOException("Failed to decode image")
}
} finally {
free()
}
}
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
@@ -70,8 +49,9 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun requestImage(
url: String,
headers: Map<String, String>,
requestId: Long,
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
) {
val signal = CancellationSignal()
@@ -79,6 +59,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
ImageFetcherManager.fetch(
url,
headers,
signal,
onSuccess = { buffer ->
requestMap.remove(requestId)
@@ -119,7 +100,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
}
}
object ImageFetcherManager {
private object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
@@ -139,11 +120,12 @@ object ImageFetcherManager {
fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
fetcher.fetch(url, signal, onSuccess, onFailure)
fetcher.fetch(url, headers, signal, onSuccess, onFailure)
}
fun clearCache(onCleared: (Result<Long>) -> Unit) {
@@ -167,9 +149,10 @@ object ImageFetcherManager {
}
}
internal sealed interface ImageFetcher {
private sealed interface ImageFetcher {
fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
@@ -180,7 +163,7 @@ internal sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit)
}
internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
@@ -196,6 +179,7 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
override fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
@@ -210,13 +194,7 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
}
}
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
request.start()
@@ -360,7 +338,7 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
}
}
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
@@ -382,7 +360,7 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
}
}
internal class OkHttpImageFetcher private constructor(
private class OkHttpImageFetcher private constructor(
private val client: OkHttpClient,
) : ImageFetcher {
private val stateLock = Any()
@@ -393,7 +371,7 @@ internal class OkHttpImageFetcher private constructor(
fun create(cacheDir: File): OkHttpImageFetcher {
val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.client.newBuilder()
val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.build()
@@ -413,6 +391,7 @@ internal class OkHttpImageFetcher private constructor(
override fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
@@ -425,6 +404,7 @@ internal class OkHttpImageFetcher private constructor(
}
val requestBuilder = Request.Builder().url(url)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val call = client.newCall(requestBuilder.build())
signal.setOnCancelListener(call::cancel)

View File

@@ -16,7 +16,6 @@ import app.alextran.immich.core.ImmichPlugin
import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -82,13 +81,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
}
if (hasSpecialFormatColumn()) {
add(SPECIAL_FORMAT_COLUMN)
} else {
// fallback to mimetype and xmp for playback style detection on older Android versions
// both only needed if special format column is not available
add(MediaStore.MediaColumns.MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.XMP)
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Fallback: read XMP from MediaStore to detect Motion Photos
// only needed if SPECIAL_FORMAT column isn't available
add(MediaStore.MediaColumns.XMP)
}
}.toTypedArray()
@@ -135,7 +131,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
@@ -182,20 +177,19 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val playbackStyle = detectPlaybackStyle(
numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
)
val isFlipped = orientation == 90 || orientation == 270
val asset = PlatformAsset(
id,
name,
assetType,
createdAt,
modifiedAt,
if (isFlipped) height else width,
if (isFlipped) width else height,
width,
height,
duration,
0L,
orientation.toLong(),
isFavorite,
playbackStyle = playbackStyle,
)
@@ -206,14 +200,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
}
/**
* Detects the playback style for an asset using _special_format (SDK Extension 21+)
* or XMP / MIME / RIFF header fallbacks.
* Detects the playback style for an asset using _special_format (API 33+)
* or XMP / MIME / RIFF header fallbacks (pre-33).
*/
@SuppressLint("NewApi")
private fun detectPlaybackStyle(
assetId: Long,
rawMediaType: Int,
mimeTypeColumn: Int,
specialFormatColumn: Int,
xmpColumn: Int,
cursor: Cursor
@@ -238,56 +231,46 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
return PlatformAssetPlaybackStyle.UNKNOWN
}
val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null
// GIFs are always animated and cannot be motion photos; no I/O needed
if (mimeType == "image/gif") {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
// Pre-API 33 fallback
val uri = ContentUris.withAppendedId(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
assetId
)
// Only WebP needs a stream check to distinguish static vs animated;
// WebP files are not used as motion photos, so skip XMP detection
if (mimeType == "image/webp") {
try {
val glide = Glide.get(ctx)
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val type = ImageHeaderParserUtils.getType(
listOf(DefaultImageHeaderParser()),
stream,
glide.arrayPool
)
// Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance
if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
// if mimeType is webp but not animated, its just an image.
return PlatformAssetPlaybackStyle.IMAGE
}
// Read XMP from cursor (API 30+)
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
val xmp: String? = if (xmpColumn != -1) {
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
} else {
// if xmp column is not available, we are on API 29 or below
// theoretically there were motion photos but the Camera:MotionPhoto xmp tag
// was only added in Android 11, so we should not have to worry about parsing XMP on older versions
null
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
null
}
}
if (xmp != null && "Camera:MotionPhoto" in xmp) {
return PlatformAssetPlaybackStyle.LIVE_PHOTO
}
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val glide = Glide.get(ctx)
val type = ImageHeaderParserUtils.getType(
glide.registry.imageHeaderParsers,
stream,
glide.arrayPool
)
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
return PlatformAssetPlaybackStyle.IMAGE
}

View File

@@ -0,0 +1,33 @@
package app.alextran.immich.widget
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.absolutePath, options)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(file.absolutePath, options)
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}

View File

@@ -1,12 +1,18 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
@@ -69,8 +75,18 @@ class ImageDownloadWorker(
)
}
fun cancel(context: Context, appWidgetId: Int) {
suspend fun cancel(context: Context, appWidgetId: Int) {
WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId")
// delete cached image
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
if (!currentImgUUID.isNullOrEmpty()) {
val file = File(context.cacheDir, imageFilename(currentImgUUID))
file.delete()
}
}
}
@@ -80,22 +96,43 @@ class ImageDownloadWorker(
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
// clear state and go to "login" if no credentials
if (!ImmichAPI.isLoggedIn(context)) {
val currentAssetId = widgetConfig[kAssetId]
if (!currentAssetId.isNullOrEmpty()) {
updateWidget(glanceId, "", "", "immich://", WidgetState.LOG_IN)
val currentImgUUID = widgetConfig[kImageUUID]
val serverConfig = ImmichAPI.getServerConfig(context)
// clear any image caches and go to "login" state if no credentials
if (serverConfig == null) {
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
updateWidget(
glanceId,
"",
"",
"immich://",
WidgetState.LOG_IN
)
}
return Result.success()
}
// fetch new image
val entry = when (widgetType) {
WidgetType.RANDOM -> fetchRandom(widgetConfig)
WidgetType.MEMORIES -> fetchMemory()
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
WidgetType.MEMORIES -> fetchMemory(serverConfig)
}
updateWidget(glanceId, entry.assetId, entry.subtitle, entry.deeplink)
// clear current image if it exists
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
}
// save a new image
val imgUUID = UUID.randomUUID().toString()
saveImage(entry.image, imgUUID)
// trigger the update routine with new image uuid
updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink)
Result.success()
} catch (e: Exception) {
@@ -110,25 +147,28 @@ class ImageDownloadWorker(
private suspend fun updateWidget(
glanceId: GlanceId,
assetId: String,
imageUUID: String,
subtitle: String?,
deeplink: String?,
widgetState: WidgetState = WidgetState.SUCCESS
) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kNow] = System.currentTimeMillis()
prefs[kAssetId] = assetId
prefs[kImageUUID] = imageUUID
prefs[kWidgetState] = widgetState.toString()
prefs[kSubtitleText] = subtitle ?: ""
prefs[kDeeplinkURL] = deeplink ?: ""
}
PhotoWidget().update(context, glanceId)
PhotoWidget().update(context,glanceId)
}
private suspend fun fetchRandom(
serverConfig: ServerConfig,
widgetConfig: Preferences
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val filters = SearchFilters()
val albumId = widgetConfig[kSelectedAlbum]
val showSubtitle = widgetConfig[kShowAlbumName]
@@ -142,27 +182,31 @@ class ImageDownloadWorker(
filters.albumIds = listOf(albumId)
}
var randomSearch = ImmichAPI.fetchSearchResults(filters)
var randomSearch = api.fetchSearchResults(filters)
// handle an empty album, fallback to random
if (randomSearch.isEmpty() && albumId != null) {
randomSearch = ImmichAPI.fetchSearchResults(SearchFilters())
randomSearch = api.fetchSearchResults(SearchFilters())
subtitle = ""
}
val random = randomSearch.first()
ImmichAPI.fetchImage(random).free() // warm the HTTP disk cache
val image = api.fetchImage(random)
return WidgetEntry(
random.id,
image,
subtitle,
assetDeeplink(random)
)
}
private suspend fun fetchMemory(): WidgetEntry {
private suspend fun fetchMemory(
serverConfig: ServerConfig
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val today = LocalDate.now()
val memories = ImmichAPI.fetchMemory(today)
val memories = api.fetchMemory(today)
val asset: Asset
var subtitle: String? = null
@@ -175,15 +219,26 @@ class ImageDownloadWorker(
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
} else {
val filters = SearchFilters(size=1)
asset = ImmichAPI.fetchSearchResults(filters).first()
asset = api.fetchSearchResults(filters).first()
}
ImmichAPI.fetchImage(asset).free() // warm the HTTP disk cache
val image = api.fetchImage(asset)
return WidgetEntry(
asset.id,
image,
subtitle,
assetDeeplink(asset)
)
}
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
file.delete()
}
private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
}
}

View File

@@ -1,97 +1,122 @@
package app.alextran.immich.widget
import android.content.Context
import android.os.CancellationSignal
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.images.ImageFetcherManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.alextran.immich.widget.model.*
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object ImmichAPI {
class ImmichAPI(cfg: ServerConfig) {
companion object {
fun getServerConfig(context: Context): ServerConfig? {
val prefs = HomeWidgetPlugin.getData(context)
val serverURL = prefs.getString("widget_server_url", "") ?: ""
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
val customHeadersJSON = prefs.getString("widget_custom_headers", "") ?: ""
if (serverURL.isBlank() || sessionKey.isBlank()) {
return null
}
var customHeaders: Map<String, String> = HashMap<String, String>()
if (customHeadersJSON.isNotBlank()) {
val stringMapType = object : TypeToken<Map<String, String>>() {}.type
customHeaders = Gson().fromJson(customHeadersJSON, stringMapType)
}
return ServerConfig(
serverURL,
sessionKey,
customHeaders
)
}
}
private val gson = Gson()
private val serverEndpoint: String
get() = HttpClientManager.serverUrl ?: throw IllegalStateException("Not logged in")
private val serverConfig = cfg
private fun initialize(context: Context) {
HttpClientManager.initialize(context)
ImageFetcherManager.initialize(context)
}
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
fun isLoggedIn(context: Context): Boolean {
initialize(context)
return HttpClientManager.serverUrl != null
}
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): String {
val url = StringBuilder("$serverEndpoint$endpoint")
if (params.isNotEmpty()) {
url.append("?")
url.append(params.joinToString("&") { (key, value) ->
"${java.net.URLEncoder.encode(key, "UTF-8")}=${java.net.URLEncoder.encode(value, "UTF-8")}"
})
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
}
return url.toString()
return URL(urlString.toString())
}
private fun HttpURLConnection.applyCustomHeaders() {
serverConfig.customHeaders.forEach { (key, value) ->
setRequestProperty(key, value)
}
}
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/search/random")
val body = gson.toJson(filters).toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(url).post(body).build()
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
applyCustomHeaders()
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<Asset>>() {}.type
gson.fromJson(responseBody, type)
doOutput = true
}
connection.outputStream.use {
OutputStreamWriter(it).use { writer ->
writer.write(gson.toJson(filters))
writer.flush()
}
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Asset>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val url = buildRequestURL("/memories", listOf("for" to iso8601))
val request = Request.Builder().url(url).get().build()
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(responseBody, type)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
applyCustomHeaders()
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchImage(asset: Asset): NativeByteBuffer = suspendCancellableCoroutine { cont ->
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true"))
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
ImageFetcherManager.fetch(
url,
signal,
onSuccess = { buffer -> cont.resume(buffer) },
onFailure = { e -> cont.resumeWithException(e) }
)
val connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)
?: throw Exception("Invalid image data")
}
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/albums")
val request = Request.Builder().url(url).get().build()
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(responseBody, type)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
applyCustomHeaders()
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(response, type)
}
}

View File

@@ -0,0 +1,58 @@
package app.alextran.immich.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MemoryReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
val provider = ComponentName(context, MemoryReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
}
}
// make sure the periodic jobs are running
glanceIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
super.onReceive(context, intent)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}

View File

@@ -2,12 +2,12 @@ package app.alextran.immich.widget
import android.content.Context
import android.content.Intent
import android.util.Size
import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.*
import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.glance.appwidget.*
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.*
import androidx.glance.action.clickable
import androidx.glance.layout.*
@@ -18,28 +18,30 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import app.alextran.immich.R
import app.alextran.immich.images.decodeBitmap
import app.alextran.immich.widget.model.*
import java.io.File
class PhotoWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
val assetId = state[kAssetId]
val subtitle = state[kSubtitleText]
val deeplinkURL = state[kDeeplinkURL]?.toUri()
val widgetState = state[kWidgetState]
val bitmap = if (!assetId.isNullOrEmpty() && ImmichAPI.isLoggedIn(context)) {
try {
ImmichAPI.fetchImage(Asset(assetId, AssetType.IMAGE)).decodeBitmap(Size(500, 500))
} catch (e: Exception) {
null
}
} else null
provideContent {
val prefs = currentState<MutablePreferences>()
val imageUUID = prefs[kImageUUID]
val subtitle = prefs[kSubtitleText]
val deeplinkURL = prefs[kDeeplinkURL]?.toUri()
val widgetState = prefs[kWidgetState]
var bitmap: Bitmap? = null
if (imageUUID != null) {
// fetch a random photo from server
val file = File(context.cacheDir, imageFilename(imageUUID))
if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500)
}
}
// WIDGET CONTENT
Box(

View File

@@ -4,11 +4,14 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import es.antonborri.home_widget.HomeWidgetPlugin
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWidgetReceiver() {
class RandomReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
@@ -19,25 +22,25 @@ abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWid
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
val provider = ComponentName(context, this::class.java)
val provider = ComponentName(context, RandomReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, widgetType)
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
}
}
// make sure the periodic jobs are running
glanceIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
}
super.onReceive(context, intent)
@@ -45,12 +48,10 @@ abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWid
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}
class MemoryReceiver : WidgetReceiver(WidgetType.MEMORIES)
class RandomReceiver : WidgetReceiver(WidgetType.RANDOM)

View File

@@ -71,18 +71,22 @@ fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId,
LaunchedEffect(Unit) {
// get albums from server
if (!ImmichAPI.isLoggedIn(context)) {
val serverCfg = ImmichAPI.getServerConfig(context)
if (serverCfg == null) {
state = WidgetConfigState.LOG_IN
return@LaunchedEffect
}
val api = ImmichAPI(serverCfg)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
var albumItems: List<DropdownItem>
try {
albumItems = ImmichAPI.fetchAlbums().map {
albumItems = api.fetchAlbums().map {
DropdownItem(it.albumName, it.id)
}

View File

@@ -1,5 +1,6 @@
package app.alextran.immich.widget.model
import android.graphics.Bitmap
import androidx.datastore.preferences.core.*
// MARK: Immich Entities
@@ -49,13 +50,19 @@ enum class WidgetConfigState {
}
data class WidgetEntry (
val assetId: String,
val image: Bitmap,
val subtitle: String?,
val deeplink: String?
)
data class ServerConfig(
val serverEndpoint: String,
val sessionKey: String,
val customHeaders: Map<String, String>
)
// MARK: Widget State Keys
val kAssetId = stringPreferencesKey("assetId")
val kImageUUID = stringPreferencesKey("uuid")
val kSubtitleText = stringPreferencesKey("subtitle")
val kNow = longPreferencesKey("now")
val kWidgetState = stringPreferencesKey("state")
@@ -68,6 +75,10 @@ const val kWorkerWidgetType = "widgetType"
const val kWorkerWidgetID = "widgetId"
const val kTriggeredFromApp = "triggeredFromApp"
fun imageFilename(id: String): String {
return "widget_image_$id.jpg"
}
fun assetDeeplink(asset: Asset): String {
return "immich://asset?id=${asset.id}"
}

File diff suppressed because one or more lines are too long

View File

@@ -140,13 +140,6 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A872EC0CA71550E4AB04E049 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Shared;
sourceTree = "<group>";
};
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -264,7 +257,6 @@
97C146EF1CF9000F007C117D /* Products */,
0FB772A5B9601143383626CA /* Pods */,
1754452DD81DA6620E279E51 /* Frameworks */,
A872EC0CA71550E4AB04E049 /* Shared */,
);
sourceTree = "<group>";
};
@@ -370,7 +362,6 @@
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
A872EC0CA71550E4AB04E049 /* Shared */,
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
FEE084F22EC172080045228E /* Schemas */,
@@ -393,7 +384,6 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
A872EC0CA71550E4AB04E049 /* Shared */,
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */,
);
name = WidgetExtension;

View File

@@ -221,11 +221,8 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NetworkApi {
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, Error>) -> Void)
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, 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], token: String?) throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -258,8 +255,8 @@ class NetworkApiSetup {
let promptTextArg = args[0] as! ClientCertPrompt
api.selectCertificate(promptText: promptTextArg) { result in
switch result {
case .success:
reply(wrapResult(nil))
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
@@ -283,48 +280,5 @@ class NetworkApiSetup {
} else {
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]
let tokenArg: String? = nilOrValue(args[2])
do {
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
setRequestHeadersChannel.setMessageHandler(nil)
}
}
}

View File

@@ -1,6 +1,5 @@
import Foundation
import UniformTypeIdentifiers
import native_video_player
enum ImportError: Error {
case noFile
@@ -17,25 +16,14 @@ class NetworkApiImpl: NetworkApi {
self.viewController = viewController
}
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, any Error>) -> Void) {
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, any Error>) -> Void) {
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
self?.activeImporter = nil
completion(result)
completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) })
}, viewController: viewController)
activeImporter = importer
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) {
let status = clearCerts()
@@ -52,55 +40,14 @@ class NetworkApiImpl: NetworkApi {
}
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], token: String?) throws {
URLSessionManager.setServerUrls(serverUrls)
if let token = token {
let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60)
for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https")
let values: [AuthCookie: String] = [
.accessToken: token,
.isAuthenticated: "true",
.authType: "password",
]
for (cookie, value) in values {
var properties: [HTTPCookiePropertyKey: Any] = [
.name: cookie.name,
.value: value,
.domain: domain,
.path: "/",
.expires: expiry,
]
if isSecure { properties[.secure] = "TRUE" }
if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" }
if let httpCookie = HTTPCookie(properties: properties) {
URLSessionManager.cookieStorage.setCookie(httpCookie)
}
}
}
}
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
URLSessionManager.shared.recreateSession()
}
}
}
private class CertImporter: NSObject, UIDocumentPickerDelegate {
private let promptText: ClientCertPrompt
private var completion: ((Result<Void, Error>) -> Void)
private var completion: ((Result<(Data, String), Error>) -> Void)
private weak var viewController: UIViewController?
init(promptText: ClientCertPrompt, completion: (@escaping (Result<Void, Error>) -> Void), viewController: UIViewController?) {
init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) {
self.promptText = promptText
self.completion = completion
self.viewController = viewController
@@ -134,7 +81,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate {
}
await URLSessionManager.shared.session.flush()
self.completion(.success(()))
self.completion(.success((data, password)))
} catch {
completion(.failure(error))
}

View File

@@ -0,0 +1,87 @@
import Foundation
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
/// Manages a shared URLSession with SSL configuration support.
class URLSessionManager: NSObject {
static let shared = URLSessionManager()
let session: URLSession
private let configuration = {
let config = URLSessionConfiguration.default
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("api", isDirectory: true)
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
config.urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir
)
config.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
return config
}()
private override init() {
session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil)
super.init()
}
}
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(challenge, completionHandler: completionHandler)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(challenge, completionHandler: completionHandler)
}
func handleChallenge(
_ challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler)
default: completionHandler(.performDefaultHandling, nil)
}
}
private func handleClientCertificate(
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
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)
if status == errSecSuccess, let identity = item {
let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
return completion(.useCredential, credential)
}
completion(.performDefaultHandling, nil)
}
}

View File

@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi {
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
}
@@ -86,9 +86,10 @@ class RemoteImageApiSetup {
requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let urlArg = args[0] as! String
let requestIdArg = args[1] as! Int64
let preferEncodedArg = args[2] as! Bool
api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
let headersArg = args[1] as! [String: String]
let requestIdArg = args[2] as! Int64
let preferEncodedArg = args[3] as! Bool
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))

View File

@@ -33,9 +33,12 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!)
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
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)

View File

@@ -1,241 +0,0 @@
import Foundation
#if canImport(native_video_player)
import native_video_player
#endif
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers"
let SERVER_URLS_KEY = "immich.server_urls"
let APP_GROUP = "group.app.immich.share"
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
enum AuthCookie: CaseIterable {
case accessToken, isAuthenticated, authType
var name: String {
switch self {
case .accessToken: return "immich_access_token"
case .isAuthenticated: return "immich_is_authenticated"
case .authType: return "immich_auth_type"
}
}
var httpOnly: Bool {
switch self {
case .accessToken, .authType: return true
case .isAuthenticated: return false
}
}
static let names: Set<String> = Set(allCases.map(\.name))
}
extension UserDefaults {
static let group = UserDefaults(suiteName: APP_GROUP)!
}
/// 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 {
static let shared = URLSessionManager()
private(set) var session: URLSession
let delegate: URLSessionManagerDelegate
private static let cacheDir: URL = {
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("api", isDirectory: true)
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
private static let urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1024 * 1024 * 1024,
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)
private static var serverUrls: [String] = []
private static var isSyncing = false
var sessionPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(session).toOpaque()
}
private override init() {
delegate = URLSessionManagerDelegate()
session = Self.buildSession(delegate: delegate)
super.init()
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
NotificationCenter.default.addObserver(
Self.self,
selector: #selector(Self.cookiesDidChange),
name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged,
object: Self.cookieStorage
)
}
func recreateSession() {
session = Self.buildSession(delegate: delegate)
}
static func setServerUrls(_ urls: [String]) {
guard urls != serverUrls else { return }
serverUrls = urls
UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY)
syncAuthCookies()
}
@objc private static func cookiesDidChange(_ notification: Notification) {
guard !isSyncing, !serverUrls.isEmpty else { return }
syncAuthCookies()
}
private static func syncAuthCookies() {
let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host })
let allCookies = cookieStorage.cookies ?? []
let now = Date()
let serverAuthCookies = allCookies.filter {
AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain)
}
var sourceCookies: [String: HTTPCookie] = [:]
for cookie in serverAuthCookies {
if cookie.expiresDate.map({ $0 > now }) ?? true {
sourceCookies[cookie.name] = cookie
}
}
isSyncing = true
defer { isSyncing = false }
if sourceCookies.isEmpty {
for cookie in serverAuthCookies {
cookieStorage.deleteCookie(cookie)
}
return
}
for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https")
for (_, source) in sourceCookies {
if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) {
continue
}
var properties: [HTTPCookiePropertyKey: Any] = [
.name: source.name,
.value: source.value,
.domain: domain,
.path: "/",
.expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60),
]
if isSecure { properties[.secure] = "TRUE" }
if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" }
if let cookie = HTTPCookie(properties: properties) {
cookieStorage.setCookie(cookie)
}
}
}
}
private static func buildSession(delegate: URLSessionDelegate) -> URLSession {
let config = URLSessionConfiguration.default
config.urlCache = urlCache
config.httpCookieStorage = cookieStorage
config.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
headers["User-Agent"] = headers["User-Agent"] ?? userAgent
config.httpAdditionalHeaders = headers
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}
}
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(session, challenge, completionHandler)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(session, challenge, completionHandler, task: task)
}
func handleChallenge(
_ session: URLSession,
_ challenge: URLAuthenticationChallenge,
_ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void,
task: URLSessionTask? = nil
) {
switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler)
case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler)
default: completionHandler(.performDefaultHandling, nil)
}
}
private func handleClientCertificate(
_ session: URLSession,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
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)
if status == errSecSuccess, let identity = item {
let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
#if canImport(native_video_player)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
#endif
return completion(.useCredential, credential)
}
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 canImport(native_video_player)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
#endif
let credential = URLCredential(user: user, password: password, persistence: .forSession)
completion(.useCredential, credential)
}
}

View File

@@ -9,7 +9,6 @@ struct ImageEntry: TimelineEntry {
var metadata: Metadata = Metadata()
struct Metadata: Codable {
var assetId: String? = nil
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
@@ -34,39 +33,80 @@ struct ImageEntry: TimelineEntry {
date: entryDate,
image: image,
metadata: EntryMetadata(
assetId: asset.id,
subtitle: subtitle,
deepLink: asset.deepLink
)
)
}
static func saveLast(for key: String, metadata: Metadata) {
if let data = try? JSONEncoder().encode(metadata) {
UserDefaults.group.set(data, forKey: "widget_last_\(key)")
func cache(for key: String) throws {
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
// build metadata JSON
let entryMetadata = try JSONEncoder().encode(self.metadata)
// write to disk
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
try entryMetadata.write(to: metadataURL, options: .atomic)
}
}
static func loadCached(for key: String, at date: Date = Date.now)
-> ImageEntry?
{
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
guard let imageData = try? Data(contentsOf: imageURL),
let metadataJSON = try? Data(contentsOf: metadataURL),
let decodedMetadata = try? JSONDecoder().decode(
Metadata.self,
from: metadataJSON
)
else {
return nil
}
return ImageEntry(
date: date,
image: UIImage(data: imageData),
metadata: decodedMetadata
)
}
return nil
}
static func handleError(
for key: String,
api: ImmichAPI? = nil,
error: WidgetError = .fetchFailed
) async -> Timeline<ImageEntry> {
// Try to show the last image from the URL cache for transient failures
if error == .fetchFailed, let api = api,
let data = UserDefaults.group.data(forKey: "widget_last_\(key)"),
let cached = try? JSONDecoder().decode(Metadata.self, from: data),
let assetId = cached.assetId,
let image = try? await api.fetchImage(asset: Asset(id: assetId, type: .image))
) -> Timeline<ImageEntry> {
var timelineEntry = ImageEntry(
date: Date.now,
image: nil,
metadata: EntryMetadata(error: error)
)
// use cache if generic failed error
// we want to show the other errors to the user since without intervention,
// it will never succeed
if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
{
let entry = ImageEntry(date: Date.now, image: image, metadata: cached)
return Timeline(entries: [entry], policy: .atEnd)
timelineEntry = cachedEntry
}
return Timeline(
entries: [ImageEntry(date: Date.now, metadata: Metadata(error: error))],
policy: .atEnd
)
return Timeline(entries: [timelineEntry], policy: .atEnd)
}
}

View File

@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
import WidgetKit
// Constants and session configuration are in Shared/SharedURLSession.swift
let IMMICH_SHARE_GROUP = "group.app.immich.share"
enum WidgetError: Error, Codable {
case noLogin
@@ -104,48 +104,87 @@ struct Album: Codable, Equatable {
// MARK: API
class ImmichAPI {
let serverEndpoint: String
typealias CustomHeaders = [String:String]
struct ServerConfig {
let serverEndpoint: String
let sessionKey: String
let customHeaders: CustomHeaders
}
let serverConfig: ServerConfig
init() async throws {
guard let serverURLs = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY),
let serverURL = serverURLs.first,
!serverURL.isEmpty
// fetch the credentials from the UserDefaults store that dart placed here
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
let serverURL = defaults.string(forKey: "widget_server_url"),
let sessionKey = defaults.string(forKey: "widget_auth_token")
else {
throw WidgetError.noLogin
}
serverEndpoint = serverURL
if serverURL == "" || sessionKey == "" {
throw WidgetError.noLogin
}
// custom headers come in the form of KV pairs in JSON
var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "")
var customHeaders: CustomHeaders = [:]
if customHeadersJSON != "",
let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) {
customHeaders = parsedHeaders
}
serverConfig = ServerConfig(
serverEndpoint: serverURL,
sessionKey: sessionKey,
customHeaders: customHeaders
)
}
private func buildRequestURL(
serverConfig: ServerConfig,
endpoint: String,
params: [URLQueryItem] = []
) throws(FetchError) -> URL? {
guard let baseURL = URL(string: serverEndpoint) else {
throw FetchError.invalidURL
) -> URL? {
guard let baseURL = URL(string: serverConfig.serverEndpoint) else {
fatalError("Invalid base URL")
}
// Combine the base URL and API path
let fullPath = baseURL.appendingPathComponent(
endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
)
// Add the session key as a query parameter
var components = URLComponents(
url: fullPath,
resolvingAgainstBaseURL: false
)
if !params.isEmpty {
components?.queryItems = params
}
components?.queryItems = [
URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey)
]
components?.queryItems?.append(contentsOf: params)
return components?.url
}
func applyCustomHeaders(for request: inout URLRequest) {
for (header, value) in serverConfig.customHeaders {
request.addValue(value, forHTTPHeaderField: header)
}
}
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws
-> [Asset]
{
// get URL
guard
let searchURL = try buildRequestURL(endpoint: "/search/random")
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/search/random"
)
else {
throw URLError(.badURL)
}
@@ -154,15 +193,20 @@ class ImmichAPI {
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(filters)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
// decode data
return try JSONDecoder().decode([Asset].self, from: data)
}
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
guard
let searchURL = try buildRequestURL(
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/memories",
params: memoryParams
)
@@ -172,8 +216,11 @@ class ImmichAPI {
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([MemoryResult].self, from: data)
}
@@ -182,7 +229,8 @@ class ImmichAPI {
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
guard
let fetchURL = try buildRequestURL(
let fetchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: assetEndpoint,
params: thumbnailParams
)
@@ -190,13 +238,9 @@ class ImmichAPI {
throw .invalidURL
}
let request = URLRequest(url: fetchURL)
guard let (data, _) = try? await URLSessionManager.shared.session.data(for: request) else {
throw .fetchFailed
}
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
throw .invalidImage
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
else {
throw .invalidURL
}
let decodeOptions: [NSString: Any] = [
@@ -219,16 +263,23 @@ class ImmichAPI {
}
func fetchAlbums() async throws -> [Album] {
// get URL
guard
let searchURL = try buildRequestURL(endpoint: "/albums")
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/albums"
)
else {
throw URLError(.badURL)
}
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
// decode data
return try JSONDecoder().decode([Album].self, from: data)
}
}

View File

@@ -0,0 +1,23 @@
//
// Utils.swift
// Runner
//
// Created by Alex Tran and Brandon Wees on 6/16/25.
//
import UIKit
extension UIImage {
/// Crops the image to ensure width and height do not exceed maxSize.
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
let canvas = CGSize(
width: width,
height: CGFloat(ceil(width / size.width * size.height))
)
let format = imageRendererFormat
format.opaque = isOpaque
return UIGraphicsImageRenderer(size: canvas, format: format).image {
_ in draw(in: CGRect(origin: .zero, size: canvas))
}
}
}

View File

@@ -24,14 +24,14 @@ struct ImmichMemoryProvider: TimelineProvider {
Task {
guard let api = try? await ImmichAPI() else {
completion(
await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
)
return
}
guard let memories = try? await api.fetchMemory(for: Date.now)
else {
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
return
}
@@ -58,7 +58,7 @@ struct ImmichMemoryProvider: TimelineProvider {
dateOffset: 0
)
else {
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
return
}
@@ -78,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider {
guard let api = try? await ImmichAPI() else {
completion(
await ImageEntry.handleError(for: cacheKey, error: .noLogin)
ImageEntry.handleError(for: cacheKey, error: .noLogin)
)
return
}
@@ -129,20 +129,20 @@ struct ImmichMemoryProvider: TimelineProvider {
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
completion(
await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
)
return
}
entries.append(contentsOf: search)
} catch {
completion(await ImageEntry.handleError(for: cacheKey, api: api))
completion(ImageEntry.handleError(for: cacheKey))
return
}
}
// save the last asset for fallback
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
// cache the last image
try? entries.last!.cache(for: cacheKey)
completion(Timeline(entries: entries, policy: .atEnd))
}

View File

@@ -65,7 +65,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
let cacheKey = "random_none_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else {
return await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
.first!
}
@@ -79,7 +79,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
dateOffset: 0
)
else {
return await ImageEntry.handleError(for: cacheKey, api: api).entries.first!
return ImageEntry.handleError(for: cacheKey).entries.first!
}
return entry
@@ -102,7 +102,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
// If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else {
return await ImageEntry.handleError(for: cacheKey, error: .noLogin)
return ImageEntry.handleError(for: cacheKey, error: .noLogin)
}
// build entries
@@ -119,16 +119,16 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
return await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
}
entries.append(contentsOf: search)
} catch {
return await ImageEntry.handleError(for: cacheKey, api: api)
return ImageEntry.handleError(for: cacheKey)
}
// save the last asset for fallback
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
// cache the last image
try? entries.last!.cache(for: cacheKey)
return Timeline(entries: entries, policy: .atEnd)
}

View File

@@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color red400 = Color(0xFFEF5350);
const Color grey200 = Color(0xFFEEEEEE);

View File

@@ -33,6 +33,12 @@ const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;
// Widget keys
const String appShareGroupId = "group.app.immich.share";
const String kWidgetAuthToken = "widget_auth_token";
const String kWidgetServerEndpoint = "widget_server_url";
const String kWidgetCustomHeaders = "widget_custom_headers";
// add widget identifiers here for new widgets
// these are used to force a widget refresh
// (iOSName, androidFQDN)

View File

@@ -46,7 +46,6 @@ sealed class BaseAsset {
bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => livePhotoVideoId != null;
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
AssetPlaybackStyle get playbackStyle {
if (isVideo) return AssetPlaybackStyle.video;

View File

@@ -1,21 +0,0 @@
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});
}

View File

@@ -3,21 +3,30 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult {
final List<BaseAsset> assets;
final double scrollOffset;
final int? nextPage;
const SearchResult({required this.assets, this.nextPage});
const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
scrollOffset: scrollOffset ?? this.scrollOffset,
);
}
@override
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
@override
bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage;
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
}
@override
int get hashCode => assets.hashCode ^ nextPage.hashCode;
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:ui';
import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -27,6 +28,7 @@ import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.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:isar/isar.dart';
import 'package:logging/logging.dart';
@@ -62,7 +64,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift;
final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi;
final _cancellationToken = Completer<void>();
final CancellationToken _cancellationToken = CancellationToken();
final Logger _logger = Logger('BackgroundWorkerBgService');
bool _isCleanedUp = false;
@@ -86,6 +88,8 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> init() async {
try {
HttpSSLOptions.apply();
await Future.wait(
[
loadTranslations(),
@@ -194,7 +198,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_ref?.dispose();
_ref = null;
_cancellationToken.complete();
_cancellationToken.cancel();
_logger.info("Cleaning up background worker");
final cleanupFutures = [

View File

@@ -70,14 +70,13 @@ extension on AssetResponseDto {
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: height?.toInt(),
width: width?.toInt(),
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
stackId: stack?.id,
isEdited: isEdited,
);
}

View File

@@ -205,10 +205,6 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetEditV1:
return _syncStreamRepository.updateAssetEditsV1(data.cast());
case SyncEntityType.assetEditDeleteV1:
return _syncStreamRepository.deleteAssetEditsV1(data.cast());
case SyncEntityType.assetMetadataV1:
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
case SyncEntityType.assetMetadataDeleteV1:
@@ -340,43 +336,39 @@ class SyncStreamService {
}
}
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
_logger.info('Processing AssetEditReadyV1 event');
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) return;
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
final List<SyncAssetV1> assets = [];
try {
if (data is! Map<String, dynamic>) {
throw ArgumentError("Invalid data format for AssetEditReadyV1 event");
for (final data in batchData) {
if (data is! Map<String, dynamic>) {
continue;
}
final payload = data;
final assetData = payload['asset'];
if (assetData == null) {
continue;
}
final asset = SyncAssetV1.fromJson(assetData);
if (asset != null) {
assets.add(asset);
}
}
final payload = data;
if (payload['asset'] == null) {
throw ArgumentError("Missing 'asset' field in AssetEditReadyV1 event data");
if (assets.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
_logger.info('Successfully processed ${assets.length} edited assets');
}
final asset = SyncAssetV1.fromJson(payload['asset']);
if (asset == null) {
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV1 event data");
}
List<SyncAssetEditV1> assetEdits = [];
// 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) {
_logger.severe("Error processing AssetEditReadyV1 websocket event", error, stackTrace);
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
}
}

View File

@@ -78,9 +78,6 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));
TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
@@ -115,7 +112,7 @@ class TimelineService {
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer = [];
_buffer.clear();
} else {
final int offset;
final int count;

View File

@@ -196,11 +196,11 @@ class BackgroundSyncManager {
});
}
Future<void> syncWebsocketEdit(dynamic data) {
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetEditReadyV1(data);
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
@@ -242,7 +242,7 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
debugLabel: 'websocket-batch',
);
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
debugLabel: 'websocket-edit',
);

View File

@@ -33,27 +33,12 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
);
}
class SnapScrollController extends ScrollController {
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
@override
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) =>
SnapScrollPosition(physics: physics, context: context, oldPosition: oldPosition);
}
class SnapScrollPosition extends ScrollPositionWithSingleContext {
double snapOffset;
SnapScrollPosition({required super.physics, required super.context, super.oldPosition, this.snapOffset = 0.0});
@override
bool get shouldIgnorePointer => false;
}
class SnapScrollPhysics extends ScrollPhysics {
static const _minFlingVelocity = 700.0;
static const minSnapDistance = 30.0;
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
const SnapScrollPhysics({super.parent});
@override
@@ -81,21 +66,91 @@ class SnapScrollPhysics extends ScrollPhysics {
}
}
return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity);
return ScrollSpringSimulation(
_spring,
position.pixels,
target(position, velocity, snapOffset),
velocity,
tolerance: toleranceFor(position),
);
}
@override
SpringDescription get spring => SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
@override
bool get allowImplicitScrolling => false;
@override
bool get allowUserScrolling => false;
static double target(ScrollMetrics position, double velocity, double snapOffset) {
if (velocity > _minFlingVelocity) return snapOffset;
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
}
}
class SnapScrollPosition extends ScrollPositionWithSingleContext {
double snapOffset;
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
}
class ProxyScrollController extends ScrollController {
final ScrollController scrollController;
ProxyScrollController({required this.scrollController});
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
@override
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
return ProxyScrollPosition(
scrollController: scrollController,
physics: physics,
context: context,
oldPosition: oldPosition,
);
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
}
class ProxyScrollPosition extends SnapScrollPosition {
final ScrollController scrollController;
ProxyScrollPosition({
required this.scrollController,
required super.physics,
required super.context,
super.oldPosition,
});
@override
double setPixels(double newPixels) {
final overscroll = super.setPixels(newPixels);
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
scrollController.position.forcePixels(pixels);
}
return overscroll;
}
@override
void forcePixels(double value) {
super.forcePixels(value);
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
scrollController.position.forcePixels(pixels);
}
}
@override
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
? scrollController.position.maxScrollExtent
: super.maxScrollExtent;
@override
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
? scrollController.position.minScrollExtent
: super.minScrollExtent;
@override
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
? scrollController.position.viewportDimension
: super.viewportDimension;
}

View File

@@ -1,33 +0,0 @@
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);
}
}

View File

@@ -1,752 +0,0 @@
// 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();
}
}

View File

@@ -2,8 +2,9 @@ part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest {
final String uri;
final Map<String, String> headers;
RemoteImageRequest({required this.uri});
RemoteImageRequest({required this.uri, required this.headers});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@@ -11,7 +12,7 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false);
// Android always returns encoded data, so we need to check for both shapes of the response.
final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
@@ -28,7 +29,7 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true);
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true);
if (info == null) return null;
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);

View File

@@ -4,7 +4,6 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.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/auth_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
@@ -25,10 +24,11 @@ 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/user.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: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
// ref: isar/isar_common.dart
const Symbol _kzoneTxn = #zoneTxn;
@@ -66,7 +66,6 @@ class IsarDatabaseRepository implements IDatabaseRepository {
AssetFaceEntity,
StoreEntity,
TrashedLocalAssetEntity,
AssetEditEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -98,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 22;
int get schemaVersion => 21;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -235,10 +234,6 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
},
from21To22: (m, v22) async {
await m.createTable(v22.assetEditEntity);
await m.createIndex(v22.idxAssetEditAssetId);
},
),
);

View File

@@ -41,11 +41,9 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i19;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
as i20;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i21;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i22;
import 'package:drift/internal/modular.dart' as i23;
as i21;
import 'package:drift/internal/modular.dart' as i22;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -87,11 +85,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
.$TrashedLocalAssetEntityTable(this);
late final i21.$AssetEditEntityTable assetEditEntity = i21
.$AssetEditEntityTable(this);
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
this,
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -129,7 +125,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i12.idxRemoteAlbumAssetAlbumAsset,
@@ -139,7 +134,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i18.idxAssetFaceAssetId,
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
];
@override
i0.StreamQueryUpdateRules
@@ -331,13 +325,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
),
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
i0.DriftDatabaseOptions get options =>
@@ -397,6 +384,4 @@ class $DriftManager {
_db,
_db.trashedLocalAssetEntity,
);
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
}

View File

@@ -9489,565 +9489,6 @@ class Shape31 extends i0.VersionedTable {
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({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -10069,7 +9510,6 @@ i0.MigrationStepWithVersion migrationSteps({
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, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -10173,11 +9613,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from20To21(migrator, schema);
return 21;
case 21:
final schema = Schema22(database: database);
final migrator = i1.Migrator(database, schema);
await from21To22(migrator, schema);
return 22;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -10205,7 +9640,6 @@ i1.OnUpgrade stepByStep({
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, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -10228,6 +9662,5 @@ i1.OnUpgrade stepByStep({
from18To19: from18To19,
from19To20: from19To20,
from20To21: from20To21,
from21To22: from21To22,
),
);

View File

@@ -2,7 +2,8 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.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])
class DriftLogger extends $DriftLogger implements IDatabaseRepository {

View File

@@ -1,55 +1,67 @@
import 'dart:ffi';
import 'dart:io';
import 'package:cronet_http/cronet_http.dart';
import 'package:cupertino_http/cupertino_http.dart';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:ok_http/ok_http.dart';
import 'package:web_socket/web_socket.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:path_provider/path_provider.dart';
class NetworkRepository {
static http.Client? _client;
static Pointer<Void>? _clientPointer;
static late Directory _cachePath;
static late String _userAgent;
static final _clients = <String, http.Client>{};
static Future<void> init() async {
final clientPointer = Pointer<Void>.fromAddress(await networkApi.getClientPointer());
if (clientPointer == _clientPointer) {
return;
}
_clientPointer = clientPointer;
_client?.close();
if (Platform.isIOS) {
final session = URLSession.fromRawPointer(clientPointer.cast());
_client = CupertinoClient.fromSharedSession(session);
} else {
_client = OkHttpClient.fromJniGlobalRef(clientPointer);
}
static Future<void> init() {
return (
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
getUserAgentString().then((userAgent) => _userAgent = userAgent),
).wait;
}
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls, {String? token}) async {
await networkApi.setRequestHeaders(headers, serverUrls, token);
if (Platform.isIOS) {
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);
static void reset() {
Future.microtask(init);
for (final client in _clients.values) {
client.close();
}
_clients.clear();
}
const NetworkRepository();
/// Returns a shared HTTP client that uses native SSL configuration.
///
/// On iOS: Uses SharedURLSessionManager's URLSession.
/// On Android: Uses SharedHttpClientManager's OkHttpClient.
///
/// Must call [init] before using this method.
static http.Client get client => _client!;
/// Note: when disk caching is enabled, only one client may use a given directory at a time.
/// Different isolates or engines must use different directories.
http.Client getHttpClient(
String directoryName, {
CacheMode cacheMode = CacheMode.memory,
int diskCapacity = 0,
int maxConnections = 6,
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);
}
}

View File

@@ -6,7 +6,6 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.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/utils/semver.dart';
import 'package:logging/logging.dart';
@@ -33,11 +32,15 @@ class SyncApiRepository {
http.Client? httpClient,
}) async {
final stopwatch = Stopwatch()..start();
final client = httpClient ?? NetworkRepository.client;
final client = httpClient ?? http.Client();
final endpoint = "${_api.apiClient.basePath}/sync/stream";
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 request = http.Request('POST', Uri.parse(endpoint));
request.headers.addAll(headers);
@@ -48,7 +51,6 @@ class SyncApiRepository {
SyncRequestType.usersV1,
SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1,
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1,
@@ -117,6 +119,8 @@ class SyncApiRepository {
}
} catch (error, stack) {
return Future.error(error, stack);
} finally {
client.close();
}
stopwatch.stop();
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
@@ -152,8 +156,6 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson,
SyncEntityType.assetEditDeleteV1: SyncAssetEditDeleteV1.fromJson,
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,

View File

@@ -5,11 +5,9 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.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_edit.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_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/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
@@ -28,8 +26,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/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction;
import 'package:openapi/api.dart' hide AlbumUserRole, UserMetadataKey, AssetEditAction, AssetVisibility;
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
class SyncStreamRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftSyncStreamRepository');
@@ -60,7 +58,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.userEntity.deleteAll();
await _db.userMetadataEntity.deleteAll();
await _db.remoteAssetCloudIdEntity.deleteAll();
await _db.assetEditEntity.deleteAll();
});
await _db.customStatement('PRAGMA foreign_keys = ON');
});
@@ -325,63 +322,6 @@ 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 {
try {
await _db.batch((batch) {
@@ -858,12 +798,3 @@ extension on String {
extension on UserAvatarColor {
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,
};
}

View File

@@ -276,19 +276,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: origin,
);
TimelineQuery fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin origin) =>
(
bucketSource: () async* {
yield _generateBuckets(getAssets().length);
yield* assetCount.map(_generateBuckets);
},
assetSource: (offset, count) {
final assets = getAssets();
return Future.value(assets.skip(offset).take(count).toList(growable: false));
},
origin: origin,
);
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
// Sort assets by date descending and group by day
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));

View File

@@ -40,6 +40,7 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.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/migration.dart';
import 'package:immich_mobile/wm_executor.dart';
@@ -59,6 +60,7 @@ void main() async {
// Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
runApp(
ProviderScope(
@@ -244,7 +246,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
@override
void reassemble() {
if (kDebugMode) {
NetworkRepository.init();
NetworkRepository.reset();
}
super.reassemble();
}

View File

@@ -1,5 +1,6 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
@@ -20,6 +21,7 @@ class BackUpState {
final DateTime progressInFileSpeedUpdateTime;
final int progressInFileSpeedUpdateSentBytes;
final double iCloudDownloadProgress;
final CancellationToken cancelToken;
final ServerDiskInfo serverInfo;
final bool autoBackup;
final bool backgroundBackup;
@@ -51,6 +53,7 @@ class BackUpState {
required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes,
required this.iCloudDownloadProgress,
required this.cancelToken,
required this.serverInfo,
required this.autoBackup,
required this.backgroundBackup,
@@ -75,6 +78,7 @@ class BackUpState {
DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes,
double? iCloudDownloadProgress,
CancellationToken? cancelToken,
ServerDiskInfo? serverInfo,
bool? autoBackup,
bool? backgroundBackup,
@@ -98,6 +102,7 @@ class BackUpState {
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
autoBackup: autoBackup ?? this.autoBackup,
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
@@ -115,7 +120,7 @@ class BackUpState {
@override
String toString() {
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)';
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)';
}
@override
@@ -132,6 +137,7 @@ class BackUpState {
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
other.iCloudDownloadProgress == iCloudDownloadProgress &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo &&
other.autoBackup == autoBackup &&
other.backgroundBackup == backgroundBackup &&
@@ -157,6 +163,7 @@ class BackUpState {
progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^
iCloudDownloadProgress.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
autoBackup.hashCode ^
backgroundBackup.hashCode ^

View File

@@ -1,8 +1,11 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
class ManualUploadState {
final CancellationToken cancelToken;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
final int currentAssetIndex;
@@ -26,6 +29,7 @@ class ManualUploadState {
required this.progressInFileSpeeds,
required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes,
required this.cancelToken,
required this.currentUploadAsset,
required this.totalAssetsToUpload,
required this.currentAssetIndex,
@@ -40,6 +44,7 @@ class ManualUploadState {
List<double>? progressInFileSpeeds,
DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes,
CancellationToken? cancelToken,
CurrentUploadAsset? currentUploadAsset,
int? totalAssetsToUpload,
int? successfulUploads,
@@ -53,6 +58,7 @@ class ManualUploadState {
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
cancelToken: cancelToken ?? this.cancelToken,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload,
currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex,
@@ -63,7 +69,7 @@ class ManualUploadState {
@override
String toString() {
return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)';
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)';
}
@override
@@ -78,6 +84,7 @@ class ManualUploadState {
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
other.cancelToken == cancelToken &&
other.currentUploadAsset == currentUploadAsset &&
other.totalAssetsToUpload == totalAssetsToUpload &&
other.currentAssetIndex == currentAssetIndex &&
@@ -93,6 +100,7 @@ class ManualUploadState {
progressInFileSpeeds.hashCode ^
progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^
cancelToken.hashCode ^
currentUploadAsset.hashCode ^
totalAssetsToUpload.hashCode ^
currentAssetIndex.hashCode ^

View File

@@ -96,6 +96,10 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
await backupNotifier.startForegroundBackup(currentUser.id);
}
Future<void> stopBackup() async {
await backupNotifier.stopForegroundBackup();
}
return Scaffold(
appBar: AppBar(
elevation: 0,
@@ -132,9 +136,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
const Divider(),
BackupToggleButton(
onStart: () async => await startBackup(),
onStop: () {
onStop: () async {
syncSuccess = null;
backupNotifier.stopForegroundBackup();
await stopBackup();
},
),
switch (error) {
@@ -148,12 +152,10 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
children: [
Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1),
const SizedBox(width: 8),
Flexible(
child: Text(
context.t.backup_error_sync_failed,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
textAlign: TextAlign.center,
),
Text(
context.t.backup_error_sync_failed,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
textAlign: TextAlign.center,
),
],
),
@@ -346,7 +348,6 @@ class _RemainderCard extends ConsumerWidget {
remainderCount.toString(),
style: context.textTheme.titleLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
fontFeatures: [const FontFeature.tabularFigures()],
),
),
if (syncStatus.isRemoteSyncing)
@@ -486,7 +487,6 @@ class _PreparingStatusState extends ConsumerState {
style: context.textTheme.titleMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w600,
fontFeatures: [const FontFeature.tabularFigures()],
),
),
],
@@ -511,7 +511,6 @@ class _PreparingStatusState extends ConsumerState {
style: context.textTheme.titleMedium?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.w600,
fontFeatures: [const FontFeature.tabularFigures()],
),
),
],

View File

@@ -112,15 +112,16 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
// Waits for hashing to be cancelled before starting a new one
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
if (isBackupEnabled) {
backupNotifier.stopForegroundBackup();
unawaited(
backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(user.id);
} else {
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
}
}),
backupNotifier.stopForegroundBackup().whenComplete(
() => backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(user.id);
} else {
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
}
}),
),
);
}
}

View File

@@ -59,15 +59,16 @@ class DriftBackupOptionsPage extends ConsumerWidget {
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundSync = ref.read(backgroundSyncProvider);
backupNotifier.stopForegroundBackup();
unawaited(
backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(currentUser.id);
} else {
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
}
}),
backupNotifier.stopForegroundBackup().whenComplete(
() => backgroundSync.syncRemote().then((success) {
if (success) {
return backupNotifier.startForegroundBackup(currentUser.id);
} else {
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
}
}),
),
);
}
},

View File

@@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
class SettingsHeader {
String key = "";
@@ -21,6 +20,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// final apiService = ref.watch(apiServiceProvider);
final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false);
@@ -75,7 +75,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
],
),
body: PopScope(
onPopInvokedWithResult: (didPop, _) => saveHeaders(ref, headers.value),
onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value),
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
itemCount: list.length,
@@ -87,7 +87,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
);
}
saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
saveHeaders(List<SettingsHeader> headers) {
final headersMap = {};
for (var header in headers) {
final key = header.key.trim();
@@ -98,8 +98,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
}
var encoded = jsonEncode(headersMap);
await Store.put(StoreKey.customHeaders, encoded);
await ref.read(apiServiceProvider).updateHeaders();
Store.put(StoreKey.customHeaders, encoded);
}
}

View File

@@ -7,10 +7,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/asset.entity.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/utils/hooks/crop_controller_hook.dart';
import 'edit.page.dart';
/// A widget for cropping an image.
/// 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

View File

@@ -179,7 +179,7 @@ class NetworkApi {
}
}
Future<void> selectCertificate(ClientCertPrompt promptText) async {
Future<ClientCertData> selectCertificate(ClientCertPrompt promptText) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -197,8 +197,13 @@ class NetworkApi {
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;
return (pigeonVar_replyList[0] as ClientCertData?)!;
}
}
@@ -224,83 +229,4 @@ class NetworkApi {
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, String? token) 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, token]);
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;
}
}
}

View File

@@ -49,7 +49,12 @@ class RemoteImageApi {
final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(String url, {required int requestId, required bool preferEncoded}) async {
Future<Map<String, int>?> requestImage(
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -57,7 +62,12 @@ class RemoteImageApi {
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, requestId, preferEncoded]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
url,
headers,
requestId,
preferEncoded,
]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -61,7 +62,7 @@ class DriftEditImagePage extends ConsumerWidget {
return;
}
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]);
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken());
} catch (e) {
ImmichToast.show(
durationInSecond: 6,

View File

@@ -80,28 +80,51 @@ class DriftSearchPage extends HookConsumerWidget {
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final userPreferences = ref.watch(userMetadataPreferencesProvider);
searchFilter(SearchFilter filter) {
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(message, style: context.textTheme.labelLarge),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: context.colorScheme.onSurface,
);
}
searchFilter(SearchFilter filter) async {
if (filter.isEmpty) {
return;
}
if (preFilter == null && filter == previousFilter.value) {
return;
}
ref.read(paginatedSearchProvider.notifier).clear();
isSearching.value = true;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter);
if (filter.isEmpty) {
previousFilter.value = null;
return;
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context)));
}
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter;
isSearching.value = false;
}
search() => searchFilter(filter.value);
loadMoreSearchResults() {
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value));
loadMoreSearchResult() async {
isSearching.value = true;
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context)));
}
isSearching.value = false;
}
searchPreFilter() {
@@ -719,10 +742,10 @@ class DriftSearchPage extends HookConsumerWidget {
),
),
),
if (filter.value.isEmpty)
const _SearchSuggestions()
if (isSearching.value)
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
else
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),
_SearchResultGrid(onScrollEnd: loadMoreSearchResult),
],
),
);
@@ -734,85 +757,45 @@ class _SearchResultGrid extends ConsumerWidget {
const _SearchResultGrid({required this.onScrollEnd});
bool _onScrollUpdateNotification(ScrollNotification notification) {
final metrics = notification.metrics;
if (metrics.axis != Axis.vertical) return false;
final isBottomSheet = notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final remaining = metrics.maxScrollExtent - metrics.pixels;
if (remaining < metrics.viewportDimension && !isBottomSheet) {
onScrollEnd();
}
return false;
}
Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
if (isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
);
}
final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null));
if (hasMore) return null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
'search_no_more_result'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty));
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets));
if (!hasAssets && !isLoading) {
return const _SearchNoResults();
if (assets.isEmpty) {
return const _SearchEmptyContent();
}
return NotificationListener<ScrollUpdateNotification>(
onNotification: _onScrollUpdateNotification,
return NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical;
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
onScrollEnd();
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent);
}
return true;
},
child: SliverFillRemaining(
child: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final notifier = ref.read(paginatedSearchProvider.notifier);
final service = ref
.watch(timelineFactoryProvider)
.fromAssetStream(
() => ref.read(paginatedSearchProvider).assets,
notifier.assetCount,
TimelineOrigin.search,
);
ref.onDispose(service.dispose);
return service;
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
key: ValueKey(assets.length),
groupBy: GroupAssetsBy.none,
appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false,
loadingWidget: const SizedBox.shrink(),
bottomSliverWidget: _bottomWidget(context, ref),
initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)),
),
),
),
@@ -820,35 +803,8 @@ class _SearchResultGrid extends ConsumerWidget {
}
}
class _SearchNoResults extends StatelessWidget {
const _SearchNoResults();
@override
Widget build(BuildContext context) {
return SliverFillRemaining(
hasScrollBody: false,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search_off_rounded, size: 72, color: context.colorScheme.onSurfaceVariant),
const SizedBox(height: 24),
Text(
'search_no_result'.t(context: context),
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
],
),
),
);
}
}
class _SearchSuggestions extends StatelessWidget {
const _SearchSuggestions();
class _SearchEmptyContent extends StatelessWidget {
const _SearchEmptyContent();
@override
Widget build(BuildContext context) {

View File

@@ -1,7 +1,5 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
@@ -23,52 +21,40 @@ class SearchFilterProvider extends Notifier<SearchFilter?> {
}
}
class SearchState {
final List<BaseAsset> assets;
final int? nextPage;
final bool isLoading;
const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false});
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
final SearchService _searchService;
final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1));
Stream<int> get assetCount => _assetCountController.stream;
Future<void> search(SearchFilter filter) async {
if (state.nextPage == null || state.isLoading) return;
state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true);
Future<bool> search(SearchFilter filter) async {
if (state.nextPage == null) {
return false;
}
final result = await _searchService.search(filter, state.nextPage!);
if (result == null) {
state = SearchState(assets: state.assets, nextPage: state.nextPage);
return;
return false;
}
final assets = [...state.assets, ...result.assets];
state = SearchState(assets: assets, nextPage: result.nextPage);
state = SearchResult(
assets: [...state.assets, ...result.assets],
nextPage: result.nextPage,
scrollOffset: state.scrollOffset,
);
_assetCountController.add(assets.length);
return true;
}
void clear() {
state = const SearchState();
_assetCountController.add(0);
void setScrollOffset(double offset) {
state = state.copyWith(scrollOffset: offset);
}
@override
void dispose() {
_assetCountController.close();
super.dispose();
clear() {
state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0);
}
}

View File

@@ -101,8 +101,7 @@ class _UploadProgressDialog extends ConsumerWidget {
actions: [
ImmichTextButton(
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
ref.read(manualUploadCancelTokenProvider)?.cancel();
Navigator.of(context).pop();
},
labelText: 'cancel'.t(context: context),

View File

@@ -19,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
@@ -49,7 +50,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
bool _showingDetails = false;
bool _isZoomed = false;
final _scrollController = SnapScrollController();
final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
double _snapOffset = 0.0;
DragStartDetails? _dragStart;
@@ -61,17 +63,17 @@ class _AssetPageState extends ConsumerState<AssetPage> {
super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) return;
_scrollController.snapPosition.snapOffset = _snapOffset;
if (!mounted || !_proxyScrollController.hasClients) return;
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
if (_showingDetails && _snapOffset > 0) {
_scrollController.jumpTo(_snapOffset);
_proxyScrollController.jumpTo(_snapOffset);
}
});
}
@override
void dispose() {
_scrollController.dispose();
_proxyScrollController.dispose();
_scaleBoundarySub?.cancel();
_eventSubscription?.cancel();
super.dispose();
@@ -86,20 +88,21 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _showDetails() {
if (!_scrollController.hasClients || _snapOffset <= 0) return;
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
_viewer.setShowingDetails(true);
_scrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
}
bool _willClose(double scrollVelocity) =>
_scrollController.hasClients &&
_snapOffset > 0 &&
_scrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) <
SnapScrollPhysics.minSnapDistance;
bool _willClose(double scrollVelocity) {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
final position = _proxyScrollController.position;
return _proxyScrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
}
void _syncShowingDetails() {
final offset = _scrollController.offset;
final offset = _proxyScrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true);
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
@@ -121,8 +124,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _startProxyDrag() {
if (_scrollController.hasClients && _dragStart != null) {
_drag = _scrollController.position.drag(_dragStart!, () => _drag = null);
if (_proxyScrollController.hasClients && _dragStart != null) {
_drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
}
}
@@ -247,6 +250,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false);
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
return;
}
@@ -286,6 +294,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent,
required bool isPlayingMotionVideo,
required BoxDecoration backgroundDecoration,
}) {
final size = context.sizeData;
@@ -296,6 +305,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
backgroundDecoration: backgroundDecoration,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
tightMode: true,
@@ -337,6 +347,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
tightMode: true,
onPageBuild: _onPageBuild,
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer(
key: _NativeVideoViewerKey(asset.heroTag),
asset: asset,
@@ -379,52 +390,57 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_snapOffset = detailsOffset - snapTarget;
if (_scrollController.hasClients) {
_scrollController.snapPosition.snapOffset = _snapOffset;
if (_proxyScrollController.hasClients) {
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
}
return Stack(
children: [
Offstage(
child: SingleChildScrollView(
controller: _proxyScrollController,
physics: const SnapScrollPhysics(),
child: const SizedBox.shrink(),
),
),
SingleChildScrollView(
controller: _scrollController,
physics: const SnapScrollPhysics(),
child: ColoredBox(
color: _showingDetails ? Colors.black : Colors.transparent,
child: Stack(
children: [
SizedBox(
width: viewportWidth,
height: viewportHeight,
child: _buildPhotoView(
asset: displayAsset,
heroAttributes: isCurrent
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
: null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
),
physics: const NeverScrollableScrollPhysics(),
child: Stack(
children: [
SizedBox(
width: viewportWidth,
height: viewportHeight,
child: _buildPhotoView(
asset: displayAsset,
heroAttributes: isCurrent
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
: null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
),
IgnorePointer(
ignoring: !_showingDetails,
child: Column(
children: [
SizedBox(height: detailsOffset),
GestureDetector(
onVerticalDragStart: _beginDrag,
onVerticalDragUpdate: _updateDrag,
onVerticalDragEnd: _endDrag,
onVerticalDragCancel: _onDragCancel,
child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2,
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
),
),
IgnorePointer(
ignoring: !_showingDetails,
child: Column(
children: [
SizedBox(height: detailsOffset),
GestureDetector(
onVerticalDragStart: _beginDrag,
onVerticalDragUpdate: _updateDrag,
onVerticalDragEnd: _endDrag,
onVerticalDragCancel: _onDragCancel,
child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2,
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
),
],
),
),
],
),
],
),
),
],
),
),
if (stackChildren != null && stackChildren.isNotEmpty)

View File

@@ -61,27 +61,15 @@ class ViewerBottomBar extends ConsumerWidget {
),
),
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),
color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),
),

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
@@ -185,7 +186,11 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
final source = await _videoSource;
if (source == null || !mounted) return;
await _notifier.load(source);
unawaited(
nc.loadVideoSource(source).catchError((error) {
_log.severe('Error loading video source: $error');
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
@@ -208,28 +213,21 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
@override
Widget build(BuildContext context) {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
// Prevent the provider from being disposed whilst the widget is alive.
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
return IgnorePointer(
child: Stack(
children: [
Center(child: widget.image),
if (!isCasting) ...[
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
Center(
child: AnimatedOpacity(
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0,
duration: const Duration(milliseconds: 400),
child: const CircularProgressIndicator(),
),
),
],
],
),
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
return Stack(
children: [
Center(child: widget.image),
if (!isCasting)
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
],
);
}
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class VideoViewerControls extends HookConsumerWidget {
final BaseAsset asset;
final Duration hideTimerDuration;
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
@override
Widget build(BuildContext context, WidgetRef ref) {
final videoPlayerName = asset.heroTag;
final assetIsVideo = asset.isVideo;
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
final cast = ref.watch(castProvider);
// A timer to hide the controls
final hideTimer = useTimer(hideTimerDuration, () {
if (!context.mounted) {
return;
}
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
// Do not hide on paused
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(assetViewerProvider.notifier).setControls(true);
}
// When playback starts, reset the hide timer
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
if (cast.isCasting) {
switch (cast.castState) {
case CastState.playing:
ref.read(castProvider.notifier).pause();
case CastState.paused:
ref.read(castProvider.notifier).play();
default:
}
return;
}
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
switch (status) {
case VideoPlaybackStatus.playing:
notifier.pause();
case VideoPlaybackStatus.completed:
notifier.restart();
default:
notifier.play();
}
}
void toggleControlsVisibility() {
if (showBuffering) return;
if (showControls) {
ref.read(assetViewerProvider.notifier).setControls(false);
} else {
showControlsAndStartHideTimer();
}
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: toggleControlsVisibility,
child: IgnorePointer(
ignoring: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else
CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: status == VideoPlaybackStatus.completed,
isPlaying:
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
],
),
),
);
}
}

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