feat: On Android, you can now share text & files from other apps directly into Standard Notes (#2352)
This commit is contained in:
@@ -43,6 +43,16 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.standardnotes;
|
package com.standardnotes;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.content.res.Configuration;
|
import android.content.res.Configuration;
|
||||||
@@ -54,4 +55,10 @@ public class MainActivity extends ReactActivity {
|
|||||||
public void invokeDefaultOnBackPressed() {
|
public void invokeDefaultOnBackPressed() {
|
||||||
moveTaskToBack(true);
|
moveTaskToBack(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class MainApplication extends Application implements ReactApplication {
|
|||||||
|
|
||||||
packages.add(new Fido2ApiPackage());
|
packages.add(new Fido2ApiPackage());
|
||||||
packages.add(new CustomWebViewPackage());
|
packages.add(new CustomWebViewPackage());
|
||||||
|
packages.add(new ReceiveSharingIntentPackage());
|
||||||
|
|
||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/mobile/src/CustomAndroidWebView.tsx
Normal file
4
packages/mobile/src/CustomAndroidWebView.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { requireNativeComponent } from 'react-native'
|
||||||
|
import { NativeWebViewAndroid } from 'react-native-webview/lib/WebViewTypes'
|
||||||
|
|
||||||
|
export default requireNativeComponent('CustomWebView') as NativeWebViewAndroid
|
||||||
@@ -2,6 +2,7 @@ import SNReactNative from '@standardnotes/react-native-utils'
|
|||||||
import {
|
import {
|
||||||
AppleIAPProductId,
|
AppleIAPProductId,
|
||||||
AppleIAPReceipt,
|
AppleIAPReceipt,
|
||||||
|
ApplicationEvent,
|
||||||
ApplicationIdentifier,
|
ApplicationIdentifier,
|
||||||
DatabaseKeysLoadChunkResponse,
|
DatabaseKeysLoadChunkResponse,
|
||||||
DatabaseLoadOptions,
|
DatabaseLoadOptions,
|
||||||
@@ -57,11 +58,13 @@ export enum MobileDeviceEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MobileDeviceEventHandler = (event: MobileDeviceEvent) => void
|
type MobileDeviceEventHandler = (event: MobileDeviceEvent) => void
|
||||||
|
type ApplicationEventHandler = (event: ApplicationEvent) => void
|
||||||
|
|
||||||
export class MobileDevice implements MobileDeviceInterface {
|
export class MobileDevice implements MobileDeviceInterface {
|
||||||
environment: Environment.Mobile = Environment.Mobile
|
environment: Environment.Mobile = Environment.Mobile
|
||||||
platform: SNPlatform.Ios | SNPlatform.Android = Platform.OS === 'ios' ? SNPlatform.Ios : SNPlatform.Android
|
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 isDarkMode = false
|
||||||
public statusBarBgColor: string | undefined
|
public statusBarBgColor: string | undefined
|
||||||
private componentUrls: Map<UuidString, string> = new Map()
|
private componentUrls: Map<UuidString, string> = new Map()
|
||||||
@@ -346,13 +349,23 @@ export class MobileDevice implements MobileDeviceInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
performSoftReset() {
|
performSoftReset() {
|
||||||
this.notifyEvent(MobileDeviceEvent.RequestsWebViewReload)
|
this.notifyMobileDeviceEvent(MobileDeviceEvent.RequestsWebViewReload)
|
||||||
}
|
}
|
||||||
|
|
||||||
addMobileWebEventReceiver(handler: MobileDeviceEventHandler): () => void {
|
addMobileDeviceEventReceiver(handler: MobileDeviceEventHandler): () => void {
|
||||||
this.eventObservers.push(handler)
|
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 () => {
|
return () => {
|
||||||
removeFromArray(thislessObservers, handler)
|
removeFromArray(thislessObservers, handler)
|
||||||
@@ -373,8 +386,14 @@ export class MobileDevice implements MobileDeviceInterface {
|
|||||||
StatusBar.setBarStyle(this.isDarkMode ? 'light-content' : 'dark-content', animated)
|
StatusBar.setBarStyle(this.isDarkMode ? 'light-content' : 'dark-content', animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyEvent(event: MobileDeviceEvent): void {
|
private notifyMobileDeviceEvent(event: MobileDeviceEvent): void {
|
||||||
for (const handler of this.eventObservers) {
|
for (const handler of this.mobileDeviceEventObservers) {
|
||||||
|
handler(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyApplicationEvent(event: ApplicationEvent): void {
|
||||||
|
for (const handler of this.applicationEventObservers) {
|
||||||
handler(event)
|
handler(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 VersionInfo from 'react-native-version-info'
|
||||||
import { WebView, WebViewMessageEvent } from 'react-native-webview'
|
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 { AndroidBackHandlerService } from './AndroidBackHandlerService'
|
||||||
import { AppStateObserverService } from './AppStateObserverService'
|
import { AppStateObserverService } from './AppStateObserverService'
|
||||||
import { ColorSchemeObserverService } from './ColorSchemeObserverService'
|
import { ColorSchemeObserverService } from './ColorSchemeObserverService'
|
||||||
|
import CustomAndroidWebView from './CustomAndroidWebView'
|
||||||
import { MobileDevice, MobileDeviceEvent } from './Lib/MobileDevice'
|
import { MobileDevice, MobileDeviceEvent } from './Lib/MobileDevice'
|
||||||
import { IsDev } from './Lib/Utils'
|
import { IsDev } from './Lib/Utils'
|
||||||
|
import { ReceivedSharedItemsHandler } from './ReceivedSharedItemsHandler'
|
||||||
const CustomWebView: NativeWebViewAndroid = requireNativeComponent('CustomWebView')
|
|
||||||
|
|
||||||
const LoggingEnabled = IsDev
|
const LoggingEnabled = IsDev
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
|
|||||||
}, [webViewRef, stateService, device, androidBackHandlerService, colorSchemeService])
|
}, [webViewRef, stateService, device, androidBackHandlerService, colorSchemeService])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = device.addMobileWebEventReceiver((event) => {
|
const observer = device.addMobileDeviceEventReceiver((event) => {
|
||||||
if (event === MobileDeviceEvent.RequestsWebViewReload) {
|
if (event === MobileDeviceEvent.RequestsWebViewReload) {
|
||||||
destroyAndReload()
|
destroyAndReload()
|
||||||
}
|
}
|
||||||
@@ -283,6 +283,21 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
|
|||||||
const requireInlineMediaPlaybackForMomentsFeature = true
|
const requireInlineMediaPlaybackForMomentsFeature = true
|
||||||
const requireMediaUserInteractionForMomentsFeature = false
|
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) {
|
if (showAndroidWebviewUpdatePrompt) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -357,7 +372,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
|
|||||||
overScrollMode="never"
|
overScrollMode="never"
|
||||||
nativeConfig={Platform.select({
|
nativeConfig={Platform.select({
|
||||||
android: {
|
android: {
|
||||||
component: CustomWebView,
|
component: CustomAndroidWebView,
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
132
packages/mobile/src/ReceivedSharedItemsHandler.ts
Normal file
132
packages/mobile/src/ReceivedSharedItemsHandler.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Environment, Platform, RawKeychainValue } from '@standardnotes/models'
|
|||||||
import { AppleIAPProductId } from './../Subscription/AppleIAPProductId'
|
import { AppleIAPProductId } from './../Subscription/AppleIAPProductId'
|
||||||
import { DeviceInterface } from './DeviceInterface'
|
import { DeviceInterface } from './DeviceInterface'
|
||||||
import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt'
|
import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt'
|
||||||
|
import { ApplicationEvent } from '../Event/ApplicationEvent'
|
||||||
|
|
||||||
export interface MobileDeviceInterface extends DeviceInterface {
|
export interface MobileDeviceInterface extends DeviceInterface {
|
||||||
environment: Environment.Mobile
|
environment: Environment.Mobile
|
||||||
@@ -27,4 +28,5 @@ export interface MobileDeviceInterface extends DeviceInterface {
|
|||||||
getColorScheme(): Promise<'light' | 'dark' | null | undefined>
|
getColorScheme(): Promise<'light' | 'dark' | null | undefined>
|
||||||
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined>
|
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined>
|
||||||
authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null>
|
authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null>
|
||||||
|
notifyApplicationEvent(event: ApplicationEvent): void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export enum ReactNativeToWebEvent {
|
|||||||
KeyboardFrameDidChange = 'KeyboardFrameDidChange',
|
KeyboardFrameDidChange = 'KeyboardFrameDidChange',
|
||||||
KeyboardWillShow = 'KeyboardWillShow',
|
KeyboardWillShow = 'KeyboardWillShow',
|
||||||
KeyboardWillHide = 'KeyboardWillHide',
|
KeyboardWillHide = 'KeyboardWillHide',
|
||||||
|
ReceivedFile = 'ReceivedFile',
|
||||||
|
ReceivedText = 'ReceivedText',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface WebApplicationInterface extends ApplicationInterface {
|
|||||||
handleMobileColorSchemeChangeEvent(): void
|
handleMobileColorSchemeChangeEvent(): void
|
||||||
handleMobileKeyboardWillChangeFrameEvent(frame: { height: number; contentHeight: number }): void
|
handleMobileKeyboardWillChangeFrameEvent(frame: { height: number; contentHeight: number }): void
|
||||||
handleMobileKeyboardDidChangeFrameEvent(frame: { height: number; contentHeight: number }): void
|
handleMobileKeyboardDidChangeFrameEvent(frame: { height: number; contentHeight: number }): void
|
||||||
|
handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void
|
||||||
|
handleReceivedTextEvent(item: { text: string; title?: string }): Promise<void>
|
||||||
isNativeMobileWeb(): boolean
|
isNativeMobileWeb(): boolean
|
||||||
mobileDevice(): MobileDeviceInterface
|
mobileDevice(): MobileDeviceInterface
|
||||||
handleAndroidBackButtonPressed(): void
|
handleAndroidBackButtonPressed(): void
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ import {
|
|||||||
BackupServiceInterface,
|
BackupServiceInterface,
|
||||||
InternalFeatureService,
|
InternalFeatureService,
|
||||||
InternalFeatureServiceInterface,
|
InternalFeatureServiceInterface,
|
||||||
|
NoteContent,
|
||||||
|
SNNote,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { makeObservable, observable } from 'mobx'
|
import { makeObservable, observable } from 'mobx'
|
||||||
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
|
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
|
||||||
import { PanelResizedData } from '@/Types/PanelResizedData'
|
import { PanelResizedData } from '@/Types/PanelResizedData'
|
||||||
import { isAndroid, isDesktopApplication, isDev, isIOS } from '@/Utils'
|
import { getBlobFromBase64, isAndroid, isDesktopApplication, isDev, isIOS } from '@/Utils'
|
||||||
import { DesktopManager } from './Device/DesktopManager'
|
import { DesktopManager } from './Device/DesktopManager'
|
||||||
import {
|
import {
|
||||||
ArchiveManager,
|
ArchiveManager,
|
||||||
@@ -66,6 +68,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
|||||||
private readonly mobileWebReceiver?: MobileWebReceiver
|
private readonly mobileWebReceiver?: MobileWebReceiver
|
||||||
private readonly androidBackHandler?: AndroidBackHandler
|
private readonly androidBackHandler?: AndroidBackHandler
|
||||||
private readonly visibilityObserver?: VisibilityObserver
|
private readonly visibilityObserver?: VisibilityObserver
|
||||||
|
private readonly mobileAppEventObserver?: () => void
|
||||||
|
|
||||||
public readonly devMode?: DevMode
|
public readonly devMode?: DevMode
|
||||||
|
|
||||||
@@ -137,6 +140,9 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
|||||||
if (this.isNativeMobileWeb()) {
|
if (this.isNativeMobileWeb()) {
|
||||||
this.mobileWebReceiver = new MobileWebReceiver(this)
|
this.mobileWebReceiver = new MobileWebReceiver(this)
|
||||||
this.androidBackHandler = new AndroidBackHandler()
|
this.androidBackHandler = new AndroidBackHandler()
|
||||||
|
this.mobileAppEventObserver = this.addEventObserver(async (event) => {
|
||||||
|
this.mobileDevice().notifyApplicationEvent(event)
|
||||||
|
})
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log = (...args) => {
|
console.log = (...args) => {
|
||||||
@@ -186,6 +192,11 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
|||||||
this.visibilityObserver.deinit()
|
this.visibilityObserver.deinit()
|
||||||
;(this.visibilityObserver as unknown) = undefined
|
;(this.visibilityObserver as unknown) = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.mobileAppEventObserver) {
|
||||||
|
this.mobileAppEventObserver()
|
||||||
|
;(this.mobileAppEventObserver as unknown) = undefined
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error while deiniting application', error)
|
console.error('Error while deiniting application', error)
|
||||||
}
|
}
|
||||||
@@ -376,6 +387,27 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
|||||||
this.notifyWebEvent(WebAppEvent.MobileKeyboardDidChangeFrame, frame)
|
this.notifyWebEvent(WebAppEvent.MobileKeyboardDidChangeFrame, frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void {
|
||||||
|
const filesController = this.getViewControllerManager().filesController
|
||||||
|
const blob = getBlobFromBase64(file.data, file.mimeType)
|
||||||
|
const mappedFile = new File([blob], file.name, { type: file.mimeType })
|
||||||
|
void filesController.uploadNewFile(mappedFile, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleReceivedTextEvent({ text, title }: { text: string; title?: string | undefined }) {
|
||||||
|
const titleForNote = title || this.getViewControllerManager().itemListController.titleForNewNote()
|
||||||
|
|
||||||
|
const note = this.items.createTemplateItem<NoteContent, SNNote>(ContentType.Note, {
|
||||||
|
title: titleForNote,
|
||||||
|
text: text,
|
||||||
|
references: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const insertedNote = await this.mutator.insertItem(note)
|
||||||
|
|
||||||
|
this.getViewControllerManager().selectionController.selectItem(insertedNote.uuid, true).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
private async lockApplicationAfterMobileEventIfApplicable(): Promise<void> {
|
private async lockApplicationAfterMobileEventIfApplicable(): Promise<void> {
|
||||||
const isLocked = await this.isLocked()
|
const isLocked = await this.isLocked()
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ export class MobileWebReceiver {
|
|||||||
messageData as { height: number; contentHeight: number },
|
messageData as { height: number; contentHeight: number },
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
case ReactNativeToWebEvent.ReceivedFile:
|
||||||
|
void this.application.handleReceivedFileEvent(
|
||||||
|
messageData as {
|
||||||
|
name: string
|
||||||
|
mimeType: string
|
||||||
|
data: string
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case ReactNativeToWebEvent.ReceivedText:
|
||||||
|
void this.application.handleReceivedTextEvent(messageData as { text: string; title?: string })
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -229,3 +229,23 @@ export const getBase64FromBlob = (blob: Blob) => {
|
|||||||
reader.readAsDataURL(blob)
|
reader.readAsDataURL(blob)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getBlobFromBase64 = (b64Data: string, contentType = '', sliceSize = 512) => {
|
||||||
|
const byteCharacters = atob(b64Data)
|
||||||
|
const byteArrays = []
|
||||||
|
|
||||||
|
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
||||||
|
const slice = byteCharacters.slice(offset, offset + sliceSize)
|
||||||
|
|
||||||
|
const byteNumbers = new Array(slice.length)
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
byteNumbers[i] = slice.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byteArray = new Uint8Array(byteNumbers)
|
||||||
|
byteArrays.push(byteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob(byteArrays, { type: contentType })
|
||||||
|
return blob
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user