From 0d5f96f5bbc97a93c4aa7601a1f2d1fae7f1da1e Mon Sep 17 00:00:00 2001 From: javad mnjd Date: Fri, 14 Oct 2022 00:32:06 +0330 Subject: [PATCH] Android: Fix note attachment issue (#6932) --- .../net/cozic/joplin/share/SharePackage.java | 41 +++- .../components/screens/ConfigScreen.tsx | 23 ++- .../app-mobile/components/screens/Note.tsx | 25 +-- packages/app-mobile/utils/ShareUtils.ts | 1 + packages/app-mobile/utils/fs-driver-rn.ts | 58 +++++- .../java/com/reactnativesafx/SafXModule.java | 41 ++-- .../reactnativesafx/utils/DocumentHelper.java | 194 ++++++++++++------ .../com/reactnativesafx/utils/UriHelper.java | 20 +- packages/react-native-saf-x/src/index.ts | 27 ++- 9 files changed, 310 insertions(+), 120 deletions(-) diff --git a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/share/SharePackage.java b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/share/SharePackage.java index ebb61102e..26e2a729b 100644 --- a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/share/SharePackage.java +++ b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/share/SharePackage.java @@ -24,6 +24,9 @@ import com.facebook.react.bridge.WritableMap; import com.facebook.react.uimanager.ViewManager; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -43,9 +46,13 @@ public class SharePackage implements ReactPackage { } public static class ShareModule extends ReactContextBaseJavaModule implements ActivityEventListener { + private final String cacheDir; + // when refactoring the `shareDirName` make sure to refactor the dir name in `ShareUtils.ts` + private static String shareDirName = "sharedFiles"; ShareModule(@NonNull ReactApplicationContext reactContext) { super(reactContext); + cacheDir = reactContext.getCacheDir().getAbsolutePath(); reactContext.addActivityEventListener(this); } @@ -139,7 +146,39 @@ public class SharePackage implements ReactPackage { mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); } - imageData.putString("uri", uri.toString()); + Uri copiedUri = null; + try { + String shareFolderPath = cacheDir + "/" + shareDirName; + String filepath = shareFolderPath + "/" + name; + + File file = new File(filepath); + copiedUri = Uri.fromFile(file); + if (new File(shareFolderPath).mkdirs()) { + try (InputStream inStream = + contentResolver.openInputStream(uri); + OutputStream outStream = + contentResolver.openOutputStream(copiedUri, "wt"); + ) { + byte[] buffer = new byte[1024 * 4]; + int length; + while ((length = inStream.read(buffer)) > 0) { + outStream.write(buffer, 0, length); + } + } + } else { + throw new IOException("Cannot create sharedFiles directory in cacheDir"); + } + + } catch (Exception e) { + e.printStackTrace(); + copiedUri = null; + } + + if (copiedUri != null) { + imageData.putString("uri", copiedUri.toString()); + } else { + imageData.putString("uri", uri.toString()); + } imageData.putString("name", name); imageData.putString("mimeType", mimeType); return imageData; diff --git a/packages/app-mobile/components/screens/ConfigScreen.tsx b/packages/app-mobile/components/screens/ConfigScreen.tsx index fc01d3a0f..1788f9f34 100644 --- a/packages/app-mobile/components/screens/ConfigScreen.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen.tsx @@ -22,7 +22,6 @@ const { themeStyle } = require('../global-style.js'); const shared = require('@joplin/lib/components/shared/config-shared.js'); import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; import { openDocumentTree } from '@joplin/react-native-saf-x'; -const RNFS = require('react-native-fs'); class ConfigScreenComponent extends BaseScreenComponent { static navigationOptions(): any { @@ -114,11 +113,18 @@ class ConfigScreenComponent extends BaseScreenComponent { const logItemCsv = service.csvCreate(logItemRows); const itemListCsv = await service.basicItemList({ format: 'csv' }); - const filePath = `${RNFS.ExternalDirectoryPath}/syncReport-${new Date().getTime()}.txt`; + + const externalDir = await shim.fsDriver().getExternalDirectoryPath(); + + if (!externalDir) { + this.setState({ creatingReport: false }); + return; + } + + const filePath = `${externalDir}/syncReport-${new Date().getTime()}.txt`; const finalText = [logItemCsv, itemListCsv].join('\n================================================================================\n'); - - await RNFS.writeFile(filePath, finalText); + await shim.fsDriver().writeFile(filePath, finalText, 'utf8'); alert(`Debug report exported to ${filePath}`); this.setState({ creatingReport: false }); }; @@ -130,7 +136,12 @@ class ConfigScreenComponent extends BaseScreenComponent { }; this.exportProfileButtonPress_ = async () => { - const p = this.state.profileExportPath ? this.state.profileExportPath : `${RNFS.ExternalStorageDirectoryPath}/JoplinProfileExport`; + const externalDir = await shim.fsDriver().getExternalDirectoryPath(); + if (!externalDir) { + return; + } + const p = this.state.profileExportPath ? this.state.profileExportPath : `${externalDir}/JoplinProfileExport`; + this.setState({ profileExportStatus: 'prompt', profileExportPath: p, @@ -500,7 +511,7 @@ class ConfigScreenComponent extends BaseScreenComponent { ); } else if (md.type === Setting.TYPE_STRING) { - if (md.key === 'sync.2.path' && Platform.OS === 'android' && Platform.Version > 28) { + if (md.key === 'sync.2.path' && shim.fsDriver().isUsingAndroidSAF()) { return ( diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index 1da9db9f9..bb6ff9584 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -13,7 +13,6 @@ const React = require('react'); const { Platform, Keyboard, View, TextInput, StyleSheet, Linking, Image, Share, PermissionsAndroid } = require('react-native'); const { connect } = require('react-redux'); // const { MarkdownEditor } = require('@joplin/lib/../MarkdownEditor/index.js'); -const RNFS = require('react-native-fs'); import Note from '@joplin/lib/models/Note'; import BaseItem from '@joplin/lib/models/BaseItem'; import Resource from '@joplin/lib/models/Resource'; @@ -37,7 +36,6 @@ const { BaseScreenComponent } = require('../base-screen.js'); const { themeStyle, editorFont } = require('../global-style.js'); const { dialogs } = require('../../utils/dialogs.js'); const DialogBox = require('react-native-dialogbox').default; -const DocumentPicker = require('react-native-document-picker').default; const ImageResizer = require('react-native-image-resizer').default; const shared = require('@joplin/lib/components/shared/note-screen-shared.js'); const ImagePicker = require('react-native-image-picker').default; @@ -543,18 +541,11 @@ class NoteScreenComponent extends BaseScreenComponent { } private async pickDocuments() { - try { - // the result is an array - const result = await DocumentPicker.pickMultiple(); - return result; - } catch (error) { - if (DocumentPicker.isCancel(error)) { - console.info('pickDocuments: user has cancelled'); - return null; - } else { - throw error; - } + const result = await shim.fsDriver().pickDocument(); + if (!result) { + console.info('pickDocuments: user has cancelled'); } + return result; } async imageDimensions(uri: string) { @@ -614,15 +605,15 @@ class NoteScreenComponent extends BaseScreenComponent { reg.logger().info('Resized image ', resizedImagePath); reg.logger().info(`Moving ${resizedImagePath} => ${targetPath}`); - await RNFS.copyFile(resizedImagePath, targetPath); + await shim.fsDriver().copy(resizedImagePath, targetPath); try { - await RNFS.unlink(resizedImagePath); + await shim.fsDriver().unlink(resizedImagePath); } catch (error) { reg.logger().warn('Error when unlinking cached file: ', error); } } else { - await RNFS.copyFile(localFilePath, targetPath); + await shim.fsDriver().copy(localFilePath, targetPath); } return true; @@ -678,8 +669,8 @@ class NoteScreenComponent extends BaseScreenComponent { return; } else { await shim.fsDriver().copy(localFilePath, targetPath); - const stat = await shim.fsDriver().stat(targetPath); + if (stat.size >= 200 * 1024 * 1024) { await shim.fsDriver().remove(targetPath); throw new Error('Resources larger than 200 MB are not currently supported as they may crash the mobile applications. The issue is being investigated and will be fixed at a later time.'); diff --git a/packages/app-mobile/utils/ShareUtils.ts b/packages/app-mobile/utils/ShareUtils.ts index 8f370bd1f..ea41de1a9 100644 --- a/packages/app-mobile/utils/ShareUtils.ts +++ b/packages/app-mobile/utils/ShareUtils.ts @@ -3,6 +3,7 @@ import { ResourceEntity } from '@joplin/lib/services/database/types'; import shim from '@joplin/lib/shim'; import { CachesDirectoryPath } from 'react-native-fs'; +// when refactoring this name, make sure to refactor the `SharePackage.java` (in android) as well const DIR_NAME = 'sharedFiles'; /** diff --git a/packages/app-mobile/utils/fs-driver-rn.ts b/packages/app-mobile/utils/fs-driver-rn.ts index 8d409d26c..4f48e3833 100644 --- a/packages/app-mobile/utils/fs-driver-rn.ts +++ b/packages/app-mobile/utils/fs-driver-rn.ts @@ -1,7 +1,10 @@ import FsDriverBase, { ReadDirStatsOptions } from '@joplin/lib/fs-driver-base'; const RNFetchBlob = require('rn-fetch-blob').default; const RNFS = require('react-native-fs'); -import RNSAF, { Encoding, DocumentFileDetail } from '@joplin/react-native-saf-x'; +const DocumentPicker = require('react-native-document-picker').default; +import { openDocument } from '@joplin/react-native-saf-x'; +import RNSAF, { Encoding, DocumentFileDetail, openDocumentTree } from '@joplin/react-native-saf-x'; +import { Platform } from 'react-native'; const ANDROID_URI_PREFIX = 'content://'; @@ -249,4 +252,57 @@ export default class FsDriverRN extends FsDriverBase { public async md5File(path: string): Promise { throw new Error(`Not implemented: md5File(): ${path}`); } + + public async getExternalDirectoryPath(): Promise { + let directory; + if (this.isUsingAndroidSAF()) { + const doc = await openDocumentTree(true); + if (doc?.uri) { + directory = doc?.uri; + } + } else { + directory = RNFS.ExternalDirectoryPath; + } + return directory; + } + + public isUsingAndroidSAF() { + return Platform.OS === 'android' && Platform.Version > 28; + } + + /** always returns an array */ + public async pickDocument(options: {multiple: false}) { + const { multiple = false } = options || {}; + let result; + try { + if (this.isUsingAndroidSAF()) { + result = await openDocument({ multiple }); + if (!result) { + // to catch the error down below using the 'cancel' keyword + throw new Error('User canceled document picker'); + } + result = result.map(r => { + (r.type as string) = r.mime; + ((r as any).fileCopyUri as string) = r.uri; + return r; + }); + } else { + // the result is an array + if (multiple) { + result = await DocumentPicker.pickMultiple(); + } else { + result = [await DocumentPicker.pick()]; + } + } + } catch (error) { + if (DocumentPicker.isCancel(error) || error?.message?.includes('cancel')) { + console.info('pickDocuments: user has cancelled'); + return null; + } else { + throw error; + } + } + + return result; + } } diff --git a/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/SafXModule.java b/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/SafXModule.java index 791950733..7dff2b23b 100644 --- a/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/SafXModule.java +++ b/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/SafXModule.java @@ -3,9 +3,11 @@ package com.reactnativesafx; import android.content.Intent; import android.net.Uri; import android.os.Build.VERSION_CODES; + import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.documentfile.provider.DocumentFile; + import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; @@ -17,6 +19,7 @@ import com.facebook.react.module.annotations.ReactModule; import com.reactnativesafx.utils.DocumentHelper; import com.reactnativesafx.utils.GeneralHelper; import com.reactnativesafx.utils.UriHelper; + import java.io.FileNotFoundException; import java.io.IOException; import java.io.OutputStream; @@ -47,8 +50,8 @@ public class SafXModule extends ReactContextBaseJavaModule { } @ReactMethod - public void openDocument(final boolean persist, final Promise promise) { - this.documentHelper.openDocument(persist, promise); + public void openDocument(final boolean persist, final boolean multiple, final Promise promise) { + this.documentHelper.openDocument(persist, multiple, promise); } @ReactMethod @@ -74,6 +77,8 @@ public class SafXModule extends ReactContextBaseJavaModule { public void exists(String uriString, final Promise promise) { try { promise.resolve(this.documentHelper.exists(uriString)); + } catch (SecurityException e) { + promise.reject("EPERM", e.getLocalizedMessage()); } catch (Exception e) { promise.reject("ERROR", e.getLocalizedMessage()); } @@ -176,7 +181,7 @@ public class SafXModule extends ReactContextBaseJavaModule { } promise.resolve(true); } catch (FileNotFoundException e) { - promise.reject("ENOENT", e.getLocalizedMessage()); + promise.resolve(true); } catch (SecurityException e) { promise.reject("EPERM", e.getLocalizedMessage()); } catch (Exception e) { @@ -188,7 +193,7 @@ public class SafXModule extends ReactContextBaseJavaModule { public void mkdir(String uriString, final Promise promise) { try { DocumentFile dir = this.documentHelper.mkdir(uriString); - DocumentHelper.resolveWithDocument(dir, promise, uriString); + DocumentHelper.resolveWithDocument(dir, uriString, promise); } catch (IOException e) { promise.reject("EEXIST", e.getLocalizedMessage()); } catch (SecurityException e) { @@ -202,7 +207,11 @@ public class SafXModule extends ReactContextBaseJavaModule { public void createFile(String uriString, String mimeType, final Promise promise) { try { DocumentFile createdFile = this.documentHelper.createFile(uriString, mimeType); - DocumentHelper.resolveWithDocument(createdFile, promise, uriString); + DocumentHelper.resolveWithDocument(createdFile, uriString, promise); + } catch (IOException e) { + promise.reject("EEXIST", e.getLocalizedMessage()); + } catch (SecurityException e) { + promise.reject("EPERM", e.getLocalizedMessage()); } catch (Exception e) { promise.reject("EUNSPECIFIED", e.getLocalizedMessage()); } @@ -236,16 +245,16 @@ public class SafXModule extends ReactContextBaseJavaModule { DocumentFile doc = this.documentHelper.goToDocument(uriString, false, true); WritableMap[] resolvedDocs = - Arrays.stream(doc.listFiles()) - .map( - docEntry -> - DocumentHelper.resolveWithDocument( - docEntry, - null, - trailingSlash.matcher(uriString).replaceFirst("") - + "/" - + docEntry.getName())) - .toArray(WritableMap[]::new); + Arrays.stream(doc.listFiles()) + .map( + docEntry -> + DocumentHelper.resolveWithDocument( + docEntry, + trailingSlash.matcher(uriString).replaceFirst("") + + "/" + + docEntry.getName(), + null)) + .toArray(WritableMap[]::new); WritableArray resolveData = Arguments.fromJavaArgs(resolvedDocs); promise.resolve(resolveData); } catch (FileNotFoundException e) { @@ -262,7 +271,7 @@ public class SafXModule extends ReactContextBaseJavaModule { try { DocumentFile doc = this.documentHelper.goToDocument(uriString, false, true); - DocumentHelper.resolveWithDocument(doc, promise, uriString); + DocumentHelper.resolveWithDocument(doc, uriString, promise); } catch (FileNotFoundException e) { promise.reject("ENOENT", e.getLocalizedMessage()); } catch (SecurityException e) { diff --git a/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/utils/DocumentHelper.java b/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/utils/DocumentHelper.java index 6f3fee7a0..0be677b8d 100644 --- a/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/utils/DocumentHelper.java +++ b/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/utils/DocumentHelper.java @@ -2,6 +2,7 @@ package com.reactnativesafx.utils; import android.annotation.SuppressLint; import android.app.Activity; +import android.content.ClipData; import android.content.ContentResolver; import android.content.Intent; import android.content.UriPermission; @@ -9,16 +10,19 @@ import android.net.Uri; import android.os.Build; import android.os.Build.VERSION_CODES; import android.util.Base64; + import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.documentfile.provider.DocumentFile; import androidx.documentfile.provider.DocumentFileHelper; + import com.facebook.react.bridge.ActivityEventListener; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; + import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; @@ -71,9 +75,9 @@ public class DocumentHelper { try { DocumentFile doc = goToDocument(uri.toString(), false); - resolveWithDocument(doc, promise, uri.toString()); + resolveWithDocument(doc, uri.toString(), promise); } catch (Exception e) { - promise.resolve(null); + promise.reject("EUNSPECIFIED", e.getLocalizedMessage()); } } else { promise.resolve(null); @@ -99,16 +103,19 @@ public class DocumentHelper { } } catch (Exception e) { - promise.reject("ERROR", e.getMessage()); + promise.reject("ERROR", e.getLocalizedMessage()); } } - public void openDocument(final boolean persist, final Promise promise) { + public void openDocument(final boolean persist, final boolean multiple, final Promise promise) { try { Intent intent = new Intent(); intent.setAction(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); + if (multiple) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } intent.setType("*/*"); if (activityEventListener != null) { @@ -122,32 +129,54 @@ public class DocumentHelper { @Override public void onActivityResult( Activity activity, int requestCode, int resultCode, Intent intent) { - if (requestCode == DOCUMENT_REQUEST_CODE && resultCode == Activity.RESULT_OK) { - if (intent != null) { + try { + WritableArray resolvedDocs = Arguments.createArray(); + if (requestCode == DOCUMENT_REQUEST_CODE + && resultCode == Activity.RESULT_OK + && intent != null) { Uri uri = intent.getData(); - if (persist) { - final int takeFlags = - intent.getFlags() - & (Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - context.getContentResolver().takePersistableUriPermission(uri, takeFlags); - } + if (uri != null) { + if (persist) { + final int takeFlags = + intent.getFlags() + & (Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + + context.getContentResolver().takePersistableUriPermission(uri, takeFlags); + } - try { DocumentFile doc = goToDocument(uri.toString(), false); - resolveWithDocument(doc, promise, uri.toString()); - } catch (Exception e) { - promise.resolve(null); + WritableMap docInfo = resolveWithDocument(doc, uri.toString(), null); + resolvedDocs.pushMap(docInfo); + } else if (multiple) { + ClipData clipData = intent.getClipData(); + if (clipData != null) { + for (int i = 0; i < clipData.getItemCount(); ++i) { + ClipData.Item item = clipData.getItemAt(i); + Uri clipUri = item.getUri(); + DocumentFile doc = goToDocument(clipUri.toString(), false); + WritableMap docInfo = resolveWithDocument(doc, clipUri.toString(), null); + resolvedDocs.pushMap(docInfo); + } + } else { + throw new Exception("Unexpected Error: ClipData was null"); + } + } else { + throw new Exception( + "Unexpected Error: Could not retrieve information about selected documents"); } } else { promise.resolve(null); + return; } - } else { - promise.resolve(null); + promise.resolve(resolvedDocs); + } catch (Exception e) { + promise.reject("EUNSPECIFIED", e.getLocalizedMessage()); + } finally { + context.removeActivityEventListener(activityEventListener); + activityEventListener = null; } - context.removeActivityEventListener(activityEventListener); - activityEventListener = null; } @Override @@ -160,11 +189,12 @@ public class DocumentHelper { if (activity != null) { activity.startActivityForResult(intent, DOCUMENT_REQUEST_CODE); } else { - promise.reject("ERROR", "Cannot get current activity, so cannot launch document picker"); + promise.reject( + "EUNSPECIFIED", "Cannot get current activity, so cannot launch document picker"); } } catch (Exception e) { - promise.reject("ERROR", e.getMessage()); + promise.reject("EUNSPECIFIED", e.getLocalizedMessage()); } } @@ -211,7 +241,7 @@ public class DocumentHelper { os.write(bytes); } assert doc != null; - resolveWithDocument(doc, promise, uri.toString()); + resolveWithDocument(doc, uri.toString(), promise); } catch (Exception e) { promise.reject("ERROR", e.getLocalizedMessage()); } @@ -239,7 +269,7 @@ public class DocumentHelper { promise.reject("ERROR", "Cannot get current activity, so cannot launch document picker"); } } catch (Exception e) { - promise.reject("ERROR", e.getMessage()); + promise.reject("ERROR", e.getLocalizedMessage()); } } @@ -272,18 +302,19 @@ public class DocumentHelper { } } - public boolean exists(final String uriString) { + public boolean exists(final String uriString) throws SecurityException { return this.exists(uriString, false); } - public boolean exists(final String uriString, final boolean shouldBeFile) { + public boolean exists(final String uriString, final boolean shouldBeFile) + throws SecurityException { try { DocumentFile fileOrFolder = goToDocument(uriString, false); if (shouldBeFile) { return !fileOrFolder.isDirectory(); } return true; - } catch (Exception e) { + } catch (IOException e) { return false; } } @@ -308,12 +339,8 @@ public class DocumentHelper { && permission.isWritePermission(); } - private String getPermissionErrorMsg(final String uriString) { - return "You don't have read/write permission to access uri: " + uriString; - } - public static WritableMap resolveWithDocument( - @NonNull DocumentFile file, Promise promise, String SimplifiedUri) { + @NonNull DocumentFile file, String SimplifiedUri, Promise promise) { WritableMap fileMap = Arguments.createMap(); fileMap.putString("uri", UriHelper.denormalize(SimplifiedUri)); fileMap.putString("name", file.getName()); @@ -362,13 +389,38 @@ public class DocumentHelper { throw new IOException( "Invalid file name: Could not extract filename from uri string provided"); } + + // maybe edited maybe not + String correctFileName = fileName; + + // only files with mime type are special, so we treat it special + if (mimeType != null && !mimeType.equals("")) { + int indexOfDot = fileName.indexOf('.'); + // len - 1 because there should be an extension that has at least 1 letter + if (indexOfDot != -1 && indexOfDot < fileName.length() - 1) { + correctFileName = fileName.substring(0, indexOfDot); + } + } + DocumentFile createdFile = parentDirOfFile.createFile( - mimeType != null && !mimeType.equals("") ? mimeType : "*/*", fileName); + mimeType != null && !mimeType.equals("") ? mimeType : "*/*", correctFileName); if (createdFile == null) { throw new IOException( "File creation failed without any specific error for '" + fileName + "'"); } + // some times setting mimetypes causes name changes, this is to prevent that. + if (!createdFile.getName().equals(fileName)) { + if (!createdFile.renameTo(fileName) || !createdFile.getName().equals(fileName)) { + createdFile.delete(); + throw new IOException( + "The created file name was not as expected: '" + + uriString + + "'" + + "but got: " + + createdFile.getUri()); + } + } return createdFile; } @@ -380,19 +432,19 @@ public class DocumentHelper { public DocumentFile goToDocument( String unknownUriStr, boolean createIfDirectoryNotExist, boolean includeLastSegment) throws SecurityException, IOException, IllegalArgumentException { - String unknownUriString = UriHelper.getUnifiedUri(unknownUriStr); + String unknownUriString = UriHelper.getUnifiedUri(unknownUriStr); if (unknownUriString.startsWith(ContentResolver.SCHEME_FILE)) { Uri uri = Uri.parse(unknownUriString); if (uri == null) { throw new IllegalArgumentException("Invalid Uri String"); } String path = - uri.getPath() - .substring( - 0, - includeLastSegment - ? uri.getPath().length() - : uri.getPath().length() - uri.getLastPathSegment().length()); + uri.getPath() + .substring( + 0, + includeLastSegment + ? uri.getPath().length() + : uri.getPath().length() - uri.getLastPathSegment().length()); if (createIfDirectoryNotExist) { File targetFile = new File(path); @@ -406,40 +458,47 @@ public class DocumentHelper { DocumentFile targetFile = DocumentFile.fromFile(new File(path)); if (!targetFile.exists()) { throw new FileNotFoundException( - "Cannot find the given document. File does not exist at '" + unknownUriString + "'"); + "Cannot find the given document. File does not exist at '" + unknownUriString + "'"); } return targetFile; + } else if (!UriHelper.isContentDocumentTreeUri(unknownUriString)) { + // It's a document picked by user + DocumentFile doc = DocumentFile.fromSingleUri(context, Uri.parse(unknownUriString)); + if (doc != null) { + if (!doc.canRead()) { + throw new SecurityException( + "You don't have read permission to access uri: " + unknownUriString); + } + if (doc.isFile() && doc.exists()) { + return doc; + } + } + throw new FileNotFoundException( + "Cannot find the given document. File does not exist at '" + unknownUriString + "'"); } + String uriString = UriHelper.normalize(unknownUriString); String baseUri = ""; String appendUri; String[] strings = new String[0]; - List uriList = context.getContentResolver().getPersistedUriPermissions(); - - for (UriPermission uriPermission : uriList) { - String uriPath = uriPermission.getUri().toString(); - if (this.permissionMatchesAndHasAccess(uriPermission, uriString)) { - baseUri = uriPath; - appendUri = Uri.decode(uriString.substring(uriPath.length())); - strings = appendUri.split("/"); - break; + { + // Helps traversal and folder creation by knowing where to start traverse + List uriList = context.getContentResolver().getPersistedUriPermissions(); + for (UriPermission uriPermission : uriList) { + String uriPath = uriPermission.getUri().toString(); + if (this.permissionMatchesAndHasAccess(uriPermission, uriString)) { + baseUri = uriPath; + appendUri = Uri.decode(uriString.substring(uriPath.length())); + strings = appendUri.split("/"); + break; + } } } if (baseUri.equals("")) { - throw new SecurityException(getPermissionErrorMsg(uriString)); - } - - if (baseUri.matches("^content://[\\w.]+/document/.*")) { - // It's a document picked by user - DocumentFile doc = DocumentFile.fromSingleUri(context, Uri.parse(uriString)); - if (doc != null && doc.isFile() && doc.exists()) { - return doc; - } else { - throw new FileNotFoundException( - "Cannot find the given document. File does not exist at '" + uriString + "'"); - } + // It's possible that the file access is temporary + baseUri = uriString; } Uri uri = Uri.parse(baseUri); @@ -479,7 +538,14 @@ public class DocumentHelper { } } } + assert dir != null; + + if (!dir.canRead() || !dir.canWrite()) { + throw new SecurityException( + "You don't have read/write permission to access uri: " + uriString); + } + return dir; } @@ -517,7 +583,7 @@ public class DocumentHelper { srcDoc.delete(); } - promise.resolve(resolveWithDocument(destDoc, promise, destUri)); + promise.resolve(resolveWithDocument(destDoc, destUri, promise)); } catch (Exception e) { promise.reject("EUNSPECIFIED", e.getLocalizedMessage()); } diff --git a/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/utils/UriHelper.java b/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/utils/UriHelper.java index 925c8aa72..37fdef579 100644 --- a/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/utils/UriHelper.java +++ b/packages/react-native-saf-x/android/src/main/java/com/reactnativesafx/utils/UriHelper.java @@ -3,19 +3,28 @@ package com.reactnativesafx.utils; import android.content.ContentResolver; import android.net.Uri; import android.os.Build.VERSION_CODES; + import androidx.annotation.RequiresApi; +import java.util.regex.Pattern; + @RequiresApi(api = VERSION_CODES.Q) public class UriHelper { public static final String CONTENT_URI_PREFIX = "content://"; + public static final Pattern DOCUMENT_TREE_PREFIX = + Pattern.compile("^content://.*?/tree/.+?", Pattern.CASE_INSENSITIVE); public static String getLastSegment(String uriString) { return Uri.parse(Uri.decode(uriString)).getLastPathSegment(); } + public static boolean isContentDocumentTreeUri(String uriString) { + return DOCUMENT_TREE_PREFIX.matcher(uriString).matches(); + } + public static String normalize(String uriString) { - if (uriString.startsWith(ContentResolver.SCHEME_CONTENT)) { + if (isContentDocumentTreeUri(uriString)) { // an abnormal uri example: // content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin/locks/2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json // normalized: @@ -29,7 +38,7 @@ public class UriHelper { } public static String denormalize(String uriString) { - if (uriString.startsWith(ContentResolver.SCHEME_CONTENT)) { + if (isContentDocumentTreeUri(uriString)) { // an normalized uri example: // content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin%2Flocks%2F2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json // denormalized: @@ -45,14 +54,9 @@ public class UriHelper { if (uri.getScheme() == null) { uri = Uri.parse(ContentResolver.SCHEME_FILE + "://" + uriString); } else if (!(uri.getScheme().equals(ContentResolver.SCHEME_FILE) - || uri.getScheme().equals(ContentResolver.SCHEME_CONTENT))) { + || uri.getScheme().equals(ContentResolver.SCHEME_CONTENT))) { throw new IllegalArgumentException("Invalid Uri: Scheme not supported"); } - - if (uri.getScheme() == null) { - throw new IllegalArgumentException("Invalid Uri: Cannot determine scheme"); - } - return uri.toString(); } } diff --git a/packages/react-native-saf-x/src/index.ts b/packages/react-native-saf-x/src/index.ts index 0daf8d77a..b6e0d576d 100644 --- a/packages/react-native-saf-x/src/index.ts +++ b/packages/react-native-saf-x/src/index.ts @@ -31,7 +31,10 @@ export type Encoding = 'utf8' | 'base64' | 'ascii'; /** Native interface of the module */ interface SafxInterface { openDocumentTree(persist: boolean): Promise; - openDocument(persist: boolean): Promise; + openDocument( + persist: boolean, + multiple: boolean, + ): Promise; createDocument( data: String, encoding?: String, @@ -97,12 +100,21 @@ export function openDocumentTree(persist: boolean) { return SafX.openDocumentTree(persist); } +export type OpenDocumentOptions = { + /** should the permission of returned document(s) be persisted ? */ + persist?: boolean; + /** should the file picker allow multiple documents ? */ + multiple?: boolean; +}; + /** * Open the Document Picker to select a file. - * Returns an object of type `DocumentFileDetail` or `null` if user did not select a file. + * DocumentFileDetail is always an array. + * @returns `DocumentFileDetail[]` or `null` if user did not select a file. */ -export function openDocument(persist: boolean) { - return SafX.openDocument(persist); +export function openDocument(options: OpenDocumentOptions) { + const { persist = false, multiple = false } = options; + return SafX.openDocument(persist, multiple); } /** @@ -163,9 +175,10 @@ export function createFile( return SafX.createFile(uriString, mimeType); } -// -// Removes the file or directory at given uri. -// Resolves with `true` if delete is successful, `false` otherwise. +/** + * Removes the file or directory at given uri. + * Resolves with `true` if delete is successful, throws otherwise. + */ export function unlink(uriString: string) { return SafX.unlink(uriString); }