Skip to content

fix(download): prevent Android crash and create missing folders on download#178

Merged
chhoumann merged 1 commit into
masterfrom
chhoumann/113-android-download-crash
Jun 15, 2026
Merged

fix(download): prevent Android crash and create missing folders on download#178
chhoumann merged 1 commit into
masterfrom
chhoumann/113-android-download-crash

Conversation

@chhoumann

@chhoumann chhoumann commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the long-standing Android crash when downloading a podcast (#113), where Obsidian crashes mid-download and no file is created. Also fixes the related "no file created" with a templated download folder (#86).

Root cause

The download path buffered the entire episode via requestUrl (kept deliberately over fetch to avoid the CORS failures from #31requestUrl has no streaming), then made two more full-size copies before writing:

copy where
response.arrayBuffer requestUrl result
new Blob([response.arrayBuffer]) downloadFile
await blob.arrayBuffer() createEpisodeFilecreateBinary

On Android these ~3× coexist in a memory-constrained Capacitor WebView, so a typical 50–200 MB episode OOM-crashes the app mid-download — matching #113 and #86 (the latter reproduced even with download path "A", ruling out illegal chars / missing folders as the crash trigger).

Changes

  1. Memory (the crash). downloadFile now returns a single { data, contentType, byteLength } and that one ArrayBuffer is threaded straight into vault.createBinary — the Blob wrap and Blob→ArrayBuffer round-trip are gone. Audio-signature detection reads a zero-copy Uint8Array view instead of Blob.slice + FileReader. PodNotes-side peak heap drops from ~3× to ~1× the episode size. Applied to both download functions; each keeps its distinct extension strategy (UI = byte-signature → URL → content-type; API = URL → HEAD content-type → mp3) and the dual-cased content-type lookup.

  2. Missing folders (Obsidian crashes ~20 secs after Podcast should be downloaded (with no file created) #86). New src/utility/ensureFolderExists.ts creates parent folders (per-segment guarded, since createFolder throws on an existing folder) before createBinary, so paths like podcast/{{podcast}}/{{title}} can write at all.

  3. File-name sanitizer. replaceIllegalFileNameCharactersInString now preserves dots (Episode 1.5 no longer becomes Episode 15), replaces control chars with spaces globally, collapses whitespace, and trims leading/trailing dots — fixing the old non-global-flag bugs.

Honest caveats

  • This is a mitigation, not a guaranteed cure for arbitrarily large files. Disassembling the installed Obsidian build shows the dominant unavoidable peak is inside requestUrl's native base64 decode (atob → binary string → Uint8Array), which no plugin can reduce. The fix removes every copy PodNotes controls; the very largest episodes on the most memory-starved devices may still OOM.
  • Filename churn (intentional). Because the sanitizer now preserves dots / collapses whitespace differently, a previously-downloaded episode whose title contained a dot (e.g. stored as Episode 15.mp3) will be re-downloaded once under the corrected name (Episode 1.5.mp3), leaving the old file orphaned.
  • A title that sanitizes to empty (all-whitespace) now yields .mp3 (previously .mp3 — both broken; vanishingly rare). Possible follow-up.

Verification

  • Gates (Node 22): lint, typecheck, format:check (Biome), check:a11y (svelte-check, 604 files / 0), build, and the full test suite (172 passing, 16 new) all green.
  • Real Obsidian runtime (dev vault): confirmed createBinary throws ENOENT on a missing folder, writes the exact bytes from an ArrayBuffer once the folder exists, and createFolder throws on an existing folder — validating both fixes' premises in the live app. The Android OOM itself is not reproducible on desktop (ample heap); desktop is a proxy for the path/write logic only.
  • Adversarial review: design + implementation passes (multi-agent), no blockers.

Notes

  • No public docs changed (the only doc reference is an illustrative QuickAdd macro snippet that already omits the dot).

Closes #113
Closes #86

…wnload

Downloading an episode buffered the whole file via requestUrl, wrapped it in
a Blob, then round-tripped back to an ArrayBuffer before writing — holding ~3x
the episode size in the JS heap. On Android's constrained WebView this OOM-
crashed Obsidian mid-download with no file created (#113).

Thread a single ArrayBuffer from requestUrl straight into vault.createBinary,
dropping the Blob and the Blob->ArrayBuffer round-trip, and read audio-signature
bytes from a zero-copy Uint8Array view instead of Blob.slice + FileReader. This
removes every full-file copy PodNotes controls (peak ~3x -> ~1x). The remaining
peak is inside requestUrl's native base64 decode, which no plugin can reduce, so
this is a mitigation rather than a guaranteed cure for the very largest files.

Also create missing parent folders before createBinary (mirroring
createPodcastNote), so templated paths like podcast/{{podcast}}/{{title}} no
longer fail to write (#86), and harden the file-name sanitizer to preserve dots
(e.g. "Episode 1.5"), strip control characters, and collapse whitespace globally.

Closes #113
Closes #86
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: fcba581
Status: ✅  Deploy successful!
Preview URL: https://68c6bb87.podnotes.pages.dev
Branch Preview URL: https://chhoumann-113-android-downlo.podnotes.pages.dev

View logs

@chhoumann chhoumann marked this pull request as ready for review June 15, 2026 06:15
@chhoumann chhoumann merged commit ecd09d5 into master Jun 15, 2026
2 checks passed

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread src/TemplateEngine.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Pre-existing issue: DownloadPathTemplateEngine extension removal leaves trailing dot

The extension removal logic at src/TemplateEngine.ts:166-169 calls getUrlExtension(template) which returns the extension WITHOUT the leading dot (e.g., "mp3"), then does template.replace(templateExtension, ""). For a template like "{{title}}.mp3", this removes only "mp3" leaving "{{title}}." with a trailing dot. When the file extension is later appended by createEpisodeFile, the result is "Episode Title..mp3" (double dot). This is pre-existing behavior, not introduced by this PR, but with dots now preserved in titles it becomes slightly more visible since titles like "Episode 1.5" produce "Episode 1.5..mp3" when the template redundantly specifies an extension.

(Refers to lines 166-169)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@chhoumann chhoumann deleted the chhoumann/113-android-download-crash branch June 16, 2026 06:16
github-actions Bot pushed a commit that referenced this pull request Jun 22, 2026
# [2.17.0](2.16.0...2.17.0) (2026-06-22)

### Bug Fixes

* behavioral-audit logic and robustness fixes (back-end, 1/2) ([#213](#213)) ([4e2845d](4e2845d))
* **download:** default per-episode download path and migrate empty default ([#183](#183)) ([#186](#186)) ([46a6486](46a6486))
* **download:** prevent Android crash and create missing folders on download ([#178](#178)) ([ecd09d5](ecd09d5)), closes [#113](#113) [#86](#86) [#113](#113) [#86](#86)
* **lifecycle:** defer mobile podcast view startup ([#208](#208)) ([8bf9ce4](8bf9ce4))
* **notes:** cap note path length and harden folder creation ([#22](#22), [#87](#87)) ([#192](#192)) ([858e280](858e280)), closes [#87-class](#87)
* **playback:** persist listened time during playback ([#33](#33)) ([#190](#190)) ([e3433c2](e3433c2)), closes [#191](#191) [#108](#108) [#163](#163) [#183](#183)
* **playback:** play local files and downloads on iOS via resource path ([#100](#100)) ([#184](#184)) ([12c503a](12c503a))
* **player:** clear progress on episode switch to stop end-of-playback glitch ([#94](#94)) ([#194](#194)) ([59dccb3](59dccb3))
* **player:** reveal PodNotes view on Play with PodNotes so local files play ([#84](#84)) ([#198](#198)) ([5953625](5953625))
* **settings:** show labelled Add/Remove buttons in podcast search ([#109](#109)) ([#195](#195)) ([33399f5](33399f5))
* show downloaded episodes in the Local Files playlist ([#176](#176)) ([#177](#177)) ([184188c](184188c))
* **timestamps:** capture into the cursor's table cell without breaking the row ([#165](#165)) ([#203](#203)) ([964e342](964e342))
* **transcription:** always transcribe the currently playing episode ([#182](#182)) ([62b488a](62b488a)), closes [#107](#107)
* **uri:** preserve '+' in episode titles and paths for timestamp links ([#181](#181)) ([8ad7aa5](8ad7aa5)), closes [#164](#164)
* **view:** reliably reveal PodNotes view via command + ribbon icon ([#55](#55)) ([#199](#199)) ([fa1d708](fa1d708))

### Features

* add podcast segment links ([#205](#205)) ([d97e59e](d97e59e))
* **api:** expose generated episode transcripts ([e264465](e264465)), closes [#105](#105)
* behavioral-audit UI and interaction fixes (front-end, 2/2) ([#215](#215)) ([894d93d](894d93d))
* **commands:** add playback rate and media timestamp controls ([#206](#206)) ([a8bb44a](a8bb44a))
* **devx:** isolated per-worktree Obsidian E2E vault wrapper ([#188](#188)) ([a8b7a4a](a8b7a4a))
* **episodes:** add a setting to control the Latest Episodes list length ([#114](#114)) ([#200](#200)) ([7b3e3c6](7b3e3c6))
* **notes:** add {{episodelink}} template tag to resume an episode from its note ([#35](#35)) ([#193](#193)) ([8c1ddd6](8c1ddd6))
* **notes:** add podcast feed-level notes ([#163](#163)) ([#187](#187)) ([db0de47](db0de47)), closes [#161](#161) [#160](#160)
* **notes:** ship a Bases-friendly default episode note template ([#160](#160)) ([#201](#201)) ([209431d](209431d)), closes [#163](#163) [#183](#183)
* **player:** scale episode title font size to its length ([#81](#81)) ([#202](#202)) ([659b6b8](659b6b8))
* **player:** support video episode playback ([#209](#209)) ([f92a91f](f92a91f))
* **queue:** add setting to disable queue auto-population and auto-advance ([#108](#108)) ([#185](#185)) ([cf3d73c](cf3d73c))
* **queue:** allow reordering the playback queue ([#80](#80)) ([#179](#179)) ([d994d61](d994d61)), closes [#173](#173)
* **settings:** import/export settings & templates ([#180](#180)) ([a27d23d](a27d23d)), closes [#162](#162) [#162](#162)
* **templates:** add {{currentDate}}, {{episodeNumber}}, {{duration}} template variables ([#189](#189)) ([ec573eb](ec573eb)), closes [#75](#75) [#34](#34) [#88](#88) [163/#186](#186)
* **templates:** add episode chapters tag ([#207](#207)) ([9c98863](9c98863))
* **transcripts:** opt-in speaker diarization for transcripts ([#168](#168)) ([#204](#204)) ([a96e12f](a96e12f))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.17.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@chrsdk

chrsdk commented Jun 23, 2026

Copy link
Copy Markdown

This reads like it was AI generated, but nevermind; more importantly, the download still crashes obsidian on mobile.

@chhoumann

Copy link
Copy Markdown
Owner Author

This reads like it was AI generated

It is. I use AI to help me develop, and that includes writing PR bodies.

more importantly, the download still crashes obsidian on mobile.

Thanks for letting me know. I've been testing on my own mobile, which is a newer iPhone, and haven't experienced crashes after this PR. Following your comment here, I thought that it might be due to the app running out of memory.
I managed to recreate the crash again by downloading 8 episodes simultaneously. Since we previously didn't stream-download (rather, we downloaded -> wrote in file-batches), that would lead to running out of memory.

I've fixed that in #218 and will release it momentarily. Would you please help me test the next release, when it comes out? That'd be a great help.
I would also appreciate any details behind the crashes (e.g. episode length, amount of episodes being downloaded, phone specs, ...).

@chrsdk

chrsdk commented Jun 23, 2026

Copy link
Copy Markdown

Sure, I'm using a Samsung S23 Ultra. It has 12 GB memory.

I've tried a few times downloading the latest episode of "A Way With Words", from 22 June:

https://waywordradio.org/flash-in-the-pan/

It's just 49 MB

The download message window appears and stays visible for about 20 secs, then Obsidian crashes.

@chhoumann

Copy link
Copy Markdown
Owner Author

Thank you!

Would you go ahead and test this? The fix is out in version 2.17.1, which was just released. The main idea is that downloads now stream to the disk in small chunks, so we keep memory usage low.

I reproduced this exact episode on the new build, and it downloads for me. While the fix is platform-agnostic, there is still the device platform difference.
Since you have a device that reliably reproduced the issue, I’d love to get a confirmation. So if you update to the latest version, could you try to download again? If it still crashes, let me know, and we’ll take it from there. Thanks again.

@chrsdk

chrsdk commented Jun 24, 2026

Copy link
Copy Markdown

There's a download this time and I see a progress window, which survived about halfway, then it crashed Obsidian. I can see a mp3 (25 MB) created in the download folder. Download itself appeared to be going rather slow as well, which I wouldn't expect for such a small file size.

@chhoumann

Copy link
Copy Markdown
Owner Author

Thanks for testing! Okay, so the streaming download does start and gets some of the way.
I've tried pretty hard to reproduce this - even setting up an Android emulator - but I just can't get it to crash. When something reproduces for you but not in a clean setting, it's usually something specific about the device or the app build.

I'd like to know a few things:

  • Your Obsidian app version. See Settings -> General -> "Version"
  • Your Android System WebView version
  • And, do small episodes (e.g. under 10MB) download fine?

I'm currently a bit in the dark as to what's actually causing the crash.
Do you have any syncs running? Like Google Drive?

And how slow is it?

Thanks!

@chrsdk

chrsdk commented Jun 25, 2026

Copy link
Copy Markdown

My Obsidian version is 1.12.7 (333), which is the latest public release as of now.

Android System Webview 149.0.7827.159

I tried a small download (Merriam Webster Word of the Day podcast) and that episode did download (hurray 🎉!). It's a 2.08 MB download, so very small.

After that I tried a bigger one, the same episode I mentioned previously and timed 55 secs to reach halfway around 20 MB and then again Obsidian crashed. It seems to be at the same halfway point each time.

I have no sync running, no Google drive or anything, but I do have a large vault and lots of plugins (60-70). I tried with a smaller vault, it still crashed, BUT then I deactivated all my plugins except podNotes and to my surprise, it downloaded !!!

So it seems there's a conflict with a plugin... I'll try to find which one by reactivating one by one. I'll report back when I find something.

@chrsdk

chrsdk commented Jun 25, 2026

Copy link
Copy Markdown

I think I may have found the culprit: waypoint plugin. After deactivating it the download completes.

Could you test with this plugin and see if you could replicate the crash?

@chhoumann

Copy link
Copy Markdown
Owner Author

Nice find - and thanks for the thorough debugging! Yeah, that reproduces for me. Looks like Waypoint is watching every time a file is modified. Even for smaller episodes, e.g. 47mb, we'd have 1 file creation and 11 file modify events. Since Waypoint reads the file each time, it'd load the growing file into memory. This is what led to the continued crashes.

I've fixed it in #220 by streaming to a temporary, hidden file that Obsidian doesn't broadcast the file events for. This means no watcher plugins, Waypoint or others, will be able to react to it while we're creating it.

I've released this now, would you test again?

@chrsdk

chrsdk commented Jun 26, 2026

Copy link
Copy Markdown

That fixed it for me! I reactivated the waypoint plugin and the download still succeeds. It's also fast I'm happy to report.

I'm thrilled that you were able to solve this. Your explanation makes a lot of sense and I understand now what was happening.

Thank you very much! ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG|Crash on Android phone when download podcast Obsidian crashes ~20 secs after Podcast should be downloaded (with no file created)

2 participants