fix: Bug - Local files should be remembered by client

This commit is contained in:
2026-06-11 01:54:00 +02:00
parent 5bf4f698df
commit 494a05e606
19 changed files with 1611 additions and 143 deletions

View File

@@ -32,7 +32,9 @@ describe('AttachmentPersistenceService', () => {
readFile: ReturnType<typeof vi.fn>;
readFileChunk: ReturnType<typeof vi.fn>;
getFileSize: ReturnType<typeof vi.fn>;
getFileUrl: ReturnType<typeof vi.fn>;
canReadFileChunks: ReturnType<typeof vi.fn>;
providesInlineObjectUrl: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
@@ -60,7 +62,9 @@ describe('AttachmentPersistenceService', () => {
readFile: vi.fn(() => Promise.resolve('QUJD')),
readFileChunk: vi.fn(() => Promise.resolve('QUJD')),
getFileSize: vi.fn(() => Promise.resolve(3)),
canReadFileChunks: vi.fn(() => true)
getFileUrl: vi.fn(() => Promise.resolve(null)),
canReadFileChunks: vi.fn(() => true),
providesInlineObjectUrl: vi.fn(() => false)
};
});
@@ -112,4 +116,57 @@ describe('AttachmentPersistenceService', () => {
expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
});
it('restores a blob from a whole-file read when the store cannot read chunks (browser store)', async () => {
attachmentStorage.canReadFileChunks.mockReturnValue(false);
const service = createService();
await service.initFromDatabase();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: false
};
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toMatch(/^blob:/);
expect(attachmentStorage.readFile).toHaveBeenCalledWith('/appdata/photo.png');
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
});
it('uses a native webview URL without rebuilding a blob (capacitor store)', async () => {
attachmentStorage.providesInlineObjectUrl.mockReturnValue(true);
attachmentStorage.resolveExistingPath.mockResolvedValue('metoyou/server/room/video/clip.mp4');
attachmentStorage.getFileUrl.mockResolvedValue('capacitor://localhost/_capacitor_file_/clip.mp4');
const service = createService();
await service.initFromDatabase();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'clip.mp4',
size: 1_024,
mime: 'video/mp4',
isImage: false,
savedPath: 'metoyou/server/room/video/clip.mp4',
available: false
};
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toBe('capacitor://localhost/_capacitor_file_/clip.mp4');
expect(attachmentStorage.getFileUrl).toHaveBeenCalledWith('metoyou/server/room/video/clip.mp4');
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
});
});

View File

@@ -149,6 +149,17 @@ export class AttachmentPersistenceService {
return false;
}
if (this.attachmentStorage.providesInlineObjectUrl()) {
const nativeUrl = await this.attachmentStorage.getFileUrl(diskPath);
if (nativeUrl) {
this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = nativeUrl;
attachment.available = true;
return true;
}
}
this.revokeAttachmentObjectUrl(attachment);
const restored = await this.restoreAttachmentBlobFromDiskPath(attachment, diskPath);

View File

@@ -46,6 +46,8 @@ describe('AttachmentTransferService', () => {
canWriteFiles: ReturnType<typeof vi.fn>;
canCopyFiles: ReturnType<typeof vi.fn>;
canReadFileChunks: ReturnType<typeof vi.fn>;
canStreamToDisk: ReturnType<typeof vi.fn>;
canPersistSize: ReturnType<typeof vi.fn>;
getFileUrl: ReturnType<typeof vi.fn>;
resolveExistingPath: ReturnType<typeof vi.fn>;
resolveLegacyImagePath: ReturnType<typeof vi.fn>;
@@ -80,6 +82,8 @@ describe('AttachmentTransferService', () => {
canWriteFiles: vi.fn(() => false),
canCopyFiles: vi.fn(() => false),
canReadFileChunks: vi.fn(() => false),
canStreamToDisk: vi.fn(() => false),
canPersistSize: vi.fn(() => true),
getFileUrl: vi.fn(async () => null),
resolveExistingPath: vi.fn(async () => null),
resolveLegacyImagePath: vi.fn(async () => null),
@@ -343,6 +347,60 @@ describe('AttachmentTransferService', () => {
expect(isCancelled()).toBe(false);
});
function registerIncomingVideo(size: number): Attachment {
const attachment: Attachment = {
id: FILE_ID,
messageId: MESSAGE_ID,
filename: 'clip.mp4',
size,
mime: 'video/mp4',
isImage: false,
uploaderPeerId: PEER_ID,
available: false,
receivedBytes: 0
};
runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]);
return attachment;
}
it('streams playable media to disk when the store supports streaming', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true);
const service = createService();
const attachment = registerIncomingVideo(3);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).toHaveBeenCalled();
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
});
it('assembles playable media in memory when the store cannot stream to disk', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(false);
const service = createService();
const attachment = registerIncomingVideo(3);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
});
it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => {
const service = createService();
const attachment = registerIncomingAttachment(9);

View File

@@ -257,39 +257,7 @@ export class AttachmentTransferService {
} catch { /* non-critical */ }
}
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file);
} else if (shouldCopyUploaderMediaToAppData(
attachment,
attachment.filePath,
this.attachmentStorage.canCopyFiles()
) && attachment.filePath) {
const savedPath = await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath);
if (savedPath) {
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
} else if (
this.isPlayableMedia(attachment) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES &&
this.attachmentStorage.canWriteFiles()
) {
const savedPath = await this.persistence.saveFileToDisk(attachment, file);
if (savedPath) {
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
}
await this.persistPublishedAttachment(attachment, file);
const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce',
@@ -760,12 +728,63 @@ export class AttachmentTransferService {
void this.persistence.persistAttachmentMeta(attachment);
}
/**
* Persist an outgoing attachment so it survives restart/logout-login: small
* files are auto-saved, oversized uploader media is copied or streamed to the
* active store, and the inline object URL is upgraded to the saved file when
* the store provides one. Oversized media on capped stores (browser) stays in
* memory / peer-served and degrades gracefully.
*/
private async persistPublishedAttachment(attachment: Attachment, file: File): Promise<void> {
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file);
return;
}
if (!this.attachmentStorage.canPersistSize(attachment.size)) {
return;
}
if (shouldCopyUploaderMediaToAppData(
attachment,
attachment.filePath,
this.attachmentStorage.canCopyFiles()
) && attachment.filePath) {
await this.applySavedPathObjectUrl(
attachment,
await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath)
);
return;
}
if (this.isPlayableMedia(attachment) && this.attachmentStorage.canWriteFiles()) {
await this.applySavedPathObjectUrl(attachment, await this.persistence.saveFileToDisk(attachment, file));
}
}
private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise<void> {
if (!savedPath) {
return;
}
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
}
private shouldReceiveToDisk(attachment: Attachment): boolean {
return this.isPlayableMedia(attachment) && !attachment.filePath && this.attachmentStorage.canWriteFiles();
return this.isPlayableMedia(attachment) &&
!attachment.filePath &&
this.attachmentStorage.canStreamToDisk() &&
this.attachmentStorage.canPersistSize(attachment.size);
}
private enqueueDiskFileChunk(