From 714b96aaeb3c3d16f937550e149eb605f9b88948 Mon Sep 17 00:00:00 2001 From: Adedeji Toki Date: Tue, 9 Jun 2026 18:28:05 -0700 Subject: [PATCH 1/3] Fix Android DocumentPicker crash on null DISPLAY_NAME / bad URL component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some content providers (notably Downloads) omit DISPLAY_NAME, which made `getColumnIndexOrThrow` throw and `selectedFilename.wrappedValue!` NPE. Separately, building the destination via `URL.appendingPathComponent` hit a NullPointerException inside skip.foundation.URL._appendingPathComponent on inputs containing encoded whitespace. - Use getColumnIndex (not …OrThrow) and tolerate -1 / null. - Fall back to a UUID-derived filename when DISPLAY_NAME is null. - Fall back to ContentResolver.getType for missing mime. - Build the cache destination with java.io.File(cacheDir, safeName) to avoid Skip's URL helper entirely. - Replace `resolver.openInputStream(uri)!` force-unwrap with a guard. --- Sources/SkipKit/DocumentPicker.swift | 66 ++++++++++++++++++---------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/Sources/SkipKit/DocumentPicker.swift b/Sources/SkipKit/DocumentPicker.swift index 4e7792b..b2aad4d 100644 --- a/Sources/SkipKit/DocumentPicker.swift +++ b/Sources/SkipKit/DocumentPicker.swift @@ -42,38 +42,56 @@ extension View { logger.log(message: "selected document uri: \(uri)") if let uri = uri { let resolver = context.contentResolver - + var resolvedName: String? = nil + var resolvedMime: String? = nil + if let query = resolver.query(uri, nil, nil, nil, nil) { - let nameIndex = query.getColumnIndexOrThrow(android.provider.OpenableColumns.DISPLAY_NAME) - let mimetypeIndex = query.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE) - query.moveToFirst() - let name = query.getString(nameIndex) - let type = query.getString(mimetypeIndex) - - selectedFilename.wrappedValue = name - selectedFileMimeType.wrappedValue = type - - // To be able to access the file from another part of the app it needs to be copied in tha cached directory: - if let storageDir = context.cacheDir, let url = URL(string: storageDir.path) { - let filemanager = FileManager.default - let destinationFileURL = url.appendingPathComponent(selectedFilename.wrappedValue!) - - if filemanager.fileExists(atPath: destinationFileURL.path) { - try? filemanager.removeItem(at: destinationFileURL) + if query.moveToFirst() { + // Some providers (notably Downloads) omit DISPLAY_NAME / mime + // columns. Use getColumnIndex (not …OrThrow) and tolerate -1. + let nameIndex = query.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if nameIndex >= 0 { + resolvedName = query.getString(nameIndex) + } + let mimeIndex = query.getColumnIndex(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE) + if mimeIndex >= 0 { + resolvedMime = query.getString(mimeIndex) } - - let inputStream = resolver.openInputStream(uri)! - let outputFile = java.io.File(destinationFileURL.path) - let outputStream = java.io.FileOutputStream(outputFile) + } + query.close() + } + + // Fall back to ContentResolver.getType for mime if the cursor didn't have one. + if resolvedMime == nil { + resolvedMime = resolver.getType(uri) + } + + // Always have a usable filename — some providers return a null DISPLAY_NAME. + let safeName: String = resolvedName ?? "import-\(java.util.UUID.randomUUID().toString())" + + selectedFilename.wrappedValue = safeName + selectedFileMimeType.wrappedValue = resolvedMime + + // Copy the picked file into the cache dir so it stays accessible after this + // callback returns. Build the destination with java.io.File rather than + // URL.appendingPathComponent — Skip's URL._appendingPathComponent NPEs on + // some inputs (encoded whitespace, etc). + if let cacheDir = context.cacheDir { + let destinationFile = java.io.File(cacheDir, safeName) + if destinationFile.exists() { + destinationFile.delete() + } + if let inputStream = resolver.openInputStream(uri) { + let outputStream = java.io.FileOutputStream(destinationFile) inputStream.copyTo(outputStream) - outputStream.close() inputStream.close() - - selectedDocumentURL.wrappedValue = destinationFileURL + selectedDocumentURL.wrappedValue = URL(fileURLWithPath: destinationFile.absolutePath) } else { selectedDocumentURL.wrappedValue = URL(platformValue: java.net.URI.create(uri.toString())) } + } else { + selectedDocumentURL.wrappedValue = URL(platformValue: java.net.URI.create(uri.toString())) } } } From 23d1f75b977d0dd5ed813f36df9947cfff304f27 Mon Sep 17 00:00:00 2001 From: Adedeji Toki Date: Tue, 9 Jun 2026 18:33:24 -0700 Subject: [PATCH 2/3] Use File.toURI() so cached-file paths with spaces don't crash URL init URL(fileURLWithPath:) feeds the raw path to java.net.URI, which rejects unencoded spaces / parens. Picked files frequently have such characters in DISPLAY_NAME ("African American Praise medley (Henrisoul).mp3"), so the post-copy URL construction crashed with URISyntaxException at "Illegal character in path at index N". java.io.File.toURI() percent-encodes the path, so wrapping its result with URL(platformValue:) gives a valid file URL on every input. --- Sources/SkipKit/DocumentPicker.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/SkipKit/DocumentPicker.swift b/Sources/SkipKit/DocumentPicker.swift index b2aad4d..8620299 100644 --- a/Sources/SkipKit/DocumentPicker.swift +++ b/Sources/SkipKit/DocumentPicker.swift @@ -86,7 +86,9 @@ extension View { inputStream.copyTo(outputStream) outputStream.close() inputStream.close() - selectedDocumentURL.wrappedValue = URL(fileURLWithPath: destinationFile.absolutePath) + // Use File.toURI() so spaces / parens / etc. get percent-encoded + // before reaching Skip's java.net.URI-backed URL initializer. + selectedDocumentURL.wrappedValue = URL(platformValue: destinationFile.toURI()) } else { selectedDocumentURL.wrappedValue = URL(platformValue: java.net.URI.create(uri.toString())) } From 39f1972f5f16cb0181d17e6ea7c4a754d40e3f16 Mon Sep 17 00:00:00 2001 From: Adedeji Toki Date: Tue, 9 Jun 2026 18:55:49 -0700 Subject: [PATCH 3/3] Tighten comments in DocumentPicker patch --- Sources/SkipKit/DocumentPicker.swift | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/Sources/SkipKit/DocumentPicker.swift b/Sources/SkipKit/DocumentPicker.swift index 8620299..8a34cdb 100644 --- a/Sources/SkipKit/DocumentPicker.swift +++ b/Sources/SkipKit/DocumentPicker.swift @@ -47,8 +47,7 @@ extension View { if let query = resolver.query(uri, nil, nil, nil, nil) { if query.moveToFirst() { - // Some providers (notably Downloads) omit DISPLAY_NAME / mime - // columns. Use getColumnIndex (not …OrThrow) and tolerate -1. + // Downloads provider omits these columns; tolerate -1. let nameIndex = query.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) if nameIndex >= 0 { resolvedName = query.getString(nameIndex) @@ -61,21 +60,16 @@ extension View { query.close() } - // Fall back to ContentResolver.getType for mime if the cursor didn't have one. if resolvedMime == nil { resolvedMime = resolver.getType(uri) } - // Always have a usable filename — some providers return a null DISPLAY_NAME. let safeName: String = resolvedName ?? "import-\(java.util.UUID.randomUUID().toString())" selectedFilename.wrappedValue = safeName selectedFileMimeType.wrappedValue = resolvedMime - // Copy the picked file into the cache dir so it stays accessible after this - // callback returns. Build the destination with java.io.File rather than - // URL.appendingPathComponent — Skip's URL._appendingPathComponent NPEs on - // some inputs (encoded whitespace, etc). + // java.io.File path avoids Skip URL.appendingPathComponent NPE. if let cacheDir = context.cacheDir { let destinationFile = java.io.File(cacheDir, safeName) if destinationFile.exists() { @@ -86,8 +80,7 @@ extension View { inputStream.copyTo(outputStream) outputStream.close() inputStream.close() - // Use File.toURI() so spaces / parens / etc. get percent-encoded - // before reaching Skip's java.net.URI-backed URL initializer. + // File.toURI() percent-encodes; raw path would crash java.net.URI. selectedDocumentURL.wrappedValue = URL(platformValue: destinationFile.toURI()) } else { selectedDocumentURL.wrappedValue = URL(platformValue: java.net.URI.create(uri.toString()))