feat: On Android, you can now share text & files from other apps directly into Standard Notes (#2352)

This commit is contained in:
Aman Harwara
2023-07-12 00:16:32 +05:30
committed by GitHub
parent 1f5354c816
commit 3dc179c7b0
16 changed files with 535 additions and 15 deletions

View File

@@ -43,6 +43,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
</application>

View File

@@ -1,6 +1,7 @@
package com.standardnotes;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.content.res.Configuration;
@@ -54,4 +55,10 @@ public class MainActivity extends ReactActivity {
public void invokeDefaultOnBackPressed() {
moveTaskToBack(true);
}
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
}
}

View File

@@ -39,6 +39,7 @@ public class MainApplication extends Application implements ReactApplication {
packages.add(new Fido2ApiPackage());
packages.add(new CustomWebViewPackage());
packages.add(new ReceiveSharingIntentPackage());
return packages;
}

View File

@@ -0,0 +1,171 @@
// Adapted from
// https://github.com/ajith-ab/react-native-receive-sharing-intent
package com.standardnotes;
import android.annotation.SuppressLint;
import android.app.Application;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableNativeMap;
import java.util.ArrayList;
import java.util.Objects;
public class ReceiveSharingIntentHelper {
private Context context;
public ReceiveSharingIntentHelper(Application context) {
this.context = context;
}
public void sendFileNames(Context context, Intent intent, Promise promise) {
try {
String action = intent.getAction();
String type = intent.getType();
if (type == null) {
return;
}
if (!type.startsWith("text") && (Objects.equals(action, Intent.ACTION_SEND) || Objects.equals(action, Intent.ACTION_SEND_MULTIPLE))) {
WritableMap files = getMediaUris(intent, context);
if (files == null) return;
promise.resolve(files);
}
else if (type.startsWith("text") && Objects.equals(action, Intent.ACTION_SEND)) {
String text = null;
String subject = null;
try {
text = intent.getStringExtra(Intent.EXTRA_TEXT);
subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
} catch (Exception ignored) {}
WritableMap files;
if (text == null) {
files = getMediaUris(intent, context);
if (files == null) return;
}
else {
files = new WritableNativeMap();
WritableMap file = new WritableNativeMap();
file.putString("contentUri", null);
file.putString("fileName", null);
file.putString("extension", null);
if (text.startsWith("http")) {
file.putString("weblink", text);
file.putString("text", null);
} else {
file.putString("weblink", null);
file.putString("text", text);
}
file.putString("subject", subject);
files.putMap("0", file);
}
promise.resolve(files);
}
else if (Objects.equals(action, Intent.ACTION_VIEW)) {
String link = intent.getDataString();
WritableMap files = new WritableNativeMap();
WritableMap file = new WritableNativeMap();
file.putString("contentUri", null);
file.putString("mimeType", null);
file.putString("text", null);
file.putString("weblink", link);
file.putString("fileName", null);
file.putString("extension", null);
files.putMap("0", file);
promise.resolve(files);
}
else if (Objects.equals(action, Intent.ACTION_PROCESS_TEXT)) {
String text = null;
try {
text = intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT);
} catch (Exception ignored) {
}
WritableMap files = new WritableNativeMap();
WritableMap file = new WritableNativeMap();
file.putString("contentUri", null);
file.putString("fileName", null);
file.putString("extension", null);
file.putString("weblink", null);
file.putString("text", text);
files.putMap("0", file);
promise.resolve(files);
}
else {
promise.reject("error", "Invalid file type.");
}
} catch (Exception e) {
promise.reject("error", e.toString());
}
}
@SuppressLint("Range")
public WritableMap getMediaUris(Intent intent, Context context) {
if (intent == null) return null;
String subject = null;
try {
subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
} catch (Exception ignored) {
}
WritableMap files = new WritableNativeMap();
if (Objects.equals(intent.getAction(), Intent.ACTION_SEND)) {
WritableMap file = new WritableNativeMap();
Uri contentUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (contentUri == null) return null;
ContentResolver contentResolver = context.getContentResolver();
file.putString("mimeType", contentResolver.getType(contentUri));
Cursor queryResult = contentResolver.query(contentUri, null, null, null, null);
queryResult.moveToFirst();
file.putString("fileName", queryResult.getString(queryResult.getColumnIndex(OpenableColumns.DISPLAY_NAME)));
file.putString("contentUri", contentUri.toString());
file.putString("text", null);
file.putString("weblink", null);
file.putString("subject", subject);
files.putMap("0", file);
queryResult.close();
} else if (Objects.equals(intent.getAction(), Intent.ACTION_SEND_MULTIPLE)) {
ArrayList<Uri> contentUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (contentUris != null) {
int index = 0;
for (Uri uri : contentUris) {
WritableMap file = new WritableNativeMap();
ContentResolver contentResolver = context.getContentResolver();
// Based on https://developer.android.com/training/secure-file-sharing/retrieve-info
file.putString("mimeType", contentResolver.getType(uri));
Cursor queryResult = contentResolver.query(uri, null, null, null, null);
queryResult.moveToFirst();
file.putString("fileName", queryResult.getString(queryResult.getColumnIndex(OpenableColumns.DISPLAY_NAME)));
file.putString("contentUri", uri.toString());
file.putString("text", null);
file.putString("weblink", null);
file.putString("subject", subject);
files.putMap(Integer.toString(index), file);
queryResult.close();
index++;
}
}
}
return files;
}
public void clearFileNames(Intent intent) {
String type = intent.getType();
if (type == null) return;
if (type.startsWith("text")) {
intent.removeExtra(Intent.EXTRA_TEXT);
} else if (type.startsWith("image") || type.startsWith("video") || type.startsWith("application")) {
intent.removeExtra(Intent.EXTRA_STREAM);
}
}
}

