Skip to content

Commit

Permalink
android: Receive and handle shares from other apps.
Browse files Browse the repository at this point in the history
Enables handling and receiving shares from other apps, for any
type of content (text, images, and other files).

Creates a native RN module `SharingModule` to handle the Android
part of this feature. 'ReceiveShareActivity.kt' parses the
received shared data and handles activating the JS part of the
codebase - either by sending an event to an already-running app,
or launching `MainActivity` with some initial shared data via
the native module.

Registers a new root component, `SharingRoot`. It is a dummy
component that does nothing, and `ReceiveShareActivity.kt`.
It exists because 'Sharing' is linked with launching an activity
in the Android ecosystem. But we don't always want to launch
`MainActivity` when receiving a share because it may cause two
instances of it to be opened simultaneously. We can't check
whether the app is running before an activity launches.
So, we launch this dummy component, then process the events,
identify whether the app is running or not, and handle that
as mentioned in the paragraph above, and then quickly kill this
dummy Activity. All of this happens fast enough that the
component does not even get time to render, so it's seamless.

Closes  #M117.
  • Loading branch information
agrawal-d committed May 28, 2020
1 parent 0045c15 commit a277e5d
Show file tree
Hide file tree
Showing 16 changed files with 817 additions and 2 deletions.
23 changes: 23 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@
</intent-filter>
</activity>

<activity
android:name=".sharing.ReceiveShareActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:label="@string/app_name"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>

<!-- When `react-native run-android` learns from the decoy `package`
attribute in our comment above that the application ID is
`com.zulipmobile.debug`, it then tries to start an activity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.zulipmobile.notifications.ConversationMap;
import com.zulipmobile.notifications.FCMPushNotifications;
import com.zulipmobile.notifications.NotificationsPackage;
import com.zulipmobile.sharing.SharingPackage;

