Skip to content

fix: stage Android receives to SAF folder and guard missing download dir - #4

Merged
Aswanidev-vs merged 1 commit into
mainfrom
develop
Aug 7, 2026
Merged

fix: stage Android receives to SAF folder and guard missing download dir#4
Aswanidev-vs merged 1 commit into
mainfrom
develop

Conversation

@Aswanidev-vs

@Aswanidev-vs Aswanidev-vs commented Aug 7, 2026

Copy link
Copy Markdown
Owner

The receiver returned HTTP 500 on Android because scoped storage blocks raw-path writes to SAF-picked folders. Go now stages incoming files into an app-internal dir; on completion the frontend bridges the file into the chosen folder via the SAF tree URI through ContentResolver, then deletes the staging copy. The picker intent now requests WRITE up front so the persistable grant succeeds, and a banner plus auto-reject guard prevents receiving without a configured folder. Also fixes PairModal keyboard overlap and Receive card overflow on mobile.

Summary by CodeRabbit

  • New Features
    • Android users can select a download folder and retain access to it across sessions.
    • Completed incoming transfers are automatically saved to the selected folder.
    • Added clear feedback when no download folder is configured or saving fails.
  • Bug Fixes
    • Improved Android folder permissions to support reliable file saving.
  • Style
    • Improved pairing modal layout on mobile, including adaptive sizing, scrolling, and viewport handling.
    • Enhanced receive-status display for smaller screens.

The receiver returned HTTP 500 on Android because scoped storage blocks
raw-path writes to SAF-picked folders. Go now stages incoming files into
an app-internal dir; on completion the frontend bridges the file into the
chosen folder via the SAF tree URI through ContentResolver, then deletes
the staging copy. The picker intent now requests WRITE up front so the
persistable grant succeeds, and a banner plus auto-reject guard prevents
receiving without a configured folder. Also fixes PairModal keyboard
overlap and Receive card overflow on mobile.
@Aswanidev-vs
Aswanidev-vs merged commit f51de69 into main Aug 7, 2026
13 of 16 checks passed
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b5b92c86-39d1-4699-962e-3d2f918239d8

📥 Commits

Reviewing files that changed from the base of the PR and between b5e408a and acef2ef.

📒 Files selected for processing (11)
  • build/android/app/src/main/java/com/wails/app/MainActivity.java
  • build/android/app/src/main/java/com/wails/app/WailsJSBridge.java
  • frontend/bindings/light/internal/light/models.ts
  • frontend/src/components/pair/PairModal.vue
  • frontend/src/composables/useSettings.ts
  • frontend/src/composables/useTransfers.ts
  • frontend/src/views/ReceiveView.vue
  • frontend/src/views/SettingsView.vue
  • internal/light/filetransfer.go
  • internal/light/models.go
  • internal/light/settings.go

📝 Walkthrough

Walkthrough

The change adds Android SAF folder persistence and file copying. Mobile transfers use internal staging before copying to the selected folder. The frontend validates folder configuration and reports copy errors. The pair modal now adapts to visual viewport size.

Changes

Android download folder

Layer / File(s) Summary
Download folder settings contract
internal/light/models.go, internal/light/settings.go, frontend/bindings/light/internal/light/models.ts, frontend/src/composables/useSettings.ts
Settings now store downloadDirUri and initialize it to an empty string when absent.
Platform-specific receive staging
internal/light/filetransfer.go
Mobile transfers use an internal staging directory. Completion events include the destination path and URI.
SAF folder copy bridge
build/android/app/src/main/java/com/wails/app/MainActivity.java, build/android/app/src/main/java/com/wails/app/WailsJSBridge.java
Android requests write-capable SAF permissions, returns the selected tree URI, and copies staged files into SAF documents.
Folder selection and receive handling
frontend/src/views/SettingsView.vue, frontend/src/composables/useTransfers.ts, frontend/src/views/ReceiveView.vue
The frontend persists the selected URI, blocks Android receives without a configured folder, reports copy errors, and displays a Settings warning. Estimated code review effort: 4 (Complex) | ~45 minutes

