fix: upload files shared into the app with their real name and type

Sharing a photo or document from another app into Bulwark failed every
time: the upload task was handed the share's `content://` URI, but it
can only stream from a file path, so it failed with "Directory for
'/external/images/media/…' doesn't exist". The attachment was also named
after the URI's last segment (a MediaStore row id such as "1000000027"),
and SEND_MULTIPLE shares only carried a wildcard type like `image/*`.

- `uploadBlob` copies `content://` URIs into the cache, uploads the copy
  and deletes it afterwards, also when the upload fails
- `ShareIntentStore` asks the providing app for the display name, size
  and MIME type when the intent arrives, falling back to the URI segment
  plus an extension for the MIME type
- the composer gets the real name, type and size, so the size limit
  check now applies to shared files too
This commit is contained in:
Linus Rath
2026-09-24 18:43:43 +02:00
parent 8d7fbfc99c
commit cd4ce9d08b
7 changed files with 211 additions and 17 deletions
@@ -19,13 +19,13 @@ class MainActivity : ReactActivity() {
setTheme(R.style.AppTheme);
super.onCreate(null)
NotificationTapStore.captureFromIntent(intent)
ShareIntentStore.captureFromIntent(intent)
ShareIntentStore.captureFromIntent(intent, contentResolver)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
ShareIntentStore.captureFromIntent(intent)?.let { share ->
ShareIntentStore.captureFromIntent(intent, contentResolver)?.let { share ->
BulwarkFcmModule.emit("app:share", share.toMap())
}
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
@@ -1,8 +1,11 @@
package com.anonymous.bulwarkmobile
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
@@ -20,7 +23,7 @@ object ShareIntentStore {
return value
}
fun captureFromIntent(intent: Intent?): SharePayload? {
fun captureFromIntent(intent: Intent?, resolver: ContentResolver): SharePayload? {
val action = intent?.action ?: return null
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
@@ -35,11 +38,14 @@ object ShareIntentStore {
val type = intent.type
if (text.isNullOrBlank() && uris.isEmpty()) return null
val files = uris.mapIndexed { i, uri -> describe(resolver, uri, type, i) }
val payload = SharePayload(
text = text,
subject = subject,
uris = uris.map { it.toString() },
mimeTypes = uris.map { type ?: "" },
mimeTypes = files.map { it.mimeType },
names = files.map { it.name },
sizes = files.map { it.size },
)
pending = payload
// Clear so a later lifecycle event doesn't replay this share.
@@ -50,6 +56,46 @@ object ShareIntentStore {
return payload
}
private class SharedFile(val name: String, val mimeType: String, val size: Double)
// Ask the provider for the file's real name, type and size: a MediaStore
// URI ends in a bare row id ("1000000027") and SEND_MULTIPLE only carries
// a wildcard type such as "image/*". Size is -1 when unknown.
private fun describe(resolver: ContentResolver, uri: Uri, intentType: String?, index: Int): SharedFile {
var displayName: String? = null
var size = -1.0
try {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameColumn >= 0 && !cursor.isNull(nameColumn)) displayName = cursor.getString(nameColumn)
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeColumn >= 0 && !cursor.isNull(sizeColumn)) size = cursor.getLong(sizeColumn).toDouble()
}
}
} catch (_: Exception) {
// Provider without OpenableColumns: fall back to the URI below.
}
val resolvedType = try {
resolver.getType(uri)
} catch (_: Exception) {
null
}
val mimeType = resolvedType ?: intentType?.takeUnless { it.contains('*') } ?: ""
val name = displayName?.takeIf { it.isNotBlank() } ?: fallbackName(uri, mimeType, index)
return SharedFile(name, mimeType, size)
}
// Last path segment plus an extension for the MIME type, so the
// recipient's mail client can still open the attachment.
private fun fallbackName(uri: Uri, mimeType: String, index: Int): String {
val base = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
?: "shared-${index + 1}"
if (base.contains('.')) return base
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
return if (extension.isNullOrEmpty()) base else "$base.$extension"
}
@Suppress("DEPRECATION")
private fun streamExtra(intent: Intent): Uri? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -71,12 +117,16 @@ object ShareIntentStore {
val subject: String?,
val uris: List<String>,
val mimeTypes: List<String>,
val names: List<String>,
val sizes: List<Double>,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
if (text != null) putString("text", text)
if (subject != null) putString("subject", subject)
putArray("uris", Arguments.fromList(uris))
putArray("mimeTypes", Arguments.fromList(mimeTypes))
putArray("names", Arguments.fromList(names))
putArray("sizes", Arguments.fromList(sizes))
}
}