fix: Bug - Local files should be remembered by client
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user