diff --git a/.eslintignore b/.eslintignore index 86a551e09..f69757cda 100644 --- a/.eslintignore +++ b/.eslintignore @@ -89,6 +89,7 @@ ElectronClient/gui/NoteToolbar/NoteToolbar.js ElectronClient/gui/ResourceScreen.js ElectronClient/gui/ShareNoteDialog.js ReactNativeClient/lib/AsyncActionQueue.js +ReactNativeClient/lib/checkPermissions.js ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js ReactNativeClient/lib/hooks/usePrevious.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js @@ -96,6 +97,8 @@ ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js +ReactNativeClient/lib/ShareExtension.js +ReactNativeClient/lib/shareHandler.js ReactNativeClient/lib/services/keychain/KeychainService.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.dummy.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.mobile.js diff --git a/.gitignore b/.gitignore index 2297c54d0..aeb34dde4 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ ElectronClient/gui/NoteToolbar/NoteToolbar.js ElectronClient/gui/ResourceScreen.js ElectronClient/gui/ShareNoteDialog.js ReactNativeClient/lib/AsyncActionQueue.js +ReactNativeClient/lib/checkPermissions.js ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js ReactNativeClient/lib/hooks/usePrevious.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js @@ -86,6 +87,8 @@ ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js +ReactNativeClient/lib/ShareExtension.js +ReactNativeClient/lib/shareHandler.js ReactNativeClient/lib/services/keychain/KeychainService.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.dummy.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.mobile.js diff --git a/ReactNativeClient/android/app/src/main/AndroidManifest.xml b/ReactNativeClient/android/app/src/main/AndroidManifest.xml index 8b00160c8..5851ab218 100644 --- a/ReactNativeClient/android/app/src/main/AndroidManifest.xml +++ b/ReactNativeClient/android/app/src/main/AndroidManifest.xml @@ -101,11 +101,17 @@ android:configChanges="orientation" android:label="@string/app_name" android:screenOrientation="portrait" - android:theme="@style/AppTheme" > + android:excludeFromRecents="true" + android:theme="@style/AppTheme"> - + + + + + + diff --git a/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/MainApplication.java b/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/MainApplication.java index fe20e550f..c267a78d6 100644 --- a/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/MainApplication.java +++ b/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/MainApplication.java @@ -2,23 +2,21 @@ package net.cozic.joplin; import android.app.Application; import android.content.Context; +import android.database.CursorWindow; import android.os.Build; import android.webkit.WebView; + import com.facebook.react.PackageList; import com.facebook.react.ReactApplication; -import com.dieam.reactnativepushnotification.ReactNativePushNotificationPackage; -import com.facebook.react.ReactInstanceManager; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; -import com.facebook.react.shell.MainReactPackage; import com.facebook.soloader.SoLoader; + +import net.cozic.joplin.share.SharePackage; + import java.lang.reflect.Field; -// import com.alinz.parkerdan.shareextension.SharePackage; -import java.util.Arrays; import java.lang.reflect.InvocationTargetException; import java.util.List; -import android.database.CursorWindow; -import com.reactNativeQuickActions.AppShortcutsPackage; public class MainApplication extends Application implements ReactApplication { @@ -31,10 +29,9 @@ public class MainApplication extends Application implements ReactApplication { @Override protected List getPackages() { - @SuppressWarnings("UnnecessaryLocalVariable") List packages = new PackageList(this).getPackages(); // Packages that cannot be autolinked yet can be added manually here, for example: - // packages.add(new MyReactNativePackage()); + packages.add(new SharePackage()); return packages; } diff --git a/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/ShareActivity.java b/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/ShareActivity.java index eb402a360..4baf7b9f4 100644 --- a/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/ShareActivity.java +++ b/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/ShareActivity.java @@ -1,10 +1,7 @@ package net.cozic.joplin.share; - -// import ReactActivity import com.facebook.react.ReactActivity; - public class ShareActivity extends ReactActivity { @Override protected String getMainComponentName() { diff --git a/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/ShareApplication.java b/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/ShareApplication.java deleted file mode 100644 index 04e8a5ce8..000000000 --- a/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/ShareApplication.java +++ /dev/null @@ -1,38 +0,0 @@ -package net.cozic.joplin.share; -// import build config -import net.cozic.joplin.BuildConfig; - -// import com.alinz.parkerdan.shareextension.SharePackage; - -import android.app.Application; - -import com.facebook.react.shell.MainReactPackage; -import com.facebook.react.ReactNativeHost; -import com.facebook.react.ReactApplication; -import com.facebook.react.ReactPackage; - -import java.util.Arrays; -import java.util.List; - - -public class ShareApplication extends Application implements ReactApplication { - private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) { - @Override - public boolean getUseDeveloperSupport() { - return BuildConfig.DEBUG; - } - - @Override - protected List getPackages() { - return Arrays.asList( - new MainReactPackage() - // new SharePackage() - ); - } - }; - - @Override - public ReactNativeHost getReactNativeHost() { - return mReactNativeHost; - } -} diff --git a/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/SharePackage.java b/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/SharePackage.java new file mode 100644 index 000000000..ebb61102e --- /dev/null +++ b/ReactNativeClient/android/app/src/main/java/net/cozic/joplin/share/SharePackage.java @@ -0,0 +1,184 @@ +package net.cozic.joplin.share; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.ActivityEventListener; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.ViewManager; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class SharePackage implements ReactPackage { + + @NonNull + @Override + public List createNativeModules(@NonNull ReactApplicationContext reactContext) { + return Collections.singletonList(new ShareModule(reactContext)); + } + + @NonNull + @Override + public List createViewManagers(@NonNull ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + public static class ShareModule extends ReactContextBaseJavaModule implements ActivityEventListener { + + ShareModule(@NonNull ReactApplicationContext reactContext) { + super(reactContext); + reactContext.addActivityEventListener(this); + } + + @Override + public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { + } + + @Override + public void onNewIntent(Intent intent) { + } + + @NonNull + @Override + public String getName() { + return "ShareExtension"; + } + + @ReactMethod + public void close() { + Activity currentActivity = getCurrentActivity(); + if (currentActivity != null) { + currentActivity.finish(); + } + } + + @ReactMethod + public void data(Promise promise) { + promise.resolve(processIntent()); + } + + private WritableMap processIntent() { + Activity currentActivity = getCurrentActivity(); + WritableMap map = Arguments.createMap(); + + if (currentActivity == null) { + return null; + } + + Intent intent = currentActivity.getIntent(); + + if (intent == null || !(Intent.ACTION_SEND.equals(intent.getAction()) + || Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction()))) { + return null; + } + + String type = intent.getType() == null ? "" : intent.getType(); + map.putString("type", type); + map.putString("title", getTitle(intent)); + map.putString("text", intent.getStringExtra(Intent.EXTRA_TEXT)); + + WritableArray resources = Arguments.createArray(); + + if (Intent.ACTION_SEND.equals(intent.getAction())) { + if (intent.hasExtra(Intent.EXTRA_STREAM)) { + resources.pushMap(getFileData(intent.getParcelableExtra(Intent.EXTRA_STREAM))); + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction())) { + ArrayList imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (imageUris != null) { + for (Uri uri : imageUris) { + resources.pushMap(getFileData(uri)); + } + } + } + + map.putArray("resources", resources); + return map; + } + + private String getTitle(Intent intent) { + if (intent.hasExtra(Intent.EXTRA_SUBJECT)) { + return intent.getStringExtra(Intent.EXTRA_SUBJECT); + } else if (intent.hasExtra(Intent.EXTRA_TITLE)) { + return intent.getStringExtra(Intent.EXTRA_TITLE); + } else { + return null; + } + } + + private WritableMap getFileData(Uri uri) { + Log.d("joplin", "getFileData: " + uri); + + WritableMap imageData = Arguments.createMap(); + + ContentResolver contentResolver = getCurrentActivity().getContentResolver(); + String mimeType = contentResolver.getType(uri); + String name = getFileName(uri, contentResolver); + + if (mimeType == null || mimeType.equals("image/*")) { + String extension = getFileExtension(name); + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + } + + imageData.putString("uri", uri.toString()); + imageData.putString("name", name); + imageData.putString("mimeType", mimeType); + return imageData; + } + + private String getFileName(Uri uri, ContentResolver contentResolver) { + if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) { + File file = new File(uri.getPath()); + return file.getName(); + } else if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) { + String name = null; + Cursor cursor = contentResolver.query(uri, null, null, null, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + name = cursor.getString(nameIndex); + } + } finally { + cursor.close(); + } + } + return name; + } else { + Log.w("joplin", "Unknown URI scheme: " + uri.getScheme()); + return null; + } + } + + private String getFileExtension(String file) { + if (file == null) { + return null; + } + String ext = null; + int i = file.lastIndexOf('.'); + if (i > 0) { + ext = file.substring(i + 1); + } + return ext; + } + } +} diff --git a/ReactNativeClient/lib/ShareExtension.ts b/ReactNativeClient/lib/ShareExtension.ts new file mode 100644 index 000000000..84681d28e --- /dev/null +++ b/ReactNativeClient/lib/ShareExtension.ts @@ -0,0 +1,19 @@ +const { NativeModules, Platform } = require('react-native'); + +export interface SharedData { + title?: string, + text?: string, + resources?: string[] +} + +const ShareExtension = (Platform.OS === 'android' && NativeModules.ShareExtension) ? + { + data: () => NativeModules.ShareExtension.data(), + close: () => NativeModules.ShareExtension.close(), + } : + { + data: () => {}, + close: () => {}, + }; + +export default ShareExtension; diff --git a/ReactNativeClient/lib/checkPermissions.ts b/ReactNativeClient/lib/checkPermissions.ts new file mode 100644 index 000000000..23d26c37d --- /dev/null +++ b/ReactNativeClient/lib/checkPermissions.ts @@ -0,0 +1,9 @@ +const { PermissionsAndroid } = require('react-native'); + +export default async (permissions: string) => { + let result = await PermissionsAndroid.check(permissions); + if (result !== PermissionsAndroid.RESULTS.GRANTED) { + result = await PermissionsAndroid.request(permissions); + } + return result === PermissionsAndroid.RESULTS.GRANTED; +}; diff --git a/ReactNativeClient/lib/components/screens/note.js b/ReactNativeClient/lib/components/screens/note.js index 19e7f44a2..a7f7ca9b4 100644 --- a/ReactNativeClient/lib/components/screens/note.js +++ b/ReactNativeClient/lib/components/screens/note.js @@ -36,7 +36,7 @@ const ImageResizer = require('react-native-image-resizer').default; const shared = require('lib/components/shared/note-screen-shared.js'); const ImagePicker = require('react-native-image-picker'); const { SelectDateTimeDialog } = require('lib/components/select-date-time-dialog.js'); -// const ShareExtension = require('react-native-share-extension').default; +const ShareExtension = require('lib/ShareExtension.js').default; const CameraView = require('lib/components/CameraView'); const SearchEngine = require('lib/services/SearchEngine'); const urlUtils = require('lib/urlUtils'); @@ -123,6 +123,19 @@ class NoteScreenComponent extends BaseScreenComponent { return true; } + if (this.state.fromShare) { + // effectively the same as NAV_BACK but NAV_BACK causes undesired behaviour in this case: + // - share to Joplin from some other app + // - open Joplin and open any note + // - go back -- with NAV_BACK this causes the app to exit rather than just showing notes + this.props.dispatch({ + type: 'NAV_GO', + routeName: 'Notes', + folderId: this.state.note.parent_id, + }); + return true; + } + return false; }; @@ -333,9 +346,9 @@ class NoteScreenComponent extends BaseScreenComponent { shared.uninstallResourceHandling(this.refreshResource); - // if (Platform.OS !== 'ios' && this.state.fromShare) { - // ShareExtension.close(); - // } + if (this.state.fromShare) { + ShareExtension.close(); + } } title_changeText(text) { @@ -527,7 +540,7 @@ class NoteScreenComponent extends BaseScreenComponent { try { if (mimeType == 'image/jpeg' || mimeType == 'image/jpg' || mimeType == 'image/png') { - const done = await this.resizeImage(localFilePath, targetPath, pickerResponse.mime); + const done = await this.resizeImage(localFilePath, targetPath, mimeType); if (!done) return; } else { if (fileType === 'image') { diff --git a/ReactNativeClient/lib/components/shared/note-screen-shared.js b/ReactNativeClient/lib/components/shared/note-screen-shared.js index 31129cd84..7824fa46d 100644 --- a/ReactNativeClient/lib/components/shared/note-screen-shared.js +++ b/ReactNativeClient/lib/components/shared/note-screen-shared.js @@ -200,8 +200,7 @@ shared.initState = async function(comp) { const note = await Note.load(comp.props.noteId); let mode = 'view'; - if (isProvisionalNote) { - // note = comp.props.itemType == 'todo' ? Note.newTodo(comp.props.folderId) : Note.new(comp.props.folderId); + if (isProvisionalNote && !comp.props.sharedData) { mode = 'edit'; comp.scheduleFocusUpdate(); } @@ -218,8 +217,25 @@ shared.initState = async function(comp) { noteResources: await shared.attachedResources(note ? note.body : ''), }); + if (comp.props.sharedData) { - this.noteComponent_change(comp, 'body', comp.props.sharedData.value); + if (comp.props.sharedData.title) { + this.noteComponent_change(comp, 'title', comp.props.sharedData.title); + } + if (comp.props.sharedData.text) { + this.noteComponent_change(comp, 'body', comp.props.sharedData.text); + } + if (comp.props.sharedData.resources) { + for (let i = 0; i < comp.props.sharedData.resources.length; i++) { + const resource = comp.props.sharedData.resources[i]; + reg.logger().info(`about to attach resource ${JSON.stringify(resource)}`); + await comp.attachFile({ + uri: resource.uri, + type: resource.mimeType, + fileName: resource.name, + }, null); + } + } } // eslint-disable-next-line require-atomic-updates diff --git a/ReactNativeClient/lib/shareHandler.ts b/ReactNativeClient/lib/shareHandler.ts new file mode 100644 index 000000000..cfe6281b6 --- /dev/null +++ b/ReactNativeClient/lib/shareHandler.ts @@ -0,0 +1,42 @@ +const Note = require('lib/models/Note.js'); +const checkPermissions = require('lib/checkPermissions.js').default; +const { ToastAndroid } = require('react-native'); +const { PermissionsAndroid } = require('react-native'); + +import ShareExtension, { SharedData } from './ShareExtension'; + +export default async (sharedData: SharedData, folderId: string, dispatch: Function) => { + + if (!!sharedData.resources && sharedData.resources.length > 0) { + const hasPermissions = await checkPermissions(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE); + + if (!hasPermissions) { + ToastAndroid.show('Cannot receive shared data - permission denied', ToastAndroid.SHORT); + ShareExtension.close(); + return; + } + } + + // This is a bit hacky, but the surest way to go to + // the needed note. We go back one screen in case there's + // already a note open - if we don't do this, the dispatch + // below will do nothing (because routeName wouldn't change) + // Then we wait a bit for the state to be set correctly, and + // finally we go to the new note. + await dispatch({ type: 'NAV_BACK' }); + + await dispatch({ type: 'SIDE_MENU_CLOSE' }); + + const newNote = await Note.save({ + parent_id: folderId, + }, { provisional: true }); + + setTimeout(() => { + dispatch({ + type: 'NAV_GO', + routeName: 'Note', + noteId: newNote.id, + sharedData: sharedData, + }); + }, 5); +}; diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index db4c76fa1..d14a25515 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -2,7 +2,7 @@ import setUpQuickActions from './setUpQuickActions'; import PluginAssetsLoader from './PluginAssetsLoader'; const React = require('react'); -const { AppState, Keyboard, NativeModules, BackHandler, Platform, Animated, View, StatusBar } = require('react-native'); +const { AppState, Keyboard, NativeModules, BackHandler, Animated, View, StatusBar } = require('react-native'); const SafeAreaView = require('lib/components/SafeAreaView'); const { connect, Provider } = require('react-redux'); const { BackButtonService } = require('lib/services/back-button.js'); @@ -57,7 +57,8 @@ const { PoorManIntervals } = require('lib/poor-man-intervals.js'); const { reducer, defaultState } = require('lib/reducer.js'); const { FileApiDriverLocal } = require('lib/file-api-driver-local.js'); const DropdownAlert = require('react-native-dropdownalert').default; -// const ShareExtension = require('react-native-share-extension').default; +const ShareExtension = require('lib/ShareExtension.js').default; +const handleShared = require('lib/shareHandler').default; const ResourceFetcher = require('lib/services/ResourceFetcher'); const SearchEngine = require('lib/services/SearchEngine'); const WelcomeUtils = require('lib/WelcomeUtils'); @@ -614,43 +615,6 @@ class AppComponent extends React.Component { }); } - if (Platform.OS !== 'ios') { - // try { - // const { type, value } = await ShareExtension.data(); - - // // reg.logger().info('Got share data:', type, value); - - // if (type != '' && this.props.selectedFolderId) { - // const newNote = await Note.save({ - // title: Note.defaultTitleFromBody(value), - // body: value, - // parent_id: this.props.selectedFolderId, - // }); - - // // This is a bit hacky, but the surest way to go to - // // the needed note. We go back one screen in case there's - // // already a note open - if we don't do this, the dispatch - // // below will do nothing (because routeName wouldn't change) - // // Then we wait a bit for the state to be set correctly, and - // // finally we go to the new note. - // this.props.dispatch({ - // type: 'NAV_BACK', - // }); - - // setTimeout(() => { - // this.props.dispatch({ - // type: 'NAV_GO', - // routeName: 'Note', - // noteId: newNote.id, - // }); - // }, 5); - // } - - // } catch (e) { - // reg.logger().error('Error in ShareExtension.data', e); - // } - } - BackButtonService.initialize(this.backButtonHandler_); AlarmService.setInAppNotificationHandler(async (alarmId) => { @@ -660,6 +624,16 @@ class AppComponent extends React.Component { }); AppState.addEventListener('change', this.onAppStateChange_); + + const sharedData = await ShareExtension.data(); + if (sharedData) { + reg.logger().info('Received shared data'); + if (this.props.selectedFolderId) { + handleShared(sharedData, this.props.selectedFolderId, this.props.dispatch); + } else { + reg.logger.info('Cannot handle share - default folder id is not set'); + } + } } componentWillUnmount() {