1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Android: Fix note attachment issue (#6932)

This commit is contained in:
javad mnjd 2022-10-14 00:32:06 +03:30 committed by GitHub
parent 5fd5be1e09
commit 0d5f96f5bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 310 additions and 120 deletions

View File

@ -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;

View File

@ -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 {
</View>
);
} 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 (
<TouchableNativeFeedback key={key} onPress={this.selectDirectoryButtonPress} style={this.styles().settingContainer}>
<View style={this.styles().settingContainer}>

View File

@ -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.');

View File

@ -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';
/**

View File

@ -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<string> {
throw new Error(`Not implemented: md5File(): ${path}`);
}
public async getExternalDirectoryPath(): Promise<string | undefined> {
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;
}
}

View File

@ -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) {

View File

@ -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<UriPermission> 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<UriPermission> 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());
}

View File

@ -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();
}
}

View File

@ -31,7 +31,10 @@ export type Encoding = 'utf8' | 'base64' | 'ascii';
/** Native interface of the module */
interface SafxInterface {
openDocumentTree(persist: boolean): Promise<DocumentFileDetail | null>;
openDocument(persist: boolean): Promise<DocumentFileDetail | null>;
openDocument(
persist: boolean,
multiple: boolean,
): Promise<DocumentFileDetail[] | null>;
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);
}