chore: Fix app

This commit is contained in:
2026-07-14 00:41:05 +02:00
parent 3e090933fd
commit edc4d935d8
98 changed files with 2878 additions and 155 deletions
@@ -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
@@ -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 = {
@@ -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() ?? '';
}
@@ -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);
});
});
@@ -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;
}
}
}
@@ -82,6 +82,7 @@ function createFakeFilesystem() {
adapter: {
filesystem,
directory: 'DATA',
exportDirectory: 'DOCUMENTS',
convertFileSrc: (url: string) => url.replace('file://', 'capacitor://localhost/_capacitor_file_')
}
};
@@ -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 {