Skip to content

Replace share intent with SAF file save dialog for exports#425

Merged
torlando-tech merged 5 commits intomainfrom
claude/fix-export-save-dialog-JnEWn
Feb 15, 2026
Merged

Replace share intent with SAF file save dialog for exports#425
torlando-tech merged 5 commits intomainfrom
claude/fix-export-save-dialog-JnEWn

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

Changed the export functionality in IdentityManagerScreen and MigrationScreen from using Android's share intent to using the Storage Access Framework (SAF) file save dialog. This provides users with direct control over where exported files are saved, rather than routing through a share sheet.

Key Changes

  • IdentityManagerScreen: Replaced Intent.ACTION_SEND share sheet with ActivityResultContracts.CreateDocument SAF dialog for identity exports
  • MigrationScreen: Replaced Intent.ACTION_SEND share sheet with ActivityResultContracts.CreateDocument SAF dialog for data exports
  • Added pendingExportSourceUri state variable to both screens to track the source file during the save operation
  • Implemented file copying logic using contentResolver.openInputStream() and openOutputStream() to transfer data from temporary export location to user-selected destination
  • Updated UI feedback text from "Share sheet opened" to "Save dialog opened"
  • Removed unused Intent import from MigrationScreen
  • Added timestamp formatting to MigrationScreen export filename for better organization

Implementation Details

  • Both screens now use identical export save launcher patterns for consistency
  • File copying is wrapped in try-catch to gracefully handle errors (user will see empty/corrupt file if something fails)
  • The pendingExportSourceUri is cleared after the save operation completes
  • Export filenames are now user-customizable through the SAF dialog ("identity.rnsidentity" for identity exports, "columba_export_TIMESTAMP.columba" for migration exports)

https://claude.ai/code/session_01UXR4yz6pwyoNDDNXUPzz2s

Data export and identity export now open Android's file save dialog
(Storage Access Framework) instead of the share sheet, allowing users
to save backup files directly to local storage.

Fixes #344

https://claude.ai/code/session_01UXR4yz6pwyoNDDNXUPzz2s
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 9, 2026

Greptile Summary

Replaces Android's share intent with Storage Access Framework (SAF) file save dialog for both identity and data exports, giving users direct control over file save location. The implementation properly handles the export flow by storing temporary URIs and copying files to user-selected destinations.

  • Both screens now use ActivityResultContracts.CreateDocument with consistent patterns
  • ViewModels handle file copying with proper error handling and user feedback
  • Migration export includes timestamp formatting for better file organization
  • Migration correctly cleans up temp files and resets state after save
  • Identity export properly tracks source URI for the save operation

Minor issue: IdentityManagerViewModel doesn't clean up _exportedIdentityUri after save, unlike MigrationViewModel which properly nulls out the URI.

Confidence Score: 4/5

  • This PR is safe to merge with minor cleanup improvement recommended
  • The SAF implementation is solid with proper error handling and state management. The file copying logic is correct and both flows work consistently. Error handling provides user feedback in ViewModels. The only issue is a minor memory cleanup inconsistency where IdentityManagerViewModel doesn't null out the stored URI after save (unlike MigrationViewModel which does). Previous review comments about silent error handling were already addressed in commit 9a0aeb1.
  • Pay attention to IdentityManagerViewModel.kt for the URI cleanup improvement

Important Files Changed

Filename Overview
app/src/main/java/com/lxmf/messenger/viewmodel/IdentityManagerViewModel.kt Added SAF file save logic with proper error handling; missing cleanup of _exportedIdentityUri after save operation
app/src/main/java/com/lxmf/messenger/viewmodel/MigrationViewModel.kt Added SAF file save logic with error handling and proper cleanup; includes new ExportSaved state for UI feedback

Sequence Diagram

sequenceDiagram
    participant User
    participant Screen as UI Screen
    participant ViewModel
    participant SAF as Storage Access Framework
    participant ContentResolver
    participant FileSystem

    User->>Screen: Click Export
    Screen->>ViewModel: exportIdentity() / startExport()
    ViewModel->>FileSystem: Create temp export file
    FileSystem-->>ViewModel: Return temp URI
    ViewModel->>ViewModel: Store in _exportedIdentityUri / _exportedFileUri
    ViewModel->>Screen: Set ExportReady / ExportComplete state
    Screen->>SAF: Launch CreateDocument dialog
    SAF->>User: Show save file dialog
    User->>SAF: Select location & filename
    SAF-->>Screen: Return destination URI
    Screen->>ViewModel: saveExportedIdentityToFile(uri) / saveExportToFile(uri)
    ViewModel->>ContentResolver: openInputStream(sourceUri)
    ViewModel->>ContentResolver: openOutputStream(destinationUri)
    ContentResolver->>FileSystem: Copy file
    FileSystem-->>ContentResolver: Success
    ContentResolver-->>ViewModel: File copied
    ViewModel->>ViewModel: cleanupExportFiles() [Migration only]
    ViewModel->>Screen: Set Success / ExportSaved state
    Screen->>User: Show success snackbar