public class MainApplication extends Application implements ReactApplication {
private final ReactModuleRegistryProvider mModuleRegistryProvider = new ReactModuleRegistryProvider(new BasePackageList().getPackageList(), null);
Expand Down Expand Up @@ -58,6 +59,7 @@ protected List<ReactPackage> getPackages() {
new RNDeviceInfo(),
new ZulipNativePackage(),
new NotificationsPackage(),
new SharingPackage(),
new ModuleRegistryAdapter(mModuleRegistryProvider)
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.zulipmobile.sharing

import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.webkit.WebView
import androidx.annotation.Nullable
import com.facebook.react.ReactActivity
import com.facebook.react.ReactApplication
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.Arguments
import com.zulipmobile.notifications.*

const val TAG = "ZulipReceiveShare"

class ReceiveShareActivity : ReactActivity() {

/**
* Returns the name of the main component registered from JavaScript.
* This is used to schedule rendering of the component.
*/
override fun getMainComponentName(): String? {
return "SharingRoot"
}

private fun sendEvent(reactContext: ReactContext,
eventName: String,
@Nullable params: WritableMap) {
Log.d(TAG, "Sending event with shared data")
emit(reactContext, eventName, params)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WebView.setWebContentsDebuggingEnabled(true)
if (intent?.action == Intent.ACTION_SEND) {
handleSend(intent)
}
finish()
}

private fun handleSend(intent: Intent) {
val application = application as ReactApplication
val host = application.reactNativeHost
val reactContext = host.tryGetReactInstanceManager()?.currentReactContext
val params: WritableMap
try {
params = getParamsFromIntent(intent)
} catch (e: ShareParamsParseException) {
Log.w(TAG, "Ignoring malformed share Intent: ${e.message}")
return
}

val appStatus = reactContext?.appStatus
when (appStatus) {
null, ReactAppStatus.NOT_RUNNING ->
// Either there's no JS environment running, or we haven't yet reached
// foreground. Expect the app to check initialSharedData on launch.
SharingModule.initialSharedData = params
ReactAppStatus.BACKGROUND, ReactAppStatus.FOREGROUND ->
// JS is running and has already reached foreground.It won't check
// initialSharedData again, but it will see a shareReceived event.
sendEvent(reactContext, "shareReceived", params)
}
when (appStatus) {
null, ReactAppStatus.NOT_RUNNING, ReactAppStatus.BACKGROUND ->
launchMainActivity(application as Context)
ReactAppStatus.FOREGROUND -> Unit
}
}

private fun getParamsFromIntent(intent: Intent): WritableMap {
val params = Arguments.createMap()
when {
"text/plain" == intent.type -> {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
params.putString("type", "text")
params.putString("sharedText", sharedText)
}
intent.type?.startsWith("image/") == true -> {
val url = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
?: throw ShareParamsParseException("Could not extract URL from Image Intent")
params.putString("type", "image")
params.putString("sharedImageUrl", url.toString())
}
else -> {
val url = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
?: throw ShareParamsParseException("Could not extract URL from File Intent")
params.putString("type", "file")
params.putString("sharedFileUrl", url.toString())
}
}
return params
}

override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val intent = Intent("onConfigurationChanged")
intent.putExtra("newConfig", newConfig)
this.sendBroadcast(intent)
}
}

class ShareParamsParseException(errorMessage: String) : RuntimeException(errorMessage)
24 changes: 24 additions & 0 deletions android/app/src/main/java/com/zulipmobile/sharing/SharingModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.zulipmobile.sharing

import com.facebook.react.bridge.*

internal class SharingModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {

override fun getName(): String {
return "Sharing"
}

@ReactMethod
fun getInitialSharedContent(promise: Promise) {
if (null == initialSharedData) {
promise.resolve(null)
} else {
promise.resolve(initialSharedData)
}

}

companion object {
var initialSharedData: WritableMap? = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.zulipmobile.sharing


import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

import java.util.ArrayList

class SharingPackage : ReactPackage {

override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}

override fun createNativeModules(
reactContext: ReactApplicationContext): List<NativeModule> {
val modules = ArrayList<NativeModule>()

modules.add(SharingModule(reactContext))

return modules
}

}
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* @flow strict-local */
import { AppRegistry } from 'react-native';
import ZulipMobile from './src/ZulipMobile';
import SharingRoot from './src/sharing/SharingRoot';

AppRegistry.registerComponent('ZulipMobile', () => ZulipMobile);
AppRegistry.registerComponent('SharingRoot', () => SharingRoot);
6 changes: 5 additions & 1 deletion src/boot/AppEventHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
NotificationListener,
notificationOnAppActive,
} from '../notification';
import { ShareReceivedListener, handleInitialShare } from '../sharing';
import { appOnline, appOrientation, initSafeAreaInsets } from '../actions';
import PresenceHeartbeat from '../presence/PresenceHeartbeat';

Expand Down Expand Up @@ -98,6 +99,7 @@ class AppEventHandlers extends PureComponent<Props> {
};

notificationListener = new NotificationListener(this.props.dispatch);
shareListener = new ShareReceivedListener(this.props.dispatch);

handleMemoryWarning = () => {
// Release memory here
Expand All @@ -106,7 +108,7 @@ class AppEventHandlers extends PureComponent<Props> {
componentDidMount() {
const { dispatch } = this.props;
handleInitialNotification(dispatch);

handleInitialShare(dispatch);
this.netInfoDisconnectCallback = NetInfo.addEventListener(this.handleConnectivityChange);
AppState.addEventListener('change', this.handleAppStateChange);
AppState.addEventListener('memoryWarning', this.handleMemoryWarning);
Expand All @@ -116,6 +118,7 @@ class AppEventHandlers extends PureComponent<Props> {
// $FlowFixMe: libdef wrongly says callback's parameter is optional
Orientation.addOrientationListener(this.handleOrientationChange);
this.notificationListener.start();
this.shareListener.start();
}

componentWillUnmount() {
Expand All @@ -128,6 +131,7 @@ class AppEventHandlers extends PureComponent<Props> {
// $FlowFixMe: libdef wrongly says callback's parameter is optional
Orientation.removeOrientationListener(this.handleOrientationChange);
this.notificationListener.stop();
this.shareListener.stop();
}

render() {
Expand Down
2 changes: 2 additions & 0 deletions src/nav/AppNavigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import TopicListScreen from '../topics/TopicListScreen';
import EmojiPickerScreen from '../emoji/EmojiPickerScreen';
import LegalScreen from '../settings/LegalScreen';
import UserStatusScreen from '../user-status/UserStatusScreen';
import SharingScreen from '../sharing/SharingScreen';

export default createStackNavigator(
// $FlowFixMe react-navigation types :-/ -- see a36814e80
Expand Down Expand Up @@ -65,6 +66,7 @@ export default createStackNavigator(
notifications: { screen: NotificationsScreen },
legal: { screen: LegalScreen },
'user-status': { screen: UserStatusScreen },
sharing: { screen: SharingScreen },
},
{
initialRouteName: 'main',
Expand Down
4 changes: 4 additions & 0 deletions src/nav/navActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
Narrow,
UserOrBot,
ApiResponseServerSettings,
SharedData,
} from '../types';
import { getSameRoutesCount } from '../selectors';

Expand Down Expand Up @@ -103,3 +104,6 @@ export const navigateToLegal = (): NavigationAction => StackActions.push({ route

export const navigateToUserStatus = (): NavigationAction =>
StackActions.push({ routeName: 'user-status' });

export const navigateToSharing = (sharedData: SharedData): NavigationAction =>
StackActions.push({ routeName: 'sharing', params: { sharedData } });
5 changes: 5 additions & 0 deletions src/nav/navReducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ const rehydrate = (state, action) => {
return getStateForRoute('loading');
}

// Dont switch to main UI if sharing screen is on.
if (state.routes.find(route => route.routeName === 'sharing')) {
return state;
}

// Great: we have an active, logged-in account, and server data for it.
// Show the main UI.
return getStateForRoute('main');
Expand Down
44 changes: 44 additions & 0 deletions src/sharing/ChooseRecipientsScreen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* @flow strict-local */
import React, { PureComponent } from 'react';
import type { User, Dispatch } from '../types';
import { connect } from '../react-redux';
import { Screen } from '../common';
import UserPickerCard from '../user-picker/UserPickerCard';

type Props = $ReadOnly<{|
dispatch: Dispatch,
onComplete: (User[]) => void,
|}>;

type State = {|
filter: string,
|};

class ChooseRecipientsScreen extends PureComponent<Props, State> {
state = {
filter: '',
};

handleFilterChange = (filter: string) => this.setState({ filter });

handleComplete = (selected: Array<User>) => {
const { onComplete } = this.props;
onComplete(selected);
};

render() {
const { filter } = this.state;
return (
<Screen
search
scrollEnabled={false}
searchBarOnChange={this.handleFilterChange}
canGoBack={false}
>
<UserPickerCard filter={filter} onComplete={this.handleComplete} />
</Screen>
);
}
}

export default connect<{||}, _, _>()(ChooseRecipientsScreen);
18 changes: 18 additions & 0 deletions src/sharing/SharingRoot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* @flow strict-local */
import React from 'react';
import { View } from 'react-native';

/**
* This is a dummy component to by-pass some weird quirks of Android Activity
* launches in a React Native context. The native code in
* `ReceiveShareActivity.kt` finishes this activity quickly, after either
* i) Sending events to an already open app in the background
* ii) Launching `MainActivity` with some initial share data.
*/
class SharingRoot extends React.Component<{||}> {
render() {
return <View />;
}
}

export default SharingRoot;
Loading

0 comments on commit a277e5d

Please sign in to comment.