Responsive pair modal

Layer / File(s) Summary
Viewport-aware pair modal
frontend/src/components/pair/PairModal.vue
The modal tracks visualViewport height, removes its listener on unmount, and adjusts mobile layout, QR sizing, scrolling, and code-entry controls.

Sequence Diagram(s)

sequenceDiagram
  participant ReceiveView
  participant useTransfers
  participant WailsJSBridge
  participant MainActivity
  participant SAFFolder
  ReceiveView->>useTransfers: Start Android receive
  useTransfers->>WailsJSBridge: Request copyToFolder
  WailsJSBridge->>MainActivity: Dispatch copy request
  MainActivity->>SAFFolder: Create document and stream staged file
  SAFFolder-->>MainActivity: Return copy result
  MainActivity-->>useTransfers: Emit android:copyDone
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 4 high · 1 medium

Alerts:
⚠ 5 issues (≤ 0 issues of at least minor severity)

Results:
5 new issues

Category Results
BestPractice 4 high
ErrorProne 1 medium

View in Codacy

🟢 Metrics 288 complexity · 4 duplication

Metric Results
Complexity 288
Duplication 4

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix Android SAF receive path with staging dir and missing-folder guard

🐞 Bug fix ✨ Enhancement 🕐 40+ Minutes

Grey Divider

AI Description

• Fixes HTTP 500 on Android receives caused by scoped storage blocking raw writes to SAF folders.
• Go now stages incoming files app-internally; frontend bridges finished files into the SAF folder
 via ContentResolver, then deletes the staging copy.
• Folder picker now requests WRITE permission upfront so persistable grants succeed.
• Adds a banner and auto-reject guard preventing receives when no download folder is configured on
 Android.
• Fixes PairModal keyboard overlap and Receive card overflow on mobile viewports.
Diagram

sequenceDiagram
    participant Sender
    participant GoReceiver as Go Receiver
    participant Staging as Staging Dir
    participant Frontend as Vue Frontend
    participant Bridge as WailsJSBridge
    participant SAF as SAF Folder

    Sender->>GoReceiver: Upload file
    GoReceiver->>Staging: Write to app-internal dir
    GoReceiver-->>Frontend: transfer-complete event with destinationUri
    Frontend->>Bridge: copyToFolder(uri, fileName, sourcePath)
    Bridge->>SAF: createDocument + stream bytes
    Bridge->>Staging: delete staged file
    Bridge-->>Frontend: android:copyDone event
Loading
High-Level Assessment

Staging to app-internal storage then bridging via ContentResolver/SAF is the standard, correct workaround for Android scoped storage restrictions on raw-path writes to user-picked folders; no simpler viable alternative exists on Android for arbitrary tree URIs.

Files changed (11) +205 / -24

Enhancement (4) +56 / -12
WailsJSBridge.javaExpose copyToFolder JS interface method +18/-0

Expose copyToFolder JS interface method

• Adds a @JavascriptInterface method copyToFolder that forwards the call to MainActivity, enabling the frontend to trigger the SAF copy of staged downloads.

build/android/app/src/main/java/com/wails/app/WailsJSBridge.java

ReceiveView.vueShow warning banner when Android download folder is missing +26/-7

Show warning banner when Android download folder is missing

• Adds a computed needsFolder flag and renders a banner with a link to Settings when receiving is unavailable on Android due to a missing SAF folder; also fixes card layout overflow on mobile.

frontend/src/views/ReceiveView.vue

SettingsView.vuePersist picked SAF tree URI into settings +5/-0

Persist picked SAF tree URI into settings

• Stores the returned SAF tree URI (data.uri) into local.downloadDirUri when the user picks a download folder.

frontend/src/views/SettingsView.vue

settings.goPersist and expose DownloadDirUri setter +7/-5

Persist and expose DownloadDirUri setter

