chore: Fix app
This commit is contained in:
@@ -28,7 +28,8 @@ attachment/
|
||||
│
|
||||
├── infrastructure/
|
||||
│ ├── services/
|
||||
│ │ └── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
||||
│ │ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
||||
│ │ └── capacitor-attachment-export.service.ts Capacitor "download": copy/write bytes into public Documents
|
||||
│ └── util/
|
||||
│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
|
||||
│
|
||||
|
||||
+33
@@ -9,8 +9,15 @@ import {
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
|
||||
const isCapacitorNativeRuntimeMock = vi.fn(() => false);
|
||||
|
||||
vi.mock('../../../../infrastructure/mobile/logic/platform-detection.rules', () => ({
|
||||
isCapacitorNativeRuntime: () => isCapacitorNativeRuntimeMock()
|
||||
}));
|
||||
|
||||
import { AttachmentDownloadService } from './attachment-download.service';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { CapacitorAttachmentExportService } from '../../infrastructure/services/capacitor-attachment-export.service';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
|
||||
describe('AttachmentDownloadService', () => {
|
||||
@@ -21,8 +28,11 @@ describe('AttachmentDownloadService', () => {
|
||||
let documentStub: Document;
|
||||
let saveExistingFileAs: ReturnType<typeof vi.fn>;
|
||||
let saveFileAs: ReturnType<typeof vi.fn>;
|
||||
let exportToDevice: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
isCapacitorNativeRuntimeMock.mockReturnValue(false);
|
||||
exportToDevice = vi.fn(async () => true);
|
||||
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||
|
||||
@@ -53,6 +63,7 @@ describe('AttachmentDownloadService', () => {
|
||||
providers: [
|
||||
AttachmentDownloadService,
|
||||
{ provide: ElectronBridgeService, useValue: electronBridge },
|
||||
{ provide: CapacitorAttachmentExportService, useValue: { exportToDevice } },
|
||||
{ provide: DOCUMENT, useValue: documentStub }
|
||||
]
|
||||
});
|
||||
@@ -78,6 +89,28 @@ describe('AttachmentDownloadService', () => {
|
||||
expect(saveFileAs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates to the Capacitor export service on a native mobile shell', async () => {
|
||||
isCapacitorNativeRuntimeMock.mockReturnValue(true);
|
||||
electronBridge.getApi = vi.fn(() => null);
|
||||
|
||||
const service = createService();
|
||||
const attachment: Attachment = {
|
||||
id: 'file-3',
|
||||
messageId: 'message-3',
|
||||
filename: 'photo.png',
|
||||
mime: 'image/png',
|
||||
size: 2048,
|
||||
available: true,
|
||||
savedPath: 'metoyou/server/room/files/photo.png'
|
||||
};
|
||||
|
||||
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(exportToDevice).toHaveBeenCalledWith(attachment);
|
||||
expect(saveExistingFileAs).not.toHaveBeenCalled();
|
||||
expect(saveFileAs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the attachment is not downloadable yet', async () => {
|
||||
const service = createService();
|
||||
const attachment: Attachment = {
|
||||
|
||||
+7
@@ -2,12 +2,15 @@ import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { isCapacitorNativeRuntime } from '../../../../infrastructure/mobile/logic/platform-detection.rules';
|
||||
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
import { CapacitorAttachmentExportService } from '../../infrastructure/services/capacitor-attachment-export.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentDownloadService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly capacitorExport = inject(CapacitorAttachmentExportService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
|
||||
@@ -15,6 +18,10 @@ export class AttachmentDownloadService {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isCapacitorNativeRuntime()) {
|
||||
return this.capacitorExport.exportToDevice(attachment);
|
||||
}
|
||||
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
const diskPath = resolveAttachmentDiskPath(attachment);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { buildAttachmentExportFileName } from './attachment-export.rules';
|
||||
|
||||
describe('buildAttachmentExportFileName', () => {
|
||||
it('appends the timestamp before the extension so exports never collide', () => {
|
||||
expect(buildAttachmentExportFileName('report.pdf', 1_720_900_000_000)).toBe('report-1720900000000.pdf');
|
||||
});
|
||||
|
||||
it('appends the timestamp at the end when there is no extension', () => {
|
||||
expect(buildAttachmentExportFileName('README', 42)).toBe('README-42');
|
||||
});
|
||||
|
||||
it('keeps dotfiles intact instead of treating the leading dot as an extension', () => {
|
||||
expect(buildAttachmentExportFileName('.env', 42)).toBe('.env-42');
|
||||
});
|
||||
|
||||
it('strips directory components from the filename', () => {
|
||||
expect(buildAttachmentExportFileName('../secret/../../etc/passwd.txt', 7)).toBe('passwd-7.txt');
|
||||
expect(buildAttachmentExportFileName('folder\\file.bin', 7)).toBe('file-7.bin');
|
||||
});
|
||||
|
||||
it('falls back to a generic name when the filename is empty after sanitising', () => {
|
||||
expect(buildAttachmentExportFileName(' ', 7)).toBe('attachment-7');
|
||||
expect(buildAttachmentExportFileName('a/b/', 7)).toBe('attachment-7');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const FALLBACK_EXPORT_BASE_NAME = 'attachment';
|
||||
|
||||
/**
|
||||
* Build the file name used when exporting an attachment to a user-visible
|
||||
* directory (e.g. Android `Documents`). The timestamp is appended before the
|
||||
* extension so repeated exports of the same file never collide - public
|
||||
* directories on Android 11+ reject overwrites of files the app did not create.
|
||||
*/
|
||||
export function buildAttachmentExportFileName(filename: string, timestamp: number): string {
|
||||
const baseName = stripDirectoryComponents(filename);
|
||||
const dotIndex = baseName.lastIndexOf('.');
|
||||
const hasExtension = dotIndex > 0;
|
||||
const stem = hasExtension ? baseName.slice(0, dotIndex) : baseName;
|
||||
const extension = hasExtension ? baseName.slice(dotIndex) : '';
|
||||
|
||||
return `${stem || FALLBACK_EXPORT_BASE_NAME}-${timestamp}${extension}`;
|
||||
}
|
||||
|
||||
function stripDirectoryComponents(filename: string): string {
|
||||
const segments = filename.split(/[/\\]/);
|
||||
|
||||
return segments[segments.length - 1]?.trim() ?? '';
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
const isCapacitorNativeRuntimeMock = vi.fn(() => true);
|
||||
const loadFilesystemMock = vi.fn();
|
||||
|
||||
vi.mock('../../../../infrastructure/mobile/logic/platform-detection.rules', () => ({
|
||||
isCapacitorNativeRuntime: () => isCapacitorNativeRuntimeMock()
|
||||
}));
|
||||
|
||||
vi.mock('./capacitor-attachment-filesystem.adapter', () => ({
|
||||
loadCapacitorAttachmentFilesystem: () => loadFilesystemMock()
|
||||
}));
|
||||
|
||||
import { CapacitorAttachmentExportService } from './capacitor-attachment-export.service';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
|
||||
function createFakeAdapter() {
|
||||
return {
|
||||
filesystem: {
|
||||
copy: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => ({ uri: 'file:///docs/out' }))
|
||||
},
|
||||
directory: 'DATA',
|
||||
exportDirectory: 'DOCUMENTS',
|
||||
convertFileSrc: (url: string) => url
|
||||
};
|
||||
}
|
||||
|
||||
function makeAttachment(overrides: Partial<Attachment>): Attachment {
|
||||
return {
|
||||
id: 'file-1',
|
||||
messageId: 'message-1',
|
||||
filename: 'photo.png',
|
||||
mime: 'image/png',
|
||||
size: 1024,
|
||||
available: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('CapacitorAttachmentExportService', () => {
|
||||
let service: CapacitorAttachmentExportService;
|
||||
let fakeAdapter: ReturnType<typeof createFakeAdapter>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_720_900_000_000);
|
||||
isCapacitorNativeRuntimeMock.mockReturnValue(true);
|
||||
fakeAdapter = createFakeAdapter();
|
||||
loadFilesystemMock.mockResolvedValue(fakeAdapter);
|
||||
service = new CapacitorAttachmentExportService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('copies a disk-backed attachment from app data into the export directory', async () => {
|
||||
const attachment = makeAttachment({ savedPath: 'metoyou/server/room/files/photo.png' });
|
||||
|
||||
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(fakeAdapter.filesystem.copy).toHaveBeenCalledWith({
|
||||
from: 'metoyou/server/room/files/photo.png',
|
||||
directory: 'DATA',
|
||||
to: 'photo-1720900000000.png',
|
||||
toDirectory: 'DOCUMENTS'
|
||||
});
|
||||
|
||||
expect(fakeAdapter.filesystem.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes an in-memory attachment fetched from its object URL into the export directory', async () => {
|
||||
const bytes = new TextEncoder().encode('hello');
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes)));
|
||||
|
||||
const attachment = makeAttachment({ objectUrl: 'blob:https://app/abc' });
|
||||
|
||||
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(fakeAdapter.filesystem.writeFile).toHaveBeenCalledWith({
|
||||
path: 'photo-1720900000000.png',
|
||||
data: btoa('hello'),
|
||||
directory: 'DOCUMENTS',
|
||||
recursive: true
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the object URL when copying from disk fails', async () => {
|
||||
fakeAdapter.filesystem.copy.mockRejectedValue(new Error('copy failed'));
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(new TextEncoder().encode('x'))));
|
||||
|
||||
const attachment = makeAttachment({
|
||||
savedPath: 'metoyou/server/room/files/photo.png',
|
||||
objectUrl: 'capacitor://localhost/_capacitor_file_/photo.png'
|
||||
});
|
||||
|
||||
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(fakeAdapter.filesystem.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false off a native shell', async () => {
|
||||
isCapacitorNativeRuntimeMock.mockReturnValue(false);
|
||||
|
||||
await expect(service.exportToDevice(makeAttachment({ savedPath: 'x' }))).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there is neither a disk path nor an object URL', async () => {
|
||||
await expect(service.exportToDevice(makeAttachment({}))).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { isCapacitorNativeRuntime } from '../../../../infrastructure/mobile/logic/platform-detection.rules';
|
||||
import { encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
|
||||
import { resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
||||
import { buildAttachmentExportFileName } from '../../domain/logic/attachment-export.rules';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
import { loadCapacitorAttachmentFilesystem } from './capacitor-attachment-filesystem.adapter';
|
||||
|
||||
/**
|
||||
* Exports attachments out of the app-private data directory into the device's
|
||||
* user-visible `Documents` directory on Capacitor. Anchor-based `download`
|
||||
* links do nothing in the Android WebView, so "download" on mobile means
|
||||
* copying the bytes somewhere the user can reach through the Files app.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CapacitorAttachmentExportService {
|
||||
async exportToDevice(attachment: Attachment): Promise<boolean> {
|
||||
if (!isCapacitorNativeRuntime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filesystem = await loadCapacitorAttachmentFilesystem();
|
||||
|
||||
if (!filesystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exportPath = buildAttachmentExportFileName(attachment.filename, Date.now());
|
||||
const diskPath = resolveAttachmentDiskPath(attachment);
|
||||
|
||||
if (diskPath) {
|
||||
try {
|
||||
await filesystem.filesystem.copy({
|
||||
from: diskPath,
|
||||
directory: filesystem.directory,
|
||||
to: exportPath,
|
||||
toDirectory: filesystem.exportDirectory
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
/* fall back to the object URL below */
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachment.objectUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const base64 = await this.fetchAsBase64(attachment.objectUrl);
|
||||
|
||||
if (base64 === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await filesystem.filesystem.writeFile({
|
||||
path: exportPath,
|
||||
data: base64,
|
||||
directory: filesystem.exportDirectory,
|
||||
recursive: true
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAsBase64(objectUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(objectUrl);
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
return encodeUint8ArrayToBase64(new Uint8Array(buffer));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -82,6 +82,7 @@ function createFakeFilesystem() {
|
||||
adapter: {
|
||||
filesystem,
|
||||
directory: 'DATA',
|
||||
exportDirectory: 'DOCUMENTS',
|
||||
convertFileSrc: (url: string) => url.replace('file://', 'capacitor://localhost/_capacitor_file_')
|
||||
}
|
||||
};
|
||||
|
||||
+3
@@ -10,6 +10,8 @@ type CapacitorCoreModule = typeof import('@capacitor/core');
|
||||
export interface CapacitorAttachmentFilesystem {
|
||||
filesystem: CapacitorFilesystemModule['Filesystem'];
|
||||
directory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
|
||||
/** User-visible directory (`Documents`) used when exporting attachments out of app storage. */
|
||||
exportDirectory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
|
||||
convertFileSrc: (url: string) => string;
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ async function resolveCapacitorAttachmentFilesystem(): Promise<CapacitorAttachme
|
||||
return {
|
||||
filesystem: filesystemModule.Filesystem,
|
||||
directory: filesystemModule.Directory.Data,
|
||||
exportDirectory: filesystemModule.Directory.Documents,
|
||||
convertFileSrc: (url: string) => coreModule.Capacitor.convertFileSrc(url)
|
||||
};
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user