From 3dc179c7b0779ef0f2857b7f9c994ea9d5385720 Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Wed, 12 Jul 2023 00:16:32 +0530 Subject: [PATCH] feat: On Android, you can now share text & files from other apps directly into Standard Notes (#2352) --- .../android/app/src/main/AndroidManifest.xml | 10 + .../java/com/standardnotes/MainActivity.java | 7 + .../com/standardnotes/MainApplication.java | 1 + .../ReceiveSharingIntentHelper.java | 171 ++++++++++++++++++ .../ReceiveSharingIntentModule.java | 64 +++++++ .../ReceiveSharingIntentPackage.java | 27 +++ packages/mobile/src/CustomAndroidWebView.tsx | 4 + packages/mobile/src/Lib/MobileDevice.ts | 33 +++- packages/mobile/src/MobileWebAppContainer.tsx | 29 ++- .../mobile/src/ReceivedSharedItemsHandler.ts | 132 ++++++++++++++ .../Domain/Device/MobileDeviceInterface.ts | 2 + .../snjs/lib/Client/ReactNativeToWebEvent.ts | 2 + .../WebApplication/WebApplicationInterface.ts | 2 + .../javascripts/Application/WebApplication.ts | 34 +++- .../NativeMobileWeb/MobileWebReceiver.ts | 12 ++ packages/web/src/javascripts/Utils/Utils.ts | 20 ++ 16 files changed, 535 insertions(+), 15 deletions(-) create mode 100644 packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentHelper.java create mode 100644 packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentModule.java create mode 100644 packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentPackage.java create mode 100644 packages/mobile/src/CustomAndroidWebView.tsx create mode 100644 packages/mobile/src/ReceivedSharedItemsHandler.ts diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index 40bff1c06..a9ff71660 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -43,6 +43,16 @@ + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/java/com/standardnotes/MainActivity.java b/packages/mobile/android/app/src/main/java/com/standardnotes/MainActivity.java index 9a4d0dd35..eeeec1ad9 100644 --- a/packages/mobile/android/app/src/main/java/com/standardnotes/MainActivity.java +++ b/packages/mobile/android/app/src/main/java/com/standardnotes/MainActivity.java @@ -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); + } } diff --git a/packages/mobile/android/app/src/main/java/com/standardnotes/MainApplication.java b/packages/mobile/android/app/src/main/java/com/standardnotes/MainApplication.java index 7461358fa..48c9f84a5 100644 --- a/packages/mobile/android/app/src/main/java/com/standardnotes/MainApplication.java +++ b/packages/mobile/android/app/src/main/java/com/standardnotes/MainApplication.java @@ -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; } diff --git a/packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentHelper.java b/packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentHelper.java new file mode 100644 index 000000000..6f56414d0 --- /dev/null +++ b/packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentHelper.java @@ -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 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); + } + } + +} diff --git a/packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentModule.java b/packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentModule.java new file mode 100644 index 000000000..99322ea05 --- /dev/null +++ b/packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentModule.java @@ -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"; + } +} diff --git a/packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentPackage.java b/packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentPackage.java new file mode 100644 index 000000000..15c69dd53 --- /dev/null +++ b/packages/mobile/android/app/src/main/java/com/standardnotes/ReceiveSharingIntentPackage.java @@ -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 createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + + modules.add(new ReceiveSharingIntentModule(reactContext)); + + return modules; + } +} diff --git a/packages/mobile/src/CustomAndroidWebView.tsx b/packages/mobile/src/CustomAndroidWebView.tsx new file mode 100644 index 000000000..04ccaa45d --- /dev/null +++ b/packages/mobile/src/CustomAndroidWebView.tsx @@ -0,0 +1,4 @@ +import { requireNativeComponent } from 'react-native' +import { NativeWebViewAndroid } from 'react-native-webview/lib/WebViewTypes' + +export default requireNativeComponent('CustomWebView') as NativeWebViewAndroid diff --git a/packages/mobile/src/Lib/MobileDevice.ts b/packages/mobile/src/Lib/MobileDevice.ts index 5bcaf1c50..306995216 100644 --- a/packages/mobile/src/Lib/MobileDevice.ts +++ b/packages/mobile/src/Lib/MobileDevice.ts @@ -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 = 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) } } diff --git a/packages/mobile/src/MobileWebAppContainer.tsx b/packages/mobile/src/MobileWebAppContainer.tsx index 0aefcbacb..cdbedb1bc 100644 --- a/packages/mobile/src/MobileWebAppContainer.tsx +++ b/packages/mobile/src/MobileWebAppContainer.tsx @@ -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 ( vo overScrollMode="never" nativeConfig={Platform.select({ android: { - component: CustomWebView, + component: CustomAndroidWebView, }, })} /> diff --git a/packages/mobile/src/ReceivedSharedItemsHandler.ts b/packages/mobile/src/ReceivedSharedItemsHandler.ts new file mode 100644 index 000000000..2b4a9afda --- /dev/null +++ b/packages/mobile/src/ReceivedSharedItemsHandler.ts @@ -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) { + 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) => { + 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) + } +} diff --git a/packages/services/src/Domain/Device/MobileDeviceInterface.ts b/packages/services/src/Domain/Device/MobileDeviceInterface.ts index 022435cdf..e96254b54 100644 --- a/packages/services/src/Domain/Device/MobileDeviceInterface.ts +++ b/packages/services/src/Domain/Device/MobileDeviceInterface.ts @@ -3,6 +3,7 @@ import { Environment, Platform, RawKeychainValue } from '@standardnotes/models' import { AppleIAPProductId } from './../Subscription/AppleIAPProductId' import { DeviceInterface } from './DeviceInterface' import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt' +import { ApplicationEvent } from '../Event/ApplicationEvent' export interface MobileDeviceInterface extends DeviceInterface { environment: Environment.Mobile @@ -27,4 +28,5 @@ export interface MobileDeviceInterface extends DeviceInterface { getColorScheme(): Promise<'light' | 'dark' | null | undefined> purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise authenticateWithU2F(authenticationOptionsJSONString: string): Promise | null> + notifyApplicationEvent(event: ApplicationEvent): void } diff --git a/packages/snjs/lib/Client/ReactNativeToWebEvent.ts b/packages/snjs/lib/Client/ReactNativeToWebEvent.ts index a67454003..c232859ee 100644 --- a/packages/snjs/lib/Client/ReactNativeToWebEvent.ts +++ b/packages/snjs/lib/Client/ReactNativeToWebEvent.ts @@ -9,4 +9,6 @@ export enum ReactNativeToWebEvent { KeyboardFrameDidChange = 'KeyboardFrameDidChange', KeyboardWillShow = 'KeyboardWillShow', KeyboardWillHide = 'KeyboardWillHide', + ReceivedFile = 'ReceivedFile', + ReceivedText = 'ReceivedText', } diff --git a/packages/ui-services/src/WebApplication/WebApplicationInterface.ts b/packages/ui-services/src/WebApplication/WebApplicationInterface.ts index 79f7a8aac..dfb128f14 100644 --- a/packages/ui-services/src/WebApplication/WebApplicationInterface.ts +++ b/packages/ui-services/src/WebApplication/WebApplicationInterface.ts @@ -15,6 +15,8 @@ export interface WebApplicationInterface extends ApplicationInterface { handleMobileColorSchemeChangeEvent(): void handleMobileKeyboardWillChangeFrameEvent(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 isNativeMobileWeb(): boolean mobileDevice(): MobileDeviceInterface handleAndroidBackButtonPressed(): void diff --git a/packages/web/src/javascripts/Application/WebApplication.ts b/packages/web/src/javascripts/Application/WebApplication.ts index c070f9cd1..479a9a939 100644 --- a/packages/web/src/javascripts/Application/WebApplication.ts +++ b/packages/web/src/javascripts/Application/WebApplication.ts @@ -24,11 +24,13 @@ import { BackupServiceInterface, InternalFeatureService, InternalFeatureServiceInterface, + NoteContent, + SNNote, } from '@standardnotes/snjs' import { makeObservable, observable } from 'mobx' import { startAuthentication, startRegistration } from '@simplewebauthn/browser' 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 { ArchiveManager, @@ -66,6 +68,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter private readonly mobileWebReceiver?: MobileWebReceiver private readonly androidBackHandler?: AndroidBackHandler private readonly visibilityObserver?: VisibilityObserver + private readonly mobileAppEventObserver?: () => void public readonly devMode?: DevMode @@ -137,6 +140,9 @@ export class WebApplication extends SNApplication implements WebApplicationInter if (this.isNativeMobileWeb()) { this.mobileWebReceiver = new MobileWebReceiver(this) this.androidBackHandler = new AndroidBackHandler() + this.mobileAppEventObserver = this.addEventObserver(async (event) => { + this.mobileDevice().notifyApplicationEvent(event) + }) // eslint-disable-next-line no-console console.log = (...args) => { @@ -186,6 +192,11 @@ export class WebApplication extends SNApplication implements WebApplicationInter this.visibilityObserver.deinit() ;(this.visibilityObserver as unknown) = undefined } + + if (this.mobileAppEventObserver) { + this.mobileAppEventObserver() + ;(this.mobileAppEventObserver as unknown) = undefined + } } catch (error) { console.error('Error while deiniting application', error) } @@ -376,6 +387,27 @@ export class WebApplication extends SNApplication implements WebApplicationInter 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(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 { const isLocked = await this.isLocked() if (isLocked) { diff --git a/packages/web/src/javascripts/NativeMobileWeb/MobileWebReceiver.ts b/packages/web/src/javascripts/NativeMobileWeb/MobileWebReceiver.ts index ef90369dd..0e26d6e2a 100644 --- a/packages/web/src/javascripts/NativeMobileWeb/MobileWebReceiver.ts +++ b/packages/web/src/javascripts/NativeMobileWeb/MobileWebReceiver.ts @@ -83,6 +83,18 @@ export class MobileWebReceiver { messageData as { height: number; contentHeight: number }, ) 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: break diff --git a/packages/web/src/javascripts/Utils/Utils.ts b/packages/web/src/javascripts/Utils/Utils.ts index de9b30374..e54528e46 100644 --- a/packages/web/src/javascripts/Utils/Utils.ts +++ b/packages/web/src/javascripts/Utils/Utils.ts @@ -229,3 +229,23 @@ export const getBase64FromBlob = (blob: 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 +}