Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f2754c7
Fix cold-start file sharing to open Submit expense
nabi-ebrahimi Sep 15, 2026
5d73a7f
Fix duplicate identifier `shouldUseNarrowLayout` in InboxTabSelector
nabi-ebrahimi Sep 16, 2026
1ba8767
Fix unsafe type assertions and lint errors in ShareRootPage
nabi-ebrahimi Sep 16, 2026
fc54b29
Move errorTitle early return to top of processFiles callback
nabi-ebrahimi Sep 17, 2026
ad46ce4
Restore temporary file cleanup for all file intents
nabi-ebrahimi Sep 29, 2026
1b7804a
Address Android share flow review feedback
nabi-ebrahimi Sep 29, 2026
1efe977
Show an error when shared attachments cannot be saved
nabi-ebrahimi Oct 2, 2026
eb89508
Prefer intent MIME type for shared attachments
nabi-ebrahimi Oct 2, 2026
4a54e40
Avoid blocking preference cleanup during share startup
nabi-ebrahimi Oct 2, 2026
8c6a181
Add tests for shared file response handling
nabi-ebrahimi Oct 2, 2026
1b8bc19
Merge branch 'main' into fix/cold-start-file-share-submit-expense
nabi-ebrahimi Oct 6, 2026
7338c12
Merge branch 'main' into fix/cold-start-file-share-submit-expense
nabi-ebrahimi Oct 16, 2026
9fc9e9f
Restore existing Android share temp directory
nabi-ebrahimi Oct 17, 2026
140dbc3
Merge branch 'main' into fix/cold-start-file-share-submit-expense
nabi-ebrahimi Oct 17, 2026
700258b
Rename Android share intent helpers
nabi-ebrahimi Oct 20, 2026
502408f
Clarify Android share intent launch behavior
nabi-ebrahimi Oct 20, 2026
05beba0
Simplify shared file extension fallback
nabi-ebrahimi Oct 20, 2026
a913dee
Simplify cold share intent handling
nabi-ebrahimi Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions android/app/src/main/java/com/expensify/chat/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.expensify.chat

import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
Expand Down Expand Up @@ -40,6 +41,14 @@ class MainActivity : ReactActivity() {
))

override fun onCreate(savedInstanceState: Bundle?) {
val initialIntent = intent
val isColdShareIntent = initialIntent?.action == Intent.ACTION_SEND
if (isColdShareIntent && handleIntent(initialIntent, shouldLaunchActivity = false)) {
setIntent(Intent(Intent.ACTION_VIEW, Uri.parse("new-expensify://share/root")).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
})
}

getSharedPreferences(APP_START_TIME_PREFERENCES, MODE_PRIVATE)
.edit()
.putLong(APP_START_TIME_PREFERENCES, System.currentTimeMillis())
Expand All @@ -60,7 +69,7 @@ class MainActivity : ReactActivity() {
)
}

if (intent != null) {
if (!isColdShareIntent && intent != null) {
handleIntent(intent)
}
}
Expand All @@ -78,12 +87,13 @@ class MainActivity : ReactActivity() {
handleIntent(intent)
}

