Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[android][expo-sharing] migrate to new modules API #20112

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/expo-sharing/CHANGELOG.md
Expand Up @@ -6,6 +6,8 @@

### 🎉 New features

- Migrated Android implementation to Expo Modules API. ([#20112](https://github.com/expo/expo/pull/20112) by [@alanhughes](https://github.com/alanjhughes))

### 🐛 Bug fixes

### 💡 Others
Expand Down
@@ -0,0 +1,15 @@
package expo.modules.sharing

import expo.modules.kotlin.exception.CodedException

internal class MissingCurrentActivityException :
CodedException("Activity which was provided during module initialization is no longer available")

internal class SharingInProgressException :
CodedException("Another share request is being processed now.")

internal class SharingFailedException(message: String, e: Exception) :
CodedException(message, e.cause)

internal class SharingInvalidArgsException(message: String?, e: Exception) :
CodedException(message, e.cause)
@@ -1,81 +1,69 @@
package expo.modules.sharing

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.core.content.FileProvider
import expo.modules.core.ExportedModule
import expo.modules.core.ModuleRegistry
import expo.modules.core.ModuleRegistryDelegate
import expo.modules.core.Promise
import expo.modules.core.arguments.ReadableArguments
import expo.modules.core.errors.InvalidArgumentException
import expo.modules.core.interfaces.ActivityEventListener
import expo.modules.core.interfaces.ActivityProvider
import expo.modules.core.interfaces.ExpoMethod
import expo.modules.core.interfaces.services.UIManager
import expo.modules.interfaces.filesystem.FilePermissionModuleInterface
import expo.modules.interfaces.filesystem.Permission
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
import java.net.URLConnection

class SharingModule(
context: Context,
private val moduleRegistryDelegate: ModuleRegistryDelegate = ModuleRegistryDelegate()
) : ExportedModule(context), ActivityEventListener {
class SharingModule : Module() {
private val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private val currentActivity
get() = appContext.currentActivity ?: throw MissingCurrentActivityException()
private var pendingPromise: Promise? = null
private val uiManager: UIManager by moduleRegistry()
override fun getName() = "ExpoSharing"

private inline fun <reified T> moduleRegistry() =
moduleRegistryDelegate.getFromModuleRegistry<T>()
override fun definition() = ModuleDefinition {
Name("ExpoSharing")

override fun onCreate(moduleRegistry: ModuleRegistry) {
moduleRegistryDelegate.onCreate(moduleRegistry)
uiManager.registerActivityEventListener(this)
}

override fun onDestroy() {
uiManager.unregisterActivityEventListener(this)
}

@ExpoMethod
fun shareAsync(url: String?, params: ReadableArguments, promise: Promise) {
if (pendingPromise != null) {
promise.reject("ERR_SHARING_MUL", "Another share request is being processed now.")
return
AsyncFunction("shareAsync") { url: String?, params: SharingOptions, promise: Promise ->
if (pendingPromise != null) {
throw SharingInProgressException()
}
try {
val fileToShare = getLocalFileFoUrl(url)
val contentUri = FileProvider.getUriForFile(
context,
context.applicationInfo.packageName + ".SharingFileProvider",
fileToShare
)
val mimeType = params.mimeType
?: URLConnection.guessContentTypeFromName(fileToShare.name)
?: "*/*"
val intent = Intent.createChooser(
createSharingIntent(contentUri, mimeType),
params.dialogTitle
)
val resInfoList = context.packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_DEFAULT_ONLY
)
resInfoList.forEach {
val packageName = it.activityInfo.packageName
context.grantUriPermission(packageName, contentUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
pendingPromise = promise
currentActivity.startActivity(intent)
} catch (e: InvalidArgumentException) {
throw SharingInvalidArgsException(e.message, e)
} catch (e: Exception) {
throw SharingFailedException("Failed to share the file: ${e.message}", e)
}
}
try {
val fileToShare = getLocalFileFoUrl(url)
val contentUri = FileProvider.getUriForFile(
context,
context.applicationInfo.packageName + ".SharingFileProvider",
fileToShare
)
val mimeType = params.getString(MIME_TYPE_OPTIONS_KEY)
?: URLConnection.guessContentTypeFromName(fileToShare.name)
?: "*/*"
val intent = Intent.createChooser(
createSharingIntent(contentUri, mimeType),
params.getString(DIALOG_TITLE_OPTIONS_KEY)
)
val resInfoList = context.packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_DEFAULT_ONLY
)
resInfoList.forEach {
val packageName = it.activityInfo.packageName
context.grantUriPermission(packageName, contentUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)

OnActivityResult { _, (requestCode) ->
if (requestCode == REQUEST_CODE && pendingPromise != null) {
pendingPromise?.resolve(null)
pendingPromise = null
}
val activityProvider: ActivityProvider by moduleRegistry()
activityProvider.currentActivity.startActivityForResult(intent, REQUEST_CODE)
pendingPromise = promise
} catch (e: InvalidArgumentException) {
promise.reject("ERR_SHARING_URL", e.message, e)
} catch (e: Exception) {
promise.reject("ERR_SHARING", "Failed to share the file: " + e.message, e)
}
}

Expand All @@ -97,8 +85,9 @@ class SharingModule(
}

private fun isAllowedToRead(url: String?): Boolean {
val permissionModuleInterface: FilePermissionModuleInterface by moduleRegistry()
return permissionModuleInterface.getPathPermissions(context, url).contains(Permission.READ)
val permissions = appContext.filePermission
return permissions?.getPathPermissions(context, url)?.contains(Permission.READ)
?: false
}

private fun createSharingIntent(uri: Uri, mimeType: String?) =
Expand All @@ -108,18 +97,7 @@ class SharingModule(
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE && pendingPromise != null) {
pendingPromise?.resolve(null)
pendingPromise = null
}
}
Comment on lines -111 to -116
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, it looks great, but you can't just remove that function and resolve the promise earlier. The time when the promise will be resolved will be different.

Before:
js calls shareAsync -> intent is invoked -> share activity was opened -> user shares something -> execution goes back to the application -> the promise is resolved

Now:
js calls shareAsync -> intent is invoked -> promise is resolved

In many cases, the flow from the js perspective looks similar, because when the rn app goes to the background (when the shared activity is active), there is a small time window when the app code can still execute. However, it not always may be true.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought this mightn't be right. I'll put it back. So resolving with null is just a way of notifying the js that an action has been completed? You'd only need to do this in situations where it's some kind of callback? If it all completes inside the AsyncFunction body, you can just let the function complete without resolving if you are not returning anything?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. The AsyncFunction will automatically call promise.resolve(null) if the closure doesn't take the promise as an argument and the function is complete. In the case where a promise is the last argument of the function, you take responsibility for managing the promise itself. It's helpful when the function is completed, but the rest of the operation happens on a different thread.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, that makes sense, thanks Łukasz  👍


override fun onNewIntent(intent: Intent) = Unit

companion object {
private const val REQUEST_CODE = 8524
private const val MIME_TYPE_OPTIONS_KEY = "mimeType"
private const val DIALOG_TITLE_OPTIONS_KEY = "dialogTitle"
}
}
@@ -0,0 +1,10 @@
package expo.modules.sharing

import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record

data class SharingOptions(
@Field val mimeType: String?,
@Field val UTI: String?,
@Field val dialogTitle: String?
) : Record

This file was deleted.

2 changes: 1 addition & 1 deletion packages/expo-sharing/build/ExpoSharing.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/expo-sharing/build/ExpoSharing.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/expo-sharing/build/ExpoSharing.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/expo-sharing/build/ExpoSharing.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions packages/expo-sharing/expo-module.config.json
@@ -0,0 +1,7 @@
{
"name": "expo-sharing",
"platforms": ["ios", "android"],
"android": {
"modules": ["expo.modules.sharing.SharingModule"]
}
}
5 changes: 2 additions & 3 deletions packages/expo-sharing/src/ExpoSharing.ts
@@ -1,3 +1,2 @@
import { NativeModulesProxy } from 'expo-modules-core';

export default NativeModulesProxy.ExpoSharing;
import { requireNativeModule } from 'expo-modules-core';
export default requireNativeModule('ExpoSharing');
4 changes: 0 additions & 4 deletions packages/expo-sharing/unimodule.json

This file was deleted.