diff --git a/frontend/src/test/file-download.test.tsx b/frontend/src/test/file-download.test.tsx new file mode 100644 index 000000000..2c4e598af --- /dev/null +++ b/frontend/src/test/file-download.test.tsx @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { FileManager } from '@/utils'; + +const { createObjectURL, revokeObjectURL } = URL; + +afterEach(() => { + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = revokeObjectURL; + vi.restoreAllMocks(); +}); + +describe('FileManager.downloadTextFile', () => { + // Android's MediaStore appends the blob type's own extension when the name's + // extension maps elsewhere, so a text/plain peer.conf lands as peer.conf.txt. + it('hands the browser an untyped blob so a mobile save keeps the given name', () => { + const blobs: Blob[] = []; + URL.createObjectURL = vi.fn((blob: Blob) => { + blobs.push(blob); + return 'blob:download'; + }); + URL.revokeObjectURL = vi.fn(); + let savedAs = ''; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement, + ) { + savedAs = this.download; + }); + + FileManager.downloadTextFile('[Interface]\n', 'alice.conf'); + + expect(savedAs).toBe('alice.conf'); + expect(blobs.map((blob) => blob.type)).toEqual(['application/octet-stream']); + }); +}); diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index fc12ffd03..4b21a87c9 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -967,10 +967,12 @@ export class LanguageManager { } export class FileManager { + // Typed text/plain, Android's MediaStore saves peer.conf as peer.conf.txt; + // octet-stream carries no extension of its own, so the given name is kept. static downloadTextFile( content: BlobPart, filename: string = 'file.txt', - options: BlobPropertyBag = { type: 'text/plain' }, + options: BlobPropertyBag = { type: 'application/octet-stream' }, ): void { const link = window.document.createElement('a'); link.download = filename;