• Updates UpdateSettings to copy DownloadDirUri and adds a SetDownloadDirUri setter method alongside existing settings setters.

internal/light/settings.go

Bug fix (4) +143 / -12
MainActivity.javaAdd copyToFolder SAF bridge method and request WRITE grant on folder pick +69/-4

Add copyToFolder SAF bridge method and request WRITE grant on folder pick

• Adds copyToFolder() which creates a document in the SAF tree, streams bytes from the staging file via ContentResolver, then deletes the staging copy and emits android:copyDone. Folder picker intent now requests WRITE grant upfront and takes a persistable read+write permission; emits the tree URI alongside the display path.

build/android/app/src/main/java/com/wails/app/MainActivity.java

PairModal.vueFix keyboard overlap and mobile layout in PairModal +26/-7

Fix keyboard overlap and mobile layout in PairModal

• Tracks visualViewport height to reposition the modal when the on-screen keyboard opens, and adjusts layout/sizing classes for small screens to prevent overflow.

frontend/src/components/pair/PairModal.vue

useTransfers.tsBridge completed Android downloads into SAF folder and guard receives without folder +29/-0

Bridge completed Android downloads into SAF folder and guard receives without folder

• On transfer completion on Android, invokes the native copyToFolder bridge using the destination URI and staged file path, then handles android:copyDone to toast errors. Rejects incoming receive requests on Android when no download folder URI is configured, notifying the user via toast.

frontend/src/composables/useTransfers.ts

filetransfer.goStage mobile receives to app-internal dir and emit destination URI +19/-1

Stage mobile receives to app-internal dir and emit destination URI

• Introduces receiveDir() which routes mobile writes to an app-internal staging directory instead of the SAF-picked DownloadDir, avoiding scoped-storage write failures. The transfer-complete event now also includes destination and destinationUri so the frontend can bridge the staged file into the correct SAF folder.

internal/light/filetransfer.go

Other (3) +6 / -0
models.tsAdd downloadDirUri field to generated Settings model +4/-0

Add downloadDirUri field to generated Settings model

• Generated TypeScript binding updated to include the new downloadDirUri field with default initialization.

frontend/bindings/light/internal/light/models.ts

useSettings.tsAdd downloadDirUri to default settings state +1/-0

Add downloadDirUri to default settings state

• Extends the settings ref with the new downloadDirUri field defaulting to empty string.

frontend/src/composables/useSettings.ts

models.goAdd DownloadDirUri field to Settings struct +1/-0

Add DownloadDirUri field to Settings struct

• Adds a DownloadDirUri field to persist the Android SAF tree URI for the chosen download folder.

internal/light/models.go

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Unchecked docUri strands files 🐞 Bug ☼ Reliability
Description
MainActivity.copyToFolder() uses the Uri returned by DocumentsContract.createDocument() without
validating it and emits low-signal errors on failure, leaving the staged file behind. This can
accumulate app-internal “downloads” files when SAF document creation/opening fails
(permission/provider/storage issues).
Code

build/android/app/src/main/java/com/wails/app/MainActivity.java[R448-451]

