mirror of
https://github.com/immich-app/immich.git
synced 2026-01-22 17:38:58 -08:00
Compare commits
71 Commits
fix-consid
...
feat/thumb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d18ec738 | ||
|
|
c531b95d54 | ||
|
|
82dde1c3e7 | ||
|
|
fe07cf6f6d | ||
|
|
87a1502ef1 | ||
|
|
cef5a7cf50 | ||
|
|
9980d06f0c | ||
|
|
e51547cd28 | ||
|
|
d898cba04d | ||
|
|
fd89607142 | ||
|
|
95fad974c1 | ||
|
|
00ea98479f | ||
|
|
24ff08e094 | ||
|
|
495cd8a6de | ||
|
|
f98b441100 | ||
|
|
e739aa1667 | ||
|
|
d83e74dc88 | ||
|
|
06467f2c0d | ||
|
|
957ee84636 | ||
|
|
8ad93ab4b1 | ||
|
|
89f9c136ac | ||
|
|
4ec0d6df9a | ||
|
|
6cba10513b | ||
|
|
2d4ee03c5b | ||
|
|
6e956bfe4c | ||
|
|
1e0e0d8066 | ||
|
|
66a0e04489 | ||
|
|
9cae61851c | ||
|
|
34f7e07be9 | ||
|
|
e098a8261e | ||
|
|
2f1d1edf10 | ||
|
|
1b032339aa | ||
|
|
dc82c13ddc | ||
|
|
417af66f30 | ||
|
|
280f906e4b | ||
|
|
b669714bda | ||
|
|
0f6606848e | ||
|
|
1a8671d940 | ||
|
|
fb94ee80aa | ||
|
|
083ee0b5fe | ||
|
|
0bae88bef6 | ||
|
|
184f1a6d32 | ||
|
|
248cb86143 | ||
|
|
1649d87360 | ||
|
|
8970566865 | ||
|
|
0b4a96140e | ||
|
|
72caf8983c | ||
|
|
61a9d5cbc7 | ||
|
|
ca0d4b283a | ||
|
|
2b4e4051f0 | ||
|
|
0f3956f654 | ||
|
|
99bd7d5f27 | ||
|
|
fe1d0edf4c | ||
|
|
4ef699e9fa | ||
|
|
3e21174dd8 | ||
|
|
1b56bb84f9 | ||
|
|
b3f5b8ede8 | ||
|
|
2b77dc8e1f | ||
|
|
97a594556b | ||
|
|
4a7c4b6d15 | ||
|
|
a8198f9934 | ||
|
|
b123beae38 | ||
|
|
1ada7a8340 | ||
|
|
5d81cace23 | ||
|
|
65f9a228ba | ||
|
|
e6eca895ba | ||
|
|
8196bd9bbd | ||
|
|
07675a2de4 | ||
|
|
a2b03f7650 | ||
|
|
fdff591a11 | ||
|
|
e4443fa43e |
@@ -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.4",
|
||||
"@types/node": "^24.10.8",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -68,6 +68,56 @@ 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.
|
||||
|
||||
@@ -17,12 +17,17 @@ 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 4GB, recommended 6GB.
|
||||
- **RAM**: Minimum 6GB, recommended 8GB.
|
||||
- **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.
|
||||
|
||||
:::tip
|
||||
:::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
|
||||
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.
|
||||
|
||||
@@ -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.4",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@@ -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 = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwriteSlowMo = Number.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',
|
||||
|
||||
350
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
350
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -26,6 +27,17 @@ 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' });
|
||||
@@ -39,6 +51,7 @@ 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);
|
||||
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
|
||||
.send({
|
||||
action: 'start',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
@@ -79,12 +93,13 @@ describe('/admin/maintenance', () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
@@ -102,6 +117,17 @@ 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({});
|
||||
@@ -158,12 +184,13 @@ describe('/admin/maintenance', () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
|
||||
@@ -348,6 +348,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
||||
checksum: asset.checksum,
|
||||
width: exifInfo.exifImageWidth ?? 1,
|
||||
height: exifInfo.exifImageHeight ?? 1,
|
||||
isEdited: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
101
e2e/src/utils.ts
101
e2e/src/utils.ts
@@ -6,7 +6,9 @@ import {
|
||||
CheckExistingAssetsDto,
|
||||
CreateAlbumDto,
|
||||
CreateLibraryDto,
|
||||
JobCreateDto,
|
||||
MaintenanceAction,
|
||||
ManualJobName,
|
||||
MetadataSearchDto,
|
||||
Permission,
|
||||
PersonCreateDto,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
checkExistingAssets,
|
||||
createAlbum,
|
||||
createApiKey,
|
||||
createJob,
|
||||
createLibrary,
|
||||
createPartner,
|
||||
createPerson,
|
||||
@@ -28,10 +31,12 @@ import {
|
||||
createStack,
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
deleteDatabaseBackup,
|
||||
getAssetInfo,
|
||||
getConfig,
|
||||
getConfigDefaults,
|
||||
getQueuesLegacy,
|
||||
listDatabaseBackups,
|
||||
login,
|
||||
runQueueCommandLegacy,
|
||||
scanLibrary,
|
||||
@@ -52,11 +57,15 @@ import {
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
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';
|
||||
@@ -84,8 +93,9 @@ 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 immichAdmin = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
||||
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 specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
@@ -149,12 +159,26 @@ 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 {
|
||||
if (!client) {
|
||||
client = new pg.Client(dbUrl);
|
||||
await client.connect();
|
||||
}
|
||||
client = await utils.connectDatabase();
|
||||
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
@@ -481,6 +505,9 @@ 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) }),
|
||||
|
||||
@@ -559,6 +586,45 @@ 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) });
|
||||
@@ -601,6 +667,25 @@ 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();
|
||||
|
||||
@@ -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, cancelAllPollers } from 'src/web/specs/timeline/utils';
|
||||
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer', () => {
|
||||
@@ -49,7 +49,6 @@ test.describe('asset-viewer', () => {
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cancelAllPollers();
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
|
||||
105
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
105
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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/system-settings?isOpen=maintenance');
|
||||
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
|
||||
await page.goto('/admin/maintenance');
|
||||
await page.getByRole('button', { name: 'Switch to 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/system-settings*', { timeout: 10_000 });
|
||||
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
|
||||
import { utils } from 'src/utils';
|
||||
import {
|
||||
assetViewerUtils,
|
||||
cancelAllPollers,
|
||||
padYearMonth,
|
||||
pageUtils,
|
||||
poll,
|
||||
@@ -64,7 +63,6 @@ test.describe('Timeline', () => {
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cancelAllPollers();
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
|
||||
@@ -23,13 +23,6 @@ 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>,
|
||||
@@ -37,21 +30,14 @@ 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;
|
||||
}
|
||||
|
||||
45
i18n/en.json
45
i18n/en.json
@@ -188,10 +188,21 @@
|
||||
"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": "Start maintenance mode",
|
||||
"maintenance_start": "Switch to 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",
|
||||
@@ -603,7 +614,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",
|
||||
@@ -928,6 +939,7 @@
|
||||
"download_include_embedded_motion_videos": "Embedded videos",
|
||||
"download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file",
|
||||
"download_notfound": "Download not found",
|
||||
"download_original": "Download original",
|
||||
"download_paused": "Download paused",
|
||||
"download_settings": "Download",
|
||||
"download_settings_description": "Manage settings related to asset download",
|
||||
@@ -937,6 +949,7 @@
|
||||
"download_waiting_to_retry": "Waiting to retry",
|
||||
"downloading": "Downloading",
|
||||
"downloading_asset_filename": "Downloading asset {filename}",
|
||||
"downloading_from_icloud": "Downloading from iCloud",
|
||||
"downloading_media": "Downloading media",
|
||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||
"duplicates": "Duplicates",
|
||||
@@ -996,9 +1009,11 @@
|
||||
"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",
|
||||
@@ -1122,6 +1137,7 @@
|
||||
"unable_to_update_workflow": "Unable to update workflow",
|
||||
"unable_to_upload_file": "Unable to upload file"
|
||||
},
|
||||
"errors_text": "Errors",
|
||||
"exclusion_pattern": "Exclusion pattern",
|
||||
"exif": "Exif",
|
||||
"exif_bottom_sheet_description": "Add Description...",
|
||||
@@ -1401,10 +1417,28 @@
|
||||
"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",
|
||||
@@ -1523,7 +1557,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",
|
||||
@@ -2157,6 +2191,7 @@
|
||||
"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",
|
||||
@@ -2212,6 +2247,7 @@
|
||||
"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",
|
||||
@@ -2236,7 +2272,6 @@
|
||||
"updated_at": "Updated",
|
||||
"updated_password": "Updated password",
|
||||
"upload": "Upload",
|
||||
"upload_action_prompt": "{count} queued for upload",
|
||||
"upload_concurrency": "Upload concurrency",
|
||||
"upload_details": "Upload Details",
|
||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||
@@ -2255,7 +2290,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.",
|
||||
|
||||
@@ -92,14 +92,14 @@ FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-core-2_2.24.8+20344_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-opencl-2_2.24.8+20344_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/intel-opencl-icd_25.48.36300.8-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/libigdgmm12_22.8.2_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||
dpkg -i *.deb && \
|
||||
rm *.deb && \
|
||||
apt-get remove wget -yqq && \
|
||||
|
||||
@@ -3,7 +3,7 @@ experimental_monorepo_root = true
|
||||
[tools]
|
||||
node = "24.13.0"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.27.0"
|
||||
pnpm = "10.28.0"
|
||||
terragrunt = "0.93.10"
|
||||
opentofu = "1.10.7"
|
||||
java = "25.0.1"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -117,6 +117,9 @@
|
||||
<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/" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v17.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v17.json
generated
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -53,8 +53,10 @@ 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())
|
||||
}
|
||||
|
||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
import Network
|
||||
|
||||
class ConnectivityApiImpl: ConnectivityApi {
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "ConnectivityMonitor")
|
||||
private var currentPath: NWPath?
|
||||
|
||||
init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
self?.currentPath = path
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
// Get initial state synchronously
|
||||
currentPath = monitor.currentPath
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
|
||||
func getCapabilities() throws -> [NetworkCapability] {
|
||||
[]
|
||||
guard let path = currentPath else {
|
||||
return []
|
||||
}
|
||||
|
||||
guard path.status == .satisfied else {
|
||||
return []
|
||||
}
|
||||
|
||||
var capabilities: [NetworkCapability] = []
|
||||
|
||||
if path.usesInterfaceType(.wifi) {
|
||||
capabilities.append(.wifi)
|
||||
}
|
||||
|
||||
if path.usesInterfaceType(.cellular) {
|
||||
capabilities.append(.cellular)
|
||||
}
|
||||
|
||||
// Check for VPN - iOS reports VPN as .other interface type in many cases
|
||||
// or through the path's expensive property when on cellular with VPN
|
||||
if path.usesInterfaceType(.other) {
|
||||
capabilities.append(.vpn)
|
||||
}
|
||||
|
||||
// Determine if connection is unmetered:
|
||||
// - Must be on WiFi (not cellular)
|
||||
// - Must not be expensive (rules out personal hotspot)
|
||||
// - Must not be constrained (Low Data Mode)
|
||||
// Note: VPN over cellular should still be considered metered
|
||||
let isOnCellular = path.usesInterfaceType(.cellular)
|
||||
let isOnWifi = path.usesInterfaceType(.wifi)
|
||||
|
||||
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
|
||||
capabilities.append(.unmetered)
|
||||
}
|
||||
|
||||
return capabilities
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?]
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
118
mobile/ios/Runner/Images/RemoteImages.g.swift
Normal file
118
mobile/ios/Runner/Images/RemoteImages.g.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
171
mobile/ios/Runner/Images/RemoteImagesImpl.swift
Normal file
171
mobile/ios/Runner/Images/RemoteImagesImpl.swift
Normal 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 = 64
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ sealed class BaseAsset {
|
||||
final int? durationInSeconds;
|
||||
final bool isFavorite;
|
||||
final String? livePhotoVideoId;
|
||||
final bool isEdited;
|
||||
|
||||
const BaseAsset({
|
||||
required this.name,
|
||||
@@ -34,6 +35,7 @@ sealed class BaseAsset {
|
||||
this.durationInSeconds,
|
||||
this.isFavorite = false,
|
||||
this.livePhotoVideoId,
|
||||
required this.isEdited,
|
||||
});
|
||||
|
||||
bool get isImage => type == AssetType.image;
|
||||
@@ -71,6 +73,7 @@ sealed class BaseAsset {
|
||||
height: ${height ?? "<NA>"},
|
||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||
isFavorite: $isFavorite,
|
||||
isEdited: $isEdited,
|
||||
}''';
|
||||
}
|
||||
|
||||
@@ -85,7 +88,8 @@ sealed class BaseAsset {
|
||||
width == other.width &&
|
||||
height == other.height &&
|
||||
durationInSeconds == other.durationInSeconds &&
|
||||
isFavorite == other.isFavorite;
|
||||
isFavorite == other.isFavorite &&
|
||||
isEdited == other.isEdited;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -99,6 +103,7 @@ sealed class BaseAsset {
|
||||
width.hashCode ^
|
||||
height.hashCode ^
|
||||
durationInSeconds.hashCode ^
|
||||
isFavorite.hashCode;
|
||||
isFavorite.hashCode ^
|
||||
isEdited.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class LocalAsset extends BaseAsset {
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required super.isEdited,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
@@ -107,6 +108,7 @@ class LocalAsset extends BaseAsset {
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
bool? isEdited,
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -125,6 +127,7 @@ class LocalAsset extends BaseAsset {
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class RemoteAsset extends BaseAsset {
|
||||
this.visibility = AssetVisibility.timeline,
|
||||
super.livePhotoVideoId,
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -104,6 +105,7 @@ class RemoteAsset extends BaseAsset {
|
||||
AssetVisibility? visibility,
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -122,6 +124,7 @@ class RemoteAsset extends BaseAsset {
|
||||
visibility: visibility ?? this.visibility,
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||
@@ -20,13 +19,13 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||
@@ -243,13 +242,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
|
||||
return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
|
||||
}
|
||||
|
||||
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
|
||||
return _ref
|
||||
?.read(uploadServiceProvider)
|
||||
.startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken);
|
||||
?.read(foregroundUploadServiceProvider)
|
||||
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
|
||||
},
|
||||
(error, stack) {
|
||||
dPrint(() => "Error in backup zone $error, $stack");
|
||||
|
||||
@@ -436,5 +436,6 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ extension on AssetResponseDto {
|
||||
thumbHash: thumbhash,
|
||||
localId: null,
|
||||
type: type.toAssetType(),
|
||||
isEdited: isEdited,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,42 @@ 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();
|
||||
|
||||
@@ -196,6 +196,16 @@ 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;
|
||||
@@ -231,3 +241,8 @@ 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',
|
||||
);
|
||||
|
||||
@@ -47,5 +47,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
cloudId: iCloudId,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ SELECT
|
||||
NULL as i_cloud_id,
|
||||
NULL as latitude,
|
||||
NULL as longitude,
|
||||
NULL as adjustmentTime
|
||||
NULL as adjustmentTime,
|
||||
rae.is_edited
|
||||
FROM
|
||||
remote_asset_entity rae
|
||||
LEFT JOIN
|
||||
@@ -61,7 +62,8 @@ SELECT
|
||||
lae.i_cloud_id,
|
||||
lae.latitude,
|
||||
lae.longitude,
|
||||
lae.adjustment_time
|
||||
lae.adjustment_time,
|
||||
0 as is_edited
|
||||
FROM
|
||||
local_asset_entity lae
|
||||
WHERE NOT EXISTS (
|
||||
|
||||
@@ -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 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}',
|
||||
'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}',
|
||||
variables: [
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
...generatedlimit.introducedVariables,
|
||||
@@ -66,6 +66,7 @@ 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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -137,6 +138,7 @@ class MergedAssetResult {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final DateTime? adjustmentTime;
|
||||
final bool isEdited;
|
||||
MergedAssetResult({
|
||||
this.remoteId,
|
||||
this.localId,
|
||||
@@ -158,6 +160,7 @@ class MergedAssetResult {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.adjustmentTime,
|
||||
required this.isEdited,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ 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};
|
||||
}
|
||||
@@ -66,5 +68,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
localId: localId,
|
||||
stackId: stackId,
|
||||
isEdited: isEdited,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ typedef $$RemoteAssetEntityTableCreateCompanionBuilder =
|
||||
required i2.AssetVisibility visibility,
|
||||
i0.Value<String?> stackId,
|
||||
i0.Value<String?> libraryId,
|
||||
i0.Value<bool> isEdited,
|
||||
});
|
||||
typedef $$RemoteAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.RemoteAssetEntityCompanion Function({
|
||||
@@ -52,6 +53,7 @@ typedef $$RemoteAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<i2.AssetVisibility> visibility,
|
||||
i0.Value<String?> stackId,
|
||||
i0.Value<String?> libraryId,
|
||||
i0.Value<bool> isEdited,
|
||||
});
|
||||
|
||||
final class $$RemoteAssetEntityTableReferences
|
||||
@@ -196,6 +198,11 @@ 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,
|
||||
@@ -318,6 +325,11 @@ 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,
|
||||
@@ -417,6 +429,9 @@ 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,
|
||||
@@ -497,6 +512,7 @@ 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,
|
||||
@@ -516,6 +532,7 @@ class $$RemoteAssetEntityTableTableManager
|
||||
visibility: visibility,
|
||||
stackId: stackId,
|
||||
libraryId: libraryId,
|
||||
isEdited: isEdited,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -537,6 +554,7 @@ 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,
|
||||
@@ -556,6 +574,7 @@ class $$RemoteAssetEntityTableTableManager
|
||||
visibility: visibility,
|
||||
stackId: stackId,
|
||||
libraryId: libraryId,
|
||||
isEdited: isEdited,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
@@ -844,6 +863,21 @@ 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,
|
||||
@@ -864,6 +898,7 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
|
||||
visibility,
|
||||
stackId,
|
||||
libraryId,
|
||||
isEdited,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -987,6 +1022,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1075,6 +1116,10 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}library_id'],
|
||||
),
|
||||
isEdited: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_edited'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1115,6 +1160,7 @@ 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,
|
||||
@@ -1134,6 +1180,7 @@ class RemoteAssetEntityData extends i0.DataClass
|
||||
required this.visibility,
|
||||
this.stackId,
|
||||
this.libraryId,
|
||||
required this.isEdited,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -1182,6 +1229,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1213,6 +1261,7 @@ class RemoteAssetEntityData extends i0.DataClass
|
||||
),
|
||||
stackId: serializer.fromJson<String?>(json['stackId']),
|
||||
libraryId: serializer.fromJson<String?>(json['libraryId']),
|
||||
isEdited: serializer.fromJson<bool>(json['isEdited']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -1241,6 +1290,7 @@ class RemoteAssetEntityData extends i0.DataClass
|
||||
),
|
||||
'stackId': serializer.toJson<String?>(stackId),
|
||||
'libraryId': serializer.toJson<String?>(libraryId),
|
||||
'isEdited': serializer.toJson<bool>(isEdited),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1263,6 +1313,7 @@ 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,
|
||||
@@ -1288,6 +1339,7 @@ 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(
|
||||
@@ -1319,6 +1371,7 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1342,7 +1395,8 @@ class RemoteAssetEntityData extends i0.DataClass
|
||||
..write('livePhotoVideoId: $livePhotoVideoId, ')
|
||||
..write('visibility: $visibility, ')
|
||||
..write('stackId: $stackId, ')
|
||||
..write('libraryId: $libraryId')
|
||||
..write('libraryId: $libraryId, ')
|
||||
..write('isEdited: $isEdited')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1367,6 +1421,7 @@ class RemoteAssetEntityData extends i0.DataClass
|
||||
visibility,
|
||||
stackId,
|
||||
libraryId,
|
||||
isEdited,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1389,7 +1444,8 @@ class RemoteAssetEntityData extends i0.DataClass
|
||||
other.livePhotoVideoId == this.livePhotoVideoId &&
|
||||
other.visibility == this.visibility &&
|
||||
other.stackId == this.stackId &&
|
||||
other.libraryId == this.libraryId);
|
||||
other.libraryId == this.libraryId &&
|
||||
other.isEdited == this.isEdited);
|
||||
}
|
||||
|
||||
class RemoteAssetEntityCompanion
|
||||
@@ -1412,6 +1468,7 @@ 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(),
|
||||
@@ -1431,6 +1488,7 @@ 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,
|
||||
@@ -1451,6 +1509,7 @@ 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),
|
||||
@@ -1476,6 +1535,7 @@ 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,
|
||||
@@ -1496,6 +1556,7 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1518,6 +1579,7 @@ 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,
|
||||
@@ -1538,6 +1600,7 @@ class RemoteAssetEntityCompanion
|
||||
visibility: visibility ?? this.visibility,
|
||||
stackId: stackId ?? this.stackId,
|
||||
libraryId: libraryId ?? this.libraryId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1602,6 +1665,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1625,7 +1691,8 @@ class RemoteAssetEntityCompanion
|
||||
..write('livePhotoVideoId: $livePhotoVideoId, ')
|
||||
..write('visibility: $visibility, ')
|
||||
..write('stackId: $stackId, ')
|
||||
..write('libraryId: $libraryId')
|
||||
..write('libraryId: $libraryId, ')
|
||||
..write('isEdited: $isEdited')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -45,5 +45,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
|
||||
height: height,
|
||||
width: width,
|
||||
orientation: orientation,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 16;
|
||||
int get schemaVersion => 17;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -201,6 +201,9 @@ 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -6911,6 +6911,503 @@ 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,
|
||||
@@ -6927,6 +7424,7 @@ 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) {
|
||||
@@ -7005,6 +7503,11 @@ 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');
|
||||
}
|
||||
@@ -7027,6 +7530,7 @@ 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,
|
||||
@@ -7044,5 +7548,6 @@ i1.OnUpgrade stepByStep({
|
||||
from13To14: from13To14,
|
||||
from14To15: from14To15,
|
||||
from15To16: from15To16,
|
||||
from16To17: from16To17,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class StorageRepository {
|
||||
const StorageRepository();
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
StorageRepository();
|
||||
|
||||
Future<File?> getFileForAsset(String assetId) async {
|
||||
File? file;
|
||||
@@ -82,6 +84,51 @@ class StorageRepository {
|
||||
return entity;
|
||||
}
|
||||
|
||||
Future<bool> isAssetAvailableLocally(String assetId) async {
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return false;
|
||||
}
|
||||
|
||||
return await entity.isLocallyAvailable(isOrigin: true);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error checking if asset is locally available $assetId", error, stackTrace);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await entity.loadFile(progressHandler: progressHandler);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
libraryId: Value(asset.libraryId),
|
||||
width: Value(asset.width),
|
||||
height: Value(asset.height),
|
||||
isEdited: Value(asset.isEdited),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
|
||||
@@ -70,6 +70,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
durationInSeconds: row.durationInSeconds,
|
||||
livePhotoVideoId: row.livePhotoVideoId,
|
||||
stackId: row.stackId,
|
||||
isEdited: row.isEdited,
|
||||
)
|
||||
: LocalAsset(
|
||||
id: row.localId!,
|
||||
@@ -88,6 +89,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
adjustmentTime: row.adjustmentTime,
|
||||
isEdited: row.isEdited,
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:path/path.dart';
|
||||
|
||||
enum ShareIntentAttachmentType { image, video }
|
||||
|
||||
enum UploadStatus { enqueued, running, complete, notFound, failed, canceled, waitingToRetry, paused }
|
||||
enum UploadStatus { enqueued, running, complete, failed }
|
||||
|
||||
class ShareIntentAttachment {
|
||||
final String path;
|
||||
|
||||
@@ -93,11 +93,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
Logger("DriftBackupPage").warning("Remote sync did not complete successfully, skipping backup");
|
||||
return;
|
||||
}
|
||||
await backupNotifier.startBackup(currentUser.id);
|
||||
await backupNotifier.startForegroundBackup(currentUser.id);
|
||||
}
|
||||
|
||||
Future<void> stopBackup() async {
|
||||
await backupNotifier.cancel();
|
||||
await backupNotifier.stopForegroundBackup();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
|
||||
@@ -113,10 +113,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
||||
if (isBackupEnabled) {
|
||||
unawaited(
|
||||
backupNotifier.cancel().whenComplete(
|
||||
backupNotifier.stopForegroundBackup().whenComplete(
|
||||
() => backgroundSync.syncRemote().then((success) {
|
||||
if (success) {
|
||||
return backupNotifier.startBackup(user.id);
|
||||
return backupNotifier.startForegroundBackup(user.id);
|
||||
} else {
|
||||
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
|
||||
}
|
||||
|
||||
@@ -60,10 +60,10 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||
unawaited(
|
||||
backupNotifier.cancel().whenComplete(
|
||||
backupNotifier.stopForegroundBackup().whenComplete(
|
||||
() => backgroundSync.syncRemote().then((success) {
|
||||
if (success) {
|
||||
return backupNotifier.startBackup(currentUser.id);
|
||||
return backupNotifier.startForegroundBackup(currentUser.id);
|
||||
} else {
|
||||
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
|
||||
}
|
||||
|
||||
@@ -11,12 +11,70 @@ import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
@RoutePage()
|
||||
class DriftUploadDetailPage extends ConsumerWidget {
|
||||
class DriftUploadDetailPage extends ConsumerStatefulWidget {
|
||||
const DriftUploadDetailPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<DriftUploadDetailPage> createState() => _DriftUploadDetailPageState();
|
||||
}
|
||||
|
||||
class _DriftUploadDetailPageState extends ConsumerState<DriftUploadDetailPage> {
|
||||
final Set<String> _seenTaskIds = {};
|
||||
final Set<String> _failedTaskIds = {};
|
||||
|
||||
final Map<String, int> _taskSlotAssignments = {};
|
||||
static const int _maxSlots = 3;
|
||||
|
||||
/// Assigns uploading items to fixed slots to prevent jumping when items complete
|
||||
List<DriftUploadStatus?> _assignItemsToSlots(List<DriftUploadStatus> uploadingItems) {
|
||||
final slots = List<DriftUploadStatus?>.filled(_maxSlots, null);
|
||||
final currentTaskIds = uploadingItems.map((e) => e.taskId).toSet();
|
||||
|
||||
_taskSlotAssignments.removeWhere((taskId, _) => !currentTaskIds.contains(taskId));
|
||||
|
||||
for (final item in uploadingItems) {
|
||||
final existingSlot = _taskSlotAssignments[item.taskId];
|
||||
if (existingSlot != null && existingSlot < _maxSlots) {
|
||||
slots[existingSlot] = item;
|
||||
}
|
||||
}
|
||||
|
||||
for (final item in uploadingItems) {
|
||||
if (_taskSlotAssignments.containsKey(item.taskId)) continue;
|
||||
|
||||
for (int i = 0; i < _maxSlots; i++) {
|
||||
if (slots[i] == null) {
|
||||
slots[i] = item;
|
||||
_taskSlotAssignments[item.taskId] = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uploadItems = ref.watch(driftBackupProvider.select((state) => state.uploadItems));
|
||||
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
|
||||
|
||||
for (final item in uploadItems.values) {
|
||||
if (item.isFailed == true) {
|
||||
_failedTaskIds.add(item.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
for (final item in uploadItems.values) {
|
||||
if (item.progress >= 1.0 && item.isFailed != true && !_failedTaskIds.contains(item.taskId)) {
|
||||
if (!_seenTaskIds.contains(item.taskId)) {
|
||||
_seenTaskIds.add(item.taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final uploadingItems = uploadItems.values.where((item) => item.progress < 1.0 && item.isFailed != true).toList();
|
||||
final failedItems = uploadItems.values.where((item) => item.isFailed == true).toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -25,98 +83,326 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
),
|
||||
body: uploadItems.isEmpty ? _buildEmptyState(context) : _buildUploadList(uploadItems),
|
||||
body: _buildTwoSectionLayout(context, uploadingItems, failedItems, iCloudProgress),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.cloud_off_rounded, size: 80, color: context.colorScheme.onSurface.withValues(alpha: 0.3)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"no_uploads_in_progress".t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||
Widget _buildTwoSectionLayout(
|
||||
BuildContext context,
|
||||
List<DriftUploadStatus> uploadingItems,
|
||||
List<DriftUploadStatus> failedItems,
|
||||
Map<String, double> iCloudProgress,
|
||||
) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// iCloud Downloads Section
|
||||
if (iCloudProgress.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSectionHeader(
|
||||
context,
|
||||
title: "Downloading from iCloud",
|
||||
count: iCloudProgress.length,
|
||||
color: context.colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final entry = iCloudProgress.entries.elementAt(index);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildICloudDownloadCard(context, entry.key, entry.value),
|
||||
);
|
||||
}, childCount: iCloudProgress.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Uploading Section
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSectionHeader(
|
||||
context,
|
||||
title: "uploading".t(context: context),
|
||||
count: uploadingItems.length,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
// Use slot-based assignment to prevent items from jumping
|
||||
final slots = _assignItemsToSlots(uploadingItems);
|
||||
final item = slots[index];
|
||||
if (item != null) {
|
||||
return _buildCurrentUploadCard(context, item);
|
||||
} else {
|
||||
return _buildPlaceholderCard(context);
|
||||
}
|
||||
}, childCount: 3),
|
||||
),
|
||||
),
|
||||
|
||||
// Errors Section
|
||||
if (failedItems.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSectionHeader(
|
||||
context,
|
||||
title: "errors_text".t(context: context),
|
||||
count: failedItems.length,
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = failedItems[index];
|
||||
return Padding(padding: const EdgeInsets.only(bottom: 8), child: _buildErrorCard(context, item));
|
||||
}, childCount: failedItems.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, {required String title, int? count, required Color color}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600, color: color),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
count != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, color: color),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadList(Map<String, DriftUploadStatus> uploadItems) {
|
||||
return ListView.separated(
|
||||
addAutomaticKeepAlives: true,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: uploadItems.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final item = uploadItems.values.elementAt(index);
|
||||
return _buildUploadCard(context, item);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) {
|
||||
final isCompleted = item.progress >= 1.0;
|
||||
final double progressPercentage = (item.progress * 100).clamp(0, 100);
|
||||
Widget _buildICloudDownloadCard(BuildContext context, String assetId, double progress) {
|
||||
final double progressPercentage = (progress * 100).clamp(0, 100);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: item.isFailed != null ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainer,
|
||||
color: context.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: context.colorScheme.tertiary.withValues(alpha: 0.3), width: 1),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _showFileDetailDialog(context, item),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.tertiary.withValues(alpha: 0.2),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Icon(Icons.cloud_download_rounded, size: 24, color: context.colorScheme.tertiary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
path.basename(item.filename),
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (item.error != null)
|
||||
Text(
|
||||
item.error!,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.onErrorContainer.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"backup_upload_details_page_more_details".t(context: context),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"downloading_from_icloud".t(context: context),
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
_buildProgressIndicator(
|
||||
context,
|
||||
item.progress,
|
||||
progressPercentage,
|
||||
isCompleted,
|
||||
item.networkSpeedAsString,
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
assetId,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: context.colorScheme.tertiary.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation(context.colorScheme.tertiary),
|
||||
minHeight: 4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
"${progressPercentage.toStringAsFixed(0)}%",
|
||||
textAlign: TextAlign.right,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentUploadCard(BuildContext context, DriftUploadStatus item) {
|
||||
final double progressPercentage = (item.progress * 100).clamp(0, 100);
|
||||
final isFailed = item.isFailed == true;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: isFailed
|
||||
? context.colorScheme.errorContainer
|
||||
: context.colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(
|
||||
color: isFailed
|
||||
? context.colorScheme.error.withValues(alpha: 0.3)
|
||||
: context.colorScheme.primary.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _showFileDetailDialog(context, item),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Row(
|
||||
children: [
|
||||
_CurrentUploadThumbnail(taskId: item.taskId),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
path.basename(item.filename),
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
isFailed
|
||||
? item.error ?? "unable_to_upload_file".t(context: context)
|
||||
: "${formatHumanReadableBytes(item.fileSize, 1)} • ${item.networkSpeedAsString}",
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: isFailed
|
||||
? context.colorScheme.error
|
||||
: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!isFailed) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: LinearProgressIndicator(
|
||||
value: item.progress,
|
||||
backgroundColor: context.colorScheme.primary.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation(context.colorScheme.primary),
|
||||
minHeight: 4,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: isFailed
|
||||
? Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28)
|
||||
: Text(
|
||||
"${progressPercentage.toStringAsFixed(0)}%",
|
||||
textAlign: TextAlign.right,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorCard(BuildContext context, DriftUploadStatus item) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: context.colorScheme.errorContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: context.colorScheme.error.withValues(alpha: 0.3), width: 1),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _showFileDetailDialog(context, item),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
_CurrentUploadThumbnail(taskId: item.taskId),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
path.basename(item.filename),
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.error ?? "unable_to_upload_file".t(context: context),
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -124,49 +410,84 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressIndicator(
|
||||
BuildContext context,
|
||||
double progress,
|
||||
double percentage,
|
||||
bool isCompleted,
|
||||
String networkSpeedAsString,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: TweenAnimationBuilder(
|
||||
tween: Tween<double>(begin: 0.0, end: progress),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, value, _) => CircularProgressIndicator(
|
||||
backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2),
|
||||
strokeWidth: 3,
|
||||
value: value,
|
||||
color: isCompleted ? context.colorScheme.primary : context.colorScheme.secondary,
|
||||
Widget _buildPlaceholderCard(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: context.colorScheme.surfaceContainerLow.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1, style: BorderStyle.solid),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.outline.withValues(alpha: 0.1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.hourglass_empty_rounded,
|
||||
size: 24,
|
||||
color: context.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isCompleted)
|
||||
Icon(Icons.check_circle_rounded, size: 28, color: context.colorScheme.primary)
|
||||
else
|
||||
Text(
|
||||
percentage.toStringAsFixed(0),
|
||||
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 14,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.outline.withValues(alpha: 0.1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
height: 10,
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.outline.withValues(alpha: 0.08),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.outline.withValues(alpha: 0.1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
networkSpeedAsString,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
"0%",
|
||||
textAlign: TextAlign.right,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,9 +499,44 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _CurrentUploadThumbnail extends ConsumerWidget {
|
||||
final String taskId;
|
||||
const _CurrentUploadThumbnail({required this.taskId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return FutureBuilder<LocalAsset?>(
|
||||
future: _getAsset(ref),
|
||||
builder: (context, snapshot) {
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primary.withValues(alpha: 0.2),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: snapshot.data != null
|
||||
? Thumbnail.fromAsset(asset: snapshot.data!, size: const Size(48, 48), fit: BoxFit.cover)
|
||||
: Icon(Icons.image, size: 24, color: context.colorScheme.primary),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<LocalAsset?> _getAsset(WidgetRef ref) async {
|
||||
try {
|
||||
return await ref.read(localAssetRepository).getById(taskId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FileDetailDialog extends ConsumerWidget {
|
||||
final DriftUploadStatus uploadStatus;
|
||||
|
||||
const FileDetailDialog({super.key, required this.uploadStatus});
|
||||
|
||||
@override
|
||||
@@ -212,14 +568,12 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
final asset = snapshot.data;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Thumbnail at the top center
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
@@ -237,7 +591,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (asset != null) ...[
|
||||
if (asset != null)
|
||||
_buildInfoSection(context, [
|
||||
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
|
||||
_buildInfoRow(context, "local_id".t(context: context), asset.id),
|
||||
@@ -254,7 +608,6 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
if (asset.checksum != null)
|
||||
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
|
||||
]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -282,7 +635,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
border: Border.all(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [...children]),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -303,12 +656,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: context.textTheme.labelMedium?.copyWith(),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: Text(value, style: context.textTheme.labelMedium, maxLines: 3, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -317,8 +665,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
|
||||
Future<LocalAsset?> _getAssetDetails(WidgetRef ref, String localAssetId) async {
|
||||
try {
|
||||
final repository = ref.read(localAssetRepository);
|
||||
return await repository.getById(localAssetId);
|
||||
return await ref.read(localAssetRepository).getById(localAssetId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class _MobileLayout extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]);
|
||||
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ class SettingsSubPage extends StatelessWidget {
|
||||
context.locale;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
|
||||
body: section.widget,
|
||||
body: Padding(padding: const EdgeInsets.only(bottom: 60.0), child: section.widget),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -49,6 +50,7 @@ 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);
|
||||
@@ -58,6 +60,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
(_) async {
|
||||
try {
|
||||
wsProvider.connect();
|
||||
unawaited(infoProvider.getServerInfo());
|
||||
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
bool syncSuccess = false;
|
||||
@@ -130,7 +133,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
if (currentUser != null) {
|
||||
unawaited(notifier.handleBackupResume(currentUser.id));
|
||||
unawaited(notifier.startForegroundBackup(currentUser.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class SyncStatusPage extends StatelessWidget {
|
||||
splashRadius: 24,
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: const SyncStatusAndActions(),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@@ -12,7 +11,7 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ShareIntentPage extends HookConsumerWidget {
|
||||
class ShareIntentPage extends ConsumerWidget {
|
||||
const ShareIntentPage({super.key, required this.attachments});
|
||||
|
||||
final List<ShareIntentAttachment> attachments;
|
||||
@@ -21,12 +20,13 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentEndpoint = getServerUrl() ?? '--';
|
||||
final candidates = ref.watch(shareIntentUploadProvider);
|
||||
final isUploaded = useState(false);
|
||||
useOnAppLifecycleStateChange((previous, current) {
|
||||
if (current == AppLifecycleState.resumed) {
|
||||
isUploaded.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
final isUploading = candidates.any((candidate) => candidate.status == UploadStatus.running);
|
||||
final isUploaded =
|
||||
candidates.isNotEmpty &&
|
||||
candidates.every(
|
||||
(candidate) => candidate.status == UploadStatus.complete || candidate.status == UploadStatus.failed,
|
||||
);
|
||||
|
||||
void removeAttachment(ShareIntentAttachment attachment) {
|
||||
ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment);
|
||||
@@ -37,11 +37,8 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void upload() async {
|
||||
for (final attachment in candidates) {
|
||||
await ref.read(shareIntentUploadProvider.notifier).upload(attachment.file);
|
||||
}
|
||||
|
||||
isUploaded.value = true;
|
||||
final files = candidates.map((candidate) => candidate.file).toList();
|
||||
await ref.read(shareIntentUploadProvider.notifier).uploadAll(files);
|
||||
}
|
||||
|
||||
bool isSelected(ShareIntentAttachment attachment) {
|
||||
@@ -84,7 +81,7 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16),
|
||||
child: LargeLeadingTile(
|
||||
onTap: () => toggleSelection(attachment),
|
||||
disabled: isUploaded.value,
|
||||
disabled: isUploading || isUploaded,
|
||||
selected: isSelected(attachment),
|
||||
leading: Stack(
|
||||
children: [
|
||||
@@ -131,8 +128,8 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: isUploaded.value ? null : upload,
|
||||
child: isUploaded.value ? UploadingText(candidates: candidates) : const Text('upload').tr(),
|
||||
onPressed: (isUploading || isUploaded) ? null : upload,
|
||||
child: (isUploading || isUploaded) ? UploadingText(candidates: candidates) : const Text('upload').tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -204,14 +201,7 @@ class UploadStatusIcon extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
UploadStatus.complete => Icon(Icons.check_circle_rounded, color: Colors.green, semanticLabel: 'completed'.tr()),
|
||||
UploadStatus.notFound ||
|
||||
UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: 'failed'.tr()),
|
||||
UploadStatus.canceled => Icon(Icons.cancel_rounded, color: Colors.red, semanticLabel: 'canceled'.tr()),
|
||||
UploadStatus.waitingToRetry || UploadStatus.paused => Icon(
|
||||
Icons.pause_circle_rounded,
|
||||
color: context.primaryColor,
|
||||
semanticLabel: 'paused'.tr(),
|
||||
),
|
||||
};
|
||||
|
||||
return statusIcon;
|
||||
|
||||
137
mobile/lib/platform/local_image_api.g.dart
generated
Normal file
137
mobile/lib/platform/local_image_api.g.dart
generated
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
101
mobile/lib/platform/remote_image_api.g.dart
generated
Normal file
101
mobile/lib/platform/remote_image_api.g.dart
generated
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ 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()),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ class _PlaceTile extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover),
|
||||
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover, thumbhash: ""),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -12,7 +13,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -78,7 +79,7 @@ class DriftEditImagePage extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(uploadServiceProvider).manualBackup([localAsset]);
|
||||
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken());
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 6,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class UploadActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
@@ -20,19 +25,38 @@ class UploadActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).upload(source);
|
||||
final isTimeline = source == ActionSource.timeline;
|
||||
List<LocalAsset>? assets;
|
||||
|
||||
final successMessage = 'upload_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
if (source == ActionSource.timeline) {
|
||||
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
} else {
|
||||
unawaited(
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => const _UploadProgressDialog(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||
|
||||
if (!isTimeline && context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
if (context.mounted && !result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +71,42 @@ class UploadActionButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadProgressDialog extends ConsumerWidget {
|
||||
const _UploadProgressDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final progressMap = ref.watch(assetUploadProgressProvider);
|
||||
|
||||
// Calculate overall progress from all assets
|
||||
final values = progressMap.values.where((v) => v >= 0).toList();
|
||||
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
|
||||
final hasError = progressMap.values.any((v) => v < 0);
|
||||
final percentage = (progress * 100).toInt();
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('uploading'.t(context: context)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasError)
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48)
|
||||
else
|
||||
CircularProgressIndicator(value: progress > 0 ? progress : null),
|
||||
const SizedBox(height: 16),
|
||||
Text(hasError ? 'Error' : '$percentage%'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {
|
||||
ref.read(manualUploadCancelTokenProvider)?.cancel();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
labelText: 'cancel'.t(context: context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,15 @@ 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/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/services/app_settings.service.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';
|
||||
@@ -310,18 +311,17 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
: const Icon(Icons.abc, color: Colors.transparent),
|
||||
onPressed: () => onMenuTapped(sortMode),
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(16, 16, 32, 16)),
|
||||
padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(12, 12, 24, 12)),
|
||||
backgroundColor: WidgetStateProperty.all(
|
||||
albumSortOption == sortMode ? context.colorScheme.primary : Colors.transparent,
|
||||
),
|
||||
shape: WidgetStateProperty.all(
|
||||
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
|
||||
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
sortMode.label.t(context: context),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: albumSortOption == sortMode
|
||||
? context.colorScheme.onPrimary
|
||||
: context.colorScheme.onSurface.withAlpha(185),
|
||||
@@ -344,15 +344,12 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: albumSortIsReverse
|
||||
? const Icon(Icons.keyboard_arrow_down)
|
||||
: const Icon(Icons.keyboard_arrow_up_rounded),
|
||||
? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface)
|
||||
: Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface),
|
||||
),
|
||||
Text(
|
||||
albumSortOption.label.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: context.colorScheme.onSurface.withAlpha(225),
|
||||
),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(225)),
|
||||
),
|
||||
isSorting
|
||||
? SizedBox(
|
||||
@@ -542,7 +539,11 @@ class _QuickSortAndViewMode extends StatelessWidget {
|
||||
initialIsReverse: currentIsReverse,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
|
||||
icon: Icon(
|
||||
isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined,
|
||||
size: 24,
|
||||
color: context.colorScheme.onSurface,
|
||||
),
|
||||
onPressed: onToggleViewMode,
|
||||
),
|
||||
],
|
||||
@@ -662,6 +663,8 @@ 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(
|
||||
@@ -680,12 +683,22 @@ class _GridAlbumCard extends ConsumerWidget {
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
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),
|
||||
),
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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 StatelessWidget {
|
||||
class AlbumTile extends ConsumerWidget {
|
||||
const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected});
|
||||
|
||||
final RemoteAlbum album;
|
||||
@@ -14,7 +16,9 @@ class AlbumTile extends StatelessWidget {
|
||||
final Function(RemoteAlbum)? onAlbumSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
|
||||
|
||||
return LargeLeadingTile(
|
||||
title: Text(
|
||||
album.name,
|
||||
@@ -29,23 +33,35 @@ class AlbumTile extends StatelessWidget {
|
||||
),
|
||||
onTap: () => onAlbumSelected?.call(album),
|
||||
leadingPadding: const EdgeInsets.only(right: 16),
|
||||
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),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -164,11 +165,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
children: [
|
||||
if (albums.isNotEmpty)
|
||||
SheetTile(
|
||||
title: 'appears_in'.t(context: context).toUpperCase(),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
title: 'appears_in'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24),
|
||||
@@ -224,9 +222,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getFileInfo(asset, exifInfo),
|
||||
subtitleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -241,9 +237,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getFileInfo(asset, exifInfo),
|
||||
subtitleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -262,11 +256,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
const SheetLocationDetails(),
|
||||
// Details header
|
||||
SheetTile(
|
||||
title: 'details'.t(context: context).toUpperCase(),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
title: 'details'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
// File info
|
||||
buildFileInfoTile(),
|
||||
@@ -278,9 +269,7 @@ 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.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
// Lens info
|
||||
@@ -291,15 +280,13 @@ 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.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
// 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: 30),
|
||||
const SizedBox(height: 60),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -77,11 +78,8 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SheetTile(
|
||||
title: 'location'.t(context: context).toUpperCase(),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
title: 'location'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
|
||||
onTap: editLocation,
|
||||
),
|
||||
@@ -105,9 +103,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
),
|
||||
Text(
|
||||
coordinates,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
),
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -53,11 +54,8 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16),
|
||||
child: Text(
|
||||
"people".t(context: context).toUpperCase(),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
"people".t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
|
||||
@@ -96,7 +96,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
try {
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await const StorageRepository().getFileForAsset(id);
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
if (!context.mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
@@ -57,17 +56,13 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enqueueCount = ref.watch(driftBackupProvider.select((state) => state.enqueueCount));
|
||||
|
||||
final enqueueTotalCount = ref.watch(driftBackupProvider.select((state) => state.enqueueTotalCount));
|
||||
|
||||
final isCanceling = ref.watch(driftBackupProvider.select((state) => state.isCanceling));
|
||||
|
||||
final uploadTasks = ref.watch(driftBackupProvider.select((state) => state.uploadItems));
|
||||
|
||||
final isSyncing = ref.watch(driftBackupProvider.select((state) => state.isSyncing));
|
||||
|
||||
final isProcessing = uploadTasks.isNotEmpty || isSyncing;
|
||||
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
|
||||
|
||||
final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
@@ -115,7 +110,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
||||
onTap: () => isCanceling ? null : _onToggle(!_isEnabled),
|
||||
onTap: () => _onToggle(!_isEnabled),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
@@ -154,35 +149,10 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
),
|
||||
],
|
||||
),
|
||||
if (enqueueCount != enqueueTotalCount)
|
||||
Text(
|
||||
"queue_status".t(
|
||||
context: context,
|
||||
args: {'count': enqueueCount.toString(), 'total': enqueueTotalCount.toString()},
|
||||
),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
),
|
||||
),
|
||||
if (isCanceling)
|
||||
Row(
|
||||
children: [
|
||||
Text("canceling".t(), style: context.textTheme.labelLarge),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
backgroundColor: context.colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch.adaptive(value: _isEnabled, onChanged: (value) => isCanceling ? null : _onToggle(value)),
|
||||
Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -112,14 +112,17 @@ 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);
|
||||
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash);
|
||||
}
|
||||
|
||||
return provider;
|
||||
@@ -132,8 +135,9 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
||||
}
|
||||
|
||||
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null;
|
||||
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
|
||||
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
|
||||
}
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));
|
||||
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited;
|
||||
|
||||
@@ -7,17 +7,16 @@ 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});
|
||||
RemoteThumbProvider({required this.assetId, required this.thumbhash});
|
||||
|
||||
@override
|
||||
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -38,9 +37,8 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
|
||||
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId),
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
return loadRequest(request, decode);
|
||||
}
|
||||
@@ -49,22 +47,22 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteThumbProvider) {
|
||||
return assetId == other.assetId;
|
||||
return assetId == other.assetId && thumbhash == other.thumbhash;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
|
||||
}
|
||||
|
||||
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
||||
with CancellableImageProviderMixin<RemoteFullImageProvider> {
|
||||
static final cacheManager = RemoteThumbnailCacheManager();
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
|
||||
RemoteFullImageProvider({required this.assetId});
|
||||
RemoteFullImageProvider({required this.assetId, required this.thumbhash});
|
||||
|
||||
@override
|
||||
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -75,7 +73,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId)),
|
||||
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
@@ -94,9 +92,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview),
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
||||
headers: headers,
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
yield* loadRequest(request, decode);
|
||||
|
||||
@@ -115,12 +112,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteFullImageProvider) {
|
||||
return assetId == other.assetId;
|
||||
return assetId == other.assetId && thumbhash == other.thumbhash;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,14 @@ class Thumbnail extends StatefulWidget {
|
||||
|
||||
const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key});
|
||||
|
||||
Thumbnail.remote({required String remoteId, this.fit = BoxFit.cover, Size size = kThumbnailResolution, super.key})
|
||||
: imageProvider = RemoteThumbProvider(assetId: remoteId),
|
||||
thumbhashProvider = null;
|
||||
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.fromAsset({
|
||||
required BaseAsset? asset,
|
||||
|
||||
@@ -6,8 +6,10 @@ 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';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
@@ -46,6 +48,7 @@ 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)
|
||||
@@ -58,10 +61,18 @@ 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;
|
||||
}
|
||||
|
||||
final uploadProgress = asset is LocalAsset
|
||||
? ref.watch(assetUploadProgressProvider.select((map) => map[asset.id]))
|
||||
: null;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
@@ -91,7 +102,11 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Hero(
|
||||
tag: '${asset?.heroTag ?? ''}_$heroIndex',
|
||||
// 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',
|
||||
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) {
|
||||
@@ -168,6 +183,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (uploadProgress != null) _UploadProgressOverlay(progress: uploadProgress),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -293,3 +309,46 @@ class _AssetTypeIcons extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadProgressOverlay extends StatelessWidget {
|
||||
final double progress;
|
||||
|
||||
const _UploadProgressOverlay({required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isError = progress < 0;
|
||||
final percentage = isError ? 0 : (progress * 100).toInt();
|
||||
|
||||
return Positioned.fill(
|
||||
child: Container(
|
||||
color: isError ? Colors.red.withValues(alpha: 0.6) : Colors.black54,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isError)
|
||||
const Icon(Icons.error_outline, color: Colors.white, size: 36)
|
||||
else
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: 3,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isError ? 'Error' : '$percentage%',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,11 @@ class DriftMemoryCard extends ConsumerWidget {
|
||||
child: SizedBox(
|
||||
width: 205,
|
||||
height: 200,
|
||||
child: Thumbnail.remote(remoteId: memory.assets[0].id, fit: BoxFit.cover),
|
||||
child: Thumbnail.remote(
|
||||
remoteId: memory.assets[0].id,
|
||||
thumbhash: memory.assets[0].thumbHash ?? "",
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -160,7 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
_resumeBackup();
|
||||
}),
|
||||
_resumeBackup(),
|
||||
backgroundManager.syncCloudIds(),
|
||||
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
]);
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
@@ -181,7 +181,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
if (currentUser != null) {
|
||||
await _safeRun(
|
||||
_ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
|
||||
_ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),
|
||||
"handleBackupResume",
|
||||
);
|
||||
}
|
||||
@@ -238,6 +238,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) {
|
||||
_ref.read(backupProvider.notifier).cancelBackup();
|
||||
}
|
||||
} else {
|
||||
await _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
|
||||
}
|
||||
|
||||
_ref.read(websocketProvider.notifier).disconnect();
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
((ref) => ShareIntentUploadStateNotifier(
|
||||
ref.watch(appRouterProvider),
|
||||
ref.watch(uploadServiceProvider),
|
||||
ref.watch(shareIntentServiceProvider),
|
||||
ref.read(foregroundUploadServiceProvider),
|
||||
ref.read(shareIntentServiceProvider),
|
||||
)),
|
||||
);
|
||||
|
||||
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
||||
final AppRouter router;
|
||||
final UploadService _uploadService;
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
|
||||
_uploadService.taskStatusStream.listen(_updateUploadStatus);
|
||||
_uploadService.taskProgressStream.listen(_taskProgressCallback);
|
||||
}
|
||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||
|
||||
void init() {
|
||||
_shareIntentService.onSharedMedia = onSharedMedia;
|
||||
@@ -67,97 +58,44 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
state = [];
|
||||
}
|
||||
|
||||
void _updateUploadStatus(TaskStatusUpdate task) async {
|
||||
if (task.status == TaskStatus.canceled) {
|
||||
return;
|
||||
Future<void> uploadAll(List<File> files) async {
|
||||
for (final file in files) {
|
||||
final fileId = p.hash(file.path).toString();
|
||||
_updateStatus(fileId, UploadStatus.running);
|
||||
}
|
||||
|
||||
final taskId = task.task.taskId;
|
||||
final uploadStatus = switch (task.status) {
|
||||
TaskStatus.complete => UploadStatus.complete,
|
||||
TaskStatus.failed => UploadStatus.failed,
|
||||
TaskStatus.canceled => UploadStatus.canceled,
|
||||
TaskStatus.enqueued => UploadStatus.enqueued,
|
||||
TaskStatus.running => UploadStatus.running,
|
||||
TaskStatus.paused => UploadStatus.paused,
|
||||
TaskStatus.notFound => UploadStatus.notFound,
|
||||
TaskStatus.waitingToRetry => UploadStatus.waitingToRetry,
|
||||
};
|
||||
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
|
||||
];
|
||||
|
||||
if (task.status == TaskStatus.failed) {
|
||||
String? error;
|
||||
final exception = task.exception;
|
||||
if (exception != null && exception is TaskHttpException) {
|
||||
final message = tryJsonDecode(exception.description)?['message'] as String?;
|
||||
if (message != null) {
|
||||
final responseCode = exception.httpResponseCode;
|
||||
error = "${exception.exceptionType}, response code $responseCode: $message";
|
||||
}
|
||||
}
|
||||
error ??= task.exception?.toString();
|
||||
|
||||
_logger.warning("Upload failed for asset: ${task.task.filename}, error: $error");
|
||||
}
|
||||
}
|
||||
|
||||
void _taskProgressCallback(TaskProgressUpdate update) {
|
||||
// Ignore if the task is canceled or completed
|
||||
if (update.progress == downloadFailed || update.progress == downloadCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final taskId = update.task.taskId;
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == taskId.toInt()) attachment.copyWith(uploadProgress: update.progress) else attachment,
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> upload(File file) async {
|
||||
final task = await _buildUploadTask(hash(file.path).toString(), file);
|
||||
|
||||
await _uploadService.enqueueTasks([task]);
|
||||
}
|
||||
|
||||
Future<UploadTask> _buildUploadTask(String id, File file, {Map<String, String>? fields}) async {
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final url = Uri.parse('$serverEndpoint/assets').toString();
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
|
||||
final stats = await file.stat();
|
||||
final fileCreatedAt = stats.changed;
|
||||
final fileModifiedAt = stats.modified;
|
||||
|
||||
final fieldsMap = {
|
||||
'filename': filename,
|
||||
'deviceAssetId': id,
|
||||
'deviceId': deviceId,
|
||||
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': 'false',
|
||||
'duration': '0',
|
||||
if (fields != null) ...fields,
|
||||
};
|
||||
|
||||
return UploadTask(
|
||||
taskId: id,
|
||||
httpRequestMethod: 'POST',
|
||||
url: url,
|
||||
headers: headers,
|
||||
filename: filename,
|
||||
fields: fieldsMap,
|
||||
baseDirectory: baseDirectory,
|
||||
directory: directory,
|
||||
fileField: 'assetData',
|
||||
group: kManualUploadGroup,
|
||||
updates: Updates.statusAndProgress,
|
||||
await _foregroundUploadService.uploadShareIntent(
|
||||
files,
|
||||
onProgress: (fileId, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
_updateProgress(fileId, progress);
|
||||
},
|
||||
onSuccess: (fileId) {
|
||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||
},
|
||||
onError: (fileId, errorMessage) {
|
||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||
_updateStatus(fileId, UploadStatus.failed);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id)
|
||||
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
|
||||
else
|
||||
attachment,
|
||||
];
|
||||
}
|
||||
|
||||
void _updateProgress(String fileId, double progress) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
@@ -34,6 +35,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final AuthService _authService;
|
||||
final ApiService _apiService;
|
||||
final UserService _userService;
|
||||
|
||||
final SecureStorageService _secureStorageService;
|
||||
final WidgetService _widgetService;
|
||||
final Ref _ref;
|
||||
@@ -45,6 +47,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
this._authService,
|
||||
this._apiService,
|
||||
this._userService,
|
||||
|
||||
this._secureStorageService,
|
||||
this._widgetService,
|
||||
this._ref,
|
||||
@@ -87,7 +90,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
await _widgetService.clearCredentials();
|
||||
|
||||
await _authService.logout();
|
||||
await _ref.read(uploadServiceProvider).cancelBackup();
|
||||
await _ref.read(backgroundUploadServiceProvider).cancel();
|
||||
_ref.read(foregroundUploadServiceProvider).cancel();
|
||||
} finally {
|
||||
await _cleanUp();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
/// Tracks per-asset upload progress.
|
||||
/// Key: local asset ID, Value: upload progress 0.0 to 1.0, or -1.0 for error
|
||||
class AssetUploadProgressNotifier extends Notifier<Map<String, double>> {
|
||||
static const double errorValue = -1.0;
|
||||
|
||||
@override
|
||||
Map<String, double> build() => {};
|
||||
|
||||
void setProgress(String localAssetId, double progress) {
|
||||
state = {...state, localAssetId: progress};
|
||||
}
|
||||
|
||||
void setError(String localAssetId) {
|
||||
state = {...state, localAssetId: errorValue};
|
||||
}
|
||||
|
||||
void remove(String localAssetId) {
|
||||
state = Map.from(state)..remove(localAssetId);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = {};
|
||||
}
|
||||
}
|
||||
|
||||
final assetUploadProgressProvider = NotifierProvider<AssetUploadProgressNotifier, Map<String, double>>(
|
||||
AssetUploadProgressNotifier.new,
|
||||
);
|
||||
|
||||
final manualUploadCancelTokenProvider = StateProvider<CancellationToken?>((ref) => null);
|
||||
@@ -1,19 +1,18 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
|
||||
class EnqueueStatus {
|
||||
final int enqueueCount;
|
||||
@@ -106,26 +105,24 @@ class DriftBackupState {
|
||||
final int remainderCount;
|
||||
final int processingCount;
|
||||
|
||||
final int enqueueCount;
|
||||
final int enqueueTotalCount;
|
||||
|
||||
final bool isSyncing;
|
||||
final bool isCanceling;
|
||||
final BackupError error;
|
||||
|
||||
final Map<String, DriftUploadStatus> uploadItems;
|
||||
final CancellationToken? cancelToken;
|
||||
|
||||
final Map<String, double> iCloudDownloadProgress;
|
||||
|
||||
const DriftBackupState({
|
||||
required this.totalCount,
|
||||
required this.backupCount,
|
||||
required this.remainderCount,
|
||||
required this.processingCount,
|
||||
required this.enqueueCount,
|
||||
required this.enqueueTotalCount,
|
||||
required this.isCanceling,
|
||||
required this.isSyncing,
|
||||
required this.uploadItems,
|
||||
this.error = BackupError.none,
|
||||
required this.uploadItems,
|
||||
this.cancelToken,
|
||||
this.iCloudDownloadProgress = const {},
|
||||
});
|
||||
|
||||
DriftBackupState copyWith({
|
||||
@@ -133,30 +130,28 @@ class DriftBackupState {
|
||||
int? backupCount,
|
||||
int? remainderCount,
|
||||
int? processingCount,
|
||||
int? enqueueCount,
|
||||
int? enqueueTotalCount,
|
||||
bool? isCanceling,
|
||||
bool? isSyncing,
|
||||
Map<String, DriftUploadStatus>? uploadItems,
|
||||
BackupError? error,
|
||||
Map<String, DriftUploadStatus>? uploadItems,
|
||||
CancellationToken? cancelToken,
|
||||
Map<String, double>? iCloudDownloadProgress,
|
||||
}) {
|
||||
return DriftBackupState(
|
||||
totalCount: totalCount ?? this.totalCount,
|
||||
backupCount: backupCount ?? this.backupCount,
|
||||
remainderCount: remainderCount ?? this.remainderCount,
|
||||
processingCount: processingCount ?? this.processingCount,
|
||||
enqueueCount: enqueueCount ?? this.enqueueCount,
|
||||
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
|
||||
isCanceling: isCanceling ?? this.isCanceling,
|
||||
isSyncing: isSyncing ?? this.isSyncing,
|
||||
uploadItems: uploadItems ?? this.uploadItems,
|
||||
error: error ?? this.error,
|
||||
uploadItems: uploadItems ?? this.uploadItems,
|
||||
cancelToken: cancelToken ?? this.cancelToken,
|
||||
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, isSyncing: $isSyncing, uploadItems: $uploadItems, error: $error)';
|
||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -168,12 +163,11 @@ class DriftBackupState {
|
||||
other.backupCount == backupCount &&
|
||||
other.remainderCount == remainderCount &&
|
||||
other.processingCount == processingCount &&
|
||||
other.enqueueCount == enqueueCount &&
|
||||
other.enqueueTotalCount == enqueueTotalCount &&
|
||||
other.isCanceling == isCanceling &&
|
||||
other.isSyncing == isSyncing &&
|
||||
other.error == error &&
|
||||
mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) &&
|
||||
mapEquals(other.uploadItems, uploadItems) &&
|
||||
other.error == error;
|
||||
other.cancelToken == cancelToken;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -182,44 +176,40 @@ class DriftBackupState {
|
||||
backupCount.hashCode ^
|
||||
remainderCount.hashCode ^
|
||||
processingCount.hashCode ^
|
||||
enqueueCount.hashCode ^
|
||||
enqueueTotalCount.hashCode ^
|
||||
isCanceling.hashCode ^
|
||||
isSyncing.hashCode ^
|
||||
error.hashCode ^
|
||||
uploadItems.hashCode ^
|
||||
error.hashCode;
|
||||
cancelToken.hashCode ^
|
||||
iCloudDownloadProgress.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
final driftBackupProvider = StateNotifierProvider<DriftBackupNotifier, DriftBackupState>((ref) {
|
||||
return DriftBackupNotifier(ref.watch(uploadServiceProvider));
|
||||
return DriftBackupNotifier(
|
||||
ref.watch(foregroundUploadServiceProvider),
|
||||
ref.watch(backgroundUploadServiceProvider),
|
||||
UploadSpeedManager(),
|
||||
);
|
||||
});
|
||||
|
||||
class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
DriftBackupNotifier(this._uploadService)
|
||||
DriftBackupNotifier(this._foregroundUploadService, this._backgroundUploadService, this._uploadSpeedManager)
|
||||
: super(
|
||||
const DriftBackupState(
|
||||
totalCount: 0,
|
||||
backupCount: 0,
|
||||
remainderCount: 0,
|
||||
processingCount: 0,
|
||||
enqueueCount: 0,
|
||||
enqueueTotalCount: 0,
|
||||
isCanceling: false,
|
||||
isSyncing: false,
|
||||
uploadItems: {},
|
||||
error: BackupError.none,
|
||||
),
|
||||
) {
|
||||
{
|
||||
_statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
|
||||
_progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final BackgroundUploadService _backgroundUploadService;
|
||||
final UploadSpeedManager _uploadSpeedManager;
|
||||
|
||||
final UploadService _uploadService;
|
||||
StreamSubscription<TaskStatusUpdate>? _statusSubscription;
|
||||
StreamSubscription<TaskProgressUpdate>? _progressSubscription;
|
||||
final _logger = Logger("DriftBackupNotifier");
|
||||
|
||||
/// Remove upload item from state
|
||||
@@ -235,120 +225,12 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip _handleTaskStatusUpdate: notifier disposed");
|
||||
return;
|
||||
}
|
||||
final taskId = update.task.taskId;
|
||||
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
if (update.task.group == kBackupGroup) {
|
||||
if (update.responseStatusCode == 201) {
|
||||
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the completed task from the upload items
|
||||
if (state.uploadItems.containsKey(taskId)) {
|
||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||
_removeUploadItem(taskId);
|
||||
});
|
||||
}
|
||||
|
||||
case TaskStatus.failed:
|
||||
// Ignore retry errors to avoid confusing users
|
||||
if (update.exception?.description == 'Delayed or retried enqueue failed') {
|
||||
_removeUploadItem(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
final currentItem = state.uploadItems[taskId];
|
||||
if (currentItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String? error;
|
||||
final exception = update.exception;
|
||||
if (exception != null && exception is TaskHttpException) {
|
||||
final message = tryJsonDecode(exception.description)?['message'] as String?;
|
||||
if (message != null) {
|
||||
final responseCode = exception.httpResponseCode;
|
||||
error = "${exception.exceptionType}, response code $responseCode: $message";
|
||||
}
|
||||
}
|
||||
error ??= update.exception?.toString();
|
||||
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
taskId: currentItem.copyWith(isFailed: true, error: error),
|
||||
},
|
||||
);
|
||||
_logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
|
||||
break;
|
||||
|
||||
case TaskStatus.canceled:
|
||||
_removeUploadItem(update.task.taskId);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTaskProgressUpdate(TaskProgressUpdate update) {
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip _handleTaskProgressUpdate: notifier disposed");
|
||||
return;
|
||||
}
|
||||
final taskId = update.task.taskId;
|
||||
final filename = update.task.displayName;
|
||||
final progress = update.progress;
|
||||
final currentItem = state.uploadItems[taskId];
|
||||
if (currentItem != null) {
|
||||
if (progress == kUploadStatusCanceled) {
|
||||
_removeUploadItem(update.task.taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
taskId: update.hasExpectedFileSize
|
||||
? currentItem.copyWith(
|
||||
progress: progress,
|
||||
fileSize: update.expectedFileSize,
|
||||
networkSpeedAsString: update.networkSpeedAsString,
|
||||
)
|
||||
: currentItem.copyWith(progress: progress),
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
taskId: DriftUploadStatus(
|
||||
taskId: taskId,
|
||||
filename: filename,
|
||||
progress: progress,
|
||||
fileSize: update.expectedFileSize,
|
||||
networkSpeedAsString: update.networkSpeedAsString,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> getBackupStatus(String userId) async {
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
|
||||
return;
|
||||
}
|
||||
final counts = await _uploadService.getBackupCounts(userId);
|
||||
final counts = await _foregroundUploadService.getBackupCounts(userId);
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
|
||||
return;
|
||||
@@ -374,47 +256,126 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
state = state.copyWith(isSyncing: isSyncing);
|
||||
}
|
||||
|
||||
Future<void> startBackup(String userId) {
|
||||
Future<void> startForegroundBackup(String userId) async {
|
||||
state = state.copyWith(error: BackupError.none);
|
||||
return _uploadService.startBackup(userId, _updateEnqueueCount);
|
||||
|
||||
final cancelToken = CancellationToken();
|
||||
state = state.copyWith(cancelToken: cancelToken);
|
||||
|
||||
return _foregroundUploadService.uploadCandidates(
|
||||
userId,
|
||||
cancelToken,
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: _handleForegroundBackupProgress,
|
||||
onSuccess: _handleForegroundBackupSuccess,
|
||||
onError: _handleForegroundBackupError,
|
||||
onICloudProgress: _handleICloudProgress,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateEnqueueCount(EnqueueStatus status) {
|
||||
state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount);
|
||||
Future<void> stopForegroundBackup() async {
|
||||
state.cancelToken?.cancel();
|
||||
_uploadSpeedManager.clear();
|
||||
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {});
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip cancel (pre-call): notifier disposed");
|
||||
return;
|
||||
void _handleICloudProgress(String localAssetId, double progress) {
|
||||
state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress});
|
||||
|
||||
if (progress >= 1.0) {
|
||||
Future.delayed(const Duration(milliseconds: 250), () {
|
||||
final updatedProgress = Map<String, double>.from(state.iCloudDownloadProgress);
|
||||
updatedProgress.remove(localAssetId);
|
||||
state = state.copyWith(iCloudDownloadProgress: updatedProgress);
|
||||
});
|
||||
}
|
||||
dPrint(() => "Canceling backup tasks...");
|
||||
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
|
||||
}
|
||||
|
||||
final activeTaskCount = await _uploadService.cancelBackup();
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip cancel (post-call): notifier disposed");
|
||||
void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) {
|
||||
if (state.cancelToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTaskCount > 0) {
|
||||
dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
|
||||
await cancel();
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
final networkSpeedAsString = _uploadSpeedManager.updateProgress(localAssetId, bytes, totalBytes);
|
||||
final currentItem = state.uploadItems[localAssetId];
|
||||
if (currentItem != null) {
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
localAssetId: currentItem.copyWith(
|
||||
filename: filename,
|
||||
progress: progress,
|
||||
fileSize: totalBytes,
|
||||
networkSpeedAsString: networkSpeedAsString,
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
dPrint(() => "All tasks canceled successfully.");
|
||||
// Clear all upload items when cancellation is complete
|
||||
state = state.copyWith(isCanceling: false, uploadItems: {});
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
localAssetId: DriftUploadStatus(
|
||||
taskId: localAssetId,
|
||||
filename: filename,
|
||||
progress: progress,
|
||||
fileSize: totalBytes,
|
||||
networkSpeedAsString: networkSpeedAsString,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleBackupResume(String userId) async {
|
||||
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
|
||||
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||
_uploadSpeedManager.removeTask(localAssetId);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||
_removeUploadItem(localAssetId);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleForegroundBackupError(String localAssetId, String errorMessage) {
|
||||
_logger.severe("Upload failed for $localAssetId: $errorMessage");
|
||||
|
||||
final currentItem = state.uploadItems[localAssetId];
|
||||
if (currentItem != null) {
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
localAssetId: currentItem.copyWith(isFailed: true, error: errorMessage),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
localAssetId: DriftUploadStatus(
|
||||
taskId: localAssetId,
|
||||
filename: 'Unknown',
|
||||
progress: 0,
|
||||
fileSize: 0,
|
||||
networkSpeedAsString: '',
|
||||
isFailed: true,
|
||||
error: errorMessage,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_uploadSpeedManager.removeTask(localAssetId);
|
||||
}
|
||||
|
||||
Future<void> startBackupWithURLSession(String userId) async {
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
|
||||
return;
|
||||
}
|
||||
_logger.info("Resuming backup tasks...");
|
||||
state = state.copyWith(error: BackupError.none);
|
||||
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
|
||||
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
|
||||
return;
|
||||
@@ -422,20 +383,12 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
_logger.info("Found ${tasks.length} tasks");
|
||||
|
||||
if (tasks.isEmpty) {
|
||||
// Start a new backup queue
|
||||
_logger.info("Start a new backup queue");
|
||||
return startBackup(userId);
|
||||
_logger.info("Start backup with URLSession");
|
||||
return _backgroundUploadService.uploadBackupCandidates(userId);
|
||||
}
|
||||
|
||||
_logger.info("Tasks to resume: ${tasks.length}");
|
||||
return _uploadService.resumeBackup();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_statusSubscription?.cancel();
|
||||
_progressSubscription?.cancel();
|
||||
super.dispose();
|
||||
return _backgroundUploadService.resume();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +398,7 @@ final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>
|
||||
return [];
|
||||
}
|
||||
|
||||
return ref.read(backupRepositoryProvider).getCandidates(user.id, onlyHashed: false);
|
||||
return ref.read(foregroundUploadServiceProvider).getBackupCandidates(user.id, onlyHashed: false);
|
||||
});
|
||||
|
||||
final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family<List<LocalAlbum>, String>((
|
||||
|
||||
@@ -69,6 +69,7 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
: AssetType.other,
|
||||
createdAt: asset.fileCreatedAt,
|
||||
updatedAt: asset.updatedAt,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
_gCastService.loadMedia(remoteAsset, reload);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@@ -13,10 +14,11 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
|
||||
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/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
import 'package:immich_mobile/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -40,7 +42,7 @@ class ActionResult {
|
||||
class ActionNotifier extends Notifier<void> {
|
||||
final Logger _logger = Logger('ActionNotifier');
|
||||
late ActionService _service;
|
||||
late UploadService _uploadService;
|
||||
late ForegroundUploadService _foregroundUploadService;
|
||||
late DownloadService _downloadService;
|
||||
late AssetService _assetService;
|
||||
|
||||
@@ -48,7 +50,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
|
||||
@override
|
||||
void build() {
|
||||
_uploadService = ref.watch(uploadServiceProvider);
|
||||
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
|
||||
_service = ref.watch(actionServiceProvider);
|
||||
_assetService = ref.watch(assetServiceProvider);
|
||||
_downloadService = ref.watch(downloadServiceProvider);
|
||||
@@ -411,14 +413,44 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> upload(ActionSource source) async {
|
||||
final assets = _getAssets(source).whereType<LocalAsset>().toList();
|
||||
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
|
||||
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
|
||||
|
||||
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
|
||||
final cancelToken = CancellationToken();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
||||
|
||||
// Initialize progress for all assets
|
||||
for (final asset in assetsToUpload) {
|
||||
progressNotifier.setProgress(asset.id, 0.0);
|
||||
}
|
||||
|
||||
try {
|
||||
await _uploadService.manualBackup(assets);
|
||||
return ActionResult(count: assets.length, success: true);
|
||||
await _foregroundUploadService.uploadManual(
|
||||
assetsToUpload,
|
||||
cancelToken,
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (localAssetId, filename, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
progressNotifier.setProgress(localAssetId, progress);
|
||||
},
|
||||
onSuccess: (localAssetId, remoteAssetId) {
|
||||
progressNotifier.remove(localAssetId);
|
||||
},
|
||||
onError: (localAssetId, errorMessage) {
|
||||
progressNotifier.setError(localAssetId);
|
||||
},
|
||||
),
|
||||
);
|
||||
return ActionResult(count: assetsToUpload.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed manually upload assets', error, stack);
|
||||
return ActionResult(count: assets.length, success: false, error: error.toString());
|
||||
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
|
||||
} finally {
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
progressNotifier.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user