private fun handleIntent(intent: Intent) {
private fun handleIntent(intent: Intent, shouldLaunchActivity: Boolean = true): Boolean {
try {
val intenthandler = IntentHandlerFactory.getIntentHandler(this, intent.type, intent.toString())
intenthandler?.handle(intent)
return intenthandler?.handle(intent, shouldLaunchActivity) ?: false
} catch (exception: Exception) {
Log.e("handleIntentException", exception.toString())
return false
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,36 @@ class ShareActionHandlerModule(reactContext: ReactApplicationContext) :

val shareObjectString = sharedPreferences.getString(IntentHandlerConstants.shareObjectProperty, null)
if (shareObjectString == null) {
callback.invoke("No data found", null)
callback.invoke(null)
return
}

val shareObject = JSONObject(shareObjectString)
val content = shareObject.optString("content")
val mimeType = shareObject.optString("mimeType")
var mimeType = shareObject.optString("mimeType", "")
val timestamp = System.currentTimeMillis()

val file = File(content)
if (file.exists() && (mimeType.isEmpty() || mimeType.endsWith("/*") || mimeType == "null")) {
val extension = file.extension.lowercase()
mimeType = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: mimeType
}
if (!file.exists()) {
val textObject = JSONObject().apply {
put("id", "text")
put("content", content)
put("mimeType", "txt")
put("processedAt", timestamp)
if (mimeType.startsWith("text/") || mimeType == "txt") {
val textObject = JSONObject().apply {
put("id", "text")
put("content", content)
put("mimeType", "txt")
put("processedAt", timestamp)
}
callback.invoke(textObject.toString())
return
}
callback.invoke(textObject.toString())
callback.invoke(null)
return
}
if (file.length() == 0L) {
callback.invoke(null)
return
}

Expand Down Expand Up @@ -84,7 +96,8 @@ class ShareActionHandlerModule(reactContext: ReactApplicationContext) :
callback.invoke(fileData.toString())

} catch (e: Exception) {
callback.invoke(e.toString(), null)
Log.e("ShareActionHandlerModule", "Error processing shared files: ${e.message}", e)
callback.invoke(null)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,41 @@ import android.net.Uri
import com.expensify.chat.utils.FileUtils

class FileIntentHandler(private val context: Context) : AbstractIntentHandler() {
override fun handle(intent: Intent): Boolean {
override fun handle(intent: Intent, shouldLaunchActivity: Boolean): Boolean {
super.clearTemporaryFiles(context)
when(intent.action) {
Intent.ACTION_SEND -> {
handleSingleFileIntent(intent, context)
onCompleted()
saveSharedFile(intent, context, shouldLaunchActivity)
// Expected persistence failures return from saveSharedFile(), allowing the warm-start Share screen to display an attachment error.
// Cold starts use new-expensify://share/root as the activity's initial URL instead.
if (shouldLaunchActivity) {
onCompleted()
}
return true
}
}
return false
}

private fun handleSingleFileIntent(intent: Intent, context: Context) {
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { fileUri ->
val resultingPath: String? = FileUtils.copyUriToStorage(fileUri, context)

if (resultingPath != null) {
val shareFileObject = ShareFileObject(resultingPath, intent.type)
private fun saveSharedFile(intent: Intent, context: Context, shouldLaunchActivity: Boolean) {
val fileUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM) ?: return
val resultingPath = FileUtils.copyUriToStorage(fileUri, context) ?: return
val mimeType = intent.type ?: try {
context.contentResolver.getType(fileUri)
} catch (exception: Exception) {
null
}
val shareFileObject = ShareFileObject(resultingPath, mimeType)

val sharedPreferences = context.getSharedPreferences(IntentHandlerConstants.preferencesFile, Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString())
editor.apply()
}
val sharedPreferences = context.getSharedPreferences(IntentHandlerConstants.preferencesFile, Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString())
if (shouldLaunchActivity) {
editor.apply()
return
}

editor.commit()
}

override fun onCompleted() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ object IntentHandlerConstants {
const val shareObjectProperty = "shareObject"
}
interface IntentHandler {
fun handle(intent: Intent): Boolean
fun handle(intent: Intent, shouldLaunchActivity: Boolean = true): Boolean
fun onCompleted()
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,64 +7,74 @@ import com.expensify.chat.utils.FileUtils


class TextIntentHandler(private val context: Context) : AbstractIntentHandler() {
override fun handle(intent: Intent): Boolean {
override fun handle(intent: Intent, shouldLaunchActivity: Boolean): Boolean {
super.clearTemporaryFiles(context)
when(intent.action) {
Intent.ACTION_SEND -> {
handleTextIntent(intent, context)
onCompleted()
saveSharedText(intent, context, shouldLaunchActivity)
// Expected persistence failures return from saveSharedText(), allowing the warm-start Share screen to display an attachment error.
// Cold starts use new-expensify://share/root as the activity's initial URL instead.
if (shouldLaunchActivity) {
onCompleted()
}
return true
}
}
return false
}

private fun handleTextIntent(intent: Intent, context: Context) {
private fun saveSharedText(intent: Intent, context: Context, shouldLaunchActivity: Boolean): Boolean {
when {
intent.type == "text/plain" -> {
val extras = intent.extras
if (extras != null) {
when {
return when {
extras.containsKey(Intent.EXTRA_STREAM) -> {
handleTextFileIntent(intent, context)
saveSharedTextFile(intent, context, shouldLaunchActivity)
}
extras.containsKey(Intent.EXTRA_TEXT) -> {
handleTextPlainIntent(intent, context)
saveSharedTextContent(intent, context, shouldLaunchActivity)
}
else -> {
throw UnsupportedOperationException("Unknown text/plain content")
}
}
}
return false
}
Regex("text/.*").matches(intent.type ?: "") -> handleTextFileIntent(intent, context)
Regex("text/.*").matches(intent.type ?: "") -> return saveSharedTextFile(intent, context, shouldLaunchActivity)
else -> throw UnsupportedOperationException("Unsupported MIME type: ${intent.type}")
}
}

private fun saveToSharedPreferences(key: String, value: String) {
private fun saveToSharedPreferences(key: String, value: String, shouldLaunchActivity: Boolean): Boolean {
val sharedPreferences = context.getSharedPreferences(IntentHandlerConstants.preferencesFile, Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString(key, value)
editor.apply()
if (shouldLaunchActivity) {
editor.apply()
return true
}

return editor.commit()
}

private fun handleTextFileIntent(intent: Intent, context: Context) {
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { fileUri ->
val resultingPath: String? = FileUtils.copyUriToStorage(fileUri, context)
if (resultingPath != null) {
val shareFileObject = ShareFileObject(resultingPath, intent.type)
saveToSharedPreferences(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString())
}
private fun saveSharedTextFile(intent: Intent, context: Context, shouldLaunchActivity: Boolean): Boolean {
val fileUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM) ?: return false
val resultingPath = FileUtils.copyUriToStorage(fileUri, context) ?: return false
val mimeType = intent.type ?: try {
context.contentResolver.getType(fileUri)
} catch (exception: Exception) {
null
}
val shareFileObject = ShareFileObject(resultingPath, mimeType)
return saveToSharedPreferences(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString(), shouldLaunchActivity)
}

private fun handleTextPlainIntent(intent: Intent, context: Context) {
var intentTextContent = intent.getStringExtra(Intent.EXTRA_TEXT)
if(intentTextContent != null) {
val shareFileObject = ShareFileObject(intentTextContent, intent.type)
saveToSharedPreferences(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString())
}
private fun saveSharedTextContent(intent: Intent, context: Context, shouldLaunchActivity: Boolean): Boolean {
val intentTextContent = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return false
val shareFileObject = ShareFileObject(intentTextContent, intent.type)
return saveToSharedPreferences(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString(), shouldLaunchActivity)
}

override fun onCompleted() {
Expand Down
62 changes: 45 additions & 17 deletions android/app/src/main/java/com/expensify/chat/utils/FileUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,21 @@ object FileUtils {
}

fun clearInternalStorageDirectory(context: Context) {
val internalStorageDirectory = getInternalStorageDirectory(context)
if (internalStorageDirectory.exists()) {
val files = internalStorageDirectory.listFiles()
if (files != null && files.isNotEmpty()) {
for (file in files) {
file.delete()
}
} else {
Log.i(tag, "No files found to delete in directory: ${internalStorageDirectory.absolutePath}")
clearStorageDirectory(getInternalStorageDirectory(context))
}

private fun clearStorageDirectory(storageDirectory: File) {
if (!storageDirectory.exists()) {
return
}

val files = storageDirectory.listFiles()
if (files != null && files.isNotEmpty()) {
for (file in files) {
file.delete()
}
} else {
Log.i(tag, "No files found to delete in directory: ${storageDirectory.absolutePath}")
}
}

Expand Down Expand Up @@ -98,28 +103,51 @@ object FileUtils {
* @return The absolute path of the image
*/
fun copyUriToStorage(fileUri: Uri, context: Context): String? {
val fileName = getFileName(context, fileUri) ?: return null
val fileName = getFileName(context, fileUri) ?: generateFileName(context, fileUri)
val destinationFile = File(getInternalStorageDirectory(context), fileName)

return try {
saveFileFromProviderUri(fileUri, destinationFile, context)
if (!destinationFile.exists()) {
destinationFile.delete()
return null
}
if (destinationFile.length() == 0L) {
destinationFile.delete()
return null
}
destinationFile.absolutePath
} catch (ex: IOException) {
} catch (ex: Exception) {
Log.e(tag, "Couldn't save file from intent", ex)
null
}
}

private fun generateFileName(context: Context, uri: Uri): String {
val mimeTypeMap = MimeTypeMap.getSingleton()
val mimeType = try {
context.contentResolver.getType(uri)
} catch (e: Exception) {
null
}
val extension = mimeType?.let(mimeTypeMap::getExtensionFromMimeType) ?: "bin"
return "${getUniqueFilePrefix()}.$extension"
}

private fun getFileName(context: Context, uri: Uri): String? {
var name: String? = null
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
name = it.getString(nameIndex)
try {
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
name = it.getString(nameIndex)
}
}
}
} catch (e: Exception) {
Log.w(tag, "Failed to query filename from ContentResolver", e)
}
return name
}
Expand Down
2 changes: 1 addition & 1 deletion src/libs/ShareActionHandlerModule/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type ShareActionContent = {
// Type for the ShareActionHandler module
type ShareActionHandlerModule = {
// Method to process files, which takes a callback function
processFiles(callback: (array: ShareActionContent[]) => void): void;
processFiles(callback: (files: ShareActionContent[] | string | ShareActionContent | null | undefined) => void): void;
};

export default ShareActionHandler;
Expand Down
33 changes: 33 additions & 0 deletions src/libs/ShareActionHandlerModule/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type {ShareTempFile} from '@src/types/onyx';

function isShareTempFile(file: unknown): file is ShareTempFile {
return typeof file === 'object' && file !== null && 'id' in file && typeof file.id === 'string' && 'content' in file && typeof file.content === 'string';
}

function getShareTempFile(processedFiles: unknown): ShareTempFile | undefined {
if (Array.isArray(processedFiles)) {
const first: unknown = processedFiles.at(0);
return isShareTempFile(first) ? first : undefined;
}

if (isShareTempFile(processedFiles)) {
return processedFiles;
}

if (typeof processedFiles !== 'string' || processedFiles.trim().length === 0) {
return undefined;
}

try {
const parsed: unknown = JSON.parse(processedFiles);
return isShareTempFile(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}

function normalizeShareFileMimeType(mimeType: string | undefined): string {
return mimeType?.split(';')[0]?.trim()?.toLowerCase() ?? '';
}

export {getShareTempFile, normalizeShareFileMimeType};
Loading
Loading