View File

@@ -0,0 +1,64 @@
// Adapted from
// https://github.com/ajith-ab/react-native-receive-sharing-intent
package com.standardnotes;
import android.app.Activity;
import android.app.Application;
import android.content.Intent;
import android.os.Build;
import androidx.annotation.RequiresApi;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
public class ReceiveSharingIntentModule extends ReactContextBaseJavaModule {
public final String Log_Tag = "ReceiveSharingIntent";
private final ReactApplicationContext reactContext;
private ReceiveSharingIntentHelper receiveSharingIntentHelper;
public ReceiveSharingIntentModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
Application applicationContext = (Application) reactContext.getApplicationContext();
receiveSharingIntentHelper = new ReceiveSharingIntentHelper(applicationContext);
}
protected void onNewIntent(Intent intent) {
Activity mActivity = getCurrentActivity();
if(mActivity == null) { return; }
mActivity.setIntent(intent);
}
@RequiresApi(api = Build.VERSION_CODES.KITKAT)
@ReactMethod
public void getFileNames(Promise promise){
Activity mActivity = getCurrentActivity();
if(mActivity == null) { return; }
Intent intent = mActivity.getIntent();
if(intent == null) { return; }
receiveSharingIntentHelper.sendFileNames(reactContext, intent, promise);
mActivity.setIntent(null);
}
@ReactMethod
public void clearFileNames(){
Activity mActivity = getCurrentActivity();
if(mActivity == null) { return; }
Intent intent = mActivity.getIntent();
if(intent == null) { return; }
receiveSharingIntentHelper.clearFileNames(intent);
}
@Override
public String getName() {
return "ReceiveSharingIntent";
}
}

View File

@@ -0,0 +1,27 @@
package com.standardnotes;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import com.standardnotes.ReceiveSharingIntentModule;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class ReceiveSharingIntentPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new ReceiveSharingIntentModule(reactContext));
return modules;
}
}

View File

@@ -0,0 +1,4 @@
import { requireNativeComponent } from 'react-native'
import { NativeWebViewAndroid } from 'react-native-webview/lib/WebViewTypes'
export default requireNativeComponent('CustomWebView') as NativeWebViewAndroid

View File

