Skip to content

Commit acc2e84

Browse files
authored
Merge pull request #58834 from software-mansion-labs/289Adam289/native-share-implementation
Revert "Revert "Share extension Android and iOS implementation""
2 parents b01a456 + 220b45a commit acc2e84

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+2564
-43
lines changed

android/app/src/main/AndroidManifest.xml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,52 @@
104104
<data android:scheme="https" android:host="staging.new.expensify.com" android:pathPrefix="/track-expense"/>
105105
<data android:scheme="https" android:host="staging.new.expensify.com" android:pathPrefix="/submit-expense"/>
106106
</intent-filter>
107+
<intent-filter>
108+
<action android:name="android.intent.action.SEND" />
109+
<category android:name="android.intent.category.DEFAULT" />
110+
111+
<!-- Images -->
112+
<data android:mimeType="image/jpg" />
113+
<data android:mimeType="image/jpeg" />
114+
<data android:mimeType="image/gif" />
115+
<data android:mimeType="image/png" />
116+
<data android:mimeType="image/tif" />
117+
<data android:mimeType="image/tiff" />
118+
119+
<!-- Documents -->
120+
<data android:mimeType="application/pdf" />
121+
<data android:mimeType="application/msword" />
122+
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
123+
<data android:mimeType="application/rtf" />
124+
<data android:mimeType="application/zip" />
125+
<data android:mimeType="message/rfc822" />
126+
127+
<!-- Text / HTML -->
128+
<data android:mimeType="text/plain" />
129+
<data android:mimeType="text/html" />
130+
<data android:mimeType="text/xml" />
131+
132+
<!-- Audio / Video -->
133+
<data android:mimeType="audio/mpeg" />
134+
<data android:mimeType="audio/aac" />
135+
<data android:mimeType="audio/flac" />
136+
<data android:mimeType="audio/wav" />
137+
<data android:mimeType="audio/x-wav" />
138+
<data android:mimeType="audio/mp3" />
139+
<data android:mimeType="audio/vorbis" />
140+
<data android:mimeType="audio/x-vorbis" />
141+
<data android:mimeType="audio/opus" />
142+
143+
<data android:mimeType="video/mp4" />
144+
<data android:mimeType="video/mp2t" />
145+
<data android:mimeType="video/webm" />
146+
<data android:mimeType="video/avc" />
147+
<data android:mimeType="video/hevc" />
148+
<data android:mimeType="video/x-vnd.on2.vp8" />
149+
<data android:mimeType="video/x-vnd.on2.vp9" />
150+
<data android:mimeType="video/av01" />
151+
152+
</intent-filter>
107153
</activity>
108154

109155
<meta-data

android/app/src/main/java/com/expensify/chat/ExpensifyAppPackage.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public List<NativeModule> createNativeModules(
2222
List<NativeModule> modules = new ArrayList<>();
2323

2424
modules.add(new StartupTimer(reactContext));
25+
modules.add(new ShareActionHandlerModule(reactContext));
2526

2627
return modules;
2728
}

android/app/src/main/java/com/expensify/chat/MainActivity.kt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
package com.expensify.chat
22

3-
import expo.modules.ReactActivityDelegateWrapper
4-
3+
import android.content.Intent
54
import android.content.pm.ActivityInfo
65
import android.os.Bundle
6+
import android.util.Log
77
import android.view.KeyEvent
88
import android.view.View
99
import android.view.WindowInsets
1010
import com.expensify.chat.bootsplash.BootSplash
11+
import com.expensify.chat.intenthandler.IntentHandlerFactory
1112
import com.expensify.reactnativekeycommand.KeyCommandModule
1213
import com.facebook.react.ReactActivity
1314
import com.facebook.react.ReactActivityDelegate
1415
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
1516
import com.facebook.react.defaults.DefaultReactActivityDelegate
17+
import expo.modules.ReactActivityDelegateWrapper
1618

1719
import com.oblador.performance.RNPerformance
1820

@@ -52,6 +54,25 @@ class MainActivity : ReactActivity() {
5254
defaultInsets.systemWindowInsetBottom
5355
)
5456
}
57+
58+
if (intent != null) {
59+
handleIntent(intent)
60+
}
61+
}
62+
63+
override fun onNewIntent(intent: Intent) {
64+
super.onNewIntent(intent)
65+
setIntent(intent) // Must store the new intent unless getIntent() will return the old one
66+
handleIntent(intent)
67+
}
68+
69+
private fun handleIntent(intent: Intent) {
70+
try {
71+
val intenthandler = IntentHandlerFactory.getIntentHandler(this, intent.type, intent.toString())
72+
intenthandler?.handle(intent)
73+
} catch (exception: Exception) {
74+
Log.e("handleIntentException", exception.toString())
75+
}
5576
}
5677

