diff --git a/android/app/src/main/java/com/expensify/chat/MainActivity.kt b/android/app/src/main/java/com/expensify/chat/MainActivity.kt index c1fc3c15b1e4..5bf5bbe3bae2 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.kt +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.kt @@ -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 @@ -40,11 +41,20 @@ class MainActivity : ReactActivity() { )) override fun onCreate(savedInstanceState: Bundle?) { + BootSplash.init(this) + + 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()) .apply() - BootSplash.init(this) super.onCreate(null) // Sets translucent status bar. This code is based on what the react-native StatusBar @@ -60,7 +70,7 @@ class MainActivity : ReactActivity() { ) } - if (intent != null) { + if (!isColdShareIntent && intent != null) { handleIntent(intent) } } @@ -78,12 +88,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 } } diff --git a/android/app/src/main/java/com/expensify/chat/ShareActionHandlerModule.kt b/android/app/src/main/java/com/expensify/chat/ShareActionHandlerModule.kt index 14258ea744f9..23cae6eaa61e 100644 --- a/android/app/src/main/java/com/expensify/chat/ShareActionHandlerModule.kt +++ b/android/app/src/main/java/com/expensify/chat/ShareActionHandlerModule.kt @@ -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 } @@ -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) } } } diff --git a/android/app/src/main/java/com/expensify/chat/intentHandler/FileIntentHandler.kt b/android/app/src/main/java/com/expensify/chat/intentHandler/FileIntentHandler.kt index 0d52c66b1248..9ed2d1a5d83d 100644 --- a/android/app/src/main/java/com/expensify/chat/intentHandler/FileIntentHandler.kt +++ b/android/app/src/main/java/com/expensify/chat/intentHandler/FileIntentHandler.kt @@ -6,31 +6,36 @@ 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(Intent.EXTRA_STREAM))?.let { fileUri -> - val resultingPath: String? = FileUtils.copyUriToStorage(fileUri, context) + private fun saveSharedFile(intent: Intent, context: Context, shouldLaunchActivity: Boolean) { + val fileUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) ?: return + val resultingPath = FileUtils.copyUriToStorage(fileUri, context) ?: return + val shareFileObject = ShareFileObject(resultingPath, intent.type) - if (resultingPath != null) { - val shareFileObject = ShareFileObject(resultingPath, intent.type) - - 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() { diff --git a/android/app/src/main/java/com/expensify/chat/intentHandler/IntentHandler.kt b/android/app/src/main/java/com/expensify/chat/intentHandler/IntentHandler.kt index d698096836b0..c4bc4d93a8a8 100644 --- a/android/app/src/main/java/com/expensify/chat/intentHandler/IntentHandler.kt +++ b/android/app/src/main/java/com/expensify/chat/intentHandler/IntentHandler.kt @@ -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() } diff --git a/android/app/src/main/java/com/expensify/chat/intentHandler/TextIntentHandler.kt b/android/app/src/main/java/com/expensify/chat/intentHandler/TextIntentHandler.kt index 35aa8ab21719..008af17e703b 100644 --- a/android/app/src/main/java/com/expensify/chat/intentHandler/TextIntentHandler.kt +++ b/android/app/src/main/java/com/expensify/chat/intentHandler/TextIntentHandler.kt @@ -7,64 +7,69 @@ 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) { when { intent.type == "text/plain" -> { val extras = intent.extras if (extras != null) { 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 } - Regex("text/.*").matches(intent.type ?: "") -> handleTextFileIntent(intent, context) + Regex("text/.*").matches(intent.type ?: "") -> 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) { val sharedPreferences = context.getSharedPreferences(IntentHandlerConstants.preferencesFile, Context.MODE_PRIVATE) val editor = sharedPreferences.edit() editor.putString(key, value) - editor.apply() + if (shouldLaunchActivity) { + editor.apply() + return + } + + editor.commit() } - private fun handleTextFileIntent(intent: Intent, context: Context) { - (intent.getParcelableExtra(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) { + val fileUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) ?: return + val resultingPath = FileUtils.copyUriToStorage(fileUri, context) ?: return + val shareFileObject = ShareFileObject(resultingPath, intent.type) + 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) { + val intentTextContent = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return + val shareFileObject = ShareFileObject(intentTextContent, intent.type) + saveToSharedPreferences(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString(), shouldLaunchActivity) } override fun onCompleted() { diff --git a/android/app/src/main/java/com/expensify/chat/utils/FileUtils.kt b/android/app/src/main/java/com/expensify/chat/utils/FileUtils.kt index a0f13ddad645..56f2b4dc3724 100644 --- a/android/app/src/main/java/com/expensify/chat/utils/FileUtils.kt +++ b/android/app/src/main/java/com/expensify/chat/utils/FileUtils.kt @@ -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}") } } @@ -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 } diff --git a/src/libs/ShareActionHandlerModule/index.ts b/src/libs/ShareActionHandlerModule/index.ts index 205bd89960d9..cb461fa391fb 100644 --- a/src/libs/ShareActionHandlerModule/index.ts +++ b/src/libs/ShareActionHandlerModule/index.ts @@ -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 | null) => void): void; }; export default ShareActionHandler; diff --git a/src/libs/ShareActionHandlerModule/utils.ts b/src/libs/ShareActionHandlerModule/utils.ts new file mode 100644 index 000000000000..b00e38523738 --- /dev/null +++ b/src/libs/ShareActionHandlerModule/utils.ts @@ -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}; diff --git a/src/pages/Share/ShareRootPage.tsx b/src/pages/Share/ShareRootPage.tsx index 6ec0c6e448d9..759f66a47ce8 100644 --- a/src/pages/Share/ShareRootPage.tsx +++ b/src/pages/Share/ShareRootPage.tsx @@ -16,6 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {shouldValidateFile} from '@libs/ReceiptUtils'; import ShareActionHandler from '@libs/ShareActionHandlerModule'; +import {getShareTempFile, normalizeShareFileMimeType} from '@libs/ShareActionHandlerModule/utils'; import {close as closeModal} from '@userActions/Modal'; import Tab from '@userActions/Tab'; @@ -99,17 +100,30 @@ function ShareRootPage() { }, [errorTitle, errorMessage]); const handleProcessFiles = useCallback(() => { - ShareActionHandler.processFiles((processedFiles) => { - const tempFile = Array.isArray(processedFiles) ? processedFiles.at(0) : (JSON.parse(processedFiles) as ShareTempFile); + ShareActionHandler.processFiles((processedFiles: unknown) => { if (errorTitle) { return; } - if (!tempFile?.mimeType || !shareFileMimeTypes.includes(tempFile?.mimeType)) { + + const tempFile = getShareTempFile(processedFiles); + if (!tempFile) { + setErrorTitle(translate('attachmentPicker.attachmentError')); + setErrorMessage(translate('attachmentPicker.errorWhileSelectingCorruptedAttachment')); + return; + } + + const rawMimeType = normalizeShareFileMimeType(tempFile.mimeType); + const isValidMimeType = + !!rawMimeType && (shareFileMimeTypes.includes(rawMimeType) || shareFileMimeTypes.some((allowed) => allowed.endsWith('/*') && rawMimeType.startsWith(allowed.slice(0, -1)))); + + if (!isValidMimeType) { setErrorTitle(translate('attachmentPicker.wrongFileType')); setErrorMessage(translate('attachmentPicker.notAllowedExtension')); return; } + tempFile.mimeType = rawMimeType; + const isImage = /image\/.*/.test(tempFile?.mimeType); if (tempFile?.mimeType && tempFile?.mimeType !== 'txt' && !isImage) { getFileSize(tempFile?.content).then((size) => { diff --git a/tests/unit/ShareActionHandlerModuleUtilsTest.ts b/tests/unit/ShareActionHandlerModuleUtilsTest.ts new file mode 100644 index 000000000000..a7cd036b57bd --- /dev/null +++ b/tests/unit/ShareActionHandlerModuleUtilsTest.ts @@ -0,0 +1,25 @@ +import {getShareTempFile, normalizeShareFileMimeType} from '@libs/ShareActionHandlerModule/utils'; + +describe('ShareActionHandlerModule utils', () => { + const sharedFile = {id: 'receipt.jpg', content: 'file:///receipt.jpg', mimeType: 'image/jpeg'}; + + it.each([ + ['an array response', [sharedFile]], + ['an object response', sharedFile], + ['a JSON string response', JSON.stringify(sharedFile)], + ])('returns the shared file from %s', (_description, response) => { + expect(getShareTempFile(response)).toEqual(sharedFile); + }); + + it.each([null, undefined, [], {}, '', ' ', '{invalid JSON}', JSON.stringify({id: 'receipt.jpg'})])('returns undefined for an invalid native response', (response) => { + expect(getShareTempFile(response)).toBeUndefined(); + }); + + it('normalizes a MIME type that contains parameters', () => { + expect(normalizeShareFileMimeType('IMAGE/JPEG; charset=utf-8')).toBe('image/jpeg'); + }); + + it('returns an empty MIME type when no MIME type is provided', () => { + expect(normalizeShareFileMimeType(undefined)).toBe(''); + }); +});