diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 33a12b929e98..db5c68ac0231 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -104,6 +104,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + createNativeModules( List modules = new ArrayList<>(); modules.add(new StartupTimer(reactContext)); + modules.add(new ShareActionHandlerModule(reactContext)); return modules; } 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 2daebb9b1c00..16d7047fac08 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.kt +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.kt @@ -1,18 +1,20 @@ package com.expensify.chat -import expo.modules.ReactActivityDelegateWrapper - +import android.content.Intent import android.content.pm.ActivityInfo import android.os.Bundle +import android.util.Log import android.view.KeyEvent import android.view.View import android.view.WindowInsets import com.expensify.chat.bootsplash.BootSplash +import com.expensify.chat.intenthandler.IntentHandlerFactory import com.expensify.reactnativekeycommand.KeyCommandModule import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate +import expo.modules.ReactActivityDelegateWrapper import com.oblador.performance.RNPerformance @@ -52,6 +54,25 @@ class MainActivity : ReactActivity() { defaultInsets.systemWindowInsetBottom ) } + + if (intent != null) { + handleIntent(intent) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) // Must store the new intent unless getIntent() will return the old one + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + try { + val intenthandler = IntentHandlerFactory.getIntentHandler(this, intent.type, intent.toString()) + intenthandler?.handle(intent) + } catch (exception: Exception) { + Log.e("handleIntentException", exception.toString()) + } } /** diff --git a/android/app/src/main/java/com/expensify/chat/ShareActionHandlerModule.kt b/android/app/src/main/java/com/expensify/chat/ShareActionHandlerModule.kt new file mode 100644 index 000000000000..dcb94d19a22d --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/ShareActionHandlerModule.kt @@ -0,0 +1,89 @@ +package com.expensify.chat + +import android.content.Context +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import com.expensify.chat.intenthandler.IntentHandlerConstants +import com.facebook.react.bridge.Callback +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import android.util.Log +import com.facebook.react.bridge.ReactMethod +import org.json.JSONObject +import java.io.File + +class ShareActionHandlerModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String { + return "ShareActionHandler" + } + + @ReactMethod + fun processFiles(callback: Callback) { + try { + val sharedPreferences = reactApplicationContext.getSharedPreferences( + IntentHandlerConstants.preferencesFile, + Context.MODE_PRIVATE + ) + + val shareObjectString = sharedPreferences.getString(IntentHandlerConstants.shareObjectProperty, null) + if (shareObjectString == null) { + callback.invoke("No data found", null) + return + } + + val shareObject = JSONObject(shareObjectString) + val content = shareObject.optString("content") + val mimeType = shareObject.optString("mimeType") + val fileUriPath = "file://$content" + val timestamp = System.currentTimeMillis() + + val file = File(content) + if (!file.exists()) { + val textObject = JSONObject().apply { + put("id", "text") + put("content", content) + put("mimeType", "txt") + put("processedAt", timestamp) + } + callback.invoke(textObject.toString()) + return + } + + val identifier = file.name + var aspectRatio = 0.0f + + if (mimeType.startsWith("image/")) { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(content, options) + aspectRatio = if (options.outHeight != 0) options.outWidth.toFloat() / options.outHeight else 1.0f + } else if (mimeType.startsWith("video/")) { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(content) + val videoWidth = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull() ?: 1f + val videoHeight = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull() ?: 1f + if (videoHeight != 0f) aspectRatio = videoWidth / videoHeight + } catch (e: Exception) { + Log.e("ShareActionHandlerModule", "Error retrieving video metadata: ${e.message}") + } finally { + retriever.release() + } + } + + val fileData = JSONObject().apply { + put("id", identifier) + put("content", fileUriPath) + put("mimeType", mimeType) + put("processedAt", timestamp) + put("aspectRatio", aspectRatio) + } + + callback.invoke(fileData.toString()) + + } catch (e: Exception) { + callback.invoke(e.toString(), null) + } + } +} diff --git a/android/app/src/main/java/com/expensify/chat/intentHandler/AbstractIntentHandler.kt b/android/app/src/main/java/com/expensify/chat/intentHandler/AbstractIntentHandler.kt new file mode 100644 index 000000000000..a5f554b53a18 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/intentHandler/AbstractIntentHandler.kt @@ -0,0 +1,19 @@ +package com.expensify.chat.intenthandler + +import android.content.Context +import com.expensify.chat.utils.FileUtils.clearInternalStorageDirectory + +abstract class AbstractIntentHandler: IntentHandler { + override fun onCompleted() {} + + protected fun clearTemporaryFiles(context: Context) { + // Clear data present in the shared preferences + val sharedPreferences = context.getSharedPreferences(IntentHandlerConstants.preferencesFile, Context.MODE_PRIVATE) + val editor = sharedPreferences.edit() + editor.clear() + editor.apply() + + // Clear leftover temporary files from previous share attempts + clearInternalStorageDirectory(context) + } +} 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 new file mode 100644 index 000000000000..0d52c66b1248 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/intentHandler/FileIntentHandler.kt @@ -0,0 +1,42 @@ +package com.expensify.chat.intenthandler + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.expensify.chat.utils.FileUtils + +class FileIntentHandler(private val context: Context) : AbstractIntentHandler() { + override fun handle(intent: Intent): Boolean { + super.clearTemporaryFiles(context) + when(intent.action) { + Intent.ACTION_SEND -> { + handleSingleFileIntent(intent, context) + 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) + + 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() + } + } + } + + override fun onCompleted() { + val uri: Uri = Uri.parse("new-expensify://share/root") + val deepLinkIntent = Intent(Intent.ACTION_VIEW, uri) + deepLinkIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(deepLinkIntent) + } +} 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 new file mode 100644 index 000000000000..d698096836b0 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/intentHandler/IntentHandler.kt @@ -0,0 +1,12 @@ +package com.expensify.chat.intenthandler + +import android.content.Intent + +object IntentHandlerConstants { + const val preferencesFile = "shareActionHandler" + const val shareObjectProperty = "shareObject" +} +interface IntentHandler { + fun handle(intent: Intent): Boolean + fun onCompleted() +} diff --git a/android/app/src/main/java/com/expensify/chat/intentHandler/IntentHandlerFactory.kt b/android/app/src/main/java/com/expensify/chat/intentHandler/IntentHandlerFactory.kt new file mode 100644 index 000000000000..e024a9172493 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/intentHandler/IntentHandlerFactory.kt @@ -0,0 +1,15 @@ +package com.expensify.chat.intenthandler + +import android.content.Context + +object IntentHandlerFactory { + fun getIntentHandler(context: Context, mimeType: String?, rest: String?): IntentHandler? { + if (mimeType == null) return null + + return when { + mimeType.matches(Regex("(image|application|audio|video)/.*")) -> FileIntentHandler(context) + mimeType.startsWith("text/") -> TextIntentHandler(context) + else -> throw UnsupportedOperationException("Unsupported MIME type: $mimeType") + } + } +} diff --git a/android/app/src/main/java/com/expensify/chat/intentHandler/ShareFileObject.kt b/android/app/src/main/java/com/expensify/chat/intentHandler/ShareFileObject.kt new file mode 100644 index 000000000000..352850f1710e --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/intentHandler/ShareFileObject.kt @@ -0,0 +1,15 @@ +package com.expensify.chat.intenthandler + +import org.json.JSONObject + +data class ShareFileObject( + val content: String, + val mimeType: String?, +) { + override fun toString(): String { + return JSONObject().apply { + put("content", content) + put("mimeType", mimeType) + }.toString() + } +} 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 new file mode 100644 index 000000000000..35aa8ab21719 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/intentHandler/TextIntentHandler.kt @@ -0,0 +1,76 @@ +package com.expensify.chat.intenthandler + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.expensify.chat.utils.FileUtils + + +class TextIntentHandler(private val context: Context) : AbstractIntentHandler() { + override fun handle(intent: Intent): Boolean { + super.clearTemporaryFiles(context) + when(intent.action) { + Intent.ACTION_SEND -> { + handleTextIntent(intent, context) + onCompleted() + return true + } + } + return false + } + + private fun handleTextIntent(intent: Intent, context: Context) { + when { + intent.type == "text/plain" -> { + val extras = intent.extras + if (extras != null) { + when { + extras.containsKey(Intent.EXTRA_STREAM) -> { + handleTextFileIntent(intent, context) + } + extras.containsKey(Intent.EXTRA_TEXT) -> { + handleTextPlainIntent(intent, context) + } + else -> { + throw UnsupportedOperationException("Unknown text/plain content") + } + } + } + } + Regex("text/.*").matches(intent.type ?: "") -> handleTextFileIntent(intent, context) + else -> throw UnsupportedOperationException("Unsupported MIME type: ${intent.type}") + } + } + + private fun saveToSharedPreferences(key: String, value: String) { + val sharedPreferences = context.getSharedPreferences(IntentHandlerConstants.preferencesFile, Context.MODE_PRIVATE) + val editor = sharedPreferences.edit() + editor.putString(key, value) + editor.apply() + } + + 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 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()) + } + } + + override fun onCompleted() { + val uri: Uri = Uri.parse("new-expensify://share/root") + val deepLinkIntent = Intent(Intent.ACTION_VIEW, uri) + deepLinkIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(deepLinkIntent) + } +} 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 new file mode 100644 index 000000000000..a0f13ddad645 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/utils/FileUtils.kt @@ -0,0 +1,126 @@ +package com.expensify.chat.utils + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import android.webkit.MimeTypeMap +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +object FileUtils { + private const val tag = "FileUtils" + private const val directoryName = "Expensify" + + private fun getInternalStorageDirectory(context: Context): File { + val internalStorageDirectory = File(context.filesDir.absolutePath, directoryName) + if (!internalStorageDirectory.exists()) { + internalStorageDirectory.mkdirs() + } + return internalStorageDirectory + } + + 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}") + } + } + } + + /** + * Creates a temporary file in the internal storage. + * + * @return unique file prefix + */ + fun getUniqueFilePrefix(): String { + return System.currentTimeMillis().toString() + } + + /** + * Synchronous method + * + * @param fileUri + * @param destinationFile + * @param context + * @throws IOException + */ + @Throws(IOException::class) + fun saveFileFromProviderUri(fileUri: Uri, destinationFile: File?, context: Context) { + val inputStream: InputStream? = context.contentResolver.openInputStream(fileUri) + val outputStream: OutputStream = FileOutputStream(destinationFile) + inputStream?.use { input -> + outputStream.use { output -> + input.copyTo(output) + } + } + } + + /** + * Creates a temporary image file in the internal storage. + * + * @param uri + * @param context + * @return + * @throws IOException + */ + @Throws(IOException::class) + fun createTemporaryFile(uri: Uri, context: Context): File { + + val mimeTypeMap = MimeTypeMap.getSingleton() + + val fileExtension = ".${mimeTypeMap.getExtensionFromMimeType(context.contentResolver.getType(uri))}" + + val file: File = File.createTempFile( + getUniqueFilePrefix(), + fileExtension, + getInternalStorageDirectory(context) + ) + + Log.i(tag, "Created a temporary file at" + file.absolutePath) + return file + } + + /** + * Copy the given Uri to storage + * + * @param uri + * @param context + * @return The absolute path of the image + */ + fun copyUriToStorage(fileUri: Uri, context: Context): String? { + val fileName = getFileName(context, fileUri) ?: return null + val destinationFile = File(getInternalStorageDirectory(context), fileName) + + return try { + saveFileFromProviderUri(fileUri, destinationFile, context) + destinationFile.absolutePath + } catch (ex: IOException) { + Log.e(tag, "Couldn't save file from intent", ex) + null + } + } + + 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) + } + } + } + return name + } +} diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 14866da313d5..35e6eb75c59e 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -48,7 +48,12 @@ D27CE6B77196EF3EF450EEAC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */; }; DD79042B2792E76D004484B4 /* RCTBootSplash.mm in Sources */ = {isa = PBXBuildFile; fileRef = DD79042A2792E76D004484B4 /* RCTBootSplash.mm */; }; DDCB2E57F334C143AC462B43 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20D83B0E39BA6D21761E72 /* ExpoModulesProvider.swift */; }; - E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + E5A27B912C7F2FA8002C36BF /* RCTShareActionHandlerModule.m in Sources */ = {isa = PBXBuildFile; fileRef = E5A27B902C7F2FA8002C36BF /* RCTShareActionHandlerModule.m */; }; + E5A27B922C7F2FA8002C36BF /* RCTShareActionHandlerModule.m in Sources */ = {isa = PBXBuildFile; fileRef = E5A27B902C7F2FA8002C36BF /* RCTShareActionHandlerModule.m */; }; + E5A27B9A2C7F427F002C36BF /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A27B992C7F427F002C36BF /* ShareViewController.swift */; }; + E5A27B9D2C7F427F002C36BF /* Base in Resources */ = {isa = PBXBuildFile; fileRef = E5A27B9C2C7F427F002C36BF /* Base */; }; + E5A27BA12C7F427F002C36BF /* ShareViewController.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E5A27B972C7F427F002C36BF /* ShareViewController.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = E9DF872C2525201700607FDC /* AirshipConfig.plist */; }; ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */; }; F0C450EA2705020500FD2970 /* colors.json in Resources */ = {isa = PBXBuildFile; fileRef = F0C450E92705020500FD2970 /* colors.json */; }; @@ -70,6 +75,13 @@ remoteGlobalIDString = 7FD73C9A2B23CE9500420AF3; remoteInfo = NotificationServiceExtension; }; + E5A27B9F2C7F427F002C36BF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E5A27B962C7F427F002C36BF; + remoteInfo = ShareViewController; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -80,6 +92,7 @@ dstSubfolderSpec = 13; files = ( 7FD73CA22B23CE9500420AF3 /* NotificationServiceExtension.appex in Embed Foundation Extensions */, + E5A27BA12C7F427F002C36BF /* ShareViewController.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -118,6 +131,7 @@ 41D2EDB009CF19BA59C5181C /* Pods-NotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; 44BF435285B94E5B95F90994 /* ExpensifyNewKansas-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNewKansas-Medium.otf"; path = "../assets/fonts/native/ExpensifyNewKansas-Medium.otf"; sourceTree = ""; }; 46B1FE4DE317D30C25A74C15 /* Pods-NewExpensify.debugdevelopment.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.debugdevelopment.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.debugdevelopment.xcconfig"; sourceTree = ""; }; + 46CFEEC62CA1C2E0003D36CC /* ShareViewController.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareViewController.entitlements; sourceTree = ""; }; 48E7775E0D42D3E3F53A5B99 /* Pods-NotificationServiceExtension.releaseadhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.releaseadhoc.xcconfig"; path = "Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.releaseadhoc.xcconfig"; sourceTree = ""; }; 499B0DA92BE2A1C000CABFB0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 4A39BBFB1A6AA6A0EB08878C /* Pods-NotificationServiceExtension.debugproduction.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.debugproduction.xcconfig"; path = "Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.debugproduction.xcconfig"; sourceTree = ""; }; @@ -158,6 +172,12 @@ DD7904292792E76D004484B4 /* RCTBootSplash.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RCTBootSplash.h; path = NewExpensify/RCTBootSplash.h; sourceTree = ""; }; DD79042A2792E76D004484B4 /* RCTBootSplash.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = RCTBootSplash.mm; path = NewExpensify/RCTBootSplash.mm; sourceTree = ""; }; E5428460BDBED9E1BA8B3599 /* Pods-NewExpensify-NewExpensifyTests.debugdevelopment.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.debugdevelopment.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.debugdevelopment.xcconfig"; sourceTree = ""; }; + E5A27B8F2C7F2F51002C36BF /* RCTShareActionHandlerModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTShareActionHandlerModule.h; sourceTree = ""; }; + E5A27B902C7F2FA8002C36BF /* RCTShareActionHandlerModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTShareActionHandlerModule.m; sourceTree = ""; }; + E5A27B972C7F427F002C36BF /* ShareViewController.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareViewController.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + E5A27B992C7F427F002C36BF /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + E5A27B9C2C7F427F002C36BF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; + E5A27B9E2C7F427F002C36BF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E704648954784DDFBAADF568 /* ExpensifyMono-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyMono-Regular.otf"; path = "../assets/fonts/native/ExpensifyMono-Regular.otf"; sourceTree = ""; }; E9DF872C2525201700607FDC /* AirshipConfig.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = AirshipConfig.plist; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; @@ -184,8 +204,8 @@ buildActionMask = 2147483647; files = ( 383643682B6D4AE2005BB9AE /* DeviceCheck.framework in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, AC131FBB2CF634F20010CE80 /* BackgroundTasks.framework in Frameworks */, 8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */, ); @@ -199,6 +219,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5A27B942C7F427F002C36BF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -295,6 +322,8 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + E5A27B8F2C7F2F51002C36BF /* RCTShareActionHandlerModule.h */, + E5A27B902C7F2FA8002C36BF /* RCTShareActionHandlerModule.m */, 0DFC45922C884D7900B56C91 /* RCTShortcutManagerModule.h */, 0DFC45932C884E0A00B56C91 /* RCTShortcutManagerModule.m */, 499B0DA92BE2A1C000CABFB0 /* PrivacyInfo.xcprivacy */, @@ -310,6 +339,7 @@ 832341AE1AAA6A7D00B99B32 /* Libraries */, 00E356EF1AD99517003FC87E /* NewExpensifyTests */, 7FD73C9C2B23CE9500420AF3 /* NotificationServiceExtension */, + E5A27B982C7F427F002C36BF /* ShareViewController */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, EC29677F0A49C2946A495A33 /* Pods */, @@ -327,6 +357,7 @@ 13B07F961A680F5B00A75B9A /* New Expensify Dev.app */, 00E356EE1AD99517003FC87E /* NewExpensifyTests.xctest */, 7FD73C9B2B23CE9500420AF3 /* NotificationServiceExtension.appex */, + E5A27B972C7F427F002C36BF /* ShareViewController.appex */, ); name = Products; sourceTree = ""; @@ -360,6 +391,17 @@ name = Resources; sourceTree = ""; }; + E5A27B982C7F427F002C36BF /* ShareViewController */ = { + isa = PBXGroup; + children = ( + 46CFEEC62CA1C2E0003D36CC /* ShareViewController.entitlements */, + E5A27B992C7F427F002C36BF /* ShareViewController.swift */, + E5A27B9B2C7F427F002C36BF /* MainInterface.storyboard */, + E5A27B9E2C7F427F002C36BF /* Info.plist */, + ); + path = ShareViewController; + sourceTree = ""; + }; EC29677F0A49C2946A495A33 /* Pods */ = { isa = PBXGroup; children = ( @@ -435,6 +477,7 @@ ); dependencies = ( 7FD73CA12B23CE9500420AF3 /* PBXTargetDependency */, + E5A27BA02C7F427F002C36BF /* PBXTargetDependency */, ); name = NewExpensify; productName = NewExpensify; @@ -459,13 +502,30 @@ productReference = 7FD73C9B2B23CE9500420AF3 /* NotificationServiceExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + E5A27B962C7F427F002C36BF /* ShareViewController */ = { + isa = PBXNativeTarget; + buildConfigurationList = E5A27BA22C7F4280002C36BF /* Build configuration list for PBXNativeTarget "ShareViewController" */; + buildPhases = ( + E5A27B932C7F427F002C36BF /* Sources */, + E5A27B942C7F427F002C36BF /* Frameworks */, + E5A27B952C7F427F002C36BF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ShareViewController; + productName = ShareViewController; + productReference = E5A27B972C7F427F002C36BF /* ShareViewController.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 83CBB9F71A601CBA00E9B192 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1500; + LastSwiftUpdateCheck = 1540; LastUpgradeCheck = 1130; TargetAttributes = { 00E356ED1AD99517003FC87E = { @@ -484,6 +544,9 @@ DevelopmentTeam = 368M544MTT; ProvisioningStyle = Manual; }; + E5A27B962C7F427F002C36BF = { + CreatedOnToolsVersion = 15.4; + }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "NewExpensify" */; @@ -502,6 +565,7 @@ 13B07F861A680F5B00A75B9A /* NewExpensify */, 00E356ED1AD99517003FC87E /* NewExpensifyTests */, 7FD73C9A2B23CE9500420AF3 /* NotificationServiceExtension */, + E5A27B962C7F427F002C36BF /* ShareViewController */, ); }; /* End PBXProject section */ @@ -551,6 +615,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5A27B952C7F427F002C36BF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E5A27B9D2C7F427F002C36BF /* Base in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -916,6 +988,7 @@ 0CDA8E35287DD650004ECBEC /* AppDelegate.mm in Sources */, 7041848626A8E47D00E09F4D /* RCTStartupTimer.m in Sources */, 7F5E81F06BCCF61AD02CEA06 /* ExpoModulesProvider.swift in Sources */, + E5A27B922C7F2FA8002C36BF /* RCTShareActionHandlerModule.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -923,6 +996,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E5A27B912C7F2FA8002C36BF /* RCTShareActionHandlerModule.m in Sources */, 18D050E0262400AF000D658B /* BridgingFile.swift in Sources */, 0DFC45942C884E0A00B56C91 /* RCTShortcutManagerModule.m in Sources */, 0F5E5350263B73FD004CA14F /* EnvironmentChecker.m in Sources */, @@ -944,6 +1018,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5A27B932C7F427F002C36BF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E5A27B9A2C7F427F002C36BF /* ShareViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -957,8 +1039,24 @@ target = 7FD73C9A2B23CE9500420AF3 /* NotificationServiceExtension */; targetProxy = 7FD73CA02B23CE9500420AF3 /* PBXContainerItemProxy */; }; + E5A27BA02C7F427F002C36BF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E5A27B962C7F427F002C36BF /* ShareViewController */; + targetProxy = E5A27B9F2C7F427F002C36BF /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ +/* Begin PBXVariantGroup section */ + E5A27B9B2C7F427F002C36BF /* MainInterface.storyboard */ = { + isa = PBXVariantGroup; + children = ( + E5A27B9C2C7F427F002C36BF /* Base */, + ); + name = MainInterface.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ 00E356F61AD99517003FC87E /* DebugDevelopment */ = { isa = XCBuildConfiguration; @@ -2563,6 +2661,444 @@ }; name = ReleaseAdHoc; }; + E5A27BA32C7F4280002C36BF /* DebugDevelopment */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareViewController/ShareViewController.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareViewController/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareViewController; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.expensify.chat.dev.ShareViewController; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "(NewApp) Development: Share Extension"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugDevelopment; + }; + E5A27BA42C7F4280002C36BF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareViewController/ShareViewController.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareViewController/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareViewController; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.expensify.chat.dev.ShareViewController; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "(NewApp) Development: Share Extension"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E5A27BA52C7F4280002C36BF /* DebugAdHoc */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareViewController/ShareViewController.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareViewController/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareViewController; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.expensify.chat.adhoc.ShareViewController; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "(NewApp) AdHoc: Share Extension"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugAdHoc; + }; + E5A27BA62C7F4280002C36BF /* DebugProduction */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareViewController/ShareViewController.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareViewController/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareViewController; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.chat.expensify.chat.ShareViewController; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "(NewApp) AppStore: Share Extension"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugProduction; + }; + E5A27BA72C7F4280002C36BF /* ReleaseDevelopment */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = ShareViewController/ShareViewController.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareViewController/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareViewController; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.expensify.chat.dev.ShareViewController; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "(NewApp) Development: Share Extension"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = ReleaseDevelopment; + }; + E5A27BA82C7F4280002C36BF /* ReleaseAdHoc */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = ShareViewController/ShareViewController.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareViewController/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareViewController; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.expensify.chat.adhoc.ShareViewController; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "(NewApp) AdHoc: Share Extension"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = ReleaseAdHoc; + }; + E5A27BA92C7F4280002C36BF /* ReleaseProduction */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = ShareViewController/ShareViewController.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 368M544MTT; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareViewController/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareViewController; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.chat.expensify.chat.ShareViewController; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "(NewApp) AppStore: Share Extension"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = ReleaseProduction; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -2622,6 +3158,20 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = DebugDevelopment; }; + E5A27BA22C7F4280002C36BF /* Build configuration list for PBXNativeTarget "ShareViewController" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E5A27BA32C7F4280002C36BF /* DebugDevelopment */, + E5A27BA42C7F4280002C36BF /* Debug */, + E5A27BA52C7F4280002C36BF /* DebugAdHoc */, + E5A27BA62C7F4280002C36BF /* DebugProduction */, + E5A27BA72C7F4280002C36BF /* ReleaseDevelopment */, + E5A27BA82C7F4280002C36BF /* ReleaseAdHoc */, + E5A27BA92C7F4280002C36BF /* ReleaseProduction */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = DebugDevelopment; + }; /* End XCConfigurationList section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; diff --git a/ios/NewExpensify.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/NewExpensify.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000000..0c67376ebacb --- /dev/null +++ b/ios/NewExpensify.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/NewExpensify/Chat.entitlements b/ios/NewExpensify/Chat.entitlements index 165745a3c1a0..831c5b2d5c8f 100644 --- a/ios/NewExpensify/Chat.entitlements +++ b/ios/NewExpensify/Chat.entitlements @@ -16,5 +16,9 @@ com.apple.developer.usernotifications.communication + com.apple.security.application-groups + + group.com.expensify.new + diff --git a/ios/RCTShareActionHandlerModule.h b/ios/RCTShareActionHandlerModule.h new file mode 100644 index 000000000000..0dc50ae51a96 --- /dev/null +++ b/ios/RCTShareActionHandlerModule.h @@ -0,0 +1,16 @@ +// +// RCTShareActionHandlerModule.h +// NewExpensify +// +// Created by Bartek Krasoń on 28/08/2024. +// + +#ifndef RCTShareActionHandlerModule_h +#define RCTShareActionHandlerModule_h + +#import + +@interface RCTShareActionHandlerModule : NSObject +@end + +#endif /* RCTShareActionHandlerModule_h */ diff --git a/ios/RCTShareActionHandlerModule.m b/ios/RCTShareActionHandlerModule.m new file mode 100644 index 000000000000..e058c304e3f1 --- /dev/null +++ b/ios/RCTShareActionHandlerModule.m @@ -0,0 +1,129 @@ +#import +#import +#import "RCTShareActionHandlerModule.h" +#import +#import + +NSString *const ShareExtensionGroupId = @"group.com.expensify.new"; +NSString *const ShareExtensionFilesKey = @"sharedFiles"; + +@implementation RCTShareActionHandlerModule + +RCT_EXPORT_MODULE(ShareActionHandler); + +RCT_EXPORT_METHOD(processFiles:(RCTResponseSenderBlock)callback) { + RCTLogInfo(@"Processing share extension files"); + NSArray *fileFinalPaths = [self fileListFromSharedContainer]; + NSArray *processedFiles = [self processFileList:fileFinalPaths]; + callback(@[processedFiles]); +} + +- (NSArray *)fileListFromSharedContainer { + NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:ShareExtensionGroupId]; + NSURL *groupURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:ShareExtensionGroupId]; + if (groupURL == NULL) { + NSLog(@"Missing app group url"); + return @[]; + } + NSURL *sharedFilesFolderPathURL = [groupURL URLByAppendingPathComponent:ShareExtensionFilesKey]; + NSString *sharedFilesFolderPath = [sharedFilesFolderPathURL path]; + [defaults removeObjectForKey:ShareExtensionFilesKey]; + [defaults synchronize]; + + NSError *error = nil; + NSArray *fileSrcPath = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:sharedFilesFolderPath error:&error]; + if (fileSrcPath.count == 0) { + NSLog(@"Failed to find files in 'sharedFilesFolderPath' %@", sharedFilesFolderPath); + return @[]; + } + + NSMutableArray *fileFinalPaths = [NSMutableArray array]; + for (NSString *source in fileSrcPath) { + if (source == NULL) { + NSLog(@"Invalid file"); + continue; + } + NSString *srcFileAbsolutePath = [sharedFilesFolderPath stringByAppendingPathComponent:source]; + [fileFinalPaths addObject:srcFileAbsolutePath]; + } + + return fileFinalPaths; +} + +- (NSArray *)processFileList:(NSArray *)filePaths { + NSMutableArray *fileObjectsArray = [[NSMutableArray alloc] init]; + for (NSString *filePath in filePaths) { + NSDictionary *fileDict = [self processSingleFile:filePath]; + if (fileDict) { + [fileObjectsArray addObject:fileDict]; + } + } + return fileObjectsArray; +} + +- (NSDictionary *)processSingleFile:(NSString *)filePath { + NSString *extension = [filePath pathExtension]; + NSString *fileName = [filePath lastPathComponent]; + NSString *fileContent, *mimeType; + CGFloat aspectRatio = 1.0; + BOOL isTextToReadFromFile = [fileName containsString:@"text_to_read"]; + NSDictionary *dict; + NSError *error; + + if (isTextToReadFromFile) { + fileContent = [NSString stringWithContentsOfFile:filePath encoding:NSUTF8StringEncoding error:&error]; + if (error) { + NSLog(@"Failed to read file: %@, error: %@", filePath, error); + return nil; + } + mimeType = @"txt"; + } else { + UTType *type = [UTType typeWithFilenameExtension:extension]; + mimeType = type.preferredMIMEType ?: @"application/octet-stream"; + NSURL *fileURL = [NSURL fileURLWithPath:filePath]; + if ([mimeType hasPrefix:@"video/"]) { + aspectRatio = [self videoAspectRatio:fileURL]; + } else if ([mimeType hasPrefix:@"image/"]) { + aspectRatio = [self imageAspectRatio:fileURL]; + } + fileContent = [@"file://" stringByAppendingString:filePath]; + } + + NSString *identifier = [self uniqueIdentifierForFilePath:filePath]; + NSString *timestamp = [NSString stringWithFormat:@"%.0f", ([[NSDate date] timeIntervalSince1970] * 1000)]; + + dict = @{ + @"id": identifier, + @"content": fileContent, + @"mimeType": mimeType, + @"processedAt": timestamp, + @"aspectRatio": @(aspectRatio) + }; + return dict; +} + +- (CGFloat)videoAspectRatio:(NSURL *)url { + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; + AVAssetTrack *track = [[asset tracksWithMediaType:AVMediaTypeVideo] firstObject]; + if (track) { + CGSize size = CGSizeApplyAffineTransform(track.naturalSize, track.preferredTransform); + return size.height != 0 ? fabs(size.width / size.height) : 1; + } + return 1; +} + +- (CGFloat)imageAspectRatio:(NSURL *)url { + UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:url]]; + if (image) { + return image.size.height != 0 ? image.size.width / image.size.height : 1; + } + return 1; +} + +- (NSString *)uniqueIdentifierForFilePath:(NSString *)filePath { + NSTimeInterval timestampInterval = [[NSDate date] timeIntervalSince1970] * 1000; + NSString *timestamp = [NSString stringWithFormat:@"%.0f", timestampInterval]; + return [NSString stringWithFormat:@"%@_%@", timestamp, filePath]; +} + +@end diff --git a/ios/ShareViewController/Base.lproj/MainInterface.storyboard b/ios/ShareViewController/Base.lproj/MainInterface.storyboard new file mode 100644 index 000000000000..286a50894d87 --- /dev/null +++ b/ios/ShareViewController/Base.lproj/MainInterface.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist new file mode 100644 index 000000000000..e0acd460a1bd --- /dev/null +++ b/ios/ShareViewController/Info.plist @@ -0,0 +1,32 @@ + + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsAttachmentsWithMaxCount + 1 + NSExtensionActivationSupportsMovieWithMaxCount + 1 + NSExtensionActivationSupportsImageWithMaxCount + 1 + NSExtensionActivationSupportsFileWithMaxCount + 1 + NSExtensionActivationSupportsText + 1 + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/ios/ShareViewController/ShareViewController.entitlements b/ios/ShareViewController/ShareViewController.entitlements new file mode 100644 index 000000000000..f52d3207d6e3 --- /dev/null +++ b/ios/ShareViewController/ShareViewController.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.expensify.new + + + diff --git a/ios/ShareViewController/ShareViewController.swift b/ios/ShareViewController/ShareViewController.swift new file mode 100644 index 000000000000..4ad39131a40c --- /dev/null +++ b/ios/ShareViewController/ShareViewController.swift @@ -0,0 +1,247 @@ +import Intents +import MobileCoreServices +import Social +import UIKit +import UniformTypeIdentifiers +import os + +class ShareViewController: UIViewController { + let APP_GROUP_ID = "group.com.expensify.new" + let FILES_DIRECTORY_NAME = "sharedFiles" + let READ_FROM_FILE_FILE_NAME = "text_to_read.txt" + + enum FileSaveError: String { + case CouldNotLoad + case URLError + case GroupSharedFolderNotFound + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + os_log("viewDidAppear triggered") + + if let content = extensionContext!.inputItems[0] as? NSExtensionItem { + os_log("Received NSExtensionItem: %@", content) + saveFileToAppGroup(content: content) { error in + guard error == nil else { + os_log("Sharing error: %@", error!.rawValue) + return + } + } + } + } + + private func saveFileToFolder(folder: URL, filename: String, fileData: NSData) -> URL? { + let filePath = folder.appendingPathComponent(filename) + os_log("Saving file to: %@", filePath.path) + + do { + try fileData.write(to: filePath, options: .completeFileProtection) + os_log("File saved successfully at: %@", filePath.path) + return filePath + } catch { + os_log("Unexpected saveFileToFolder error: %@", error.localizedDescription) + return nil + } + } + + private func saveFileToAppGroup(content: NSExtensionItem, completion: @escaping (FileSaveError?) -> Void) { + guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: self.APP_GROUP_ID) else { + completion(.GroupSharedFolderNotFound) + os_log("Group shared folder not found") + return + } + + let sharedFileFolder = groupURL.appendingPathComponent(FILES_DIRECTORY_NAME, isDirectory: true) + os_log("Shared file folder: %@", sharedFileFolder.path) + setupSharedFolder(folder: sharedFileFolder) + + guard let attachments = content.attachments else { + completion(.CouldNotLoad) + os_log("Could not load attachments") + return + } + + processAttachments(attachments, in: sharedFileFolder, completion: completion) + } + + private func setupSharedFolder(folder: URL) { + os_log("Setting up shared folder: %@", folder.path) + do { + try FileManager.default.createDirectory(atPath: folder.path, withIntermediateDirectories: true, attributes: nil) + } catch { + os_log("Failed to create folder: %@, error: %@", folder.path, error.localizedDescription) + return + } + + do { + let filePaths = try FileManager.default.contentsOfDirectory(atPath: folder.path) + os_log("Clearing folder with contents: %@", filePaths) + + for filePath in filePaths { + try FileManager.default.removeItem(atPath: folder.appendingPathComponent(filePath).path) + } + } catch { + os_log("Could not clear temp folder: %@", error.localizedDescription) + } + } + + private func processAttachments(_ attachments: [NSItemProvider], in folder: URL, completion: @escaping (FileSaveError?) -> Void) { + os_log("Processing attachments") + let group = DispatchGroup() + + for attachment in attachments { + group.enter() + os_log("Processing attachment") + loadData(for: attachment, in: folder, group: group) { error in + group.leave() + if let error = error { + os_log("Error loading attachment: %@", error.rawValue) + completion(error) + } + } + } + + group.notify(queue: .main) { + os_log("Finished processing all attachments") + self.openMainApp() + } + } + + private func loadData(for attachment: NSItemProvider, in folder: URL, group: DispatchGroup, completion: @escaping (FileSaveError?) -> Void) { + os_log("Loading data for attachment") + let isURL = attachment.hasItemConformingToTypeIdentifier("public.url") && !attachment.hasItemConformingToTypeIdentifier("public.file-url") + let typeIdentifier = isURL ? (kUTTypeURL as String) : (kUTTypeData as String) + + attachment.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { (data, error) in + DispatchQueue.main.async { + if let error = error { + os_log("Sharing error: %@", error.localizedDescription) + completion(.CouldNotLoad) + return + } + + if isURL, let url = data as? URL { + os_log("Handling URL: %@", url.absoluteString) + self.handleURL(url, folder: folder, completion: completion) + } else { + os_log("Handling data for attachment") + self.handleData(data, folder: folder, completion: completion) + } + } + } + } + + private func handleURL(_ url: URL, folder: URL, completion: @escaping (FileSaveError?) -> Void) { + os_log("Handling URL: %@", url.absoluteString) + if let fileData = url.absoluteString.data(using: .utf8) as NSData? { + if let fileFinalPath = saveFileToFolder(folder: folder, filename: READ_FROM_FILE_FILE_NAME, fileData: fileData) { + os_log("URL saved to: %@", fileFinalPath.path) + completion(nil) + } else { + os_log("Could not save URL") + completion(.CouldNotLoad) + } + } else { + os_log("URL error encountered") + completion(.URLError) + } + } + + private func handleData(_ data: Any?, folder: URL, completion: @escaping (FileSaveError?) -> Void) { + os_log("Handling generic data") + guard let data = data else { + os_log("Data is nil", type: .error) + completion(.CouldNotLoad) + return + } + + if let dataString = data as? String { + os_log("Handling string data: %@", dataString) + handleStringData(dataString, folder: folder, completion: completion) + } else if let url = data as? NSURL { + os_log("Handling URL data: %@", url) + handleURLData(url, folder: folder, completion: completion) + } else if let file = data as? UIImage { + os_log("Handling image data") + handleImageData(file, folder: folder, completion: completion) + } else { + os_log("Received data of unhandled type", type: .error) + completion(.URLError) + } + } + + private func handleStringData(_ dataString: String, folder: URL, completion: @escaping (FileSaveError?) -> Void) { + os_log("Handling string data without file prefix") + if !dataString.hasPrefix("file://") { + processAndSave(data: dataString.data(using: .utf8), filename: READ_FROM_FILE_FILE_NAME, folder: folder, completion: completion) + } + } + + private func handleURLData(_ url: NSURL, folder: URL, completion: @escaping (FileSaveError?) -> Void) { + os_log("Handling NSURL data") + guard let filename = url.lastPathComponent else { + os_log("Could not get last path component") + completion(.CouldNotLoad) + return + } + + let fileData = NSData(contentsOf: url as URL) as Data? + processAndSave(data: fileData, filename: filename, folder: folder, completion: completion) + } + + private func handleImageData(_ image: UIImage, folder: URL, completion: @escaping (FileSaveError?) -> Void) { + os_log("Handling image data") + let filename = "shared_image.png" + processAndSave(data: image.pngData(), filename: filename, folder: folder, completion: completion) + } + + private func processAndSave(data: Data?, filename: String, folder: URL, completion: @escaping (FileSaveError?) -> Void) { + os_log("Processing and saving data") + guard let fileData = data as NSData? else { + os_log("Failed to convert data", type: .error) + completion(.CouldNotLoad) + return + } + + if saveFileToFolder(folder: folder, filename: filename, fileData: fileData) != nil { + os_log("File saved successfully: %@", filename) + completion(nil) + } else { + os_log("Failed to save file: %@", filename) + completion(.CouldNotLoad) + } + } + + private func openMainApp() { + os_log("Attempting to open main app") + let url = URL(string: "new-expensify://share/root")! + + if launchApp(customURL: url) { + os_log("Main app opened successfully") + self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil) + } else { + os_log("Failed to open main app") + self.extensionContext!.cancelRequest(withError: NSError(domain: "", code: 0, userInfo: nil)) + } + } + + private func launchApp(customURL: URL?) -> Bool { + os_log("Launching app with custom URL") + guard let url = customURL else { + os_log("Invalid custom URL") + return false + } + + var responder: UIResponder? = self + while responder != nil { + if let application = responder as? UIApplication { + application.open(url, options: [:], completionHandler: nil) + os_log("Application opened with URL: %@", url.absoluteString) + return true + } + responder = responder?.next + } + return false + } +} diff --git a/src/CONST.ts b/src/CONST.ts index 0ef02fcbd328..8ab86cfc594a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1799,7 +1799,69 @@ const CONST = { PNG: 'image/png', WEBP: 'image/webp', JPEG: 'image/jpeg', + JPG: 'image/jpg', + GIF: 'image/gif', + TIF: 'image/tif', + TIFF: 'image/tiff', }, + + RECEIPT_ALLOWED_FILE_TYPES: { + PNG: 'image/png', + WEBP: 'image/webp', + JPEG: 'image/jpeg', + JPG: 'image/jpg', + GIF: 'image/gif', + TIF: 'image/tif', + TIFF: 'image/tiff', + IMG: 'image/*', + HTML: 'text/html', + XML: 'text/xml', + RTF: 'application/rtf', + PDF: 'application/pdf', + OFFICE: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + MSWORD: 'application/msword', + ZIP: 'application/zip', + RFC822: 'message/rfc822', + }, + + SHARE_FILE_MIMETYPE: { + JPG: 'image/jpg', + JPEG: 'image/jpeg', + GIF: 'image/gif', + PNG: 'image/png', + WEBP: 'image/webp', + TIF: 'image/tif', + TIFF: 'image/tiff', + IMG: 'image/*', + PDF: 'application/pdf', + MSWORD: 'application/msword', + OFFICE: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + RTF: 'application/rtf', + ZIP: 'application/zip', + RFC822: 'message/rfc822', + TEXT: 'text/plain', + HTML: 'text/html', + XML: 'text/xml', + MPEG: 'audio/mpeg', + AAC: 'audio/aac', + FLAC: 'audio/flac', + WAV: 'audio/wav', + XWAV: 'audio/x-wav', + MP3: 'audio/mp3', + VORBIS: 'audio/vorbis', + XVORBIS: 'audio/x-vorbis', + OPUS: 'audio/opus', + MP4: 'video/mp4', + MP2T: 'video/mp2t', + WEBM: 'video/webm', + AVC: 'video/avc', + HEVC: 'video/hevc', + XVND8: 'video/x-vnd.on2.vp8', + XVND9: 'video/x-vnd.on2.vp9', + AV01: 'video/av01', + TXT: 'txt', + }, + ATTACHMENT_TYPE: { REPORT: 'r', NOTE: 'n', @@ -4950,6 +5012,11 @@ const CONST = { NEW_ROOM: 'room', RECEIPT_TAB_ID: 'ReceiptTab', IOU_REQUEST_TYPE: 'iouRequestType', + SHARE: { + NAVIGATOR_ID: 'ShareNavigatorID', + SHARE: 'ShareTab', + SUBMIT: 'SubmitTab', + }, }, TAB_REQUEST: { MANUAL: 'manual', diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts index 0bf4059cfbd2..853e2591b499 100644 --- a/src/NAVIGATORS.ts +++ b/src/NAVIGATORS.ts @@ -15,5 +15,6 @@ export default { SETTINGS_SPLIT_NAVIGATOR: 'SettingsSplitNavigator', WORKSPACE_SPLIT_NAVIGATOR: 'WorkspaceSplitNavigator', SEARCH_FULLSCREEN_NAVIGATOR: 'SearchFullscreenNavigator', + SHARE_MODAL_NAVIGATOR: 'ShareModalNavigator', PUBLIC_RIGHT_MODAL_NAVIGATOR: 'PublicRightModalNavigator', } as const; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index db7b18d865e0..8b9abd213c81 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -4,7 +4,7 @@ import type {OnboardingCompanySize} from './CONST'; import type Platform from './libs/getPlatform/types'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; -import type {Attendee} from './types/onyx/IOU'; +import type {Attendee, Participant} from './types/onyx/IOU'; import type Onboarding from './types/onyx/Onboarding'; import type AssertTypesEqual from './types/utils/AssertTypesEqual'; import type DeepValueOf from './types/utils/DeepValueOf'; @@ -464,6 +464,12 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /** The details of unknown user while sharing a file - we don't know if they exist */ + SHARE_UNKNOWN_USER_DETAILS: 'shareUnknownUserDetails', + + /** Temporary file to be shared from outside the app */ + SHARE_TEMP_FILE: 'shareTempFile', + /** Corpay fields to be used in the bank account creation setup */ CORPAY_FIELDS: 'corpayFields', @@ -1081,6 +1087,8 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; + [ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS]: Participant; + [ONYXKEYS.SHARE_TEMP_FILE]: OnyxTypes.ShareTempFile; [ONYXKEYS.CORPAY_FIELDS]: OnyxTypes.CorpayFields; [ONYXKEYS.PRESERVED_USER_SESSION]: OnyxTypes.Session; [ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING]: OnyxTypes.DismissedProductTraining; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e722caeed905..c50a327d687a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1691,6 +1691,16 @@ const ROUTES = { route: 'referral/:contentType', getRoute: (contentType: string, backTo?: string) => getUrlWithBackToParam(`referral/${contentType}`, backTo), }, + SHARE_ROOT: 'share/root', + SHARE_DETAILS: { + route: 'share/share-details/:reportOrAccountID', + getRoute: (reportOrAccountID: string) => `share/share-details/${reportOrAccountID}` as const, + }, + SHARE_SUBMIT_DETAILS: { + route: 'share/submit-details/:reportOrAccountID', + getRoute: (reportOrAccountID: string) => `share/submit-details/${reportOrAccountID}` as const, + }, + PROCESS_MONEY_REQUEST_HOLD: { route: 'hold-expense-educational', getRoute: (backTo?: string) => getUrlWithBackToParam('hold-expense-educational', backTo), diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 526bcbaec712..f743eb2b6a3c 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -677,6 +677,11 @@ const SCREENS = { REIMBURSEMENT_ACCOUNT: 'ReimbursementAccount', REFERRAL_DETAILS: 'Referral_Details', KEYBOARD_SHORTCUTS: 'KeyboardShortcuts', + SHARE: { + ROOT: 'Share_Root', + SHARE_DETAILS: 'Share_Details', + SUBMIT_DETAILS: 'Submit_Details', + }, TRANSACTION_RECEIPT: 'TransactionReceipt', FEATURE_TRAINING_ROOT: 'FeatureTraining_Root', RESTRICTED_ACTION_ROOT: 'RestrictedAction_Root', diff --git a/src/components/AttachmentPreview.tsx b/src/components/AttachmentPreview.tsx new file mode 100644 index 000000000000..c845833d8df7 --- /dev/null +++ b/src/components/AttachmentPreview.tsx @@ -0,0 +1,106 @@ +import {Str} from 'expensify-common'; +import {ResizeMode, Video} from 'expo-av'; +import React, {useState} from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import {checkIsFileImage} from './Attachments/AttachmentView'; +import DefaultAttachmentView from './Attachments/AttachmentView/DefaultAttachmentView'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; +import ImageView from './ImageView'; +import PDFThumbnail from './PDFThumbnail'; +import {PressableWithFeedback} from './Pressable'; + +type AttachmentPreviewProps = { + /** Source for file. */ + source: string; + + /** Media's aspect ratio to calculate the thumbnail */ + aspectRatio: number | undefined; + + /** Function to call when pressing thumbnail */ + onPress: () => void; + + /** The attachment load error callback */ + onLoadError?: () => void; +}; + +function AttachmentPreview({source, aspectRatio = 1, onPress, onLoadError}: AttachmentPreviewProps) { + const styles = useThemeStyles(); + + const fileName = source.split('/').pop() ?? undefined; + const fillStyle = aspectRatio < 1 ? styles.h100 : styles.w100; + const [isEncryptedPDF, setIsEncryptedPDF] = useState(false); + + if (typeof source === 'string' && Str.isVideo(source)) { + return ( + + + ); + } + const isFileImage = checkIsFileImage(source, fileName); + + if (isFileImage) { + return ( + + + + + + ); + } + + if (typeof source === 'string' && Str.isPDF(source) && !isEncryptedPDF) { + return ( + setIsEncryptedPDF(true)} + /> + ); + } + + return ; +} + +AttachmentPreview.displayName = 'AttachmentPreview'; + +export default AttachmentPreview; diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 93729a8e0da3..3a76c3f5b663 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -1,13 +1,13 @@ import {Str} from 'expensify-common'; import React, {memo, useEffect, useState} from 'react'; -import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, ImageURISource, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import DistanceEReceipt from '@components/DistanceEReceipt'; import EReceipt from '@components/EReceipt'; import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; +import {Gallery} from '@components/Icon/Expensicons'; import PerDiemEReceipt from '@components/PerDiemEReceipt'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -85,6 +85,13 @@ type AttachmentViewProps = Attachment & { reportID?: string; }; +function checkIsFileImage(source: string | number | ImageURISource | ImageURISource[], fileName: string | undefined) { + const isSourceImage = typeof source === 'number' || (typeof source === 'string' && Str.isImage(source)); + const isFileNameImage = fileName && Str.isImage(fileName); + + return isSourceImage || isFileNameImage; +} + function AttachmentView({ source, previewSource, @@ -229,9 +236,7 @@ function AttachmentView({ // For this check we use both source and file.name since temporary file source is a blob // both PDFs and images will appear as images when pasted into the text field. // We also check for numeric source since this is how static images (used for preview) are represented in RN. - const isSourceImage = typeof source === 'number' || (typeof source === 'string' && Str.isImage(source)); - const isFileNameImage = file?.name && Str.isImage(file.name); - const isFileImage = isSourceImage || isFileNameImage; + const isFileImage = checkIsFileImage(source, file?.name); if (isFileImage) { if (imageError && (typeof fallbackSource === 'number' || typeof fallbackSource === 'function')) { @@ -258,7 +263,7 @@ function AttachmentView({ <> void; + + /** The PDF password callback */ + onPDFPassword?: () => void; }; type MoneyRequestConfirmationListItem = Participant | OptionData; @@ -212,6 +218,8 @@ function MoneyRequestConfirmationList({ shouldPlaySound = true, isConfirmed, isConfirming, + onPDFLoadError, + onPDFPassword, }: MoneyRequestConfirmationListProps) { const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); @@ -1062,6 +1070,8 @@ function MoneyRequestConfirmationList({ transaction={transaction} transactionID={transactionID} unit={unit} + onPDFLoadError={onPDFLoadError} + onPDFPassword={onPDFPassword} /> ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index e8b23b164b9e..e728b7adc494 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -186,6 +186,12 @@ type MoneyRequestConfirmationListFooterProps = { /** The unit */ unit: Unit | undefined; + + /** The PDF load error callback */ + onPDFLoadError?: () => void; + + /** The PDF password callback */ + onPDFPassword?: () => void; }; function MoneyRequestConfirmationListFooter({ @@ -234,6 +240,8 @@ function MoneyRequestConfirmationListFooter({ transaction, transactionID, unit, + onPDFLoadError, + onPDFPassword, }: MoneyRequestConfirmationListFooterProps) { const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); @@ -708,6 +716,8 @@ function MoneyRequestConfirmationListFooter({ ) : ( @@ -751,13 +761,15 @@ function MoneyRequestConfirmationListFooter({ translate, shouldDisplayReceipt, resolvedReceiptImage, + onPDFLoadError, + onPDFPassword, isThumbnail, resolvedThumbnail, receiptThumbnail, fileExtension, isDistanceRequest, - reportID, transactionID, + reportID, ], ); diff --git a/src/components/Share/ShareTabParticipantsSelector.tsx b/src/components/Share/ShareTabParticipantsSelector.tsx new file mode 100644 index 000000000000..713b1be409d0 --- /dev/null +++ b/src/components/Share/ShareTabParticipantsSelector.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import {saveUnknownUserDetails} from '@libs/actions/Share'; +import Navigation from '@libs/Navigation/Navigation'; +import MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestParticipantsSelector'; +import {getOptimisticChatReport, saveReportDraft} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type ROUTES from '@src/ROUTES'; + +type ShareTabParticipantsSelectorProps = { + detailsPageRouteObject: typeof ROUTES.SHARE_SUBMIT_DETAILS | typeof ROUTES.SHARE_DETAILS; +}; + +export default function ShareTabParticipantsSelector({detailsPageRouteObject}: ShareTabParticipantsSelectorProps) { + return ( + { + const participant = value.at(0); + let reportID = participant?.reportID ?? CONST.DEFAULT_NUMBER_ID; + const accountID = participant?.accountID; + if (accountID && !reportID) { + saveUnknownUserDetails(participant); + const optimisticReport = getOptimisticChatReport(accountID); + reportID = optimisticReport.reportID; + + saveReportDraft(reportID, optimisticReport).then(() => { + Navigation.navigate(detailsPageRouteObject.getRoute(reportID.toString())); + }); + } else { + Navigation.navigate(detailsPageRouteObject.getRoute(reportID.toString())); + } + }} + action="create" + /> + ); +} diff --git a/src/components/Skeletons/TabNavigatorSkeleton.tsx b/src/components/Skeletons/TabNavigatorSkeleton.tsx new file mode 100644 index 000000000000..e6def0a2026a --- /dev/null +++ b/src/components/Skeletons/TabNavigatorSkeleton.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {View} from 'react-native'; +import {Rect} from 'react-native-svg'; +import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +function TabNavigatorSkeleton() { + const styles = useThemeStyles(); + const theme = useTheme(); + + return ( + + + + + + + + + ); +} + +TabNavigatorSkeleton.displayName = 'TabNavigatorSkeleton'; + +export default TabNavigatorSkeleton; diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index db819738adf1..daa728e87978 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -41,6 +41,10 @@ function getIconAndTitle(route: string, translate: LocaleContextProps['translate return {icon: Expensicons.Hashtag, title: translate('tabSelector.room')}; case CONST.TAB_REQUEST.DISTANCE: return {icon: Expensicons.Car, title: translate('common.distance')}; + case CONST.TAB.SHARE.SHARE: + return {icon: Expensicons.UploadAlt, title: translate('common.share')}; + case CONST.TAB.SHARE.SUBMIT: + return {icon: Expensicons.Receipt, title: translate('common.submit')}; case CONST.TAB_REQUEST.PER_DIEM: return {icon: Expensicons.CalendarSolid, title: translate('common.perDiem')}; default: diff --git a/src/languages/en.ts b/src/languages/en.ts index 9955d6957ea8..50fa7a6e86eb 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1137,6 +1137,10 @@ const translations = { rates: 'Rates', submitsTo: ({name}: SubmitsToParams) => `Submits to ${name}`, }, + share: { + shareToExpensify: 'Share to Expensify', + messageInputLabel: 'Message', + }, notificationPreferencesPage: { header: 'Notification preferences', label: 'Notify me about new messages', diff --git a/src/languages/es.ts b/src/languages/es.ts index 6f8d22970367..ebeea2f0f288 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1134,6 +1134,10 @@ const translations = { rates: 'Tasas', submitsTo: ({name}: SubmitsToParams) => `Se envía a ${name}`, }, + share: { + shareToExpensify: 'Compartir para Expensify', + messageInputLabel: 'Mensaje', + }, notificationPreferencesPage: { header: 'Preferencias de avisos', label: 'Avisar sobre nuevos mensajes', diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index abeae7e42701..892ec2d6ce65 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -58,6 +58,7 @@ import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; import createRootStackNavigator from './createRootStackNavigator'; import {reportsSplitsWithEnteringAnimation, workspaceSplitsWithoutEnteringAnimation} from './createRootStackNavigator/GetStateForActionHandlers'; import defaultScreenOptions from './defaultScreenOptions'; +import {ShareModalStackNavigator} from './ModalStackNavigators'; import ExplanationModalNavigator from './Navigators/ExplanationModalNavigator'; import FeatureTrainingModalNavigator from './Navigators/FeatureTrainingModalNavigator'; import LeftModalNavigator from './Navigators/LeftModalNavigator'; @@ -580,6 +581,12 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie options={rootNavigatorScreenOptions.fullScreen} component={DesktopSignInRedirectPage} /> + require('../../../../pages/RestrictedAction/Workspace/WorkspaceRestrictedActionPage').default, }); +const ShareModalStackNavigator = createModalStackNavigator({ + [SCREENS.SHARE.ROOT]: () => require('@pages/Share/ShareRootPage').default, + [SCREENS.SHARE.SHARE_DETAILS]: () => require('@pages/Share/ShareDetailsPage').default, + [SCREENS.SHARE.SUBMIT_DETAILS]: () => require('@pages/Share/SubmitDetailsPage').default, +}); + const MissingPersonalDetailsModalStackNavigator = createModalStackNavigator({ [SCREENS.MISSING_PERSONAL_DETAILS_ROOT]: () => require('../../../../pages/MissingPersonalDetails').default, }); @@ -781,6 +788,7 @@ export { SearchReportModalStackNavigator, RestrictedActionModalStackNavigator, SearchAdvancedFiltersModalStackNavigator, + ShareModalStackNavigator, SearchSavedSearchModalStackNavigator, MissingPersonalDetailsModalStackNavigator, DebugModalStackNavigator, diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts index bb796c74eee1..a23f27b527b9 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -15,6 +15,7 @@ const MODAL_ROUTES_TO_DISMISS: string[] = [ NAVIGATORS.RIGHT_MODAL_NAVIGATOR, NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR, NAVIGATORS.FEATURE_TRANING_MODAL_NAVIGATOR, + NAVIGATORS.SHARE_MODAL_NAVIGATOR, SCREENS.NOT_FOUND, SCREENS.ATTACHMENTS, SCREENS.TRANSACTION_RECEIPT, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index f56b71e6519e..45983270edfc 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1659,6 +1659,14 @@ const config: LinkingOptions['config'] = { }, }, }, + [NAVIGATORS.SHARE_MODAL_NAVIGATOR]: { + initialRouteName: SCREENS.SHARE.ROOT, + screens: { + [SCREENS.SHARE.ROOT]: {path: ROUTES.SHARE_ROOT}, + [SCREENS.SHARE.SHARE_DETAILS]: {path: ROUTES.SHARE_DETAILS.route}, + [SCREENS.SHARE.SUBMIT_DETAILS]: {path: ROUTES.SHARE_SUBMIT_DETAILS.route}, + }, + }, }, }; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 9d2a12f8d70c..dd029783650a 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1794,6 +1794,12 @@ type SharedScreensParamList = { }; }; +type ShareNavigatorParamList = { + [SCREENS.SHARE.ROOT]: undefined; + [SCREENS.SHARE.SHARE_DETAILS]: {reportOrAccountID: string}; + [SCREENS.SHARE.SUBMIT_DETAILS]: {reportOrAccountID: string}; +}; + type PublicScreensParamList = SharedScreensParamList & { [SCREENS.UNLINK_LOGIN]: { accountID?: string; @@ -1855,6 +1861,7 @@ type AuthScreensParamList = SharedScreensParamList & { isFromReviewDuplicates?: string; }; [SCREENS.CONNECTION_COMPLETE]: undefined; + [NAVIGATORS.SHARE_MODAL_NAVIGATOR]: NavigatorScreenParams; [SCREENS.BANK_CONNECTION_COMPLETE]: undefined; }; @@ -1997,6 +2004,7 @@ export type { ReportSettingsNavigatorParamList, ReportsSplitNavigatorParamList, RestrictedActionParamList, + ShareNavigatorParamList, RightModalNavigatorParamList, RoomMembersNavigatorParamList, RootNavigatorParamList, diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index d2fbe6a3322c..e299236b932d 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -27,7 +27,7 @@ import type { TransactionViolation, } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; -import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type {Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import Timing from './actions/Timing'; @@ -155,13 +155,13 @@ type OptionTree = { tooltipText: string; isDisabled: boolean; isSelected: boolean; - pendingAction?: OnyxCommon.PendingAction; + pendingAction?: PendingAction; } & Option; type PayeePersonalDetails = { text: string; alternateText: string; - icons: OnyxCommon.Icon[]; + icons: Icon[]; descriptiveText: string; login: string; accountID: number; @@ -234,8 +234,8 @@ type MemberForList = { isDisabled: boolean; accountID?: number; login: string; - icons?: OnyxCommon.Icon[]; - pendingAction?: OnyxCommon.PendingAction; + icons?: Icon[]; + pendingAction?: PendingAction; reportID: string; }; @@ -251,7 +251,14 @@ type Options = { selfDMChat?: OptionData | undefined; }; -type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; showPersonalDetails?: boolean}; +type PreviewConfig = { + showChatPreviewLine?: boolean; + forcePolicyNamePreview?: boolean; + showPersonalDetails?: boolean; + isDisabled?: boolean | null; + selected?: boolean; + isSelected?: boolean; +}; type FilterUserToInviteConfig = Pick & { canInviteUser?: boolean; @@ -422,7 +429,7 @@ Onyx.connect({ * @param defaultValues {login: accountID} In workspace invite page, when new user is added we pass available data to opt in * @returns Returns avatar data for a list of user accountIDs */ -function getAvatarsForAccountIDs(accountIDs: number[], personalDetails: OnyxEntry, defaultValues: Record = {}): OnyxCommon.Icon[] { +function getAvatarsForAccountIDs(accountIDs: number[], personalDetails: OnyxEntry, defaultValues: Record = {}): Icon[] { const reversedDefaultValues: Record = {}; Object.entries(defaultValues).forEach((item) => { @@ -586,6 +593,24 @@ function getAlternateText(option: OptionData, {showChatPreviewLine = false, forc : formatPhoneNumber(option.participantsList && option.participantsList.length > 0 ? option.participantsList.at(0)?.login ?? '' : ''); } +/** + * Searches for a match when provided with a value + */ +function isSearchStringMatch(searchValue: string, searchText?: string | null, participantNames = new Set(), isReportChatRoom = false): boolean { + const searchWords = new Set(searchValue.replace(/,/g, ' ').split(' ')); + const valueToSearch = searchText?.replace(new RegExp(/ /g), ''); + let matching = true; + searchWords.forEach((word) => { + // if one of the word is not matching, we don't need to check further + if (!matching) { + return; + } + const matchRegex = new RegExp(Str.escapeForRegExp(word), 'i'); + matching = matchRegex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)); + }); + return matching; +} + function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchValue: string) { let memberDetails = ''; if (personalDetail.login) { @@ -753,7 +778,7 @@ function createOption( reportActions: ReportActions, config?: PreviewConfig, ): OptionData { - const {showChatPreviewLine = false, forcePolicyNamePreview = false, showPersonalDetails = false} = config ?? {}; + const {showChatPreviewLine = false, forcePolicyNamePreview = false, showPersonalDetails = false, selected, isSelected, isDisabled} = config ?? {}; const result: OptionData = { text: undefined, alternateText: undefined, @@ -786,6 +811,9 @@ function createOption( isOptimisticPersonalDetail: false, lastMessageText: '', lastVisibleActionCreated: undefined, + selected, + isSelected, + isDisabled, }; const personalDetailMap = getPersonalDetailsForAccountIDs(accountIDs, personalDetails); @@ -919,6 +947,43 @@ function getReportOption(participant: Participant): OptionData { return option; } +/** + * Get the display option for a given report. + */ +function getReportDisplayOption(report: OnyxEntry, unknownUserDetails: OnyxEntry): OptionData { + const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, true); + + const option = createOption( + visibleParticipantAccountIDs, + allPersonalDetails ?? {}, + !isEmptyObject(report) ? report : undefined, + {}, + { + showChatPreviewLine: false, + forcePolicyNamePreview: false, + }, + ); + + // Update text & alternateText because createOption returns workspace name only if report is owned by the user + if (option.isSelfDM) { + option.alternateText = translateLocal('reportActionsView.yourSpace'); + } else if (option.isInvoiceRoom) { + option.text = getReportName(report); + option.alternateText = translateLocal('workspace.common.invoices'); + } else if (unknownUserDetails && !option.text) { + option.text = unknownUserDetails.text ?? unknownUserDetails.login; + option.alternateText = unknownUserDetails.login; + option.participantsList = [{...unknownUserDetails, displayName: unknownUserDetails.login, accountID: unknownUserDetails.accountID ?? CONST.DEFAULT_NUMBER_ID}]; + } else if (report?.ownerAccountID !== 0 || !option.text) { + option.text = getPolicyName({report}); + option.alternateText = translateLocal('workspace.common.workspace'); + } + option.isDisabled = true; + option.selected = false; + option.isSelected = false; + + return option; +} /** * Get the option for a policy expense report. */ @@ -948,24 +1013,6 @@ function getPolicyExpenseReportOption(participant: Participant | OptionData): Op return option; } -/** - * Searches for a match when provided with a value - */ -function isSearchStringMatch(searchValue: string, searchText?: string | null, participantNames = new Set(), isChatRoom = false): boolean { - const searchWords = new Set(searchValue.replace(/,/g, ' ').split(' ')); - const valueToSearch = searchText?.replace(new RegExp(/ /g), ''); - let matching = true; - searchWords.forEach((word) => { - // if one of the word is not matching, we don't need to check further - if (!matching) { - return; - } - const matchRegex = new RegExp(Str.escapeForRegExp(word), 'i'); - matching = matchRegex.test(valueToSearch ?? '') || (!isChatRoom && participantNames.has(word)); - }); - return matching; -} - /** * Checks if the given userDetails is currentUser or not. * Note: We can't migrate this off of using logins because this is used to check if you're trying to start a chat with @@ -1842,8 +1889,8 @@ function formatSectionsFromSearchTerm( title: undefined, data: shouldGetOptionDetails ? selectedOptions.map((participant) => { - const isPolicyExpenseChat = participant.isPolicyExpenseChat ?? false; - return isPolicyExpenseChat ? getPolicyExpenseReportOption(participant) : getParticipantsOption(participant, personalDetails); + const isReportPolicyExpenseChat = participant.isPolicyExpenseChat ?? false; + return isReportPolicyExpenseChat ? getPolicyExpenseReportOption(participant) : getParticipantsOption(participant, personalDetails); }) : selectedOptions, shouldShow: selectedOptions.length > 0, @@ -1868,8 +1915,8 @@ function formatSectionsFromSearchTerm( title: undefined, data: shouldGetOptionDetails ? selectedParticipantsWithoutDetails.map((participant) => { - const isPolicyExpenseChat = participant.isPolicyExpenseChat ?? false; - return isPolicyExpenseChat ? getPolicyExpenseReportOption(participant) : getParticipantsOption(participant, personalDetails); + const isReportPolicyExpenseChat = participant.isPolicyExpenseChat ?? false; + return isReportPolicyExpenseChat ? getPolicyExpenseReportOption(participant) : getParticipantsOption(participant, personalDetails); }) : selectedParticipantsWithoutDetails, shouldShow: selectedParticipantsWithoutDetails.length > 0, @@ -2206,6 +2253,7 @@ export { shouldUseBoldText, getAttendeeOptions, getAlternateText, + getReportDisplayOption, hasReportErrors, combineOrderingOfReportsAndPersonalDetails, filterWorkspaceChats, diff --git a/src/libs/ShareActionHandlerModule/index.ts b/src/libs/ShareActionHandlerModule/index.ts new file mode 100644 index 000000000000..205bd89960d9 --- /dev/null +++ b/src/libs/ShareActionHandlerModule/index.ts @@ -0,0 +1,21 @@ +import {NativeModules} from 'react-native'; + +const {ShareActionHandler} = NativeModules; + +// Type for the content of a share action +type ShareActionContent = { + id: string; + content: string; + mimeType: string; + processedAt: string; + aspectRatio: number; +}; + +// Type for the ShareActionHandler module +type ShareActionHandlerModule = { + // Method to process files, which takes a callback function + processFiles(callback: (array: ShareActionContent[]) => void): void; +}; + +export default ShareActionHandler; +export type {ShareActionHandlerModule}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 660926815b25..7ea897a2532c 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1160,6 +1160,17 @@ function openReport( } } +/** + * This will return an optimistic report object for a given user we want to create a chat with without saving it, when the only thing we know about recipient is his accountID. * + * @param accountID accountID of the user that the optimistic chat report is created with. + */ +function getOptimisticChatReport(accountID: number): OptimisticChatReport { + return buildOptimisticChatReport({ + participantList: [accountID, currentUserAccountID], + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }); +} + /** * This will find an existing chat, or create a new one if none exists, for the given user or set of users. It will then navigate to this chat. * @@ -1513,6 +1524,11 @@ function togglePinnedState(reportID: string | undefined, isPinnedChat: boolean) API.write(WRITE_COMMANDS.TOGGLE_PINNED_CHAT, parameters, {optimisticData}); } +/** Saves the report draft to Onyx */ +function saveReportDraft(reportID: string, report: Report) { + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`, report); +} + /** * Saves the comment left by the user as they are typing. By saving this data the user can switch between chats, close * tab, refresh etc without worrying about loosing what they typed out. @@ -5164,6 +5180,8 @@ export { updateReportName, updateRoomVisibility, updateWriteCapability, + getOptimisticChatReport, + saveReportDraft, prepareOnboardingOnyxData, dismissChangePolicyModal, changeReportPolicy, diff --git a/src/libs/actions/Share.ts b/src/libs/actions/Share.ts new file mode 100644 index 000000000000..6341b30766e6 --- /dev/null +++ b/src/libs/actions/Share.ts @@ -0,0 +1,35 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ShareTempFile} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; + +/** +Function for clearing old saved data before at the start of share-extension flow + */ +function clearShareData() { + Onyx.multiSet({ + [ONYXKEYS.SHARE_TEMP_FILE]: null, + [ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS]: null, + }); +} + +/** +Function storing natively shared file's properties for processing across share-extension screens + +function addTempShareFile(file: ShareTempFile) { + * @param file shared file's object with additional props + */ +function addTempShareFile(file: ShareTempFile) { + Onyx.merge(ONYXKEYS.SHARE_TEMP_FILE, file); +} + +/** +Function storing selected user's details for the duration of share-extension flow, if account doesn't exist + + * @param user selected user's details + */ +function saveUnknownUserDetails(user: Participant) { + Onyx.merge(ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS, user); +} + +export {addTempShareFile, saveUnknownUserDetails, clearShareData}; diff --git a/src/pages/Share/ShareDetailsPage.tsx b/src/pages/Share/ShareDetailsPage.tsx new file mode 100644 index 000000000000..03ed527d5d58 --- /dev/null +++ b/src/pages/Share/ShareDetailsPage.tsx @@ -0,0 +1,182 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useMemo, useState} from 'react'; +import {SafeAreaView, View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import AttachmentModal from '@components/AttachmentModal'; +import AttachmentPreview from '@components/AttachmentPreview'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {FallbackAvatar} from '@components/Icon/Expensicons'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {addAttachment, addComment, getCurrentUserAccountID, openReport} from '@libs/actions/Report'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import {getFileName, readFileAsync} from '@libs/fileDownload/FileUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {ShareNavigatorParamList} from '@libs/Navigation/types'; +import {getReportDisplayOption} from '@libs/OptionsListUtils'; +import {getReportOrDraftReport, isDraftReport} from '@libs/ReportUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import UserListItem from '@src/components/SelectionList/UserListItem'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Report as ReportType} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {showErrorAlert} from './ShareRootPage'; + +type ShareDetailsPageProps = StackScreenProps; + +function ShareDetailsPage({ + route: { + params: {reportOrAccountID}, + }, +}: ShareDetailsPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [unknownUserDetails] = useOnyx(ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS); + const [currentAttachment] = useOnyx(ONYXKEYS.SHARE_TEMP_FILE); + const isTextShared = currentAttachment?.mimeType === 'txt'; + const [message, setMessage] = useState(isTextShared ? currentAttachment?.content ?? '' : ''); + + const report: OnyxEntry = getReportOrDraftReport(reportOrAccountID); + const displayReport = useMemo(() => getReportDisplayOption(report, unknownUserDetails), [report, unknownUserDetails]); + if (isEmptyObject(report)) { + return ; + } + + const isDraft = isDraftReport(reportOrAccountID); + const currentUserID = getCurrentUserAccountID(); + const shouldShowAttachment = !isTextShared; + + const fileName = currentAttachment?.content.split('/').pop(); + + const handleShare = () => { + if (!currentAttachment) { + return; + } + + if (isTextShared) { + addComment(report.reportID, message); + const routeToNavigate = ROUTES.REPORT_WITH_ID.getRoute(reportOrAccountID); + Navigation.navigate(routeToNavigate); + return; + } + + readFileAsync( + currentAttachment.content, + getFileName(currentAttachment.content), + (file) => { + if (isDraft) { + openReport( + report.reportID, + '', + displayReport.participantsList?.filter((u) => u.accountID !== currentUserID).map((u) => u.login ?? '') ?? [], + report, + undefined, + undefined, + undefined, + undefined, + ); + } + if (report.reportID) { + addAttachment(report.reportID, file, message); + } + + const routeToNavigate = ROUTES.REPORT_WITH_ID.getRoute(reportOrAccountID); + Navigation.navigate(routeToNavigate, {forceReplace: true}); + }, + () => {}, + ); + }; + + return ( + + + + {!!report && ( + <> + + {translate('common.to')} + + {}} + pressableStyle={[styles.flexRow]} + shouldSyncFocus={false} + isDisabled + /> + + )} + + + + + + + + {shouldShowAttachment && ( + <> + + {translate('common.attachment')} + + + + {({show}) => ( + { + showErrorAlert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingCorruptedAttachment')); + }} + /> + )} + + + + )} + + + +