5778
/**
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.expensify.chat
2+
3+
import android.content.Context
4+
import android.graphics.BitmapFactory
5+
import android.media.MediaMetadataRetriever
6+
import com.expensify.chat.intenthandler.IntentHandlerConstants
7+
import com.facebook.react.bridge.Callback
8+
import com.facebook.react.bridge.ReactApplicationContext
9+
import com.facebook.react.bridge.ReactContextBaseJavaModule
10+
import android.util.Log
11+
import com.facebook.react.bridge.ReactMethod
12+
import org.json.JSONObject
13+
import java.io.File
14+
15+
class ShareActionHandlerModule(reactContext: ReactApplicationContext) :
16+
ReactContextBaseJavaModule(reactContext) {
17+
18+
override fun getName(): String {
19+
return "ShareActionHandler"
20+
}
21+
22+
@ReactMethod
23+
fun processFiles(callback: Callback) {
24+
try {
25+
val sharedPreferences = reactApplicationContext.getSharedPreferences(
26+
IntentHandlerConstants.preferencesFile,
27+
Context.MODE_PRIVATE
28+
)
29+
30+
val shareObjectString = sharedPreferences.getString(IntentHandlerConstants.shareObjectProperty, null)
31+
if (shareObjectString == null) {
32+
callback.invoke("No data found", null)
33+
return
34+
}
35+
36+
val shareObject = JSONObject(shareObjectString)
37+
val content = shareObject.optString("content")
38+
val mimeType = shareObject.optString("mimeType")
39+
val fileUriPath = "file://$content"
40+
val timestamp = System.currentTimeMillis()
41+
42+
val file = File(content)
43+
if (!file.exists()) {
44+
val textObject = JSONObject().apply {
45+
put("id", "text")
46+
put("content", content)
47+
put("mimeType", "txt")
48+
put("processedAt", timestamp)
49+
}
50+
callback.invoke(textObject.toString())
51+
return
52+
}
53+
54+
val identifier = file.name
55+
var aspectRatio = 0.0f
56+
57+
if (mimeType.startsWith("image/")) {
58+
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
59+
BitmapFactory.decodeFile(content, options)
60+
aspectRatio = if (options.outHeight != 0) options.outWidth.toFloat() / options.outHeight else 1.0f
61+
} else if (mimeType.startsWith("video/")) {
62+
val retriever = MediaMetadataRetriever()
63+
try {
64+
retriever.setDataSource(content)
65+
val videoWidth = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull() ?: 1f
66+
val videoHeight = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull() ?: 1f
67+
if (videoHeight != 0f) aspectRatio = videoWidth / videoHeight
68+
} catch (e: Exception) {
69+
Log.e("ShareActionHandlerModule", "Error retrieving video metadata: ${e.message}")
70+
} finally {
71+
retriever.release()
72+
}
73+
}
74+
75+
val fileData = JSONObject().apply {
76+
put("id", identifier)
77+
put("content", fileUriPath)
78+
put("mimeType", mimeType)
79+
put("processedAt", timestamp)
80+
put("aspectRatio", aspectRatio)
81+
}
82+
83+
callback.invoke(fileData.toString())
84+
85+
} catch (e: Exception) {
86+
callback.invoke(e.toString(), null)
87+
}
88+
}
89+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.expensify.chat.intenthandler
2+
3+
import android.content.Context
4+
import com.expensify.chat.utils.FileUtils.clearInternalStorageDirectory
5+
6+
abstract class AbstractIntentHandler: IntentHandler {
7+
override fun onCompleted() {}
8+
9+
protected fun clearTemporaryFiles(context: Context) {
10+
// Clear data present in the shared preferences
11+
val sharedPreferences = context.getSharedPreferences(IntentHandlerConstants.preferencesFile, Context.MODE_PRIVATE)
12+
val editor = sharedPreferences.edit()
13+
editor.clear()
14+
editor.apply()
15+
16+
// Clear leftover temporary files from previous share attempts
17+
clearInternalStorageDirectory(context)
18+
}
19+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.expensify.chat.intenthandler
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.net.Uri
6+
import com.expensify.chat.utils.FileUtils
7+
8+
class FileIntentHandler(private val context: Context) : AbstractIntentHandler() {
9+
override fun handle(intent: Intent): Boolean {
10+
super.clearTemporaryFiles(context)
11+
when(intent.action) {
12+
Intent.ACTION_SEND -> {
13+
handleSingleFileIntent(intent, context)
14+
onCompleted()
15+
return true
16+
}
17+
}
18+
return false
19+
}
20+
21+
private fun handleSingleFileIntent(intent: Intent, context: Context) {
22+
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { fileUri ->
23+
val resultingPath: String? = FileUtils.copyUriToStorage(fileUri, context)
24+
25+
if (resultingPath != null) {
26+
val shareFileObject = ShareFileObject(resultingPath, intent.type)
27+
28+
val sharedPreferences = context.getSharedPreferences(IntentHandlerConstants.preferencesFile, Context.MODE_PRIVATE)
29+
val editor = sharedPreferences.edit()
30+
editor.putString(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString())
31+
editor.apply()
32+
}
33+
}
34+
}
35+
36+
override fun onCompleted() {
37+
val uri: Uri = Uri.parse("new-expensify://share/root")
38+
val deepLinkIntent = Intent(Intent.ACTION_VIEW, uri)
39+
deepLinkIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
40+
context.startActivity(deepLinkIntent)
41+
}
42+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.expensify.chat.intenthandler
2+
3+
import android.content.Intent
4+
5+
object IntentHandlerConstants {
6+
const val preferencesFile = "shareActionHandler"
7+
const val shareObjectProperty = "shareObject"
8+
}
9+
interface IntentHandler {
10+
fun handle(intent: Intent): Boolean
11+
fun onCompleted()
12+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.expensify.chat.intenthandler
2+
3+
import android.content.Context
4+
5+
object IntentHandlerFactory {
6+
fun getIntentHandler(context: Context, mimeType: String?, rest: String?): IntentHandler? {
7+
if (mimeType == null) return null
8+
9+
return when {
10+
mimeType.matches(Regex("(image|application|audio|video)/.*")) -> FileIntentHandler(context)
11+
mimeType.startsWith("text/") -> TextIntentHandler(context)
12+
else -> throw UnsupportedOperationException("Unsupported MIME type: $mimeType")
13+
}
14+
}
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.expensify.chat.intenthandler
2+
3+
import org.json.JSONObject
4+
5+
data class ShareFileObject(
6+
val content: String,
7+
val mimeType: String?,
8+
) {
9+
override fun toString(): String {
10+
return JSONObject().apply {
11+
put("content", content)
12+
put("mimeType", mimeType)
13+
}.toString()
14+
}
15+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.expensify.chat.intenthandler
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.net.Uri
6+
import com.expensify.chat.utils.FileUtils
7+
8+
9+
class TextIntentHandler(private val context: Context) : AbstractIntentHandler() {
10+
override fun handle(intent: Intent): Boolean {
11+
super.clearTemporaryFiles(context)
12+
when(intent.action) {
13+
Intent.ACTION_SEND -> {
14+
handleTextIntent(intent, context)
15+
onCompleted()
16+
return true
17+
}
18+
}
19+
return false
20+
}
21+
22+
private fun handleTextIntent(intent: Intent, context: Context) {
23+
when {
24+
intent.type == "text/plain" -> {
25+
val extras = intent.extras
26+
if (extras != null) {
27+
when {
28+
extras.containsKey(Intent.EXTRA_STREAM) -> {
29+
handleTextFileIntent(intent, context)
30+
}
31+
extras.containsKey(Intent.EXTRA_TEXT) -> {
32+
handleTextPlainIntent(intent, context)
33+
}
34+
else -> {
35+
throw UnsupportedOperationException("Unknown text/plain content")
36+
}
37+
}
38+
}
39+
}
40+
Regex("text/.*").matches(intent.type ?: "") -> handleTextFileIntent(intent, context)
41+
else -> throw UnsupportedOperationException("Unsupported MIME type: ${intent.type}")
42+
}
43+
}
44+
45+
private fun saveToSharedPreferences(key: String, value: String) {
46+
val sharedPreferences = context.getSharedPreferences(IntentHandlerConstants.preferencesFile, Context.MODE_PRIVATE)
47+
val editor = sharedPreferences.edit()
48+
editor.putString(key, value)
49+
editor.apply()
50+
}
51+
52+
private fun handleTextFileIntent(intent: Intent, context: Context) {
53+
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { fileUri ->
54+
val resultingPath: String? = FileUtils.copyUriToStorage(fileUri, context)
55+
if (resultingPath != null) {
56+
val shareFileObject = ShareFileObject(resultingPath, intent.type)
57+
saveToSharedPreferences(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString())
58+
}
59+
}
60+
}
61+
62+
private fun handleTextPlainIntent(intent: Intent, context: Context) {
63+
var intentTextContent = intent.getStringExtra(Intent.EXTRA_TEXT)
64+
if(intentTextContent != null) {
65+
val shareFileObject = ShareFileObject(intentTextContent, intent.type)
66+
saveToSharedPreferences(IntentHandlerConstants.shareObjectProperty, shareFileObject.toString())
67+
}
68+
}
69+
70+
override fun onCompleted() {
71+
val uri: Uri = Uri.parse("new-expensify://share/root")
72+
val deepLinkIntent = Intent(Intent.ACTION_VIEW, uri)
73+
deepLinkIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
74+
context.startActivity(deepLinkIntent)
75+
}
76+
}

0 commit comments

Comments
 (0)