@@ -2,6 +2,7 @@ import SNReactNative from '@standardnotes/react-native-utils'
import {
AppleIAPProductId,
AppleIAPReceipt,
ApplicationEvent,
ApplicationIdentifier,
DatabaseKeysLoadChunkResponse,
DatabaseLoadOptions,
@@ -57,11 +58,13 @@ export enum MobileDeviceEvent {
}
type MobileDeviceEventHandler = (event: MobileDeviceEvent) => void
type ApplicationEventHandler = (event: ApplicationEvent) => void
export class MobileDevice implements MobileDeviceInterface {
environment: Environment.Mobile = Environment.Mobile
platform: SNPlatform.Ios | SNPlatform.Android = Platform.OS === 'ios' ? SNPlatform.Ios : SNPlatform.Android
private eventObservers: MobileDeviceEventHandler[] = []
private applicationEventObservers: ApplicationEventHandler[] = []
private mobileDeviceEventObservers: MobileDeviceEventHandler[] = []
public isDarkMode = false
public statusBarBgColor: string | undefined
private componentUrls: Map<UuidString, string> = new Map()
@@ -346,13 +349,23 @@ export class MobileDevice implements MobileDeviceInterface {
}
performSoftReset() {
this.notifyEvent(MobileDeviceEvent.RequestsWebViewReload)
this.notifyMobileDeviceEvent(MobileDeviceEvent.RequestsWebViewReload)
}
addMobileWebEventReceiver(handler: MobileDeviceEventHandler): () => void {
this.eventObservers.push(handler)
addMobileDeviceEventReceiver(handler: MobileDeviceEventHandler): () => void {
this.mobileDeviceEventObservers.push(handler)
const thislessObservers = this.eventObservers
const thislessObservers = this.mobileDeviceEventObservers
return () => {
removeFromArray(thislessObservers, handler)
}
}
addApplicationEventReceiver(handler: ApplicationEventHandler): () => void {
this.applicationEventObservers.push(handler)
const thislessObservers = this.applicationEventObservers
return () => {
removeFromArray(thislessObservers, handler)
@@ -373,8 +386,14 @@ export class MobileDevice implements MobileDeviceInterface {
StatusBar.setBarStyle(this.isDarkMode ? 'light-content' : 'dark-content', animated)
}
private notifyEvent(event: MobileDeviceEvent): void {
for (const handler of this.eventObservers) {
private notifyMobileDeviceEvent(event: MobileDeviceEvent): void {
for (const handler of this.mobileDeviceEventObservers) {
handler(event)
}
}
notifyApplicationEvent(event: ApplicationEvent): void {
for (const handler of this.applicationEventObservers) {
handler(event)
}
}

View File

@@ -1,16 +1,16 @@
import { ReactNativeToWebEvent } from '@standardnotes/snjs'
import { ApplicationEvent, ReactNativeToWebEvent } from '@standardnotes/snjs'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Keyboard, Platform, requireNativeComponent, Text, View } from 'react-native'
import { Button, Keyboard, Platform, Text, View } from 'react-native'
import VersionInfo from 'react-native-version-info'
import { WebView, WebViewMessageEvent } from 'react-native-webview'
import { NativeWebViewAndroid, OnShouldStartLoadWithRequest } from 'react-native-webview/lib/WebViewTypes'
import { OnShouldStartLoadWithRequest } from 'react-native-webview/lib/WebViewTypes'
import { AndroidBackHandlerService } from './AndroidBackHandlerService'
import { AppStateObserverService } from './AppStateObserverService'
import { ColorSchemeObserverService } from './ColorSchemeObserverService'
import CustomAndroidWebView from './CustomAndroidWebView'
import { MobileDevice, MobileDeviceEvent } from './Lib/MobileDevice'
import { IsDev } from './Lib/Utils'
const CustomWebView: NativeWebViewAndroid = requireNativeComponent('CustomWebView')
import { ReceivedSharedItemsHandler } from './ReceivedSharedItemsHandler'
const LoggingEnabled = IsDev
@@ -110,7 +110,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
}, [webViewRef, stateService, device, androidBackHandlerService, colorSchemeService])
useEffect(() => {
const observer = device.addMobileWebEventReceiver((event) => {
const observer = device.addMobileDeviceEventReceiver((event) => {
if (event === MobileDeviceEvent.RequestsWebViewReload) {
destroyAndReload()
}
@@ -283,6 +283,21 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
const requireInlineMediaPlaybackForMomentsFeature = true
const requireMediaUserInteractionForMomentsFeature = false
const receivedSharedItemsHandler = useRef(new ReceivedSharedItemsHandler(webViewRef))
useEffect(() => {
const receivedSharedItemsHandlerInstance = receivedSharedItemsHandler.current
return () => {
receivedSharedItemsHandlerInstance.deinit()
}
}, [])
useEffect(() => {
return device.addApplicationEventReceiver((event) => {
if (event === ApplicationEvent.Launched) {
receivedSharedItemsHandler.current.setIsApplicationLaunched(true)
}
})
}, [device])
if (showAndroidWebviewUpdatePrompt) {
return (
<View
@@ -357,7 +372,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
overScrollMode="never"
nativeConfig={Platform.select({
android: {
component: CustomWebView,
component: CustomAndroidWebView,
},
})}
/>

View File

@@ -0,0 +1,132 @@
import { ReactNativeToWebEvent } from '@standardnotes/snjs'
import { RefObject } from 'react'
import { AppState, NativeEventSubscription, NativeModules } from 'react-native'
import { readFile } from 'react-native-fs'
import WebView from 'react-native-webview'
const { ReceiveSharingIntent } = NativeModules
type ReceivedItem = {
contentUri?: string | null
fileName?: string | null
mimeType?: string | null
extension?: string | null
text?: string | null
weblink?: string | null
subject?: string | null
}
type ReceivedFile = ReceivedItem & {
contentUri: string
mimeType: string
}
type ReceivedWeblink = ReceivedItem & {
weblink: string
}
type ReceivedText = ReceivedItem & {
text: string
}
const isReceivedFile = (item: ReceivedItem): item is ReceivedFile => {
return !!item.contentUri && !!item.mimeType
}
const isReceivedWeblink = (item: ReceivedItem): item is ReceivedWeblink => {
return !!item.weblink
}
const isReceivedText = (item: ReceivedItem): item is ReceivedText => {
return !!item.text
}
export class ReceivedSharedItemsHandler {
private appStateEventSub: NativeEventSubscription | null = null
private receivedItemsQueue: ReceivedItem[] = []
private isApplicationLaunched = false
constructor(private webViewRef: RefObject<WebView>) {
this.registerNativeEventSub()
}
setIsApplicationLaunched = (isApplicationLaunched: boolean) => {
this.isApplicationLaunched = isApplicationLaunched
if (isApplicationLaunched) {
this.handleItemsQueue().catch(console.error)
}
}
deinit() {
this.receivedItemsQueue = []
this.appStateEventSub?.remove()
}
private registerNativeEventSub = () => {
this.appStateEventSub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
ReceiveSharingIntent.getFileNames()
.then(async (filesObject: Record<string, ReceivedItem>) => {
const items = Object.values(filesObject)
this.receivedItemsQueue.push(...items)
if (this.isApplicationLaunched) {
this.handleItemsQueue().catch(console.error)
}
})
.then(() => ReceiveSharingIntent.clearFileNames())
.catch(console.error)
}
})
}
handleItemsQueue = async () => {
if (!this.receivedItemsQueue.length) {
return
}
const item = this.receivedItemsQueue.shift()
if (!item) {
return
}
if (isReceivedFile(item)) {
const data = await readFile(item.contentUri, 'base64')
const file = {
name: item.fileName || item.contentUri,
data,
mimeType: item.mimeType,
}
this.webViewRef.current?.postMessage(
JSON.stringify({
reactNativeEvent: ReactNativeToWebEvent.ReceivedFile,
messageType: 'event',
messageData: file,
}),
)
} else if (isReceivedWeblink(item)) {
this.webViewRef.current?.postMessage(
JSON.stringify({
reactNativeEvent: ReactNativeToWebEvent.ReceivedText,
messageType: 'event',
messageData: {
title: item.subject || item.weblink,
text: item.weblink,
},
}),
)
} else if (isReceivedText(item)) {
this.webViewRef.current?.postMessage(
JSON.stringify({
reactNativeEvent: ReactNativeToWebEvent.ReceivedText,
messageType: 'event',
messageData: {
text: item.text,
},
}),
)
}
this.handleItemsQueue().catch(console.error)
}
}