Compare commits

..

28 Commits

Author SHA1 Message Date
mertalev
ec413be78f re-enable cache 2026-01-21 18:48:02 -05:00
mertalev
9b494ad8cd ios optimizations 2026-01-21 16:57:52 -05:00
mertalev
5f52d5f5b8 fast path for known content length 2026-01-21 15:49:58 -05:00
mertalev
29e9b4c2e2 refactor, fix capacity handling 2026-01-21 02:46:21 -05:00
mertalev
15dc5c2e5c embedded cronet 2026-01-20 18:28:54 -05:00
mertalev
129663985f potential race conditions 2026-01-20 16:45:24 -05:00
mertalev
8fb0a23595 refactor 2026-01-20 16:31:27 -05:00
mertalev
d3f7d9da3a fixed locking 2026-01-20 15:41:14 -05:00
mertalev
bb643c6625 optimized http 2026-01-20 15:25:19 -05:00
mertalev
ca660c5abe flutter-side decode 2026-01-17 23:46:46 -05:00
mertalev
67a3d27dc4 set user agent 2026-01-17 03:58:32 -05:00
mertalev
af0455d9cd enable disk cache 2026-01-17 03:33:05 -05:00
mertalev
5441e6f214 clarify parameter 2026-01-17 03:27:05 -05:00
mertalev
95d34f04cd conditional cronet 2026-01-17 02:35:44 -05:00
mertalev
8da501cd2f lock approach is slower on ios 2026-01-17 01:05:12 -05:00
mertalev
e415efa488 memory optimization 2026-01-17 00:52:24 -05:00
mertalev
ff08873aae android impl 2026-01-16 22:03:25 -05:00
mertalev
ebca72d31f remove incremental 2026-01-16 18:08:22 -05:00
mertalev
7c61a649c7 optimized decoding 2026-01-16 17:27:30 -05:00
mertalev
605b024cf0 disable disk cache by default 2026-01-16 17:27:30 -05:00
mertalev
34a2f7203a unrelated change 2026-01-16 17:27:30 -05:00
mertalev
20eb477a63 move to bootstrap 2026-01-16 17:27:30 -05:00
mertalev
38e0be00a2 set defaults 2026-01-16 17:27:30 -05:00
mertalev
5ce5cd2d8d init before app launch 2026-01-16 17:27:30 -05:00
mertalev
7d435c8792 custom user agent 2026-01-16 17:27:30 -05:00
mertalev
5a22096e4a fix hot reload 2026-01-16 17:27:30 -05:00
mertalev
01280c4a0c uppercase http method 2026-01-16 17:27:30 -05:00
mertalev
545fdc3506 platform clients 2026-01-16 17:27:30 -05:00
329 changed files with 4943 additions and 19073 deletions

View File

@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
- name: Load parameters
id: parameters

View File

@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
- name: Destroy Docs Subdomain
env:

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.10.8",
"@types/node": "^24.10.4",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -68,56 +68,6 @@ Now make sure that the local album is selected in the backup screen (steps 1-2 a
title="Upload button after photos selection"
/>
## Free Up Space
The **Free Up Space** tool allows you to remove local media files from your device that have already been successfully backed up to your Immich server (and are not in the Immich trash). This helps reclaim storage on your mobile device without losing your memories.
### How it works
1. **Configuration:**
- **Cutoff Date:** You can select a cutoff date. The tool will only look for photos and videos **on or before** this date.
- **Filter Options:** You can choose to remove **All** assets, or restrict removal to **Photos only** or **Videos only**.
- **Keep Favorites:** By default, local assets marked as favorites are preserved on your device, even if they match the cutoff date.
2. **Scan & Review:** Before any files are removed, you are presented with a review screen to verify which items will be deleted.
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. They will be permanently removed by the OS based on your system settings (usually after 30 days).
:::info Android Permissions
For the smoothest experience on Android, you should grant Immich special delete privileges. Without this, you may be prompted to confirm deletion for every single image.
Go to **Immich Settings > Advanced** and enable **"Media Management Access"**.
:::
### iCloud Photos (iOS Users)
If you use **iCloud Photos** alongside Immich, it is vital to understand how deletion affects your data. iCloud utilizes a two-way sync; this means deleting a photo from your iPhone to free up space will **also delete it from iCloud**.
:::warning iCloud & Backups
If you rely on iCloud as a secondary backup (part of a 3-2-1 backup strategy), using the Free Up Space feature in Immich will remove the file from both your phone and iCloud.
Once deleted, the photo will exist **only** on your Immich server (and your phone's "Recently Deleted" folder for 30 days).
When you use iCloud Photos and delete a photo or video on one device, it's also deleted on all other devices where you're signed in with the same Apple Account.
More information on the [Apple Support](https://support.apple.com/en-us/108922#iCloud_photo_library) website
**Shared Albums**
Assets that are part of an **iCloud Shared Album** are automatically excluded from the cleanup scan to ensure they remain viewable to others in the shared album.
:::
### External App Dependencies (WhatsApp, etc.)
:::danger WhatsApp & Local Files
Android applications like **WhatsApp** rely on local files to display media in chat history.
If Immich backs up your WhatsApp folder and you run **Free Up Space**, the local copies of these images will be deleted. Consequently, **media in your WhatsApp chats will appear blurry or missing.** You will only be able to view these photos inside the Immich app; they will no longer be visible within the WhatsApp interface.
**Recommendation:** If keeping chat history intact is important, please ensure you review the deletion list carefully or consider excluding WhatsApp folders from the backup if you intend to use this feature frequently.
:::
:::info reclaim storage
You must empty the system/gallery trash manually to reclaim storage.
:::
## Album Sync
You can sync or mirror an album from your phone to the Immich server on your account. For example, if you select Recents, Camera and Videos album for backup, the corresponding album with the same name will be created on the server. Once the assets from those albums are uploaded, they will be put into the target albums automatically.

View File

@@ -17,17 +17,12 @@ Hardware and software requirements for Immich:
- Immich runs well in a virtualized environment when running in a full virtual machine.
The use of Docker in LXC containers is [not recommended](https://pve.proxmox.com/wiki/Linux_Container), but may be possible for advanced users.
If you have issues, we recommend that you switch to a supported VM deployment.
- **RAM**: Minimum 6GB, recommended 8GB.
- **RAM**: Minimum 4GB, recommended 6GB.
- **CPU**: Minimum 2 cores, recommended 4 cores.
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
:::note RAM requirements
For a smooth experience, especially during asset upload, Immich requires at least 6GB of RAM.
For systems with only 4GB of RAM, Immich can be run with machine learning features disabled.
:::
:::tip Postgres setup
:::tip
Good performance and a stable connection to the Postgres database is critical to a smooth Immich experience.
The Postgres database files are typically between 1-3 GB in size.
For this reason, the Postgres database (`DB_DATA_LOCATION`) should ideally use local SSD storage, and never a network share of any kind.

View File

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

View File

@@ -8,7 +8,7 @@ dotenv.config({ path: resolve(import.meta.dirname, '.env') });
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
// {
// name: 'parallel tests',
// use: { ...devices['Desktop Chrome'] },
// testMatch: /.*\.parallel-e2e-spec\.ts/,
// fullyParallel: true,
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
// },
{
name: 'parallel tests',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.parallel-e2e-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
// name: 'firefox',

View File

@@ -1,350 +0,0 @@
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/admin/database-backups', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.resetBackups(admin.accessToken);
});
describe('GET /', async () => {
it('should succeed and be empty', async () => {
const { status, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
backups: [],
});
});
it('should contain a created backup', async () => {
await utils.createJob(admin.accessToken, {
name: ManualJobName.BackupDatabase,
});
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
await expect
.poll(
async () => {
const { status, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 10_000,
},
)
.toEqual(
expect.objectContaining({
backups: [
expect.objectContaining({
filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
filesize: expect.any(Number),
}),
],
}),
);
});
});
describe('DELETE /', async () => {
it('should delete backup', async () => {
const filename = await utils.createBackup(admin.accessToken);
const { status } = await request(app)
.delete(`/admin/database-backups`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ backups: [filename] });
expect(status).toBe(200);
const { status: listStatus, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(listStatus).toBe(200);
expect(body).toEqual(
expect.objectContaining({
backups: [],
}),
);
});
});
// => action: restore database flow
describe.sequential('POST /start-restore', () => {
afterAll(async () => {
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
admin = await utils.adminSetup();
});
it.sequential('should not work when the server is configured', async () => {
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
});
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
await utils.resetDatabase(); // reset database before running this test
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status2).toBe(200);
expect(body).toEqual({
active: true,
action: 'select_database_restore',
});
});
});
// => action: restore database
describe.sequential('POST /backups/restore', () => {
beforeAll(async () => {
await utils.disconnectDatabase();
});
afterAll(async () => {
await utils.connectDatabase();
});
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
let filename = await utils.createBackup(admin.accessToken);
// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';
const { status } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: filename,
});
expect(status).toBe(201);
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status2).toBe(200);
expect(body).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
}),
);
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 60_000,
},
)
.toBeFalsy();
});
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
await utils.prepareTestBackup('corrupted');
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: 'development-corrupted.sql.gz',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 10_000,
},
)
.toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: 'Something went wrong, see logs!',
}),
);
const { status: status2, body: body2 } = await request(app)
.get('/admin/maintenance/status')
.set('cookie', cookie!)
.send({ token: 'token' });
expect(status2).toBe(200);
expect(body2).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: expect.stringContaining('IM CORRUPTED'),
}),
);
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
});
it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
await utils.prepareTestBackup('empty');
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: 'development-empty.sql.gz',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 30_000,
},
)
.toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: 'Something went wrong, see logs!',
}),
);
const { status: status2, body: body2 } = await request(app)
.get('/admin/maintenance/status')
.set('cookie', cookie!)
.send({ token: 'token' });
expect(status2).toBe(200);
expect(body2).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: expect.stringContaining('Server health check failed, no admin exists.'),
}),
);
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
});
});
});

View File

