diff --git a/src/panels/config/automation/ha-automation-trace.ts b/src/panels/config/automation/ha-automation-trace.ts index 665def9d6d..f296c7dce2 100644 --- a/src/panels/config/automation/ha-automation-trace.ts +++ b/src/panels/config/automation/ha-automation-trace.ts @@ -464,7 +464,6 @@ export class HaAutomationTrace extends LitElement { url, `trace ${this._entityId} ${this._trace!.timestamp.start}.json` ); - URL.revokeObjectURL(url); } private _importTrace() { diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts index 5123050525..7f7a4f5d8d 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts @@ -806,7 +806,6 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { url, `zwave_js_backup_${new Date().toISOString().replace(/[:.]/g, "-")}.bin` ); - URL.revokeObjectURL(url); } catch (err: any) { showAlertDialog(this, { title: this.hass.localize( diff --git a/src/panels/config/script/ha-script-trace.ts b/src/panels/config/script/ha-script-trace.ts index ac75a60b9d..2d86782c71 100644 --- a/src/panels/config/script/ha-script-trace.ts +++ b/src/panels/config/script/ha-script-trace.ts @@ -479,7 +479,6 @@ export class HaScriptTrace extends LitElement { url, `trace ${this._entityId} ${this._trace!.timestamp.start}.json` ); - URL.revokeObjectURL(url); } private _importTrace() { diff --git a/src/util/file_download.ts b/src/util/file_download.ts index 1b93a133e5..511aaeaa1c 100644 --- a/src/util/file_download.ts +++ b/src/util/file_download.ts @@ -1,3 +1,9 @@ +// 10 seconds gives the Android WebView download listener enough time +// to open the blob before it is revoked, while still freeing memory +// promptly. Revoking immediately would invalidate the URL before the +// native layer can read it. +const BLOB_REVOKE_DELAY_MS = 10_000; + export const fileDownload = (href: string, filename = ""): void => { const element = document.createElement("a"); element.target = "_blank"; @@ -7,4 +13,14 @@ export const fileDownload = (href: string, filename = ""): void => { document.body.appendChild(element); element.dispatchEvent(new MouseEvent("click")); document.body.removeChild(element); + + if (href.startsWith("blob:")) { + // Revoke blob URLs after a delay on Android so the WebView download + // listener has time to fetch the blob before it becomes invalid. + if (window.externalApp) { + setTimeout(() => URL.revokeObjectURL(href), BLOB_REVOKE_DELAY_MS); + } else { + URL.revokeObjectURL(href); + } + } }; diff --git a/test/util/file_download.test.ts b/test/util/file_download.test.ts new file mode 100644 index 0000000000..cdf62ceb85 --- /dev/null +++ b/test/util/file_download.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fileDownload } from "../../src/util/file_download"; + +describe("fileDownload", () => { + let appendChildSpy: ReturnType; + let removeChildSpy: ReturnType; + let dispatchEventSpy: ReturnType; + let createdElement: HTMLAnchorElement; + + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + createdElement = document.createElement("a"); + vi.spyOn(createdElement, "dispatchEvent"); + vi.spyOn(document, "createElement").mockReturnValue(createdElement); + appendChildSpy = vi + .spyOn(document.body, "appendChild") + .mockImplementation((node) => node); + removeChildSpy = vi + .spyOn(document.body, "removeChild") + .mockImplementation((node) => node); + dispatchEventSpy = vi.spyOn(createdElement, "dispatchEvent"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete (window as any).externalApp; + }); + + it("sets href, download, and triggers a click", () => { + fileDownload("https://example.com/file.json", "file.json"); + + expect(createdElement.href).toBe("https://example.com/file.json"); + expect(createdElement.download).toBe("file.json"); + expect(appendChildSpy).toHaveBeenCalledWith(createdElement); + expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(MouseEvent)); + expect(removeChildSpy).toHaveBeenCalledWith(createdElement); + }); + + it("defaults filename to empty string", () => { + fileDownload("https://example.com/file.json"); + expect(createdElement.download).toBe(""); + }); + + it("does not revoke non-blob URLs", () => { + fileDownload("https://example.com/file.json", "file.json"); + vi.runAllTimers(); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + }); + + it("revokes blob URLs immediately outside Android", () => { + fileDownload("blob:http://localhost/abc-123", "file.json"); + expect(URL.revokeObjectURL).toHaveBeenCalledWith( + "blob:http://localhost/abc-123" + ); + }); + + it("revokes blob URL after delay on Android", () => { + (window as any).externalApp = {}; + fileDownload("blob:http://localhost/abc-123", "file.json"); + vi.advanceTimersByTime(9_999); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(URL.revokeObjectURL).toHaveBeenCalledWith( + "blob:http://localhost/abc-123" + ); + }); +});