Skip to content

fix(android): copy file to cache before querying + handle text/plain files#204

Closed
mertselimb wants to merge 2 commits intoachorein:mainfrom
mertselimb:fix/android-permission-and-text-plain
Closed

fix(android): copy file to cache before querying + handle text/plain files#204
mertselimb wants to merge 2 commits intoachorein:mainfrom
mertselimb:fix/android-permission-and-text-plain

Conversation

@mertselimb
Copy link

Summary

Fixes two Android bugs in handleShareIntent / getFileInfo:

Bug 1: SecurityException on shared files (fixes #175)

getFileInfo calls resolver.query(uri) directly on the content URI. On Android 12+, content providers like Google Photos (MediaContentProvider) and Downloads (DownloadStorageProvider) are not exported. The temporary URI permission granted by FLAG_GRANT_READ_URI_PERMISSION can expire before the async getShareIntent call reaches the native module, causing:

java.lang.SecurityException: Permission Denial: opening provider
com.google.android.apps.photos.contentprovider.impl.MediaContentProvider
... that is not exported from UID 10170

Fix: Copy the file to app cache via openInputStream immediately (while URI permission is valid), then read metadata from the local copy. Metadata query and MIME type resolution are wrapped in try-catch so a single provider failure doesn't crash the entire share flow.

Bug 2: Files with text/* MIME types silently dropped

handleShareIntent checks intent.type.startsWith("text/plain") before checking for EXTRA_STREAM. When a file with a text/* MIME type (.txt, .csv, .html, etc.) is shared from a file manager, the MIME type is text/plain (or another text/* variant) but the file is delivered via EXTRA_STREAM (not EXTRA_TEXT). The code enters the text branch, reads EXTRA_TEXT (null), and the share is silently lost.

Fix: Check for EXTRA_STREAM first in ACTION_SEND. If a stream URI exists, always treat it as a file regardless of MIME type. Text handling is only used when there is no stream attachment.

Testing

Tested with:

  • Share image from Google Photos → ✅ file received (was SecurityException)
  • Share PDF from Downloads → ✅ file received (was SecurityException)
  • Share .txt file from file manager → ✅ file received (was silently dropped)
  • Share plain text from browser → ✅ text received (unchanged)
  • Share multiple images → ✅ files received (unchanged)

Fixes SecurityException when accessing content URIs from providers like
Google Photos (MediaContentProvider) and Downloads (DownloadStorageProvider)
that are not exported. The previous code called resolver.query() directly
on the content URI, which fails when the temporary URI permission has
expired or is inaccessible from the async context.

Now copies the file to app cache via openInputStream first (while the
permission is still valid), then reads metadata from the local copy.

Fixes achorein#175
When sharing a .txt file (or any text/* file), Android sets the MIME
type to text/plain but delivers the file via EXTRA_STREAM (not
EXTRA_TEXT). The previous code checked the MIME type first and routed
text/plain to the text handler, which read EXTRA_TEXT (null for file
shares) — silently dropping the file.

Now checks for EXTRA_STREAM first in ACTION_SEND. If a stream URI exists,
it is always treated as a file regardless of MIME type. Text handling is
only used when there is no stream attachment.
@achorein
Copy link
Owner

achorein commented Mar 5, 2026

Thanks for the PR I will check that tomorrow.
The bugs were in expo 55 ?

@mertselimb
Copy link
Author

I am using 54 but I am getting some flaky behavior on dev client. May need more tests.

@mertselimb mertselimb closed this Mar 5, 2026
@mertselimb
Copy link
Author

Need more testing I had to close it.

@mertselimb
Copy link
Author

mertselimb commented Mar 7, 2026

@achorein I fixed my code with the code below it works now. I will try to create another pull when I have time.

const { withMainActivity } = require('@expo/config-plugins');

/**
 * Fixes Android cold-start share intent SecurityException.
 *
 * Problem: expo-share-intent defers reading content:// URIs to a background
 * thread (AsyncFunction). By that time the temporary URI read permission
 * granted by Android has expired → SecurityException.
 *
 * Fix: Override MainActivity.onCreate() to copy the shared file to local
 * cache *immediately* (while permission is still valid), write metadata to
 * a JSON file, and neutralize the intent so the library doesn't try to
 * process the expired URI. The JS layer reads the cached metadata on mount.
 *
 * Warm-start (app already open) is unaffected — the library's OnNewIntent
 * handler processes the intent synchronously while permission is valid.
 */
function withShareIntentFix(config) {
  return withMainActivity(config, (config) => {
    let contents = config.modResults.contents;

    // ── 1. Add imports ──────────────────────────────────────────────────
    const importAnchor = 'import android.os.Bundle';
    const newImports = [
      'import android.content.Intent',
      'import android.net.Uri',
      'import android.os.Build',
      'import android.provider.OpenableColumns',
      'import android.util.Log',
      'import android.webkit.MimeTypeMap',
      'import java.io.File',
      'import java.io.FileOutputStream',
      'import org.json.JSONObject',
    ]
      .filter((imp) => !contents.includes(imp))
      .join('\n');

    if (newImports) {
      contents = contents.replace(importAnchor, `${importAnchor}\n${newImports}`);
    }

    // ── 2. Inject caching call before super.onCreate() ──────────────────
    // SDK 55 passes `null` to super.onCreate(), older SDKs pass `savedInstanceState`.
    // Match either variant with a regex.
    contents = contents.replace(
      /super\.onCreate\((savedInstanceState|null)\)/,
      [
        '// [ShareIntentFix] Cache shared files while URI permission is valid',
        '        if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_SEND_MULTIPLE) {',
        '            cacheShareIntentToLocal()',
        '        }',
        '        super.onCreate($1)',
      ].join('\n        ')
    );

    // ── 3. Add helper method before final closing brace ─────────────────
    const helperMethod = `
    // ── ShareIntentFix ──────────────────────────────────────────────────
    // Copies the shared content:// file to local cache during onCreate
    // (while the temporary URI read permission is still valid) and writes
    // metadata to a JSON file. JS reads this on cold start.
    private fun cacheShareIntentToLocal() {
        val uri: Uri? = when (intent?.action) {
            Intent.ACTION_SEND -> {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                    intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
                } else {
                    @Suppress("DEPRECATION")
                    intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri
                }
            }
            Intent.ACTION_SEND_MULTIPLE -> {
                val uris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                    intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
                } else {
                    @Suppress("DEPRECATION")
                    intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
                }
                uris?.firstOrNull()
            }
            else -> null
        }
        if (uri == null || uri.scheme != "content") return

        try {
            val resolver = contentResolver

            // Read metadata while permission is valid
            var fileName: String? = null
            var fileSize: Long? = null
            resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor ->
                if (cursor.moveToFirst()) {
                    val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                    val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
                    if (nameIdx >= 0) fileName = cursor.getString(nameIdx)
                    if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) fileSize = cursor.getLong(sizeIdx)
                }
            }

            val mimeType = resolver.getType(uri)

            // Fallback filename
            if (fileName == null) {
                val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
                fileName = "shared_${"$"}{System.currentTimeMillis()}${"$"}{if (ext != null) ".$ext" else ""}"
            }

            // Copy file to cache
            val dir = File(cacheDir, "share_intent_cache")
            dir.mkdirs()
            dir.listFiles()?.forEach { it.delete() } // clean previous

            val targetFile = File(dir, fileName!!)
            resolver.openInputStream(uri)?.use { input ->
                FileOutputStream(targetFile).use { output ->
                    input.copyTo(output)
                }
            } ?: return // stream unavailable — bail without neutralizing

            if (fileSize == null) fileSize = targetFile.length()

            // Write metadata JSON for JS to consume
            File(dir, "metadata.json").writeText(
                JSONObject().apply {
                    put("filePath", android.net.Uri.fromFile(targetFile).toString())
                    put("fileName", fileName)
                    put("fileSize", fileSize)
                    put("mimeType", mimeType ?: "application/octet-stream")
                    put("timestamp", System.currentTimeMillis())
                }.toString()
            )

            // Replace the entire intent so expo-share-intent sees a clean MAIN intent.
            // Field-by-field clearing isn't enough — the library may read from
            // intent.data, clipData, or extras independently.
            setIntent(Intent(Intent.ACTION_MAIN))

            Log.d("ShareIntentFix", "Cached: $fileName ($fileSize bytes)")
        } catch (e: Exception) {
            Log.e("ShareIntentFix", "Failed to cache share intent: ${"$"}{e.message}", e)
            // Don't neutralize — let the library try (may still fail)
        }
    }
`;

    const lastBrace = contents.lastIndexOf('}');
    contents = contents.slice(0, lastBrace) + helperMethod + '\n}\n';

    config.modResults.contents = contents;
    return config;
  });
}

module.exports = withShareIntentFix;

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.

[BUG] Android - Cannot access shared files due to permission issues with returned URIs

2 participants