@@ -14,7 +14,6 @@ describe('/admin/maintenance', () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
await utils.resetBackups(admin.accessToken);
});
// => outside of maintenance mode
@@ -27,17 +26,6 @@ describe('/admin/maintenance', () => {
});
});
describe('GET /status', async () => {
it('to always indicate we are not in maintenance mode', async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
expect(body).toEqual({
active: false,
action: 'end',
});
});
});
describe('POST /login', async () => {
it('should not work out of maintenance mode', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
@@ -51,7 +39,6 @@ describe('/admin/maintenance', () => {
describe.sequential('POST /', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({
active: false,
action: 'end',
});
expect(status).toBe(401);
@@ -82,7 +69,6 @@ describe('/admin/maintenance', () => {
.send({
action: 'start',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
@@ -93,13 +79,12 @@ describe('/admin/maintenance', () => {
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
interval: 5e2,
timeout: 1e4,
},
)
.toBeTruthy();
@@ -117,17 +102,6 @@ describe('/admin/maintenance', () => {
});
});
describe('GET /status', async () => {
it('to indicate we are in maintenance mode', async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
expect(body).toEqual({
active: true,
action: 'start',
});
});
});
describe('POST /login', async () => {
it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
@@ -184,13 +158,12 @@ describe('/admin/maintenance', () => {
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
interval: 5e2,
timeout: 1e4,
},
)
.toBeFalsy();

View File

@@ -348,7 +348,6 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
checksum: asset.checksum,
width: exifInfo.exifImageWidth ?? 1,
height: exifInfo.exifImageHeight ?? 1,
isEdited: false,
};
}

View File

@@ -6,9 +6,7 @@ import {
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
JobCreateDto,
MaintenanceAction,
ManualJobName,
MetadataSearchDto,
Permission,
PersonCreateDto,
@@ -23,7 +21,6 @@ import {
checkExistingAssets,
createAlbum,
createApiKey,
createJob,
createLibrary,
createPartner,
createPerson,
@@ -31,12 +28,10 @@ import {
createStack,
createUserAdmin,
deleteAssets,
deleteDatabaseBackup,
getAssetInfo,
getConfig,
getConfigDefaults,
getQueuesLegacy,
listDatabaseBackups,
login,
runQueueCommandLegacy,
scanLibrary,
@@ -57,15 +52,11 @@ import {
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { dirname, resolve } from 'node:path';
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
import { promisify } from 'node:util';
import { createGzip } from 'node:zlib';
import pg from 'pg';
import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
@@ -93,9 +84,8 @@ export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer $
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
export const immichCli = (args: string[]) =>
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
export const dockerExec = (args: string[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
export const immichAdmin = (args: string[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
@@ -159,26 +149,12 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
};
export const utils = {
connectDatabase: async () => {
if (!client) {
client = new pg.Client(dbUrl);
client.on('end', () => (client = null));
client.on('error', () => (client = null));
await client.connect();
}
return client;
},
disconnectDatabase: async () => {
if (client) {
await client.end();
}
},
resetDatabase: async (tables?: string[]) => {
try {
client = await utils.connectDatabase();
if (!client) {
client = new pg.Client(dbUrl);
await client.connect();
}
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
@@ -505,9 +481,6 @@ export const utils = {
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
createJob: async (accessToken: string, jobCreateDto: JobCreateDto) =>
createJob({ jobCreateDto }, { headers: asBearerAuth(accessToken) }),
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
@@ -586,45 +559,6 @@ export const utils = {
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
},
async move(source: string, dest: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
},
createBackup: async (accessToken: string) => {
await utils.createJob(accessToken, {
name: ManualJobName.BackupDatabase,
});
return utils.poll(
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
({ status, body }) => status === 200 && body.backups.length === 1,
({ body }) => body.backups[0].filename,
);
},
resetBackups: async (accessToken: string) => {
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
const backupFiles = backups.map((b) => b.filename);
await deleteDatabaseBackup(
{ databaseBackupDeleteDto: { backups: backupFiles } },
{ headers: asBearerAuth(accessToken) },
);
},
prepareTestBackup: async (generate: 'empty' | 'corrupted') => {
const dir = await mkdtemp(join(tmpdir(), 'test-'));
const fn = join(dir, 'file');
const sql = Readable.from(generate === 'corrupted' ? 'IM CORRUPTED;' : 'SELECT 1;');
const gzip = createGzip();
const writeStream = createWriteStream(fn);
await pipeline(sql, gzip, writeStream);
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
.promise;
},
resetAdminConfig: async (accessToken: string) => {
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
@@ -667,25 +601,6 @@ export const utils = {
await utils.waitForQueueFinish(accessToken, 'sidecar');
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
},
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
let timeout = 0;
while (true) {
try {
const data = await cb();
if (validate(data)) {
return map ? map(data) : data;
}
timeout++;
if (timeout >= 10) {
throw 'Could not clean up test.';
}
await new Promise((resolve) => setTimeout(resolve, 5e2));
} catch {
// no-op
}
}
},
};
utils.initSdk();

View File

@@ -12,7 +12,7 @@ import {
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils';
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => {
@@ -49,6 +49,7 @@ test.describe('asset-viewer', () => {
});
test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];

View File

@@ -1,105 +0,0 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('Database Backups', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('restore a backup from settings', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
const filename = await utils.createBackup(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
await page.goto('/admin/maintenance?isOpen=backups');
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
});
test('handle backup restore failure', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
await utils.prepareTestBackup('corrupted');
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/maintenance?isOpen=backups');
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/admin/maintenance**');
});
test('rollback to restore point if backup is missing admin', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
await utils.prepareTestBackup('empty');
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/maintenance?isOpen=backups');
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await expect(page.getByText('Server health check failed, no admin exists.')).toBeVisible({ timeout: 60_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/admin/maintenance**');
});
test('restore a backup from onboarding', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
const filename = await utils.createBackup(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
await utils.resetDatabase();
await page.goto('/');
await page.getByRole('button', { name: 'Restore from backup' }).click();
try {
await page.waitForURL('/maintenance**');
} catch {
// when chained with the rest of the tests
// this navigation may fail..? not sure why...
await page.goto('/maintenance');
await page.waitForURL('/maintenance**');
}
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await page.waitForURL('/photos', { timeout: 60_000 });
});
});

View File

@@ -16,12 +16,12 @@ test.describe('Maintenance', () => {
test('enter and exit maintenance mode', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/maintenance');
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
await page.goto('/admin/system-settings?isOpen=maintenance');
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
});
test('maintenance shows no options to users until they authenticate', async ({ page }) => {

View File

@@ -18,6 +18,7 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
import { utils } from 'src/utils';
import {
assetViewerUtils,
cancelAllPollers,
padYearMonth,
pageUtils,
poll,
@@ -63,6 +64,7 @@ test.describe('Timeline', () => {
});
test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];

View File

@@ -23,6 +23,13 @@ export async function throttlePage(context: BrowserContext, page: Page) {
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
}
let activePollsAbortController = new AbortController();
export const cancelAllPollers = () => {
activePollsAbortController.abort();
activePollsAbortController = new AbortController();
};
export const poll = async <T>(
page: Page,
query: () => Promise<T>,
@@ -30,14 +37,21 @@ export const poll = async <T>(
) => {
let result;
const timeout = Date.now() + 10_000;
const signal = activePollsAbortController.signal;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) {
if (signal.aborted) {
return;
}
try {
result = await query();
} catch {
// ignore
}
if (signal.aborted) {
return;
}
if (page.isClosed()) {
return;
}

View File

@@ -188,21 +188,10 @@
"machine_learning_smart_search_enabled": "Enable smart search",
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
"maintenance_delete_backup": "Delete Backup",
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
"maintenance_delete_error": "Failed to delete backup.",
"maintenance_restore_backup": "Restore Backup",
"maintenance_restore_backup_description": "Immich will be wiped and restored from the chosen backup. A backup will be created before continuing.",
"maintenance_restore_backup_different_version": "This backup was created with a different version of Immich!",
"maintenance_restore_backup_unknown_version": "Couldn't determine backup version.",
"maintenance_restore_database_backup": "Restore database backup",
"maintenance_restore_database_backup_description": "Rollback to an earlier database state using a backup file",
"maintenance_settings": "Maintenance",
"maintenance_settings_description": "Put Immich into maintenance mode.",
"maintenance_start": "Switch to maintenance mode",
"maintenance_start": "Start maintenance mode",
"maintenance_start_error": "Failed to start maintenance mode.",
"maintenance_upload_backup": "Upload database backup file",
"maintenance_upload_backup_error": "Could not upload backup, is it an .sql/.sql.gz file?",
"manage_concurrency": "Manage Concurrency",
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
"manage_log_settings": "Manage log settings",
@@ -614,7 +603,7 @@
"backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
"backup_albums_sync": "Backup Albums Synchronization",
"backup_albums_sync": "Backup albums synchronization",
"backup_all": "All",
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
"backup_background_service_complete_notification": "Asset backup complete",
@@ -1009,11 +998,9 @@
"error_getting_places": "Error getting places",
"error_loading_image": "Error loading image",
"error_loading_partners": "Error loading partners: {error}",
"error_retrieving_asset_information": "Error retrieving asset information",
"error_saving_image": "Error: {error}",
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
"error_title": "Error - Something went wrong",
"error_while_navigating": "Error while navigating to asset",
"errors": {
"cannot_navigate_next_asset": "Cannot navigate to the next asset",
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
@@ -1417,28 +1404,10 @@
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu",
"maintenance_action_restore": "Restoring Database",
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
"maintenance_end": "End maintenance mode",
"maintenance_end_error": "Failed to end maintenance mode.",
"maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_restore_from_backup": "Restore From Backup",
"maintenance_restore_library": "Restore Your Library",
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
"maintenance_restore_library_description": "Restoring Database",
"maintenance_restore_library_folder_has_files": "{folder} has {count} folder(s)",
"maintenance_restore_library_folder_no_files": "{folder} is missing files!",
"maintenance_restore_library_folder_pass": "readable and writable",
"maintenance_restore_library_folder_read_fail": "not readable",
"maintenance_restore_library_folder_write_fail": "not writable",
"maintenance_restore_library_hint_missing_files": "You may be missing important files",
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
"maintenance_task_backup": "Creating a backup of the existing database…",
"maintenance_task_migrations": "Running database migrations…",
"maintenance_task_restore": "Restoring the chosen backup…",
"maintenance_task_rollback": "Restore failed, rolling back to restore point…",
"maintenance_title": "Temporarily Unavailable",
"make": "Make",
"manage_geolocation": "Manage location",
@@ -1557,7 +1526,7 @@
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.",
"no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
"no_assets_message": "Click to upload your first photo",
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
"no_assets_to_show": "No assets to show",
"no_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets",
@@ -2191,7 +2160,6 @@
"theme_setting_theme_subtitle": "Choose the app's theme setting",
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
"then": "Then",
"they_will_be_merged_together": "They will be merged together",
"third_party_resources": "Third-Party Resources",
"time": "Time",
@@ -2247,7 +2215,6 @@
"unhide_person": "Unhide person",
"unknown": "Unknown",
"unknown_country": "Unknown Country",
"unknown_date": "Unknown date",
"unknown_year": "Unknown Year",
"unlimited": "Unlimited",
"unlink_motion_video": "Unlink motion video",
@@ -2290,7 +2257,7 @@
"url": "URL",
"usage": "Usage",
"use_biometric": "Use biometric",
"use_current_connection": "Use current connection",
"use_current_connection": "use current connection",
"use_custom_date_range": "Use custom date range instead",
"user": "User",
"user_has_been_deleted": "This user has been deleted.",

View File

@@ -3,7 +3,7 @@ experimental_monorepo_root = true
[tools]
node = "24.13.0"
flutter = "3.35.7"
pnpm = "10.28.0"
pnpm = "10.27.0"
terragrunt = "0.93.10"
opentofu = "1.10.7"
java = "25.0.1"

View File

@@ -8,3 +8,5 @@ project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
)
target_link_libraries(native_buffer jnigraphics)

View File

@@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) {
android {
compileSdkVersion 35
ndkVersion = "28.1.13356709"
ndkVersion = "28.2.13676358"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -48,6 +48,7 @@ android {
}
buildFeatures {
buildConfig true
compose true
}
@@ -105,8 +106,11 @@ dependencies {
def serialization_version = '1.8.1'
def compose_version = '1.1.1'
def gson_version = '2.10.1'
def okhttp_version = '4.12.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"

View File

@@ -117,9 +117,6 @@
<data
android:host="my.immich.app"
android:pathPrefix="/memories/" />
<data
android:host="my.immich.app"
android:path="/memory" />
<data
android:host="my.immich.app"
android:pathPrefix="/photos/" />

View File

@@ -1,40 +1,38 @@
#include <jni.h>
#include <stdlib.h>
#include <string.h>
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_allocateNative(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_allocateNative(
Java_app_alextran_immich_NativeBuffer_allocate(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative(
Java_app_alextran_immich_NativeBuffer_free(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
}
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_realloc(
JNIEnv *env, jclass clazz, jlong address, jint size) {
void *ptr = realloc((void *) address, size);
return (jlong) ptr;
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_NativeBuffer_wrap(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_freeNative(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
Java_app_alextran_immich_NativeBuffer_copy(
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
void *src = (*env)->GetDirectBufferAddress(env, buffer);
if (src != NULL) {
memcpy((void *) destAddress, (char *) src + offset, length);
}
}

View File

@@ -2,6 +2,7 @@ package app.alextran.immich
import android.annotation.SuppressLint
import android.content.Context
import app.alextran.immich.core.SSLConfig
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
@@ -51,15 +52,18 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
when (call.method) {
"apply" -> {
val args = call.arguments<ArrayList<*>>()!!
val allowSelfSigned = args[0] as Boolean
val serverHost = args[1] as? String
val clientCertHash = (args[2] as? ByteArray)
var tm: Array<TrustManager>? = null
if (args[0] as Boolean) {
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
if (allowSelfSigned) {
tm = arrayOf(AllowSelfSignedTrustManager(serverHost))
}
var km: Array<KeyManager>? = null
if (args[2] != null) {
val cert = ByteArrayInputStream(args[2] as ByteArray)
if (clientCertHash != null) {
val cert = ByteArrayInputStream(clientCertHash)
val password = (args[3] as String).toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(cert, password)
@@ -69,6 +73,9 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
km = keyManagerFactory.keyManagers
}
// Update shared SSL config for OkHttp and other HTTP clients
SSLConfig.apply(km, tm, allowSelfSigned, serverHost, clientCertHash?.contentHashCode() ?: 0)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(km, tm, null)
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)

View File

@@ -10,8 +10,10 @@ import app.alextran.immich.background.BackgroundWorkerLockApi
import app.alextran.immich.connectivity.ConnectivityApi
import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.images.ThumbnailApi
import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -36,7 +38,9 @@ class MainActivity : FlutterFragmentActivity() {
NativeSyncApiImpl30(ctx)
}
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))

View File

@@ -0,0 +1,52 @@
package app.alextran.immich
import java.nio.ByteBuffer
const val INITIAL_BUFFER_SIZE = 32 * 1024
object NativeBuffer {
init {
System.loadLibrary("native_buffer")
}
@JvmStatic
external fun allocate(size: Int): Long
@JvmStatic
external fun free(address: Long)
@JvmStatic
external fun realloc(address: Long, size: Int): Long
@JvmStatic
external fun wrap(address: Long, capacity: Int): ByteBuffer
@JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
}
class NativeByteBuffer(initialCapacity: Int) {
var pointer = NativeBuffer.allocate(initialCapacity)
var capacity = initialCapacity
var offset = 0
inline fun ensureHeadroom() {
if (offset == capacity) {
capacity *= 2
pointer = NativeBuffer.realloc(pointer, capacity)
}
}
inline fun wrapRemaining() = NativeBuffer.wrap(pointer + offset, capacity - offset)
inline fun advance(bytesRead: Int) {
offset += bytesRead
}
inline fun free() {
if (pointer != 0L) {
NativeBuffer.free(pointer)
pointer = 0L
}
}
}

View File

@@ -0,0 +1,73 @@
package app.alextran.immich.core
import java.security.KeyStore
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* Shared SSL configuration for OkHttp and HttpsURLConnection.
* Stores the SSLSocketFactory and X509TrustManager configured by HttpSSLOptionsPlugin.
*/
object SSLConfig {
var sslSocketFactory: SSLSocketFactory? = null
private set
var trustManager: X509TrustManager? = null
private set
var requiresCustomSSL: Boolean = false
private set
private val listeners = mutableListOf<() -> Unit>()
private var configHash: Int = 0
fun addListener(listener: () -> Unit) {
listeners.add(listener)
}
fun apply(
keyManagers: Array<KeyManager>?,
trustManagers: Array<TrustManager>?,
allowSelfSigned: Boolean,
serverHost: String?,
clientCertHash: Int
) {
synchronized(this) {
val newHash = computeHash(allowSelfSigned, serverHost, clientCertHash)
val newRequiresCustomSSL = allowSelfSigned || keyManagers != null
if (newHash == configHash && sslSocketFactory != null && requiresCustomSSL == newRequiresCustomSSL) {
return // Config unchanged, skip
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(keyManagers, trustManagers, null)
sslSocketFactory = sslContext.socketFactory
trustManager = trustManagers?.filterIsInstance<X509TrustManager>()?.firstOrNull()
?: getDefaultTrustManager()
requiresCustomSSL = newRequiresCustomSSL
configHash = newHash
notifyListeners()
}
}
private fun computeHash(allowSelfSigned: Boolean, serverHost: String?, clientCertHash: Int): Int {
var result = allowSelfSigned.hashCode()
result = 31 * result + (serverHost?.hashCode() ?: 0)
result = 31 * result + clientCertHash
return result
}
private fun notifyListeners() {
listeners.forEach { it() }
}
private fun getDefaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
}
}

View File

@@ -13,7 +13,7 @@ import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object ThumbnailsPigeonUtils {
private object LocalImagesPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
@@ -47,7 +47,7 @@ class FlutterError (
override val message: String? = null,
val details: Any? = null
) : Throwable()
private open class ThumbnailsPigeonCodec : StandardMessageCodec() {
private open class LocalImagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
@@ -58,22 +58,22 @@ private open class ThumbnailsPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ThumbnailApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>>) -> Unit)
fun cancelImageRequest(requestId: Long)
interface LocalImageApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
companion object {
/** The codec used by ThumbnailApi. */
/** The codec used by LocalImageApi. */
val codec: MessageCodec<Any?> by lazy {
ThumbnailsPigeonCodec()
LocalImagesPigeonCodec()
}
/** Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. */
/** Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") {
fun setUp(binaryMessenger: BinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
@@ -82,13 +82,13 @@ interface ThumbnailApi {
val widthArg = args[2] as Long
val heightArg = args[3] as Long
val isVideoArg = args[4] as Boolean
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>> ->
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
reply.reply(LocalImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
reply.reply(LocalImagesPigeonUtils.wrapResult(data))
}
}
}
@@ -97,16 +97,16 @@ interface ThumbnailApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val requestIdArg = args[0] as Long
val wrapped: List<Any?> = try {
api.cancelImageRequest(requestIdArg)
api.cancelRequest(requestIdArg)
listOf(null)
} catch (exception: Throwable) {
ThumbnailsPigeonUtils.wrapError(exception)
LocalImagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -115,7 +115,7 @@ interface ThumbnailApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
@@ -123,10 +123,10 @@ interface ThumbnailApi {
api.getThumbhash(thumbhashArg) { result: Result<Map<String, Long>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
reply.reply(LocalImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
reply.reply(LocalImagesPigeonUtils.wrapResult(data))
}
}
}

View File

@@ -11,7 +11,8 @@ import android.os.OperationCanceledException
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
import java.nio.ByteBuffer
import androidx.annotation.RequiresApi
import app.alextran.immich.NativeBuffer
import kotlin.math.*
import java.util.concurrent.Executors
import com.bumptech.glide.Glide
@@ -26,10 +27,42 @@ import java.util.concurrent.Future
data class Request(
val taskFuture: Future<*>,
val cancellationSignal: CancellationSignal,
val callback: (Result<Map<String, Long>>) -> Unit
val callback: (Result<Map<String, Long>?>) -> Unit
)
class ThumbnailsImpl(context: Context) : ThumbnailApi {
@RequiresApi(Build.VERSION_CODES.Q)
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 = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
}
fun Bitmap.toNativeBuffer(): Map<String, Long> {
val size = width * height * 4
val pointer = NativeBuffer.allocate(size)
try {
val buffer = NativeBuffer.wrap(pointer, size)
copyPixelsToBuffer(buffer)
recycle()
return mapOf(
"pointer" to pointer,
"width" to width.toLong(),
"height" to height.toLong(),
"rowBytes" to (width * 4).toLong()
)
} catch (e: Exception) {
NativeBuffer.free(pointer)
recycle()
throw e
}
}
class LocalImagesImpl(context: Context) : LocalImageApi {
private val ctx: Context = context.applicationContext
private val resolver: ContentResolver = ctx.contentResolver
private val requestThread = Executors.newSingleThreadExecutor()
@@ -38,21 +71,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
private val requestMap = ConcurrentHashMap<Long, Request>()
companion object {
val CANCELLED = Result.success<Map<String, Long>>(mapOf())
val CANCELLED = Result.success<Map<String, Long>?>(null)
val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
init {
System.loadLibrary("native_buffer")
}
@JvmStatic
external fun allocateNative(size: Int): Long
@JvmStatic
external fun freeNative(pointer: Long)
@JvmStatic
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
}
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
@@ -63,7 +83,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
val res = mapOf(
"pointer" to image.pointer,
"width" to image.width.toLong(),
"height" to image.height.toLong()
"height" to image.height.toLong(),
"rowBytes" to (image.width * 4).toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
@@ -78,7 +99,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
val task = threadPool.submit {
@@ -98,7 +119,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
requestMap[requestId] = request
}
override fun cancelImageRequest(requestId: Long) {
override fun cancelRequest(requestId: Long) {
val request = requestMap.remove(requestId) ?: return
request.taskFuture.cancel(false)
request.cancellationSignal.cancel()
@@ -117,7 +138,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit,
callback: (Result<Map<String, Long>?>) -> Unit,
signal: CancellationSignal
) {
signal.throwIfCanceled()
@@ -131,31 +152,12 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
decodeImage(id, size, signal)
}
processBitmap(bitmap, callback, signal)
}
private fun processBitmap(
bitmap: Bitmap, callback: (Result<Map<String, Long>>) -> Unit, signal: CancellationSignal
) {
signal.throwIfCanceled()
val actualWidth = bitmap.width
val actualHeight = bitmap.height
val size = actualWidth * actualHeight * 4
val pointer = allocateNative(size)
try {
signal.throwIfCanceled()
val buffer = wrapAsBuffer(pointer, size)
bitmap.copyPixelsToBuffer(buffer)
bitmap.recycle()
val res = bitmap.toNativeBuffer()
signal.throwIfCanceled()
val res = mapOf(
"pointer" to pointer, "width" to actualWidth.toLong(), "height" to actualHeight.toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
freeNative(pointer)
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
}
}
@@ -191,16 +193,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val source = ImageDecoder.createSource(resolver, uri)
signal.throwIfCanceled()
ImageDecoder.decodeBitmap(source) { 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 = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(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

@@ -0,0 +1,104 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.images
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object RemoteImagesPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}
private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface RemoteImageApi {
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
companion object {
/** The codec used by RemoteImageApi. */
val codec: MessageCodec<Any?> by lazy {
RemoteImagesPigeonCodec()
}
/** Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val urlArg = args[0] as String
val headersArg = args[1] as Map<String, String>
val requestIdArg = args[2] as Long
api.requestImage(urlArg, headersArg, requestIdArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(RemoteImagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val requestIdArg = args[0] as Long
val wrapped: List<Any?> = try {
api.cancelRequest(requestIdArg)
listOf(null)
} catch (exception: Throwable) {
RemoteImagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -0,0 +1,432 @@
package app.alextran.immich.images
import android.content.Context
import android.os.CancellationSignal
import android.os.OperationCanceledException
import app.alextran.immich.BuildConfig
import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.SSLConfig
import okhttp3.Cache
import okhttp3.Call
import okhttp3.Callback
import okhttp3.ConnectionPool
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import java.io.EOFException
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
private const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
private const val MAX_REQUESTS_PER_HOST = 64
private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
class RemoteImagesImpl(context: Context) : RemoteImageApi {
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
init {
ImageFetcherManager.initialize(context)
}
companion object {
val CANCELLED = Result.success<Map<String, Long>?>(null)
}
override fun requestImage(
url: String,
headers: Map<String, String>,
requestId: Long,
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
requestMap[requestId] = RemoteRequest(signal)
ImageFetcherManager.fetch(
url,
headers,
signal,
onSuccess = { buffer ->
requestMap.remove(requestId)
if (signal.isCanceled) {
NativeBuffer.free(buffer.pointer)
return@fetch callback(CANCELLED)
}
callback(
Result.success(
mapOf(
"pointer" to buffer.pointer,
"length" to buffer.offset.toLong()
)
)
)
},
onFailure = { e ->
requestMap.remove(requestId)
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
callback(result)
}
)
}
override fun cancelRequest(requestId: Long) {
requestMap.remove(requestId)?.cancellationSignal?.cancel()
}
}
private object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
private var initialized = false
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
cacheDir = context.cacheDir
fetcher = build()
SSLConfig.addListener(::invalidate)
initialized = true
}
}
fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
fetcher.fetch(url, headers, signal, onSuccess, onFailure)
}
private fun invalidate() {
synchronized(this) {
val oldFetcher = fetcher
if (oldFetcher is OkHttpImageFetcher && SSLConfig.requiresCustomSSL) {
fetcher = oldFetcher.reconfigure(SSLConfig.sslSocketFactory, SSLConfig.trustManager)
return
}
fetcher = build()
oldFetcher.drain()
}
}
private fun build(): ImageFetcher {
return if (SSLConfig.requiresCustomSSL) {
OkHttpImageFetcher.create(cacheDir, SSLConfig.sslSocketFactory, SSLConfig.trustManager)
} else {
CronetImageFetcher(appContext, cacheDir)
}
}
}
private sealed interface ImageFetcher {
fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
)
fun drain()
}
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
private val stateLock = Any()
private var activeCount = 0
private var draining = false
init {
val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
engine = CronetEngine.Builder(context)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(storageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
.build()
}
override fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
synchronized(stateLock) {
if (draining) {
onFailure(IllegalStateException("Engine is draining"))
return
}
activeCount++
}
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
request.start()
}
private fun onComplete() {
val shouldShutdown = synchronized(stateLock) {
activeCount--
draining && activeCount == 0
}
if (shouldShutdown) {
engine.shutdown()
executor.shutdown()
}
}
override fun drain() {
val shouldShutdown = synchronized(stateLock) {
if (draining) return
draining = true
activeCount == 0
}
if (shouldShutdown) {
engine.shutdown()
executor.shutdown()
}
}
private class FetchCallback(
private val onSuccess: (NativeByteBuffer) -> Unit,
private val onFailure: (Exception) -> Unit,
private val onComplete: () -> Unit,
) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null
private var wrapped: ByteBuffer? = null
private var httpError: IOException? = null
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
request.followRedirect()
}
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
if (info.httpStatusCode !in 200..299) {
httpError = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
return request.cancel()
}
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
if (contentLength > 0) {
buffer = NativeByteBuffer(contentLength + 1)
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
request.read(wrapped)
} else {
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
request.read(buffer!!.wrapRemaining())
}
}
override fun onReadCompleted(
request: UrlRequest,
info: UrlResponseInfo,
byteBuffer: ByteBuffer
) {
val buf = if (wrapped == null) {
buffer!!.run {
advance(byteBuffer.position())
ensureHeadroom()
wrapRemaining()
}
} else {
wrapped
}
request.read(buf)
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
wrapped?.let { buffer!!.advance(it.position()) }
onSuccess(buffer!!)
onComplete()
}
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
buffer?.free()
onFailure(error)
onComplete()
}
override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) {
buffer?.free()
onFailure(httpError ?: OperationCanceledException())
onComplete()
}
}
}
private class OkHttpImageFetcher private constructor(
private val client: OkHttpClient,
) : ImageFetcher {
private val stateLock = Any()
private var activeCount = 0
private var draining = false
companion object {
fun create(
cacheDir: File,
sslSocketFactory: SSLSocketFactory?,
trustManager: X509TrustManager?,
): OkHttpImageFetcher {
val dir = File(cacheDir, "okhttp")
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES,
timeUnit = TimeUnit.MINUTES
)
val builder = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.header("User-Agent", USER_AGENT)
.build()
)
}
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
.connectionPool(connectionPool)
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
if (sslSocketFactory != null && trustManager != null) {
builder.sslSocketFactory(sslSocketFactory, trustManager)
}
return OkHttpImageFetcher(builder.build())
}
}
fun reconfigure(
sslSocketFactory: SSLSocketFactory?,
trustManager: X509TrustManager?,
): OkHttpImageFetcher {
val builder = client.newBuilder()
if (sslSocketFactory != null && trustManager != null) {
builder.sslSocketFactory(sslSocketFactory, trustManager)
}
// Evict idle connections using old SSL config
client.connectionPool.evictAll()
return OkHttpImageFetcher(builder.build())
}
private fun onComplete() {
val shouldClose = synchronized(stateLock) {
activeCount--
draining && activeCount == 0
}
if (shouldClose) {
client.cache?.close()
}
}
override fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
synchronized(stateLock) {
if (draining) {
return onFailure(IllegalStateException("Client is draining"))
}
activeCount++
}
val requestBuilder = Request.Builder().url(url)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val call = client.newCall(requestBuilder.build())
signal.setOnCancelListener(call::cancel)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
onFailure(e)
onComplete()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
return onFailure(IOException("HTTP ${response.code}: ${response.message}")).also { onComplete() }
}
val body = response.body
?: return onFailure(IOException("Empty response body")).also { onComplete() }
if (call.isCanceled()) {
onFailure(OperationCanceledException())
return onComplete()
}
body.source().use { source ->
val length = body.contentLength().toInt()
val buffer = NativeByteBuffer(if (length > 0) length else INITIAL_BUFFER_SIZE)
try {
if (length > 0) {
val wrapped = NativeBuffer.wrap(buffer.pointer, length)
while (wrapped.hasRemaining()) {
if (call.isCanceled()) throw OperationCanceledException()
if (source.read(wrapped) == -1) throw EOFException()
}
buffer.advance(length)
} else {
while (true) {
if (call.isCanceled()) throw OperationCanceledException()
val bytesRead = source.read(buffer.wrapRemaining())
if (bytesRead == -1) break
buffer.advance(bytesRead)
buffer.ensureHeadroom()
}
}
onSuccess(buffer)
} catch (e: Exception) {
buffer.free()
onFailure(e)
}
}
}
}
})
}
override fun drain() {
val shouldClose = synchronized(stateLock) {
if (draining) return
draining = true
activeCount == 0
}
client.connectionPool.evictAll()
if (shouldClose) {
client.cache?.close()
}
}
}

View File

@@ -7,6 +7,8 @@ package app.alextran.immich.images;
import java.nio.ByteBuffer;
import app.alextran.immich.NativeBuffer;
// modified to use native allocations
public final class ThumbHash {
/**
@@ -56,8 +58,8 @@ public final class ThumbHash {
int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
int size = w * h * 4;
long pointer = ThumbnailsImpl.allocateNative(size);
ByteBuffer rgba = ThumbnailsImpl.wrapAsBuffer(pointer, size);
long pointer = NativeBuffer.allocate(size);
ByteBuffer rgba = NativeBuffer.wrap(pointer, size);
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
float[] fx = new float[cx_stop];

File diff suppressed because one or more lines are too long

View File

@@ -6,6 +6,9 @@ PODS:
- FlutterMacOS
- connectivity_plus (0.0.1):
- Flutter
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
@@ -136,6 +139,7 @@ DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
@@ -184,6 +188,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
@@ -249,6 +255,7 @@ SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -29,9 +29,11 @@
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F12F1197D8006016CB /* LocalImages.g.swift */; };
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F22F1197D8006016CB /* RemoteImages.g.swift */; };
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; };
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; };
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
@@ -118,9 +120,11 @@
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
FE5499F12F1197D8006016CB /* LocalImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImages.g.swift; sourceTree = "<group>"; };
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImages.g.swift; sourceTree = "<group>"; };
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = "<group>"; };
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -136,15 +140,11 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -158,8 +158,6 @@
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
@@ -321,9 +319,11 @@
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */,
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
);
path = Images;
sourceTree = "<group>";
@@ -549,10 +549,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -581,10 +585,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -600,12 +608,14 @@
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,

View File

@@ -53,7 +53,8 @@ import UIKit
public static func registerPlugins(with engine: FlutterEngine) {
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
}

View File

@@ -47,41 +47,41 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
}
private class ThumbnailsPigeonCodecReader: FlutterStandardReader {
private class LocalImagesPigeonCodecReader: FlutterStandardReader {
}
private class ThumbnailsPigeonCodecWriter: FlutterStandardWriter {
private class LocalImagesPigeonCodecWriter: FlutterStandardWriter {
}
private class ThumbnailsPigeonCodecReaderWriter: FlutterStandardReaderWriter {
private class LocalImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return ThumbnailsPigeonCodecReader(data: data)
return LocalImagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return ThumbnailsPigeonCodecWriter(data: data)
return LocalImagesPigeonCodecWriter(data: data)
}
}
class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = ThumbnailsPigeonCodec(readerWriter: ThumbnailsPigeonCodecReaderWriter())
class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = LocalImagesPigeonCodec(readerWriter: LocalImagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol ThumbnailApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], Error>) -> Void)
func cancelImageRequest(requestId: Int64) throws
protocol LocalImageApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class ThumbnailApiSetup {
static var codec: FlutterStandardMessageCodec { ThumbnailsPigeonCodec.shared }
/// Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") {
class LocalImageApiSetup {
static var codec: FlutterStandardMessageCodec { LocalImagesPigeonCodec.shared }
/// Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
@@ -102,22 +102,22 @@ class ThumbnailApiSetup {
} else {
requestImageChannel.setMessageHandler(nil)
}
let cancelImageRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelImageRequestChannel.setMessageHandler { message, reply in
cancelRequestChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let requestIdArg = args[0] as! Int64
do {
try api.cancelImageRequest(requestId: requestIdArg)
try api.cancelRequest(requestId: requestIdArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelImageRequestChannel.setMessageHandler(nil)
cancelRequestChannel.setMessageHandler(nil)
}
let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getThumbhashChannel.setMessageHandler { message, reply in
let args = message as! [Any?]

View File

@@ -1,19 +1,19 @@
import CryptoKit
import Accelerate
import Flutter
import MobileCoreServices
import Photos
class Request {
class LocalImageRequest {
weak var workItem: DispatchWorkItem?
var isCancelled = false
let callback: (Result<[String: Int64], any Error>) -> Void
let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64], any Error>) -> Void) {
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback
}
}
class ThumbnailApiImpl: ThumbnailApi {
class LocalImageApiImpl: LocalImageApi {
private static let imageManager = PHImageManager.default()
private static let fetchOptions = {
let fetchOptions = PHFetchOptions()
@@ -36,47 +36,39 @@ class ThumbnailApiImpl: ThumbnailApi {
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
private static var requests = [Int64: Request]()
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .defaultIntent
)!
private static var requests = [Int64: LocalImageRequest]()
private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
return assetCache
}()
private static let activitySemaphore = DispatchSemaphore(value: 1)
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.suspend()
activitySemaphore.wait()
}
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.resume()
activitySemaphore.signal()
}
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
"width": Int64(width),
"height": Int64(height),
"rowBytes": Int64(width * 4)
]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let request = Request(callback: completion)
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
let request = LocalImageRequest(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(Self.cancelledResult)
@@ -93,7 +85,7 @@ class ThumbnailApiImpl: ThumbnailApi {
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.removeRequest(requestId: requestId)
Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
@@ -119,70 +111,54 @@ class ThumbnailApiImpl: ThumbnailApi {
guard let image = image,
let cgImage = image.cgImage else {
Self.removeRequest(requestId: requestId)
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
alignment: MemoryLayout<UInt8>.alignment
)
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
guard let context = CGContext(
data: pointer,
width: cgImage.width,
height: cgImage.height,
bitsPerComponent: 8,
bytesPerRow: cgImage.width * 4,
space: Self.rgbColorSpace,
bitmapInfo: Self.bitmapInfo
) else {
pointer.deallocate()
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
return completion(Self.cancelledResult)
}
request.callback(.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
]))
print("Successful response for \(requestId)")
Self.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
}
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
context.interpolationQuality = .none
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId)
}
request.workItem = item
Self.addRequest(requestId: requestId, request: request)
Self.add(requestId: requestId, request: request)
Self.processingQueue.async(execute: item)
}
func cancelImageRequest(requestId: Int64) {
Self.cancelRequest(requestId: requestId)
func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId)
}
private static func addRequest(requestId: Int64, request: Request) -> Void {
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func removeRequest(requestId: Int64) -> Void {
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancelRequest(requestId: Int64) -> Void {
private static func cancel(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
@@ -203,9 +179,4 @@ class ThumbnailApiImpl: ThumbnailApi {
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset
}
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
}

View File

@@ -0,0 +1,118 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
return [
pigeonError.code,
pigeonError.message,
pigeonError.details,
]
}
if let flutterError = error as? FlutterError {
return [
flutterError.code,
flutterError.message,
flutterError.details,
]
}
return [
"\(error)",
"\(type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
private class RemoteImagesPigeonCodecReader: FlutterStandardReader {
}
private class RemoteImagesPigeonCodecWriter: FlutterStandardWriter {
}
private class RemoteImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return RemoteImagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return RemoteImagesPigeonCodecWriter(data: data)
}
}
class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = RemoteImagesPigeonCodec(readerWriter: RemoteImagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi {
func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class RemoteImageApiSetup {
static var codec: FlutterStandardMessageCodec { RemoteImagesPigeonCodec.shared }
/// Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let urlArg = args[0] as! String
let headersArg = args[1] as! [String: String]
let requestIdArg = args[2] as! Int64
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestImageChannel.setMessageHandler(nil)
}
let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelRequestChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let requestIdArg = args[0] as! Int64
do {
try api.cancelRequest(requestId: requestIdArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelRequestChannel.setMessageHandler(nil)
}
}
}

View File

@@ -0,0 +1,171 @@
import Accelerate
import Flutter
import MobileCoreServices
import Photos
class RemoteImageRequest {
weak var task: URLSessionDataTask?
let id: Int64
var isCancelled = false
var data: CFMutableData?
let completion: (Result<[String: Int64]?, any Error>) -> Void
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.id = id
self.task = task
self.data = nil
self.completion = completion
}
}
class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static let delegate = RemoteImageApiDelegate()
static let session = {
let config = URLSessionConfiguration.default
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
let thumbnailPath = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true)
try! FileManager.default.createDirectory(at: thumbnailPath, withIntermediateDirectories: true)
config.urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1 << 30,
directory: thumbnailPath
)
config.httpMaximumConnectionsPerHost = 16
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}()
func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!)
for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
let task = Self.session.dataTask(with: urlRequest)
let imageRequest = RemoteImageRequest(id: requestId, task: task, completion: completion)
Self.delegate.add(taskId: task.taskIdentifier, request: imageRequest)
task.resume()
}
func cancelRequest(requestId: Int64) {
Self.delegate.cancel(requestId: requestId)
}
}
class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated, attributes: .concurrent)
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .perceptual
)!
private static var requestByTaskId = [Int: RemoteImageRequest]()
private static var taskIdByRequestId = [Int64: Int]()
private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
private static let decodeOptions = [
kCGImageSourceShouldCache: false,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
] as CFDictionary
func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let request = get(taskId: dataTask.taskIdentifier)
else {
return completionHandler(.cancel)
}
let capacity = max(Int(response.expectedContentLength), 0)
request.data = CFDataCreateMutable(nil, capacity)
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
didReceive data: Data) {
guard let request = get(taskId: dataTask.taskIdentifier) else { return }
data.withUnsafeBytes { bytes in
CFDataAppendBytes(request.data, bytes.bindMemory(to: UInt8.self).baseAddress, data.count)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask,
didCompleteWithError error: Error?) {
guard let request = get(taskId: task.taskIdentifier) else { return }
defer { remove(taskId: task.taskIdentifier, requestId: request.id) }
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
return request.completion(Self.cancelledResult)
}
return request.completion(.failure(error))
}
guard let data = request.data else {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
guard let imageSource = CGImageSourceCreateWithData(data, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, Self.decodeOptions) else {
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
}
if request.isCancelled {
return request.completion(Self.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
return request.completion(Self.cancelledResult)
}
request.completion(
.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes),
]))
} catch {
return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil)))
}
}
@inline(__always) func get(taskId: Int) -> RemoteImageRequest? {
Self.requestQueue.sync { Self.requestByTaskId[taskId] }
}
@inline(__always) func add(taskId: Int, request: RemoteImageRequest) -> Void {
Self.requestQueue.async(flags: .barrier) {
Self.requestByTaskId[taskId] = request
Self.taskIdByRequestId[request.id] = taskId
}
}
@inline(__always) func remove(taskId: Int, requestId: Int64) -> Void {
Self.requestQueue.async(flags: .barrier) {
Self.taskIdByRequestId[requestId] = nil
Self.requestByTaskId[taskId] = nil
}
}
@inline(__always) func cancel(requestId: Int64) -> Void {
guard let request: RemoteImageRequest = (Self.requestQueue.sync {
guard let taskId = Self.taskIdByRequestId[requestId] else { return nil }
return Self.requestByTaskId[taskId]
}) else { return }
request.isCancelled = true
request.task?.cancel()
}
}

View File

@@ -22,7 +22,6 @@ sealed class BaseAsset {
final int? durationInSeconds;
final bool isFavorite;
final String? livePhotoVideoId;
final bool isEdited;
const BaseAsset({
required this.name,
@@ -35,7 +34,6 @@ sealed class BaseAsset {
this.durationInSeconds,
this.isFavorite = false,
this.livePhotoVideoId,
required this.isEdited,
});
bool get isImage => type == AssetType.image;
@@ -73,7 +71,6 @@ sealed class BaseAsset {
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
isFavorite: $isFavorite,
isEdited: $isEdited,
}''';
}
@@ -88,8 +85,7 @@ sealed class BaseAsset {
width == other.width &&
height == other.height &&
durationInSeconds == other.durationInSeconds &&
isFavorite == other.isFavorite &&
isEdited == other.isEdited;
isFavorite == other.isFavorite;
}
return false;
}
@@ -103,7 +99,6 @@ sealed class BaseAsset {
width.hashCode ^
height.hashCode ^
durationInSeconds.hashCode ^
isFavorite.hashCode ^
isEdited.hashCode;
isFavorite.hashCode;
}
}

View File

@@ -28,7 +28,6 @@ class LocalAsset extends BaseAsset {
this.adjustmentTime,
this.latitude,
this.longitude,
required super.isEdited,
}) : remoteAssetId = remoteId;
@override
@@ -108,7 +107,6 @@ class LocalAsset extends BaseAsset {
DateTime? adjustmentTime,
double? latitude,
double? longitude,
bool? isEdited,
}) {
return LocalAsset(
id: id ?? this.id,
@@ -127,7 +125,6 @@ class LocalAsset extends BaseAsset {
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited,
);
}
}

View File

@@ -28,7 +28,6 @@ class RemoteAsset extends BaseAsset {
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.stackId,
required super.isEdited,
}) : localAssetId = localId;
@override
@@ -105,7 +104,6 @@ class RemoteAsset extends BaseAsset {
AssetVisibility? visibility,
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -124,7 +122,6 @@ class RemoteAsset extends BaseAsset {
visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
);
}
}

View File

@@ -436,6 +436,5 @@ extension PlatformToLocalAsset on PlatformAsset {
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
isEdited: false,
);
}

View File

@@ -77,7 +77,6 @@ extension on AssetResponseDto {
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
isEdited: isEdited,
);
}
}

View File

@@ -247,42 +247,6 @@ class SyncStreamService {
}
}
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 {
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);
}
}
if (assets.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
_logger.info('Successfully processed ${assets.length} edited assets');
}
} catch (error, stackTrace) {
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
}
}
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return Future.value();

View File

@@ -196,16 +196,6 @@ class BackgroundSyncManager {
});
}
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future;
@@ -241,8 +231,3 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData),
debugLabel: 'websocket-batch',
);
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
debugLabel: 'websocket-edit',
);

View File

@@ -47,6 +47,5 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
latitude: latitude,
longitude: longitude,
cloudId: iCloudId,
isEdited: false,
);
}

View File

@@ -25,8 +25,7 @@ SELECT
NULL as i_cloud_id,
NULL as latitude,
NULL as longitude,
NULL as adjustmentTime,
rae.is_edited
NULL as adjustmentTime
FROM
remote_asset_entity rae
LEFT JOIN
@@ -62,8 +61,7 @@ SELECT
lae.i_cloud_id,
lae.latitude,
lae.longitude,
lae.adjustment_time,
0 as is_edited
lae.adjustment_time
FROM
local_asset_entity lae
WHERE NOT EXISTS (

View File

@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -66,7 +66,6 @@ class MergedAssetDrift extends i1.ModularAccessor {
latitude: row.readNullable<double>('latitude'),
longitude: row.readNullable<double>('longitude'),
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
isEdited: row.read<bool>('is_edited'),
),
);
}
@@ -138,7 +137,6 @@ class MergedAssetResult {
final double? latitude;
final double? longitude;
final DateTime? adjustmentTime;
final bool isEdited;
MergedAssetResult({
this.remoteId,
this.localId,
@@ -160,7 +158,6 @@ class MergedAssetResult {
this.latitude,
this.longitude,
this.adjustmentTime,
required this.isEdited,
});
}

View File

@@ -44,8 +44,6 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
TextColumn get libraryId => text().nullable()();
BoolColumn get isEdited => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
@@ -68,6 +66,5 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
livePhotoVideoId: livePhotoVideoId,
localId: localId,
stackId: stackId,
isEdited: isEdited,
);
}

View File

@@ -31,7 +31,6 @@ typedef $$RemoteAssetEntityTableCreateCompanionBuilder =
required i2.AssetVisibility visibility,
i0.Value<String?> stackId,
i0.Value<String?> libraryId,
i0.Value<bool> isEdited,
});
typedef $$RemoteAssetEntityTableUpdateCompanionBuilder =
i1.RemoteAssetEntityCompanion Function({
@@ -53,7 +52,6 @@ typedef $$RemoteAssetEntityTableUpdateCompanionBuilder =
i0.Value<i2.AssetVisibility> visibility,
i0.Value<String?> stackId,
i0.Value<String?> libraryId,
i0.Value<bool> isEdited,
});
final class $$RemoteAssetEntityTableReferences
@@ -198,11 +196,6 @@ class $$RemoteAssetEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<bool> get isEdited => $composableBuilder(
column: $table.isEdited,
builder: (column) => i0.ColumnFilters(column),
);
i5.$$UserEntityTableFilterComposer get ownerId {
final i5.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
@@ -325,11 +318,6 @@ class $$RemoteAssetEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<bool> get isEdited => $composableBuilder(
column: $table.isEdited,
builder: (column) => i0.ColumnOrderings(column),
);
i5.$$UserEntityTableOrderingComposer get ownerId {
final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
@@ -429,9 +417,6 @@ class $$RemoteAssetEntityTableAnnotationComposer
i0.GeneratedColumn<String> get libraryId =>
$composableBuilder(column: $table.libraryId, builder: (column) => column);
i0.GeneratedColumn<bool> get isEdited =>
$composableBuilder(column: $table.isEdited, builder: (column) => column);
i5.$$UserEntityTableAnnotationComposer get ownerId {
final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
@@ -512,7 +497,6 @@ class $$RemoteAssetEntityTableTableManager
const i0.Value.absent(),
i0.Value<String?> stackId = const i0.Value.absent(),
i0.Value<String?> libraryId = const i0.Value.absent(),
i0.Value<bool> isEdited = const i0.Value.absent(),
}) => i1.RemoteAssetEntityCompanion(
name: name,
type: type,
@@ -532,7 +516,6 @@ class $$RemoteAssetEntityTableTableManager
visibility: visibility,
stackId: stackId,
libraryId: libraryId,
isEdited: isEdited,
),
createCompanionCallback:
({
@@ -554,7 +537,6 @@ class $$RemoteAssetEntityTableTableManager
required i2.AssetVisibility visibility,
i0.Value<String?> stackId = const i0.Value.absent(),
i0.Value<String?> libraryId = const i0.Value.absent(),
i0.Value<bool> isEdited = const i0.Value.absent(),
}) => i1.RemoteAssetEntityCompanion.insert(
name: name,
type: type,
@@ -574,7 +556,6 @@ class $$RemoteAssetEntityTableTableManager
visibility: visibility,
stackId: stackId,
libraryId: libraryId,
isEdited: isEdited,
),
withReferenceMapper: (p0) => p0
.map(
@@ -863,21 +844,6 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _isEditedMeta = const i0.VerificationMeta(
'isEdited',
);
@override
late final i0.GeneratedColumn<bool> isEdited = i0.GeneratedColumn<bool>(
'is_edited',
aliasedName,
false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_edited" IN (0, 1))',
),
defaultValue: const i4.Constant(false),
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
@@ -898,7 +864,6 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
visibility,
stackId,
libraryId,
isEdited,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1022,12 +987,6 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
libraryId.isAcceptableOrUnknown(data['library_id']!, _libraryIdMeta),
);
}
if (data.containsKey('is_edited')) {
context.handle(
_isEditedMeta,
isEdited.isAcceptableOrUnknown(data['is_edited']!, _isEditedMeta),
);
}
return context;
}
@@ -1116,10 +1075,6 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
i0.DriftSqlType.string,
data['${effectivePrefix}library_id'],
),
isEdited: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}is_edited'],
)!,
);
}
@@ -1160,7 +1115,6 @@ class RemoteAssetEntityData extends i0.DataClass
final i2.AssetVisibility visibility;
final String? stackId;
final String? libraryId;
final bool isEdited;
const RemoteAssetEntityData({
required this.name,
required this.type,
@@ -1180,7 +1134,6 @@ class RemoteAssetEntityData extends i0.DataClass
required this.visibility,
this.stackId,
this.libraryId,
required this.isEdited,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -1229,7 +1182,6 @@ class RemoteAssetEntityData extends i0.DataClass
if (!nullToAbsent || libraryId != null) {
map['library_id'] = i0.Variable<String>(libraryId);
}
map['is_edited'] = i0.Variable<bool>(isEdited);
return map;
}
@@ -1261,7 +1213,6 @@ class RemoteAssetEntityData extends i0.DataClass
),
stackId: serializer.fromJson<String?>(json['stackId']),
libraryId: serializer.fromJson<String?>(json['libraryId']),
isEdited: serializer.fromJson<bool>(json['isEdited']),
);
}
@override
@@ -1290,7 +1241,6 @@ class RemoteAssetEntityData extends i0.DataClass
),
'stackId': serializer.toJson<String?>(stackId),
'libraryId': serializer.toJson<String?>(libraryId),
'isEdited': serializer.toJson<bool>(isEdited),
};
}
@@ -1313,7 +1263,6 @@ class RemoteAssetEntityData extends i0.DataClass
i2.AssetVisibility? visibility,
i0.Value<String?> stackId = const i0.Value.absent(),
i0.Value<String?> libraryId = const i0.Value.absent(),
bool? isEdited,
}) => i1.RemoteAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -1339,7 +1288,6 @@ class RemoteAssetEntityData extends i0.DataClass
visibility: visibility ?? this.visibility,
stackId: stackId.present ? stackId.value : this.stackId,
libraryId: libraryId.present ? libraryId.value : this.libraryId,
isEdited: isEdited ?? this.isEdited,
);
RemoteAssetEntityData copyWithCompanion(i1.RemoteAssetEntityCompanion data) {
return RemoteAssetEntityData(
@@ -1371,7 +1319,6 @@ class RemoteAssetEntityData extends i0.DataClass
: this.visibility,
stackId: data.stackId.present ? data.stackId.value : this.stackId,
libraryId: data.libraryId.present ? data.libraryId.value : this.libraryId,
isEdited: data.isEdited.present ? data.isEdited.value : this.isEdited,
);
}
@@ -1395,8 +1342,7 @@ class RemoteAssetEntityData extends i0.DataClass
..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility, ')
..write('stackId: $stackId, ')
..write('libraryId: $libraryId, ')
..write('isEdited: $isEdited')
..write('libraryId: $libraryId')
..write(')'))
.toString();
}
@@ -1421,7 +1367,6 @@ class RemoteAssetEntityData extends i0.DataClass
visibility,
stackId,
libraryId,
isEdited,
);
@override
bool operator ==(Object other) =>
@@ -1444,8 +1389,7 @@ class RemoteAssetEntityData extends i0.DataClass
other.livePhotoVideoId == this.livePhotoVideoId &&
other.visibility == this.visibility &&
other.stackId == this.stackId &&
other.libraryId == this.libraryId &&
other.isEdited == this.isEdited);
other.libraryId == this.libraryId);
}
class RemoteAssetEntityCompanion
@@ -1468,7 +1412,6 @@ class RemoteAssetEntityCompanion
final i0.Value<i2.AssetVisibility> visibility;
final i0.Value<String?> stackId;
final i0.Value<String?> libraryId;
final i0.Value<bool> isEdited;
const RemoteAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1488,7 +1431,6 @@ class RemoteAssetEntityCompanion
this.visibility = const i0.Value.absent(),
this.stackId = const i0.Value.absent(),
this.libraryId = const i0.Value.absent(),
this.isEdited = const i0.Value.absent(),
});
RemoteAssetEntityCompanion.insert({
required String name,
@@ -1509,7 +1451,6 @@ class RemoteAssetEntityCompanion
required i2.AssetVisibility visibility,
this.stackId = const i0.Value.absent(),
this.libraryId = const i0.Value.absent(),
this.isEdited = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id),
@@ -1535,7 +1476,6 @@ class RemoteAssetEntityCompanion
i0.Expression<int>? visibility,
i0.Expression<String>? stackId,
i0.Expression<String>? libraryId,
i0.Expression<bool>? isEdited,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1556,7 +1496,6 @@ class RemoteAssetEntityCompanion
if (visibility != null) 'visibility': visibility,
if (stackId != null) 'stack_id': stackId,
if (libraryId != null) 'library_id': libraryId,
if (isEdited != null) 'is_edited': isEdited,
});
}
@@ -1579,7 +1518,6 @@ class RemoteAssetEntityCompanion
i0.Value<i2.AssetVisibility>? visibility,
i0.Value<String?>? stackId,
i0.Value<String?>? libraryId,
i0.Value<bool>? isEdited,
}) {
return i1.RemoteAssetEntityCompanion(
name: name ?? this.name,
@@ -1600,7 +1538,6 @@ class RemoteAssetEntityCompanion
visibility: visibility ?? this.visibility,
stackId: stackId ?? this.stackId,
libraryId: libraryId ?? this.libraryId,
isEdited: isEdited ?? this.isEdited,
);
}
@@ -1665,9 +1602,6 @@ class RemoteAssetEntityCompanion
if (libraryId.present) {
map['library_id'] = i0.Variable<String>(libraryId.value);
}
if (isEdited.present) {
map['is_edited'] = i0.Variable<bool>(isEdited.value);
}
return map;
}
@@ -1691,8 +1625,7 @@ class RemoteAssetEntityCompanion
..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility, ')
..write('stackId: $stackId, ')
..write('libraryId: $libraryId, ')
..write('isEdited: $isEdited')
..write('libraryId: $libraryId')
..write(')'))
.toString();
}

View File

@@ -45,6 +45,5 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
height: height,
width: width,
orientation: orientation,
isEdited: false,
);
}

View File

@@ -1,15 +1,12 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ffi/ffi.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:logging/logging.dart';
part 'local_image_request.dart';
part 'thumbhash_image_request.dart';
@@ -37,27 +34,53 @@ abstract class ImageRequest {
void _onCancelled();
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
final address = info['pointer'];
if (address == null) {
return null;
}
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {
malloc.free(pointer);
return null;
}
final int actualWidth;
final int actualHeight;
final int actualSize;
final ui.ImmutableBuffer buffer;
try {
actualWidth = info['width']!;
actualHeight = info['height']!;
actualSize = actualWidth * actualHeight * 4;
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(length));
} finally {
malloc.free(pointer);
}
if (_isCancelled) {
buffer.dispose();
return null;
}
final descriptor = await ui.ImageDescriptor.encoded(buffer);
if (_isCancelled) {
buffer.dispose();
descriptor.dispose();
return null;
}
final codec = await descriptor.instantiateCodec();
if (_isCancelled) {
buffer.dispose();
descriptor.dispose();
codec.dispose();
return null;
}
return await codec.getNextFrame();
}
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {
malloc.free(pointer);
return null;
}
final size = rowBytes * height;
final ui.ImmutableBuffer buffer;
try {
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(size));
} finally {
malloc.free(pointer);
}
@@ -69,8 +92,9 @@ abstract class ImageRequest {
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: actualWidth,
height: actualHeight,
width: width,
height: height,
rowBytes: rowBytes,
pixelFormat: ui.PixelFormat.rgba8888,
);
final codec = await descriptor.instantiateCodec();

View File

@@ -16,20 +16,23 @@ class LocalImageRequest extends ImageRequest {
return null;
}
final Map<String, int> info = await thumbnailApi.requestImage(
final info = await localImageApi.requestImage(
localId,
requestId: requestId,
width: width,
height: height,
isVideo: assetType == AssetType.video,
);
if (info == null) {
return null;
}
final frame = await _fromPlatformImage(info);
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
Future<void> _onCancelled() {
return thumbnailApi.cancelImageRequest(requestId);
return localImageApi.cancelRequest(requestId);
}
}

View File

@@ -1,14 +1,10 @@
part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest {
static final log = Logger('RemoteImageRequest');
static final client = HttpClient()..maxConnectionsPerHost = 16;
final RemoteCacheManager? cacheManager;
final String uri;
final Map<String, String> headers;
HttpClientRequest? _request;
RemoteImageRequest({required this.uri, required this.headers, this.cacheManager});
RemoteImageRequest({required this.uri, required this.headers});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@@ -16,164 +12,18 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
// so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call.
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
if (cachedFileImage != null) {
return cachedFileImage;
}
try {
final buffer = await _downloadImage(uri);
if (buffer == null) {
return null;
}
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
if (_isCancelled) {
return null;
}
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
if (cachedFileImage != null) {
return cachedFileImage;
}
rethrow;
} finally {
_request = null;
}
}
Future<ImmutableBuffer?> _downloadImage(String url) async {
if (_isCancelled) {
return null;
}
final request = _request = await client.getUrl(Uri.parse(url));
if (_isCancelled) {
request.abort();
return _request = null;
}
for (final entry in headers.entries) {
request.headers.set(entry.key, entry.value);
}
final response = await request.close();
if (_isCancelled) {
return null;
}
final cacheManager = this.cacheManager;
final streamController = StreamController<List<int>>(sync: true);
final Stream<List<int>> stream;
unawaited(cacheManager?.putStreamedFile(url, streamController.stream));
stream = response.map((chunk) {
if (_isCancelled) {
throw StateError('Cancelled request');
}
if (cacheManager != null) {
streamController.add(chunk);
}
return chunk;
});
try {
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
unawaited(streamController.close());
return await ImmutableBuffer.fromUint8List(bytes);
} catch (e) {
streamController.addError(e);
unawaited(streamController.close());
if (_isCancelled) {
return null;
}
rethrow;
}
}
Future<Uint8List> _downloadBytes(Stream<List<int>> stream, int length) async {
final Uint8List bytes;
int offset = 0;
if (length > 0) {
// Known content length - use pre-allocated buffer
bytes = Uint8List(length);
await stream.listen((chunk) {
bytes.setAll(offset, chunk);
offset += chunk.length;
}, cancelOnError: true).asFuture();
} else {
// Unknown content length - collect chunks dynamically
final chunks = <List<int>>[];
int totalLength = 0;
await stream.listen((chunk) {
chunks.add(chunk);
totalLength += chunk.length;
}, cancelOnError: true).asFuture();
bytes = Uint8List(totalLength);
for (final chunk in chunks) {
bytes.setAll(offset, chunk);
offset += chunk.length;
}
}
return bytes;
}
Future<ImageInfo?> _loadCachedFile(
String url,
ImageDecoderCallback decode,
double scale, {
required bool inMemoryOnly,
}) async {
final cacheManager = this.cacheManager;
if (_isCancelled || cacheManager == null) {
return null;
}
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
if (_isCancelled || file == null) {
return null;
}
try {
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
log.severe('Failed to decode cached image', e);
unawaited(_evictFile(url));
return null;
}
}
Future<void> _evictFile(String url) async {
try {
await cacheManager?.removeFile(url);
} catch (e) {
log.severe('Failed to remove cached image', e);
}
}
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
if (_isCancelled) {
buffer.dispose();
return null;
}
final codec = await decode(buffer);
if (_isCancelled) {
buffer.dispose();
codec.dispose();
return null;
}
final frame = await codec.getNextFrame();
return ImageInfo(image: frame.image, scale: scale);
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
_ => null,
};
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
void _onCancelled() {
_request?.abort();
_request = null;
Future<void> _onCancelled() {
return remoteImageApi.cancelRequest(requestId);
}
}

View File

@@ -11,8 +11,8 @@ class ThumbhashImageRequest extends ImageRequest {
return null;
}
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
final frame = await _fromPlatformImage(info);
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash);
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}

View File

@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 17;
int get schemaVersion => 16;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -201,9 +201,6 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity);
},
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
),
);

View File

@@ -6911,503 +6911,6 @@ i1.GeneratedColumn<DateTime> _column_100(String aliasedName) =>
true,
type: i1.DriftSqlType.dateTime,
);
final class Schema17 extends i0.VersionedSchema {
Schema17({required super.database}) : super(version: 17);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
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 Shape26 localAssetEntity = Shape26(
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,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index 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 idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final 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 Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 trashedLocalAssetEntity = Shape25(
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,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
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)',
);
}
class Shape28 extends i0.VersionedTable {
Shape28({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get localDateTime =>
columnsByName['local_date_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get thumbHash =>
columnsByName['thumb_hash']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get livePhotoVideoId =>
columnsByName['live_photo_video_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get visibility =>
columnsByName['visibility']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get stackId =>
columnsByName['stack_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get libraryId =>
columnsByName['library_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isEdited =>
columnsByName['is_edited']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<bool> _column_101(String aliasedName) =>
i1.GeneratedColumn<bool>(
'is_edited',
aliasedName,
false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_edited" IN (0, 1))',
),
defaultValue: const CustomExpression('0'),
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -7424,7 +6927,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -7503,11 +7005,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from15To16(migrator, schema);
return 16;
case 16:
final schema = Schema17(database: database);
final migrator = i1.Migrator(database, schema);
await from16To17(migrator, schema);
return 17;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -7530,7 +7027,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -7548,6 +7044,5 @@ i1.OnUpgrade stepByStep({
from13To14: from13To14,
from14To15: from14To15,
from15To16: from15To16,
from16To17: from16To17,
),
);

View File

@@ -0,0 +1,67 @@
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/utils/user_agent.dart';
import 'package:path_provider/path_provider.dart';
class NetworkRepository {
static late Directory _cachePath;
static late String _userAgent;
static final _clients = <String, http.Client>{};
static Future<void> init() {
return (
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
getUserAgentString().then((userAgent) => _userAgent = userAgent),
).wait;
}
static void reset() {
Future.microtask(init);
for (final client in _clients.values) {
client.close();
}
_clients.clear();
}
const NetworkRepository();
/// 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

@@ -200,7 +200,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
isEdited: Value(asset.isEdited),
);
batch.insert(

View File

@@ -70,7 +70,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
durationInSeconds: row.durationInSeconds,
livePhotoVideoId: row.livePhotoVideoId,
stackId: row.stackId,
isEdited: row.isEdited,
)
: LocalAsset(
id: row.localId!,
@@ -89,7 +88,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
latitude: row.latitude,
longitude: row.longitude,
adjustmentTime: row.adjustmentTime,
isEdited: row.isEdited,
),
)
.get();

View File

@@ -19,6 +19,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/intl_keys.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
@@ -237,6 +238,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
super.dispose();
}
@override
void reassemble() {
if (kDebugMode) {
NetworkRepository.reset();
}
super.reassemble();
}
@override
Widget build(BuildContext context) {
final router = ref.watch(appRouterProvider);

View File

@@ -92,7 +92,7 @@ class _MobileLayout extends StatelessWidget {
],
)
.toList();
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]);
}
}
@@ -142,7 +142,7 @@ class SettingsSubPage extends StatelessWidget {
context.locale;
return Scaffold(
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
body: Padding(padding: const EdgeInsets.only(bottom: 60.0), child: section.widget),
body: section.widget,
);
}
}

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
@@ -50,7 +49,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
@@ -60,7 +58,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
(_) async {
try {
wsProvider.connect();
unawaited(infoProvider.getServerInfo());
if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false;

View File

@@ -18,7 +18,6 @@ class SyncStatusPage extends StatelessWidget {
splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded),
),
centerTitle: false,
),
body: const SyncStatusAndActions(),
);

137
mobile/lib/platform/local_image_api.g.dart generated Normal file
View File

@@ -0,0 +1,137 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class LocalImageApi {
/// Constructor for [LocalImageApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
LocalImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(
String assetId, {
required int requestId,
required int width,
required int height,
required bool isVideo,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
assetId,
requestId,
width,
height,
isVideo,
]);
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 (pigeonVar_replyList[0] as Map<Object?, Object?>?)?.cast<String, int>();
}
}
Future<void> cancelRequest(int requestId) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
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;
}
}
Future<Map<String, int>> getThumbhash(String thumbhash) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[thumbhash]);
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 Map<Object?, Object?>?)!.cast<String, int>();
}
}
}

View File

@@ -0,0 +1,101 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class RemoteImageApi {
/// Constructor for [RemoteImageApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
RemoteImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(
String url, {
required Map<String, String> headers,
required int requestId,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]);
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 (pigeonVar_replyList[0] as Map<Object?, Object?>?)?.cast<String, int>();
}
}
Future<void> cancelRequest(int requestId) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
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

@@ -118,7 +118,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
]);
}

View File

@@ -167,7 +167,7 @@ class _PlaceTile extends StatelessWidget {
child: SizedBox(
width: 80,
height: 80,
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover, thumbhash: ""),
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover),
),
),
);

View File

@@ -14,15 +14,14 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -311,17 +310,18 @@ class _SortButtonState extends ConsumerState<_SortButton> {
: const Icon(Icons.abc, color: Colors.transparent),
onPressed: () => onMenuTapped(sortMode),
style: ButtonStyle(
padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(12, 12, 24, 12)),
padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(16, 16, 32, 16)),
backgroundColor: WidgetStateProperty.all(
albumSortOption == sortMode ? context.colorScheme.primary : Colors.transparent,
),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
),
),
child: Text(
sortMode.label.t(context: context),
style: context.textTheme.labelLarge?.copyWith(
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: albumSortOption == sortMode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface.withAlpha(185),
@@ -344,12 +344,15 @@ class _SortButtonState extends ConsumerState<_SortButton> {
Padding(
padding: const EdgeInsets.only(right: 5),
child: albumSortIsReverse
? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface)
: Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface),
? const Icon(Icons.keyboard_arrow_down)
: const Icon(Icons.keyboard_arrow_up_rounded),
),
Text(
albumSortOption.label.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(225)),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(225),
),
),
isSorting
? SizedBox(
@@ -539,11 +542,7 @@ class _QuickSortAndViewMode extends StatelessWidget {
initialIsReverse: currentIsReverse,
),
IconButton(
icon: Icon(
isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined,
size: 24,
color: context.colorScheme.onSurface,
),
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: onToggleViewMode,
),
],
@@ -663,8 +662,6 @@ class _GridAlbumCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return GestureDetector(
onTap: () => onAlbumSelected(album),
child: Card(
@@ -683,22 +680,12 @@ class _GridAlbumCard extends ConsumerWidget {
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
child: SizedBox(
width: double.infinity,
child: FutureBuilder(
future: albumThumbnailAsset,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Thumbnail.remote(
remoteId: album.thumbnailAssetId!,
thumbhash: snapshot.data!.thumbHash ?? "",
);
}
return Container(
color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
);
},
),
child: album.thumbnailAssetId != null
? Thumbnail.remote(remoteId: album.thumbnailAssetId!)
: Container(
color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
),
),
),
),

View File

@@ -1,14 +1,12 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
class AlbumTile extends ConsumerWidget {
class AlbumTile extends StatelessWidget {
const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected});
final RemoteAlbum album;
@@ -16,9 +14,7 @@ class AlbumTile extends ConsumerWidget {
final Function(RemoteAlbum)? onAlbumSelected;
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
Widget build(BuildContext context) {
return LargeLeadingTile(
title: Text(
album.name,
@@ -33,35 +29,23 @@ class AlbumTile extends ConsumerWidget {
),
onTap: () => onAlbumSelected?.call(album),
leadingPadding: const EdgeInsets.only(right: 16),
leading: FutureBuilder(
future: albumThumbnailAsset,
builder: (context, snapshot) {
return snapshot.hasData && snapshot.data != null
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(
width: 80,
height: 80,
child: Thumbnail.remote(
remoteId: album.thumbnailAssetId!,
thumbhash: snapshot.data!.thumbHash ?? "",
),
),
)
: SizedBox(
width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
);
},
),
leading: album.thumbnailAssetId != null
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)),
)
: SizedBox(
width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
),
);
}
}

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -165,8 +164,11 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
children: [
if (albums.isNotEmpty)
SheetTile(
title: 'appears_in'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
title: 'appears_in'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
Padding(
padding: const EdgeInsets.only(left: 24),
@@ -222,7 +224,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
);
},
);
@@ -237,7 +241,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
);
}
}
@@ -256,8 +262,11 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
const SheetLocationDetails(),
// Details header
SheetTile(
title: 'details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
title: 'details'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
// File info
buildFileInfoTile(),
@@ -269,7 +278,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
),
],
// Lens info
@@ -280,13 +291,15 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off
const SizedBox(height: 60),
const SizedBox(height: 30),
],
);
}

View File

@@ -4,7 +4,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -78,8 +77,11 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetTile(
title: 'location'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
title: 'location'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation,
),
@@ -103,7 +105,9 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
),
Text(
coordinates,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
),
],
),

View File

@@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@@ -54,8 +53,11 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16),
child: Text(
"people".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
"people".t(context: context).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
),
SizedBox(

View File

@@ -112,17 +112,14 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
} else {
final String assetId;
final String thumbhash;
if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!;
thumbhash = "";
} else if (asset is RemoteAsset) {
assetId = asset.id;
thumbhash = asset.thumbHash ?? "";
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash);
provider = RemoteFullImageProvider(assetId: assetId);
}
return provider;
@@ -135,9 +132,8 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
}
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited;
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));

View File

@@ -7,18 +7,15 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId;
final String thumbhash;
RemoteThumbProvider({required this.assetId, required this.thumbhash});
RemoteThumbProvider({required this.assetId});
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -39,9 +36,8 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
uri: getThumbnailUrlForRemoteId(key.assetId),
headers: ApiService.getRequestHeaders(),
cacheManager: cacheManager,
);
return loadRequest(request, decode);
}
@@ -50,23 +46,21 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => assetId.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId;
final String thumbhash;
RemoteFullImageProvider({required this.assetId, required this.thumbhash});
RemoteFullImageProvider({required this.assetId});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -77,7 +71,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@@ -95,11 +89,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
}
final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
cacheManager: cacheManager,
);
final request = this.request = RemoteImageRequest(uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview), headers: headers);
yield* loadRequest(request, decode);
if (isCancelled) {
@@ -117,12 +107,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => assetId.hashCode;
}

View File

@@ -21,14 +21,9 @@ class Thumbnail extends StatefulWidget {
const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key});
Thumbnail.remote({
required String remoteId,
required String thumbhash,
this.fit = BoxFit.cover,
Size size = kThumbnailResolution,
super.key,
}) : imageProvider = RemoteThumbProvider(assetId: remoteId, thumbhash: thumbhash),
thumbhashProvider = null;
Thumbnail.remote({required String remoteId, this.fit = BoxFit.cover, Size size = kThumbnailResolution, super.key})
: imageProvider = RemoteThumbProvider(assetId: remoteId),
thumbhashProvider = null;
Thumbnail.fromAsset({
required BaseAsset? asset,

View File

@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
@@ -48,7 +47,6 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
Widget build(BuildContext context) {
final asset = widget.asset;
final heroIndex = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final isCurrentAsset = ref.watch(assetViewerProvider.select((current) => current.currentAsset == asset));
final assetContainerColor = context.isDarkTheme
? context.primaryColor.darken(amount: 0.4)
@@ -61,10 +59,6 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
final bool storageIndicator =
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator;
if (!isCurrentAsset) {
_hideIndicators = false;
}
if (isSelected) {
_showSelectionContainer = true;
}
@@ -102,11 +96,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
children: [
Positioned.fill(
child: Hero(
// This key resets the hero animation when the asset is changed in the asset viewer.
// It doesn't seem like the best solution, and only works to reset the hero, not prime the hero of the new active asset for animation,
// but other solutions have failed thus far.
key: ValueKey(isCurrentAsset),
tag: '${asset?.heroTag}_$heroIndex',
tag: '${asset?.heroTag ?? ''}_$heroIndex',
child: Thumbnail.fromAsset(asset: asset, size: widget.size),
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
placeholderBuilder: (context, heroSize, child) {

View File

@@ -60,11 +60,7 @@ class DriftMemoryCard extends ConsumerWidget {
child: SizedBox(
width: 205,
height: 200,
child: Thumbnail.remote(
remoteId: memory.assets[0].id,
thumbhash: memory.assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
child: Thumbnail.remote(remoteId: memory.assets[0].id, fit: BoxFit.cover),
),
),
Positioned(

View File

@@ -2,9 +2,11 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB

View File

@@ -160,7 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup();
}),
_resumeBackup(),
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
backgroundManager.syncCloudIds(),
]);
} else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View File

@@ -69,7 +69,6 @@ class CastNotifier extends StateNotifier<CastManagerState> {
: AssetType.other,
createdAt: asset.fileCreatedAt,
updatedAt: asset.updatedAt,
isEdited: false,
);
_gCastService.loadMedia(remoteAsset, reload);

View File

@@ -1,148 +1,25 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
// ignore: implementation_imports
import 'package:flutter_cache_manager/src/cache_store.dart';
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
abstract class RemoteCacheManager extends CacheManager {
static final _log = Logger('RemoteCacheManager');
RemoteCacheManager.custom(super.config, CacheStore store)
// Unfortunately, CacheStore is not a public API
// ignore: invalid_use_of_visible_for_testing_member
: super.custom(cacheStore: store);
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
});
// Unlike `putFileStream`, this method handles request cancellation,
// does not make a (slow) DB call checking if the file is already cached,
// does not synchronously check if a file exists,
// and deletes the file on cancellation without making these checks again.
Future<void> putStreamedFileToStore(
CacheStore store,
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) async {
final path = '${const Uuid().v1()}.$fileExtension';
final file = await store.fileSystem.createFile(path);
final sink = file.openWrite();
try {
await source.listen(sink.add, cancelOnError: true).asFuture();
} catch (e) {
try {
await sink.close();
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
try {
await sink.flush();
await sink.close();
} catch (e) {
try {
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
final cacheObject = CacheObject(
url,
key: key,
relativePath: path,
validTill: DateTime.now().add(maxAge),
eTag: eTag,
);
try {
await store.putFile(cacheObject);
} catch (e) {
try {
await file.delete();
} catch (e) {
_log.severe('Failed to delete untracked cache file: $e');
}
}
}
}
class RemoteImageCacheManager extends RemoteCacheManager {
class RemoteImageCacheManager extends CacheManager {
static const key = 'remoteImageCacheKey';
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
static final _store = CacheStore(_config);
factory RemoteImageCacheManager() {
return _instance;
}
RemoteImageCacheManager._() : super.custom(_config, _store);
@override
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) {
return putStreamedFileToStore(
_store,
url,
source,
key: key,
eTag: eTag,
maxAge: maxAge,
fileExtension: fileExtension,
);
}
RemoteImageCacheManager._() : super(_config);
}
/// The cache manager for full size images [ImmichRemoteImageProvider]
class RemoteThumbnailCacheManager extends RemoteCacheManager {
class RemoteThumbnailCacheManager extends CacheManager {
static const key = 'remoteThumbnailCacheKey';
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
static final _store = CacheStore(_config);
factory RemoteThumbnailCacheManager() {
return _instance;
}
RemoteThumbnailCacheManager._() : super.custom(_config, _store);
@override
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) {
return putStreamedFileToStore(
_store,
url,
source,
key: key,
eTag: eTag,
maxAge: maxAge,
fileExtension: fileExtension,
);
}
RemoteThumbnailCacheManager._() : super(_config);
}

View File

@@ -4,7 +4,8 @@ import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/thumbnail_api.g.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:immich_mobile/platform/remote_image_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
@@ -16,4 +17,6 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
final thumbnailApi = ThumbnailApi();
final localImageApi = LocalImageApi();
final remoteImageApi = RemoteImageApi();

View File

@@ -144,7 +144,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_asset_hidden', _handleOnAssetHidden);
} else {
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReady);
}
socket.on('on_config_update', _handleOnConfigUpdate);
@@ -193,12 +192,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void stopListeningToBetaEvents() {
state.socket?.off('AssetUploadReadyV1');
state.socket?.off('AssetEditReadyV1');
}
void startListeningToBetaEvents() {
state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
state.socket?.on('AssetEditReadyV1', _handleSyncAssetEditReady);
}
void listenUploadEvent() {
@@ -318,10 +315,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchDebouncer.run(_processBatchedAssetUploadReady);
}
void _handleSyncAssetEditReady(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditBatch([data]));
}
void _processBatchedAssetUploadReady() {
if (_batchedAssetUploadReady.isEmpty) {
return;

View File

@@ -25,7 +25,6 @@ class FileMediaRepository {
type: AssetType.image,
createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime,
isEdited: false,
);
}

View File

@@ -285,12 +285,7 @@ class BackgroundUploadService {
return null;
}
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
String metadata = UploadTaskMetadata(
@@ -312,10 +307,10 @@ class BackgroundUploadService {
priority: priority,
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
cloudId: entity.isLivePhoto ? null : asset.cloudId,
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
longitude: entity.isLivePhoto ? null : asset.longitude?.toString(),
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}

View File

@@ -16,7 +16,6 @@ import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
@@ -41,7 +40,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider),
);
});
@@ -57,7 +55,6 @@ class ForegroundUploadService {
this._backupRepository,
this._connectivityApi,
this._appSettingsService,
this._assetMediaRepository,
);
final UploadRepository _uploadRepository;
@@ -65,7 +62,6 @@ class ForegroundUploadService {
final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi;
final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository;
final Logger _logger = Logger('ForegroundUploadService');
bool shouldAbortUpload = false;
@@ -315,17 +311,7 @@ class ForegroundUploadService {
return;
}
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
/// Handle special file name from DJI or Fusion app
/// If the file name has no extension, likely due to special renaming template by specific apps
/// we append the original extension from the asset name
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name;
final deviceId = Store.get(StoreKey.deviceId);
final headers = ApiService.getRequestHeaders();
@@ -336,6 +322,19 @@ class ForegroundUploadService {
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(),
'duration': asset.duration.toString(),
if (CurrentPlatform.isIOS && asset.cloudId != null)
'metadata': jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: asset.cloudId,
createdAt: asset.createdAt.toIso8601String(),
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
),
),
]),
};
// Upload live photo video first if available
@@ -364,22 +363,6 @@ class ForegroundUploadService {
fields['livePhotoVideoId'] = livePhotoVideoId;
}
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: asset.cloudId,
createdAt: asset.createdAt.toIso8601String(),
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
),
),
]);
}
final result = await _uploadRepository.uploadFile(
file: file,
originalFileName: originalFileName,

View File

@@ -61,12 +61,7 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale
),
),
chipTheme: const ChipThemeData(side: BorderSide.none),
sliderTheme: const SliderThemeData(
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
trackHeight: 2.0,
// ignore: deprecated_member_use
year2023: false,
),
sliderTheme: const SliderThemeData(thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), trackHeight: 2.0),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.fixed),
popupMenuTheme: const PopupMenuThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),

View File

@@ -21,6 +21,7 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
@@ -106,5 +107,7 @@ abstract final class Bootstrap {
storeRepository: storeRepo,
shouldBuffer: shouldBufferLogs,
);
await NetworkRepository.init();
}
}

View File

@@ -50,10 +50,8 @@ String getThumbnailUrlForRemoteId(
final String id, {
AssetMediaSize type = AssetMediaSize.thumbnail,
bool edited = true,
String? thumbhash,
}) {
final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url;
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
}
String getPlaybackUrlForRemoteId(final String id) {

View File

@@ -49,7 +49,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
}
case 'SyncAssetV1':
if (value is Map) {
addDefault(value, 'isEdited', false);
addDefault(value, 'editCount', 0);
}
case 'ServerFeaturesDto':
if (value is Map) {

View File

@@ -33,7 +33,7 @@ class PersonNameEditForm extends HookConsumerWidget {
decoration: InputDecoration(
hintText: 'name'.tr(),
border: const OutlineInputBorder(),
errorText: isError.value ? 'Error occurred' : null,
errorText: isError.value ? 'Error occured' : null,
),
),
),

View File

@@ -1,14 +1,14 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
class GroupSettings extends HookConsumerWidget {
const GroupSettings({super.key});
@@ -33,24 +33,12 @@ class GroupSettings extends HookConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingGroupTitle(
title: "asset_list_group_by_sub_title".t(context: context),
icon: Icons.group_work_outlined,
),
SettingsSubTitle(title: "asset_list_group_by_sub_title".tr()),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'asset_list_layout_settings_group_by_month_day'.t(context: context),
value: GroupAssetsBy.day,
),
SettingsRadioGroup(
title: 'month'.t(context: context),
value: GroupAssetsBy.month,
),
SettingsRadioGroup(
title: 'asset_list_layout_settings_group_automatically'.t(context: context),
value: GroupAssetsBy.auto,
),
SettingsRadioGroup(title: 'asset_list_layout_settings_group_by_month_day'.tr(), value: GroupAssetsBy.day),
SettingsRadioGroup(title: 'month'.tr(), value: GroupAssetsBy.month),
SettingsRadioGroup(title: 'asset_list_layout_settings_group_automatically'.tr(), value: GroupAssetsBy.auto),
],
groupBy: groupBy,
onRadioChanged: changeGroupValue,

View File

@@ -1,12 +1,11 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
class LayoutSettings extends HookConsumerWidget {
@@ -20,13 +19,10 @@ class LayoutSettings extends HookConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingGroupTitle(
title: "asset_list_layout_sub_title".t(context: context),
icon: Icons.view_module_outlined,
),
SettingsSubTitle(title: "asset_list_layout_sub_title".tr()),
SettingsSwitchListTile(
valueNotifier: useDynamicLayout,
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
title: "asset_list_layout_settings_dynamic_layout_title".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSliderListTile(

View File

@@ -1,9 +1,10 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@@ -18,21 +19,21 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingGroupTitle(
title: "photos".t(context: context),
icon: Icons.image_outlined,
subtitle: "setting_image_viewer_help".t(context: context),
SettingsSubTitle(title: "setting_image_viewer_title".tr()),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
title: Text('setting_image_viewer_help', style: context.textTheme.bodyMedium).tr(),
),
SettingsSwitchListTile(
valueNotifier: isPreview,
title: "setting_image_viewer_preview_title".t(context: context),
subtitle: "setting_image_viewer_preview_subtitle".t(context: context),
title: "setting_image_viewer_preview_title".tr(),
subtitle: "setting_image_viewer_preview_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSwitchListTile(
valueNotifier: isOriginal,
title: "setting_image_viewer_original_title".t(context: context),
subtitle: "setting_image_viewer_original_subtitle".t(context: context),
title: "setting_image_viewer_original_title".tr(),
subtitle: "setting_image_viewer_original_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],

View File

@@ -1,9 +1,9 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@@ -19,26 +19,23 @@ class VideoViewerSettings extends HookConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingGroupTitle(
title: "videos".t(context: context),
icon: Icons.video_camera_back_outlined,
),
SettingsSubTitle(title: "videos".tr()),
SettingsSwitchListTile(
valueNotifier: useAutoPlayVideo,
title: "setting_video_viewer_auto_play_title".t(context: context),
subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context),
title: "setting_video_viewer_auto_play_title".tr(),
subtitle: "setting_video_viewer_auto_play_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSwitchListTile(
valueNotifier: useLoopVideo,
title: "setting_video_viewer_looping_title".t(context: context),
subtitle: "loop_videos_description".t(context: context),
title: "setting_video_viewer_looping_title".tr(),
subtitle: "loop_videos_description".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSwitchListTile(
valueNotifier: useOriginalVideo,
title: "setting_video_viewer_original_video_title".t(context: context),
subtitle: "setting_video_viewer_original_video_subtitle".t(context: context),
title: "setting_video_viewer_original_video_title".tr(),
subtitle: "setting_video_viewer_original_video_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],

View File

@@ -16,8 +16,6 @@ import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
class DriftBackupSettings extends ConsumerWidget {
@@ -27,25 +25,36 @@ class DriftBackupSettings extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return SettingsSubPageScaffold(
settings: [
SettingGroupTitle(
title: "network_requirements".t(context: context),
icon: Icons.cell_tower,
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
"network_requirements".t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
),
),
const _UseWifiForUploadVideosButton(),
const _UseWifiForUploadPhotosButton(),
if (CurrentPlatform.isAndroid) ...[
const Divider(),
SettingGroupTitle(
title: "background_options".t(context: context),
icon: Icons.charging_station_rounded,
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
"background_options".t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
const _BackupOnlyWhenChargingButton(),
const _BackupDelaySlider(),
],
const Divider(),
SettingGroupTitle(
title: "backup_albums_sync".t(context: context),
icon: Icons.sync,
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
"backup_albums_sync".t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
),
),
const _AlbumSyncActionButton(),
],
@@ -96,67 +105,81 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: ListView(
shrinkWrap: true,
children: [
StreamBuilder(
stream: Store.watch(StoreKey.syncAlbums),
initialData: Store.tryGet(StoreKey.syncAlbums) ?? false,
builder: (context, snapshot) {
final albumSyncEnable = snapshot.data ?? false;
return Column(
children: [
SettingListTile(
title: "sync_albums".t(context: context),
subtitle: "sync_upload_album_setting_subtitle".t(context: context),
trailing: Switch(
value: albumSyncEnable,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
return ListView(
shrinkWrap: true,
children: [
StreamBuilder(
stream: Store.watch(StoreKey.syncAlbums),
initialData: Store.tryGet(StoreKey.syncAlbums) ?? false,
builder: (context, snapshot) {
final albumSyncEnable = snapshot.data ?? false;
return Column(
children: [
ListTile(
title: Text(
"sync_albums".t(context: context),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
),
subtitle: Text(
"sync_upload_album_setting_subtitle".t(context: context),
style: context.textTheme.labelLarge,
),
trailing: Switch(
value: albumSyncEnable,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
if (newValue == true) {
await _manageLinkedAlbums();
}
},
),
if (newValue == true) {
await _manageLinkedAlbums();
}
},
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: albumSyncEnable ? 1.0 : 0.0,
child: albumSyncEnable
? SettingListTile(
onTap: _manualSyncAlbums,
contentPadding: const EdgeInsets.only(left: 32, right: 16),
title: "organize_into_albums".t(context: context),
subtitle: "organize_into_albums_description".t(context: context),
trailing: isAlbumSyncInProgress
? const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: IconButton(
onPressed: _manualSyncAlbums,
icon: const Icon(Icons.sync_rounded),
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
iconSize: 20,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
)
: const SizedBox.shrink(),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: albumSyncEnable ? 1.0 : 0.0,
child: albumSyncEnable
? ListTile(
onTap: _manualSyncAlbums,
contentPadding: const EdgeInsets.only(left: 32, right: 16),
title: Text(
"organize_into_albums".t(context: context),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.normal,
),
),
subtitle: Text(
"organize_into_albums_description".t(context: context),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
trailing: isAlbumSyncInProgress
? const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: IconButton(
onPressed: _manualSyncAlbums,
icon: const Icon(Icons.sync_rounded),
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
iconSize: 20,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
)
: const SizedBox.shrink(),
),
],
);
},
),
],
),
),
],
);
},
),
],
);
}
}
@@ -199,24 +222,24 @@ class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: SettingListTile(
title: widget.titleKey.t(context: context),
subtitle: widget.subtitleKey.t(context: context),
trailing: StreamBuilder(
stream: valueStream,
initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
builder: (context, snapshot) {
final value = snapshot.data ?? false;
return Switch(
value: value,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue);
},
);
},
),
return ListTile(
title: Text(
widget.titleKey.t(context: context),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
),
subtitle: Text(widget.subtitleKey.t(context: context), style: context.textTheme.labelLarge),
trailing: StreamBuilder(
stream: valueStream,
initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
builder: (context, snapshot) {
final value = snapshot.data ?? false;
return Switch(
value: value,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue);
},
);
},
),
);
}
@@ -331,7 +354,7 @@ class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
'backup_controller_page_background_delay'.tr(
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
),
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
),
),
Slider(

View File

@@ -34,36 +34,33 @@ class EntityCountTile extends StatelessWidget {
children: [
// Icon and Label
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(icon, color: context.primaryColor, size: 14),
const SizedBox(width: 4),
Icon(icon, color: context.primaryColor),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w500),
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
),
],
),
// Number
const Spacer(),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'GoogleSansCode'),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
),
TextSpan(
text: count.toString(),
style: TextStyle(color: context.colorScheme.onSurface),
),
],
),
RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'GoogleSansCode', fontWeight: FontWeight.w600),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
),
TextSpan(
text: count.toString(),
style: TextStyle(color: context.primaryColor),
),
],
),
),
],

View File

@@ -16,8 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart'
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
@@ -114,39 +112,48 @@ class SyncStatusAndActions extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 16, bottom: 96),
children: [
const _SyncStatsCounts(),
const Divider(height: 10),
const SizedBox(height: 16),
SettingGroupTitle(title: "jobs".t(context: context)),
SettingListTile(
title: "sync_local".t(context: context),
subtitle: "tap_to_run_job".t(context: context),
const Divider(height: 1, indent: 16, endIndent: 16),
const SizedBox(height: 24),
_SectionHeaderText(text: "jobs".t(context: context)),
ListTile(
title: Text(
"sync_local".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text("tap_to_run_job".t(context: context)),
leading: const Icon(Icons.sync),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus),
onTap: () {
ref.read(backgroundSyncProvider).syncLocal(full: true);
},
),
SettingListTile(
title: "sync_remote".t(context: context),
subtitle: "tap_to_run_job".t(context: context),
ListTile(
title: Text(
"sync_remote".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text("tap_to_run_job".t(context: context)),
leading: const Icon(Icons.cloud_sync),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus),
onTap: () {
ref.read(backgroundSyncProvider).syncRemote();
},
),
SettingListTile(
title: "hash_asset".t(context: context),
ListTile(
title: Text(
"hash_asset".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
leading: const Icon(Icons.tag),
subtitle: "tap_to_run_job".t(context: context),
subtitle: Text("tap_to_run_job".t(context: context)),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus),
onTap: () {
ref.read(backgroundSyncProvider).hashAssets();
},
),
const Divider(height: 1),
const SizedBox(height: 16),
SettingGroupTitle(title: "actions".t(context: context)),
const Divider(height: 1, indent: 16, endIndent: 16),
const SizedBox(height: 24),
_SectionHeaderText(text: "actions".t(context: context)),
ListTile(
title: Text(
"clear_file_cache".t(context: context),
@@ -195,6 +202,26 @@ class _SyncStatusIcon extends StatelessWidget {
}
}
class _SectionHeaderText extends StatelessWidget {
final String text;
const _SectionHeaderText({required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
text.toUpperCase(),
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(200),
),
),
);
}
}
class _SyncStatsCounts extends ConsumerWidget {
const _SyncStatsCounts();
@@ -252,9 +279,9 @@ class _SyncStatsCounts extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingGroupTitle(title: "assets".t(context: context)),
_SectionHeaderText(text: "assets".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
// 1. Wrap in IntrinsicHeight
child: IntrinsicHeight(
child: Flex(
@@ -282,9 +309,9 @@ class _SyncStatsCounts extends ConsumerWidget {
),
),
),
SettingGroupTitle(title: "albums".t(context: context)),
_SectionHeaderText(text: "albums".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
@@ -310,9 +337,9 @@ class _SyncStatsCounts extends ConsumerWidget {
),
),
),
SettingGroupTitle(title: "other".t(context: context)),
_SectionHeaderText(text: "other".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
@@ -341,7 +368,7 @@ class _SyncStatsCounts extends ConsumerWidget {
// To be removed once the experimental feature is stable
if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
SettingGroupTitle(title: "trash".t(context: context)),
_SectionHeaderText(text: "trash".t(context: context)),
Consumer(
builder: (context, ref, _) {
final counts = ref.watch(trashedAssetsCountProvider);

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