
Debug Document & File Picker Crashes on iOS & Android
Every mobile app that handles user files leans on a system document picker or file picker at some point, and that single sheet of UI is quietly one of the most reliable crash generators on both platforms. On iOS it is UIDocumentPickerViewController; on Android it is the Storage Access Framework built around ACTION_OPEN_DOCUMENT. The crashes they produce rarely point at the real problem — a delegate that got deallocated, a security-scoped URL that was accessed after its lease expired, or a ParcelFileDescriptor that was never closed. This guide walks through the concrete crash modes on each platform, with code you can lift directly into your app, and shows how to instrument the picker path so the next crash arrives with context instead of a mystery.
Pickers are inherently a handoff: your app surrenders control to a system process, then receives back a URL or Uri that is valid only under conditions the system never fully explains — an object lifecycle, a sandbox boundary, and a permission lease, any of which can silently lapse.
Why file pickers crash differently than other UI
First, pickers return references, not data. The URL or Uri you get back is a pointer into a system-managed store, and it expires — when the app is relaunched, a permission is revoked, or the document provider is reclaimed. Second, pickers are asynchronous and lifecycle-bound; the callback arrives on a thread and at a time you do not control, which makes object-lifetime mistakes invisible until production. Third, pickers straddle a security boundary, so a large share of their failures are permission and sandbox violations that throw at exactly the wrong moment.
iOS: UIDocumentPickerViewController
Apple's UIDocumentPickerViewController lets users import, export, and open documents from iCloud and third-party providers. It is small on the surface and full of traps underneath; the four failures below account for most iOS picker crashes in the field.
Delegate retain cycles and over-release
The picker's delegate is held weakly by UIKit, exactly like URLSession holds its delegate. If the only reference to your delegate is the picker itself, it is deallocated while the picker is still presented, and the next callback arrives at a dangling pointer.
final class DocumentImporter {
func present(in viewController: UIViewController) {
let delegate = PickerDelegate() // local — dies when this scope ends
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.pdf])
picker.delegate = delegate // weak reference, not retained
viewController.present(picker, animated: true)
}
}The fix is to store the delegate in a property that outlives the presentation. A subtler variant is over-releasing a delegate that is still needed: freeing the controller while the picker is mid-flight, then force-unwrapping the document in documentPicker(_:didPickDocumentsAt:). Keep strong references to both picker and delegate until dismissal completes, and guard every force-unwrap.
Security-scoped resources and EXC_BAD_ACCESS
When a document is returned, iOS may hand you a security-scoped URL. You must call startAccessingSecurityScopedResource() before reading it and stopAccessingSecurityScopedResource() after. Apple's documentation on NSURL security-scoped resources is explicit: access outside the scope crashes rather than errors.
func documentPicker(_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
let ok = url.startAccessingSecurityScopedResource()
defer { if ok { url.stopAccessingSecurityScopedResource() } }
// BUG: if startAccessing... returned false, reading below touches a
// URL the sandbox has not granted -> EXC_BAD_ACCESS.
let data = try! Data(contentsOf: url)
}The classic bug is assuming startAccessingSecurityScopedResource() always succeeds. It returns a Bool precisely because it can fail — on iCloud documents still downloading, on revoked leases, or on stale bookmarks after relaunch. Check the return value and fall back gracefully. For documents you need again later, persist a security-scoped bookmark and resolve it on relaunch instead of storing the raw URL, which will not survive a restart.
NSFileCoordinator deadlocks
Reading a document in iCloud or a shared container through NSFileCoordinator is correct — and a reliable deadlock source when done from the picker's delegate callback, which runs on the main thread. Blocking the main thread on a coordinated read while another process holds the file can stall the UI long enough to trigger a watchdog termination.
func documentPicker(_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
let coordinator = NSFileCoordinator(filePresenter: nil)
var error: NSError?
coordinator.coordinate(readingItemAt: url, options: [], error: &error) { readURL in
// Heavy read performed on the main thread — can deadlock the UI.
}
}Move coordinated reads to a background queue, hop back to the main thread for UI updates, and guard the error pointer rather than ignoring a failed coordination.
UTType and MIME mismatch crashes
When you build a picker with forOpeningContentTypes:, the system filters by Uniform Type Identifiers. A mismatch — a provider reporting a PDF but returning another type, or a mislabeled file — routes the wrong code path and crashes the decoder.
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
// A provider that mislabels a type will route a non-image here and crash
// the decoder. Verify the resolved UTType before assuming the content.Apple's UTType reference treats types as hints, not guarantees. Resolve the real type with URLResourceKey.contentTypeKey before decoding, and default to a safe error path. MIME metadata from external providers is especially unreliable.
iCloud entitlement issues
If your app declares iCloud document support but its entitlement, container identifier, or provisioning profile is misconfigured, the picker still opens and the document appears picked — then every coordinated read fails, or the app crashes accessing a container it was never granted. Verify the iCloud container string, the com.apple.developer.ubiquity-container-identifiers array, and the entitlements on the signing side before chasing phantom file-system crashes.
Android: Storage Access Framework
Android's Storage Access Framework exposes documents through a content Uri your app never owns outright. The system documentation on opening files using the Storage Access Framework lays out the happy path; the crashes live in the paths around it.
Unhandled ActivityResult and null crashes
The most common SAF crash is assuming the result is always present. When the user cancels, or the system returns anything other than RESULT_OK, the intent data is null.
private val openDoc = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
// BUG: uri is null when the user cancels. Calling read(uri!!) or
// a contentResolver call on null throws immediately.
read(uri!!)
}Check the result code and nullability before touching the Uri. OpenDocument, CreateDocument, and OpenDocumentTree each have their own cancellation semantics — a user tapping back is normal, not an error.
takePersistableUriPermission loss and SecurityException
The access grant from ACTION_OPEN_DOCUMENT is temporary. To keep it across restarts you must call takePersistableUriPermission with the exact flags the provider returned. Persist the Uri but skip this step, and the next access throws a SecurityException on a cold start.
val flags = resultData!!.flags and
(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
contentResolver.takePersistableUriPermission(uri, flags)A related failure is requesting more permission than the provider granted — persist only the flags actually returned in the result intent. Some providers offer no persistable permission at all; fall back to reading within the callback's lifetime only.
DocumentsContract query crashes
Inspecting metadata — display name, size, MIME type — goes through DocumentsContract queries. These crash by querying on the main thread (a NetworkOnMainThreadException for slow providers) or by assuming the cursor is non-empty.
val cursor = contentResolver.query(uri, null, null, null, null)
// BUG: cursor can be null if the provider is unavailable, and
// moveToFirst() on a null or empty cursor crashes.
cursor?.use {
if (it.moveToFirst()) {
val name = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}Run the query off the main thread, null-check the cursor, and treat a missing column or empty result as normal. A cloud provider going offline between the pick and the query is a real scenario.
ParcelFileDescriptor leaks
Reading through ParcelFileDescriptor gives you a file descriptor into the provider. Leaving it open leaks the descriptor and holds the provider's resources hostage; accumulating open descriptors on low-memory devices triggers Too many open files and crashes your app or the provider.
val pfd = contentResolver.openFileDescriptor(uri, "r")
// BUG: pfd never closed — descriptor leaks accumulate across picks.
val stream = FileInputStream(pfd!!.fileDescriptor)Wrap the descriptor in use { } so it closes deterministically, and never detach the file descriptor and close only half the pair.
Memory-mapped files and large-file pressure
Mapping a picked file wholesale is fast for small documents and a guaranteed OutOfMemoryError for large ones. A FileChannel.map() produces a MappedByteBuffer that cannot be explicitly unmapped — it is reclaimed only on GC — so repeatedly mapping large files on a small heap crashes the app even though each file individually fits.
val channel = FileInputStream(fd).channel
val mapped = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size())
// BUG: a 2 GB file on a device with a 256 MB heap -> OutOfMemoryError,
// and the mapping is not released deterministically.Prefer streaming reads with a bounded buffer for anything over a few megabytes, and reserve memory-mapping for small, read-only blobs. On iOS the same applies to Data(contentsOf:) versus streaming a multi-gigabyte document.
FileProvider URI crashes
When your app shares its own files, it must hand out a content:// URI via FileProvider, not a raw file:// path. A raw path triggers FileUriExposedException, and a provider authority that does not match the <provider> manifest entry throws IllegalArgumentException the moment the picker or share sheet resolves it. Keep the authority in one constant, use FileProvider.getUriForFile(...), and grant read/write flags on the intent.
Cross-cutting failures: cancellation, lifecycle, and revocation
Some picker crashes are platform-agnostic. The user cancels — normal on both systems, never a crash path. The activity or view controller is torn down while the picker is presented, so the callback fires into a dead context. A permission is revoked mid-session, and the next access throws SecurityException on Android or fails the lease on iOS. Cancel requests in onDestroy / deinit, guard callbacks against vanished contexts, and re-validate persisted references before use. These are the same lifecycle and permission disciplines covered in our guide to runtime permission debugging, applied to the document handoff.
Each of these failures looks unrelated until you trace it back to the same handoff: a SecurityException on cold start, an EXC_BAD_ACCESS on a background read, an OutOfMemoryError after the tenth pick.
Instrumenting pickers for crash reporting
A report that only says "EXC_BAD_ACCESS in UIDocumentPicker" tells you almost nothing. Instrument the picker path so every failure carries context: the document type requested, the result code, whether the security-scoped lease or persistable grant succeeded, the file size and MIME type, and the provider. Attach these as breadcrumbs before the crash fires so a SecurityException on relaunch is correlated with the grant never taken.
That correlation is what a purpose-built mobile observability tool gives you. Bugspulse captures picker failures with document context intact, so you see whether a crash came from a stale bookmark or a leaked descriptor instead of guessing. If you are still on a generic reporter that drops picker metadata, take a look at how Bugspulse handles mobile crash reporting and instrument your first document picker today.
Ready to stop chasing anonymous file picker crashes? Create a free Bugspulse account and ship your next build with the document handoff fully instrumented.