Loading

Last reviewed commit: 9a0aeb1

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Feb 9, 2026

@torlando-tech torlando-tech linked an issue Feb 9, 2026 that may be closed by this pull request
@torlando-tech torlando-tech added this to the v0.9.0 milestone Feb 9, 2026
…g change safety

- Move file copy logic from composable callbacks into ViewModel methods
  (saveExportToFile, saveExportedIdentityToFile) so errors are reported
  via UiState → snackbar instead of being silently swallowed
- Use ViewModel-held source URIs (_exportedFileUri, _exportedIdentityUri)
  which survive configuration changes, fixing state loss on rotation
- Run file copy on Dispatchers.IO to avoid ANR on large exports

https://claude.ai/code/session_01UXR4yz6pwyoNDDNXUPzz2s
… feedback

- MigrationScreen: reset uiState after launching SAF save dialog to
  prevent it from re-triggering on configuration changes (rotation)
- MigrationViewModel: add onExportSaveDialogLaunched() for state reset,
  add ExportSaved state, clean up temp file after successful save
- IdentityManagerViewModel: show success snackbar after identity file
  is saved to user-chosen destination
- MigrationScreen: show "Export saved successfully" snackbar on save

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

4 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +652 to +670
fun saveExportedIdentityToFile(destinationUri: Uri) {
val sourceUri = _exportedIdentityUri ?: return
viewModelScope.launch {
try {
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(sourceUri)?.use { input ->
context.contentResolver.openOutputStream(destinationUri)?.use { output ->
input.copyTo(output)
}
} ?: error("Could not open identity file")
}
_uiState.value =
IdentityManagerUiState.Success("Identity exported successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to save identity file", e)
_uiState.value =
IdentityManagerUiState.Error("Failed to save identity: ${e.message}")
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_exportedIdentityUri is never cleared after the save operation completes. Consider adding _exportedIdentityUri = null after line 669 to prevent memory leaks from holding onto the URI reference

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/viewmodel/IdentityManagerViewModel.kt
Line: 652:670

Comment:
`_exportedIdentityUri` is never cleared after the save operation completes. Consider adding `_exportedIdentityUri = null` after line 669 to prevent memory leaks from holding onto the URI reference

How can I resolve this? If you propose a fix, please make it concise.

torlando-tech and others added 2 commits February 15, 2026 15:14
…agerViewModel

Merge main's encrypted import states (PasswordRequired, WrongPassword,
password field on ImportPreview) alongside this branch's ExportSaved state.

Also null out _exportedIdentityUri after save and add onCleared() cleanup
to prevent holding stale URI references, matching MigrationViewModel's pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cover saveExportToFile, onExportSaveDialogLaunched, and
saveExportedIdentityToFile including success, failure, null URI,
and URI cleanup after save scenarios.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +306 to +311
withContext(Dispatchers.IO) {
contentResolver.openInputStream(sourceUri)?.use { input ->
contentResolver.openOutputStream(destinationUri)?.use { output ->
input.copyTo(output)
}
} ?: error("Could not open export file")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: A nested safe-call can cause a silent failure if openOutputStream() returns null, resulting in an empty export file without any error being reported.
Severity: CRITICAL

Suggested Fix

Explicitly check the result of contentResolver.openOutputStream(destinationUri). If it is null, throw an error to prevent the silent failure. This can be done by adding another elvis operator check for the inner use block, for example: ...?.use { output -> ... } ?: error("Could not open destination file").

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: app/src/main/java/com/lxmf/messenger/viewmodel/MigrationViewModel.kt#L306-L311

Potential issue: In `MigrationViewModel.saveExportToFile()` and
`IdentityManagerViewModel.saveExportedIdentityToFile()`, the file copying logic uses a
nested safe-call operator. If `contentResolver.openInputStream(sourceUri)` succeeds but
`contentResolver.openOutputStream(destinationUri)` returns null, the inner `?.use` block
is silently skipped. The `?: error(...)` at the end of the chain is not triggered
because the outer expression is not null. This causes the function to report a
successful export, but results in an empty file because no data was written, leading to
silent data loss.

@torlando-tech torlando-tech merged commit 266b155 into main Feb 15, 2026
9 of 10 checks passed
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.

[Minor] Export uses Share sheet and not File Save dialog

2 participants