+                Uri docUri = DocumentsContract.createDocument(getContentResolver(),
+                        treeUri, mime != null ? mime : "application/octet-stream", fileName);
+
+                try (InputStream in = new FileInputStream(source);
Evidence
The staged file path is emitted by the Go backend and then forwarded by the frontend into
wails.copyToFolder(...). On the Android side, copyToFolder creates a destination document and
immediately calls openOutputStream(docUri) without checking docUri and only deletes the staging
file on the success path.

build/android/app/src/main/java/com/wails/app/MainActivity.java[424-473]
internal/light/filetransfer.go[194-265]
frontend/src/composables/useTransfers.ts[52-83]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`MainActivity.copyToFolder()` doesn’t validate the document Uri returned by `DocumentsContract.createDocument(...)` and doesn’t attempt cleanup when the SAF copy fails. This can strand staged files in app-internal storage (and potentially leave partially created destination documents), while emitting an unhelpful error (e.g., NPE message `null`).

### Issue Context
The Go receiver stages to an internal directory on mobile, and the frontend requests Android to bridge the file into the SAF folder via `copyToFolder`.

### Fix Focus Areas
- build/android/app/src/main/java/com/wails/app/MainActivity.java[430-473]
 - Check `docUri == null` and emit a clear error without calling `openOutputStream`.
 - If `openOutputStream` fails/returns null or streaming throws, consider deleting the newly created document (best-effort) and/or keep the staging file for retry.
 - Emit errors via a safe JSON builder (`JSONObject`) so error messages are always non-null and properly escaped.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Reset keeps stale folder URI 🐞 Bug ≡ Correctness
Description
SettingsView.reset() only resets downloadDir, but not the new downloadDirUri, so the displayed
folder can diverge from the SAF destination actually used for Android saves. If the user saves after
Reset, incoming files may be copied into a different folder than the UI indicates.
Code

frontend/src/views/SettingsView.vue[R49-51]

+        if (data?.uri) {
+          local.value.downloadDirUri = data.uri
+        }
Evidence
The PR adds persistence of downloadDirUri from the folder picker result, but the existing reset
code only updates downloadDir. The Android receive flow uses downloadDirUri to gate receiving
and to supply the destination Uri for the native SAF copy.

frontend/src/views/SettingsView.vue[26-61]
frontend/src/composables/useTransfers.ts[84-95]
internal/light/filetransfer.go[197-265]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`downloadDirUri` is now the authoritative Android destination (used for SAF copies), but `reset()` only restores `downloadDir`. This allows persisting inconsistent settings (path points to A, URI points to B) and saving into an unexpected folder.

### Issue Context
Folder picking sets both `downloadDir` (display path) and `downloadDirUri` (tree URI). Receiving on Android is gated by `downloadDirUri`, and the copy operation uses `destinationUri`.

### Fix Focus Areas
- frontend/src/views/SettingsView.vue[26-52]
 - Update `reset()` to also restore `local.value.downloadDirUri = settings.value.downloadDirUri` (or clear both fields if “reset” means unset).
 - (Optional) On Android, consider making the `downloadDir` input read-only or clearing `downloadDirUri` when the user edits the path manually to avoid mismatch.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Persistable grant ignores result flags 🐞 Bug ☼ Reliability
Description
handleFolderPickerResult() calls takePersistableUriPermission() with a hard-coded READ|WRITE mask
instead of using the flags actually granted in the result Intent. If a provider grants fewer
permissions than requested, folder picking fails (exception path) and Android receiving can’t be
configured.
Code

build/android/app/src/main/java/com/wails/app/MainActivity.java[R399-401]

            getContentResolver().takePersistableUriPermission(treeUri,
-                    Intent.FLAG_GRANT_READ_URI_PERMISSION);
+                    Intent.FLAG_GRANT_READ_URI_PERMISSION
+                            | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
Evidence
The folder picker handler ignores the returning intent flags and always persists both READ and
WRITE. The method’s exception path emits an error event instead of recording the folder, meaning the
new Android SAF workflow cannot proceed for those users.

build/android/app/src/main/java/com/wails/app/MainActivity.java[389-421]
build/android/app/src/main/java/com/wails/app/MainActivity.java[631-646]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`takePersistableUriPermission(treeUri, READ|WRITE)` should only request persistable permissions that were actually granted in the returning `Intent` flags. Hard-coding READ|WRITE risks a `SecurityException` for providers that don’t return WRITE (even if requested).

### Issue Context
The picker is launched with READ|WRITE|PERSISTABLE flags, and the code then immediately attempts to persist both READ and WRITE.

### Fix Focus Areas
- build/android/app/src/main/java/com/wails/app/MainActivity.java[389-421]
 - Compute `int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION)` and pass `takeFlags`.
 - If WRITE isn’t present in `takeFlags`, emit a clear error stating that write access wasn’t granted and copying into that folder won’t work.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +448 to +451
Uri docUri = DocumentsContract.createDocument(getContentResolver(),
treeUri, mime != null ? mime : "application/octet-stream", fileName);

try (InputStream in = new FileInputStream(source);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Unchecked docuri strands files 🐞 Bug ☼ Reliability

MainActivity.copyToFolder() uses the Uri returned by DocumentsContract.createDocument() without
validating it and emits low-signal errors on failure, leaving the staged file behind. This can
accumulate app-internal “downloads” files when SAF document creation/opening fails
(permission/provider/storage issues).
Agent Prompt
### Issue description
`MainActivity.copyToFolder()` doesn’t validate the document Uri returned by `DocumentsContract.createDocument(...)` and doesn’t attempt cleanup when the SAF copy fails. This can strand staged files in app-internal storage (and potentially leave partially created destination documents), while emitting an unhelpful error (e.g., NPE message `null`).

### Issue Context
The Go receiver stages to an internal directory on mobile, and the frontend requests Android to bridge the file into the SAF folder via `copyToFolder`.

### Fix Focus Areas
- build/android/app/src/main/java/com/wails/app/MainActivity.java[430-473]
  - Check `docUri == null` and emit a clear error without calling `openOutputStream`.
  - If `openOutputStream` fails/returns null or streaming throws, consider deleting the newly created document (best-effort) and/or keep the staging file for retry.
  - Emit errors via a safe JSON builder (`JSONObject`) so error messages are always non-null and properly escaped.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +49 to +51
if (data?.uri) {
local.value.downloadDirUri = data.uri
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Reset keeps stale folder uri 🐞 Bug ≡ Correctness

SettingsView.reset() only resets downloadDir, but not the new downloadDirUri, so the displayed
folder can diverge from the SAF destination actually used for Android saves. If the user saves after
Reset, incoming files may be copied into a different folder than the UI indicates.
Agent Prompt
### Issue description
`downloadDirUri` is now the authoritative Android destination (used for SAF copies), but `reset()` only restores `downloadDir`. This allows persisting inconsistent settings (path points to A, URI points to B) and saving into an unexpected folder.

### Issue Context
Folder picking sets both `downloadDir` (display path) and `downloadDirUri` (tree URI). Receiving on Android is gated by `downloadDirUri`, and the copy operation uses `destinationUri`.

### Fix Focus Areas
- frontend/src/views/SettingsView.vue[26-52]
  - Update `reset()` to also restore `local.value.downloadDirUri = settings.value.downloadDirUri` (or clear both fields if “reset” means unset).
  - (Optional) On Android, consider making the `downloadDir` input read-only or clearing `downloadDirUri` when the user edits the path manually to avoid mismatch.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines 399 to +401
getContentResolver().takePersistableUriPermission(treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION);
Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. Persistable grant ignores result flags 🐞 Bug ☼ Reliability

handleFolderPickerResult() calls takePersistableUriPermission() with a hard-coded READ|WRITE mask
instead of using the flags actually granted in the result Intent. If a provider grants fewer
permissions than requested, folder picking fails (exception path) and Android receiving can’t be
configured.
Agent Prompt
### Issue description
`takePersistableUriPermission(treeUri, READ|WRITE)` should only request persistable permissions that were actually granted in the returning `Intent` flags. Hard-coding READ|WRITE risks a `SecurityException` for providers that don’t return WRITE (even if requested).

### Issue Context
The picker is launched with READ|WRITE|PERSISTABLE flags, and the code then immediately attempts to persist both READ and WRITE.

### Fix Focus Areas
- build/android/app/src/main/java/com/wails/app/MainActivity.java[389-421]
  - Compute `int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION)` and pass `takeFlags`.
  - If WRITE isn’t present in `takeFlags`, emit a clear error stating that write access wasn’t granted and copying into that folder won’t work.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant