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

Android: Resolves #6942: Improve filesystem sync performance (#7637)

This commit is contained in:
javad mnjd 2023-01-21 06:11:37 -08:00 committed by GitHub
parent 2f254d81cd
commit b82bf16505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1502 additions and 1862 deletions

View File

@ -3,11 +3,12 @@ buildscript {
repositories {
google()
mavenCentral()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.2'
classpath("com.android.tools.build:gradle:7.2.1")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("de.undercouch:gradle-download-task:5.0.1")
}
}
}
@ -50,7 +51,6 @@ repositories {
}
google()
mavenCentral()
jcenter()
}
dependencies {

View File

@ -6,7 +6,6 @@ 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;
@ -14,28 +13,19 @@ 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.module.annotations.ReactModule;
import com.reactnativesafx.utils.DocumentHelper;
import com.reactnativesafx.utils.GeneralHelper;
import com.reactnativesafx.utils.EfficientDocumentHelper;
import com.reactnativesafx.utils.UriHelper;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.regex.Pattern;
@RequiresApi(api = VERSION_CODES.Q)
@ReactModule(name = SafXModule.NAME)
public class SafXModule extends ReactContextBaseJavaModule {
public static final String NAME = "SafX";
public static Pattern trailingSlash = Pattern.compile("[/\\\\]$");
private final DocumentHelper documentHelper;
private final EfficientDocumentHelper efficientDocumentHelper;
public SafXModule(ReactApplicationContext reactContext) {
super(reactContext);
this.documentHelper = new DocumentHelper(reactContext);
this.efficientDocumentHelper = new EfficientDocumentHelper(reactContext);
}
@Override
@ -46,12 +36,12 @@ public class SafXModule extends ReactContextBaseJavaModule {
@ReactMethod
public void openDocumentTree(final boolean persist, final Promise promise) {
this.documentHelper.openDocumentTree(persist, promise);
this.efficientDocumentHelper.openDocumentTree(persist, promise);
}
@ReactMethod
public void openDocument(final boolean persist, final boolean multiple, final Promise promise) {
this.documentHelper.openDocument(persist, multiple, promise);
this.efficientDocumentHelper.openDocument(persist, multiple, promise);
}
@ReactMethod
@ -61,54 +51,22 @@ public class SafXModule extends ReactContextBaseJavaModule {
final String initialName,
final String mimeType,
final Promise promise) {
this.documentHelper.createDocument(data, encoding, initialName, mimeType, promise);
this.efficientDocumentHelper.createDocument(data, encoding, initialName, mimeType, promise);
}
@ReactMethod
public void hasPermission(String uriString, final Promise promise) {
if (this.documentHelper.hasPermission(uriString)) {
promise.resolve(true);
} else {
promise.resolve(false);
}
this.efficientDocumentHelper.hasPermission(uriString, promise);
}
@ReactMethod
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());
}
this.efficientDocumentHelper.exists(uriString, promise);
}
@ReactMethod
public void readFile(String uriString, String encoding, final Promise promise) {
try {
DocumentFile file;
try {
file = this.documentHelper.goToDocument(uriString, false, true);
} catch (FileNotFoundException e) {
promise.reject("ENOENT", "'" + uriString + "' does not exist");
return;
}
if (encoding != null) {
if (encoding.equals("ascii")) {
WritableArray arr =
(WritableArray) this.documentHelper.readFromUri(file.getUri(), encoding);
promise.resolve((arr));
} else {
promise.resolve(this.documentHelper.readFromUri(file.getUri(), encoding));
}
} else {
promise.resolve(this.documentHelper.readFromUri(file.getUri(), "utf8"));
}
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
}
this.efficientDocumentHelper.readFile(uriString, encoding, promise);
}
@ReactMethod
@ -119,102 +77,35 @@ public class SafXModule extends ReactContextBaseJavaModule {
String mimeType,
boolean append,
final Promise promise) {
try {
DocumentFile file;
try {
file = this.documentHelper.goToDocument(uriString, false, true);
} catch (FileNotFoundException e) {
file = this.documentHelper.createFile(uriString, mimeType);
}
byte[] bytes = GeneralHelper.stringToBytes(data, encoding);
try (OutputStream fout =
this.getReactApplicationContext()
.getContentResolver()
.openOutputStream(file.getUri(), append ? "wa" : "wt")) {
fout.write(bytes);
}
promise.resolve(uriString);
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
}
this.efficientDocumentHelper.writeFile(
uriString, data, encoding, mimeType, append, promise
);
}
@ReactMethod
public void transferFile(
String srcUri, String destUri, boolean replaceIfDestExists, boolean copy, Promise promise) {
this.documentHelper.transferFile(srcUri, destUri, replaceIfDestExists, copy, promise);
this.efficientDocumentHelper.transferFile(srcUri, destUri, replaceIfDestExists, copy, promise);
}
@ReactMethod
public void rename(String uriString, String newName, final Promise promise) {
try {
DocumentFile doc;
try {
doc = this.documentHelper.goToDocument(uriString, false, true);
} catch (FileNotFoundException e) {
promise.reject("ENOENT", "'" + uriString + "' does not exist");
return;
}
if (doc.renameTo(newName)) {
promise.resolve(true);
} else {
promise.resolve(false);
}
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
}
this.efficientDocumentHelper.renameTo(uriString, newName, promise);
}
@ReactMethod
public void unlink(String uriString, final Promise promise) {
try {
DocumentFile doc = this.documentHelper.goToDocument(uriString, false, true);
boolean result = doc.delete();
if (!result) {
throw new Exception("Failed to unlink file. Unknown error.");
}
promise.resolve(true);
} catch (FileNotFoundException e) {
promise.resolve(true);
} catch (SecurityException e) {
promise.reject("EPERM", e.getLocalizedMessage());
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
}
this.efficientDocumentHelper.unlink(uriString, promise);
}
@ReactMethod
public void mkdir(String uriString, final Promise promise) {
try {
DocumentFile dir = this.documentHelper.mkdir(uriString);
DocumentHelper.resolveWithDocument(dir, 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());
}
this.efficientDocumentHelper.mkdir(uriString, promise);
}
@ReactMethod
public void createFile(String uriString, String mimeType, final Promise promise) {
try {
DocumentFile createdFile = this.documentHelper.createFile(uriString, mimeType);
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());
}
this.efficientDocumentHelper.createFile(uriString, mimeType, promise);
}
@ReactMethod
@ -241,43 +132,11 @@ public class SafXModule extends ReactContextBaseJavaModule {
@ReactMethod
public void listFiles(String uriString, final Promise promise) {
try {
DocumentFile doc = this.documentHelper.goToDocument(uriString, false, true);
WritableMap[] resolvedDocs =
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) {
promise.reject("ENOENT", e.getLocalizedMessage());
} catch (SecurityException e) {
promise.reject("EPERM", e.getLocalizedMessage());
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
}
efficientDocumentHelper.listFiles(uriString, promise);
}
@ReactMethod
public void stat(String uriString, final Promise promise) {
try {
DocumentFile doc = this.documentHelper.goToDocument(uriString, false, true);
DocumentHelper.resolveWithDocument(doc, uriString, promise);
} catch (FileNotFoundException e) {
promise.reject("ENOENT", e.getLocalizedMessage());
} catch (SecurityException e) {
promise.reject("EPERM", e.getLocalizedMessage());
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
}
efficientDocumentHelper.stat(uriString, promise);
}
}

View File

@ -0,0 +1,31 @@
package com.reactnativesafx.utils;
import android.os.Handler;
import android.os.Looper;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
// from https://stackoverflow.com/a/73666782/3542461
public class Async {
private static final ExecutorService executorService = Executors.newCachedThreadPool();
private static final Handler handler = new Handler(Looper.getMainLooper());
public static <T> void execute(Task<T> task) {
executorService.execute(() -> {
T t = task.doAsync();
handler.post(() -> {
task.doSync(t);
});
});
}
public interface Task<T> {
T doAsync();
void doSync(T t);
}
}

View File

@ -1,585 +0,0 @@
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;
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;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
@RequiresApi(api = VERSION_CODES.Q)
public class DocumentHelper {
private static final int DOCUMENT_TREE_REQUEST_CODE = 1;
private static final int DOCUMENT_REQUEST_CODE = 2;
private static final int DOCUMENT_CREATE_CODE = 3;
private final ReactApplicationContext context;
private ActivityEventListener activityEventListener;
public DocumentHelper(ReactApplicationContext context) {
this.context = context;
}
public void openDocumentTree(final boolean persist, final Promise promise) {
try {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_OPEN_DOCUMENT_TREE);
if (activityEventListener != null) {
context.removeActivityEventListener(activityEventListener);
activityEventListener = null;
}
activityEventListener =
new ActivityEventListener() {
@SuppressLint("WrongConstant")
@Override
public void onActivityResult(
Activity activity, int requestCode, int resultCode, Intent intent) {
if (requestCode == DOCUMENT_TREE_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
if (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);
}
try {
DocumentFile doc = goToDocument(uri.toString(), false);
resolveWithDocument(doc, uri.toString(), promise);
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
}
} else {
promise.resolve(null);
}
} else {
promise.resolve(null);
}
context.removeActivityEventListener(activityEventListener);
activityEventListener = null;
}
@Override
public void onNewIntent(Intent intent) {}
};
context.addActivityEventListener(activityEventListener);
Activity activity = context.getCurrentActivity();
if (activity != null) {
activity.startActivityForResult(intent, DOCUMENT_TREE_REQUEST_CODE);
} else {
promise.reject("ERROR", "Cannot get current activity, so cannot launch document picker");
}
} catch (Exception e) {
promise.reject("ERROR", e.getLocalizedMessage());
}
}
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) {
context.removeActivityEventListener(activityEventListener);
activityEventListener = null;
}
activityEventListener =
new ActivityEventListener() {
@SuppressLint("WrongConstant")
@Override
public void onActivityResult(
Activity activity, int requestCode, int resultCode, Intent intent) {
try {
WritableArray resolvedDocs = Arguments.createArray();
if (requestCode == DOCUMENT_REQUEST_CODE
&& resultCode == Activity.RESULT_OK
&& intent != null) {
Uri uri = intent.getData();
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);
}
DocumentFile doc = goToDocument(uri.toString(), false);
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;
}
promise.resolve(resolvedDocs);
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
} finally {
context.removeActivityEventListener(activityEventListener);
activityEventListener = null;
}
}
@Override
public void onNewIntent(Intent intent) {}
};
context.addActivityEventListener(activityEventListener);
Activity activity = context.getCurrentActivity();
if (activity != null) {
activity.startActivityForResult(intent, DOCUMENT_REQUEST_CODE);
} else {
promise.reject(
"EUNSPECIFIED", "Cannot get current activity, so cannot launch document picker");
}
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
}
}
public void createDocument(
final String data,
final String encoding,
final String initialName,
final String mimeType,
final Promise promise) {
try {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
if (initialName != null) {
intent.putExtra(Intent.EXTRA_TITLE, initialName);
}
if (mimeType != null) {
intent.setType(mimeType);
} else {
intent.setType("*/*");
}
if (activityEventListener != null) {
context.removeActivityEventListener(activityEventListener);
activityEventListener = null;
}
activityEventListener =
new ActivityEventListener() {
@Override
public void onActivityResult(
Activity activity, int requestCode, int resultCode, Intent intent) {
if (requestCode == DOCUMENT_CREATE_CODE && resultCode == Activity.RESULT_OK) {
if (intent != null) {
Uri uri = intent.getData();
DocumentFile doc = DocumentFile.fromSingleUri(context, uri);
try {
byte[] bytes = GeneralHelper.stringToBytes(data, encoding);
try (OutputStream os = context.getContentResolver().openOutputStream(uri)) {
os.write(bytes);
}
assert doc != null;
resolveWithDocument(doc, uri.toString(), promise);
} catch (Exception e) {
promise.reject("ERROR", e.getLocalizedMessage());
}
} else {
promise.resolve(null);
}
} else {
promise.resolve(null);
}
context.removeActivityEventListener(activityEventListener);
activityEventListener = null;
}
@Override
public void onNewIntent(Intent intent) {}
};
context.addActivityEventListener(activityEventListener);
Activity activity = context.getCurrentActivity();
if (activity != null) {
activity.startActivityForResult(intent, DOCUMENT_CREATE_CODE);
} else {
promise.reject("ERROR", "Cannot get current activity, so cannot launch document picker");
}
} catch (Exception e) {
promise.reject("ERROR", e.getLocalizedMessage());
}
}
@RequiresApi(api = Build.VERSION_CODES.Q)
@SuppressWarnings({"UnusedDeclaration", "UnusedAssignment"})
public Object readFromUri(Uri uri, String encoding) throws IOException {
byte[] bytes;
int bytesRead;
int length;
InputStream inputStream = context.getContentResolver().openInputStream(uri);
length = inputStream.available();
bytes = new byte[length];
bytesRead = inputStream.read(bytes);
inputStream.close();
switch (encoding.toLowerCase()) {
case "base64":
return Base64.encodeToString(bytes, Base64.NO_WRAP);
case "ascii":
WritableArray asciiResult = Arguments.createArray();
for (byte b : bytes) {
asciiResult.pushInt(b);
}
return asciiResult;
case "utf8":
default:
return new String(bytes);
}
}
public boolean exists(final String uriString) throws SecurityException {
return this.exists(uriString, false);
}
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 (IOException e) {
return false;
}
}
public boolean hasPermission(String uriString) {
// list of all persisted permissions for our app
List<UriPermission> uriList = context.getContentResolver().getPersistedUriPermissions();
for (UriPermission uriPermission : uriList) {
if (permissionMatchesAndHasAccess(uriPermission, UriHelper.normalize(uriString))) {
return true;
}
}
return false;
}
public boolean permissionMatchesAndHasAccess(
UriPermission permission, String normalizedUriString) {
String permittedUri = permission.getUri().toString();
return (permittedUri.startsWith(normalizedUriString)
|| normalizedUriString.startsWith(permittedUri))
&& permission.isReadPermission()
&& permission.isWritePermission();
}
public static WritableMap resolveWithDocument(
@NonNull DocumentFile file, String SimplifiedUri, Promise promise) {
WritableMap fileMap = Arguments.createMap();
fileMap.putString("uri", UriHelper.denormalize(SimplifiedUri));
fileMap.putString("name", file.getName());
fileMap.putString("type", file.isDirectory() ? "directory" : "file");
if (file.isFile()) {
fileMap.putString("mime", file.getType());
fileMap.putDouble("size", file.length());
}
fileMap.putDouble("lastModified", file.lastModified());
if (promise != null) {
promise.resolve(fileMap);
}
return fileMap;
}
public DocumentFile mkdir(String uriString)
throws IOException, SecurityException, IllegalArgumentException {
return this.mkdir(uriString, true);
}
/**
* @return a DocumentFile that is created using DocumentFile.fromTreeUri()
*/
public DocumentFile mkdir(String uriString, boolean includeLastSegment)
throws IOException, SecurityException, IllegalArgumentException {
DocumentFile dir = goToDocument(uriString, true, includeLastSegment);
assert dir != null;
return dir;
}
public DocumentFile createFile(String uriString) throws IOException, SecurityException {
return createFile(uriString, null);
}
public DocumentFile createFile(String uriString, String mimeType)
throws IOException, SecurityException {
if (this.exists(uriString)) {
throw new IOException("a file or directory already exist at: " + uriString);
}
DocumentFile parentDirOfFile = this.mkdir(uriString, false);
// it should be safe because user cannot select sd root or primary root
// and any other path would have at least one '/' to provide a file name in a folder
String fileName = UriHelper.getLastSegment(uriString);
if (fileName.indexOf(':') != -1) {
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 : "*/*", 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;
}
public DocumentFile goToDocument(String uriString, boolean createIfDirectoryNotExist)
throws SecurityException, IOException {
return goToDocument(uriString, createIfDirectoryNotExist, true);
}
public DocumentFile goToDocument(
String unknownUriStr, boolean createIfDirectoryNotExist, boolean includeLastSegment)
throws SecurityException, IOException, IllegalArgumentException {
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());
if (createIfDirectoryNotExist) {
File targetFile = new File(path);
if (!targetFile.exists()) {
boolean madeFolder = targetFile.mkdirs();
if (!madeFolder) {
throw new IOException("mkdir failed for Uri with `file` scheme");
}
}
}
DocumentFile targetFile = DocumentFile.fromFile(new File(path));
if (!targetFile.exists()) {
throw new FileNotFoundException(
"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) {
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];
{
// 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("")) {
// It's possible that the file access is temporary
baseUri = uriString;
}
Uri uri = Uri.parse(baseUri);
DocumentFile dir = DocumentFile.fromTreeUri(context, uri);
int pathSegmentsToTraverseLength = includeLastSegment ? strings.length : strings.length - 1;
for (int i = 0; i < pathSegmentsToTraverseLength; i++) {
if (!strings[i].equals("")) {
assert dir != null;
DocumentFile childDoc = DocumentFileHelper.findFile(context, dir, strings[i]);
if (childDoc != null) {
if (childDoc.isDirectory()) {
dir = childDoc;
} else if (i == pathSegmentsToTraverseLength - 1) {
// we are at the last part to traverse, its our destination, doesn't matter if its a
// file or directory
dir = childDoc;
} else {
// child doc is a file
throw new IOException(
"There's a document with the same name as the one we are trying to traverse at: '"
+ childDoc.getUri()
+ "'");
}
} else {
if (createIfDirectoryNotExist) {
dir = dir.createDirectory(strings[i]);
} else {
throw new FileNotFoundException(
"Cannot traverse to the pointed document. Directory '"
+ strings[i]
+ "'"
+ " does not exist in '"
+ dir.getUri()
+ "'");
}
}
}
}
assert dir != null;
if (!dir.canRead() || !dir.canWrite()) {
throw new SecurityException(
"You don't have read/write permission to access uri: " + uriString);
}
return dir;
}
public void transferFile(
String srcUri, String destUri, boolean replaceIfDestExists, boolean copy, Promise promise) {
try {
DocumentFile srcDoc = this.goToDocument(srcUri, false, true);
if (srcDoc.isDirectory()) {
throw new IllegalArgumentException("Cannot move directories");
}
DocumentFile destDoc;
try {
destDoc = this.goToDocument(destUri, false, true);
if (!replaceIfDestExists) {
throw new IOException("a document with the same name already exists in destination");
}
} catch (FileNotFoundException e) {
destDoc = this.createFile(destUri, srcDoc.getType());
}
try (InputStream inStream =
this.context.getContentResolver().openInputStream(srcDoc.getUri());
OutputStream outStream =
this.context.getContentResolver().openOutputStream(destDoc.getUri(), "wt"); ) {
byte[] buffer = new byte[1024 * 4];
int length;
while ((length = inStream.read(buffer)) > 0) {
outStream.write(buffer, 0, length);
}
}
if (!copy) {
srcDoc.delete();
}
promise.resolve(resolveWithDocument(destDoc, destUri, promise));
} catch (Exception e) {
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
}
}
}

View File

@ -0,0 +1,132 @@
package com.reactnativesafx.utils;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.DocumentsContract;
import android.webkit.MimeTypeMap;
import androidx.annotation.RequiresApi;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.WritableMap;
import java.io.File;
@RequiresApi(api = Build.VERSION_CODES.N)
public class DocumentStat {
private final Uri uri;
private final Uri internalUri;
private final String displayName;
private final String mimeType;
private final Boolean isDirectory;
private final long size;
private final long lastModified;
private static final String encodedSlash = Uri.encode("/");
private static final String PATH_DOCUMENT = "document";
/**
* Cursor columns must be in the following format:
* 0 - DocumentsContract.Document.COLUMN_DOCUMENT_ID
* 1 - DocumentsContract.Document.COLUMN_DISPLAY_NAME
* 2 - DocumentsContract.Document.COLUMN_MIME_TYPE
* 3 - DocumentsContract.Document.COLUMN_SIZE
* 4 - DocumentsContract.Document.COLUMN_LAST_MODIFIED
*
* @param uri if this is a tree uri, it must be in it's simplified form
* (before being processed for the library)
*/
public DocumentStat(Cursor c, final Uri uri) {
if (DocumentsContract.isTreeUri(uri)) {
final Uri tree = DocumentsContract.buildTreeDocumentUri(uri.getAuthority(), DocumentsContract.getTreeDocumentId(uri));
this.internalUri = DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0));
if (uri.toString().contains("document/raw") || !c.getString(0).contains(DocumentsContract.getTreeDocumentId(uri))) {
this.uri = this.internalUri;
} else {
this.uri = DocumentsContract.buildTreeDocumentUri(uri.getAuthority(), c.getString(0));
}
} else {
this.uri = uri;
this.internalUri = this.uri;
}
this.displayName = c.getString(1);
this.mimeType = c.getString(2);
this.size = c.getLong(3);
this.lastModified = c.getLong(4);
this.isDirectory = DocumentsContract.Document.MIME_TYPE_DIR.equals(this.mimeType);
}
public DocumentStat(final File file) {
this.uri = Uri.fromFile(file);
this.internalUri = this.uri;
this.displayName = file.getName();
this.lastModified = file.lastModified();
this.size = file.length();
this.isDirectory = file.isDirectory();
if (this.isDirectory) {
this.mimeType = DocumentsContract.Document.MIME_TYPE_DIR;
} else {
this.mimeType = getTypeForName(file.getName());
}
}
public WritableMap getWritableMap() {
WritableMap fileMap = Arguments.createMap();
fileMap.putString("uri", UriHelper.denormalize(uri));
fileMap.putString("name", displayName);
if (isDirectory) {
fileMap.putString("type", "directory");
} else {
fileMap.putString("type", "file");
}
fileMap.putString("mime", mimeType);
fileMap.putDouble("size", size);
fileMap.putDouble("lastModified", lastModified);
return fileMap;
}
public String getUri() {
return UriHelper.denormalize(uri);
}
public String getDisplayName() {
return displayName;
}
public String getMimeType() {
return mimeType;
}
public Boolean isDirectory() {
return this.isDirectory;
}
public long getSize() {
return size;
}
public long getLastModified() {
return lastModified;
}
private static String getTypeForName(String name) {
final int lastDot = name.lastIndexOf('.');
if (lastDot >= 0) {
final String extension = name.substring(lastDot + 1).toLowerCase();
final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (mime != null) {
return mime;
}
}
return "application/octet-stream";
}
public Uri getInternalUri() {
return internalUri;
}
}

View File

@ -1,6 +1,7 @@
package com.reactnativesafx.utils;
import android.util.Base64;
import java.nio.charset.StandardCharsets;
public class GeneralHelper {

View File

@ -2,61 +2,92 @@ package com.reactnativesafx.utils;
import android.content.ContentResolver;
import android.net.Uri;
import android.os.Build.VERSION_CODES;
import android.os.Build;
import android.provider.DocumentsContract;
import androidx.annotation.RequiresApi;
import java.util.regex.Pattern;
import com.reactnativesafx.utils.exceptions.IllegalArgumentExceptionFast;
@RequiresApi(api = VERSION_CODES.Q)
import java.util.List;
@RequiresApi(api = Build.VERSION_CODES.N)
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);
private static final String PATH_TREE = "tree";
private static final String PATH_DOCUMENT = "document";
public static String getLastSegment(String uriString) {
return Uri.parse(Uri.decode(uriString)).getLastPathSegment();
public static String getFileName(String uriStr) {
// it should be safe because user cannot select sd root or primary root
// and any other path would have at least one '/' to provide a file name in a folder
String fileName = Uri.parse(Uri.decode(uriStr)).getLastPathSegment();
if (fileName.indexOf(':') != -1) {
throw new RuntimeException(
"Invalid file name: Could not extract filename from uri string provided");
}
public static boolean isContentDocumentTreeUri(String uriString) {
return DOCUMENT_TREE_PREFIX.matcher(uriString).matches();
return fileName;
}
public static String normalize(String uriString) {
if (isContentDocumentTreeUri(uriString)) {
return normalize(Uri.parse(uriString));
}
public static String normalize(Uri uri) {
if (DocumentsContract.isTreeUri(uri)) {
// an abnormal uri example:
// content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin/locks/2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
// normalized:
// content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin%2Flocks%2F2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
if (uri.getPath().indexOf(":") != -1) {
// uri parts:
String[] parts = Uri.decode(uriString).split(":");
String[] parts = Uri.decode(uri.toString()).split(":");
return parts[0] + ":" + parts[1] + Uri.encode(":" + parts[2]);
}
return uriString;
}
return uri.toString();
}
public static String denormalize(String uriString) {
if (isContentDocumentTreeUri(uriString)) {
public static String denormalize(Uri uri) {
if (DocumentsContract.isTreeUri(uri)) {
// an normalized uri example:
// content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin%2Flocks%2F2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
// denormalized:
// content://com.android.externalstorage.documents/tree/1707-3F0B/Ajoplin/locks/2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
return Uri.decode(normalize(uriString));
}
return uriString;
}
public static String getUnifiedUri(String uriString) throws IllegalArgumentException {
Uri uri = Uri.parse(uriString);
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))) {
throw new IllegalArgumentException("Invalid Uri: Scheme not supported");
return Uri.decode(normalize(uri));
}
return uri.toString();
}
public static Uri getUnifiedUri(String unknownUriStr) {
if (unknownUriStr == null || unknownUriStr.equals("")) {
throw new IllegalArgumentExceptionFast("Invalid Uri: No input was given");
}
Uri uri = Uri.parse(unknownUriStr);
if (uri.getScheme() == null) {
uri = Uri.parse(ContentResolver.SCHEME_FILE + "://" + unknownUriStr);
} else if (!(uri.getScheme().equals(ContentResolver.SCHEME_FILE)
|| uri.getScheme().equals(ContentResolver.SCHEME_CONTENT))) {
throw new IllegalArgumentExceptionFast("Invalid Uri: Scheme not supported");
}
return uri;
}
public static boolean isContentUri(Uri uri) {
return uri != null && ContentResolver.SCHEME_CONTENT.equals(uri.getScheme());
}
public static boolean isDocumentUri(Uri uri) {
if (isContentUri(uri)) {
final List<String> paths = uri.getPathSegments();
if (paths.size() >= 4) {
return PATH_TREE.equals(paths.get(0)) && PATH_DOCUMENT.equals(paths.get(2));
} else if (paths.size() >= 2) {
return PATH_DOCUMENT.equals(paths.get(0));
}
}
return false;
}
}

View File

@ -0,0 +1,12 @@
package com.reactnativesafx.utils.exceptions;
public class ExceptionFast extends Exception {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
public ExceptionFast(final String message) {
super(message);
}
}

View File

@ -0,0 +1,18 @@
package com.reactnativesafx.utils.exceptions;
import java.io.FileNotFoundException;
public class FileNotFoundExceptionFast extends FileNotFoundException {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
public FileNotFoundExceptionFast(final String message) {
super(message);
}
public FileNotFoundExceptionFast() {
super();
}
}

View File

@ -0,0 +1,14 @@
package com.reactnativesafx.utils.exceptions;
import java.io.IOException;
public class IOExceptionFast extends IOException {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
public IOExceptionFast(final String message) {
super(message);
}
}

View File

@ -0,0 +1,12 @@
package com.reactnativesafx.utils.exceptions;
public class IllegalArgumentExceptionFast extends IllegalArgumentException {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
public IllegalArgumentExceptionFast(final String message) {
super(message);
}
}

View File

@ -0,0 +1,48 @@
package com.reactnativesafx.utils.exceptions;
import android.net.Uri;
import java.io.IOException;
public class RenameFailedException extends IOException {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
private final Uri inputUri;
private final String inputName;
private final Uri resultUri;
private final String resultName;
public RenameFailedException(final Uri inputUri, final String inputName, final Uri resultUri, final String resultName) {
super("Failed to rename file at: " + inputUri + " expected: '"
+ inputName
+ "'"
+ "but got: "
+ resultName);
this.inputUri = inputUri;
this.inputName = inputName;
this.resultUri = resultUri;
this.resultName = resultName;
}
public Uri getInputUri() {
return inputUri;
}
public String getInputName() {
return inputName;
}
public Uri getResultUri() {
return resultUri;
}
public String getResultName() {
return resultName;
}
}

View File

@ -0,0 +1,12 @@
package com.reactnativesafx.utils.exceptions;
public class SecurityExceptionFast extends SecurityException {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
public SecurityExceptionFast(final String message) {
super(message);
}
}

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -5,7 +5,6 @@
"main": "src/index",
"react-native": "src/index",
"source": "src/index",
"private": true,
"files": [
"src",
"lib",
@ -15,7 +14,10 @@
"react-native-saf-x.podspec",
"!lib/typescript/example",
"!android/build",
"!ios/build"
"!ios/build",
"!**/__tests__",
"!**/__fixtures__",
"!**/__mocks__"
],
"scripts": {
"build": "tsc --project tsconfig.json",
@ -30,8 +32,10 @@
"scoped",
"storage",
"SAF",
"storage-access-framework"
"storage-access-framework",
"fs"
],
"repository": "https://github.com/jd1378/react-native-saf-x",
"author": "Javad Mnjd (https://github.com/jd1378)",
"license": "AGPL-3.0-or-later",
"homepage": "https://github.com/laurent22/joplin/tree/dev/packages/react-native-saf-x",
@ -40,10 +44,9 @@
},
"devDependencies": {
"@babel/core": "7.16.0",
"@types/react": "16.14.21",
"@types/react-native": "0.64.19",
"react": "18.2.0",
"react-native": "0.66.1",
"react-native": "0.70.6",
"typescript": "4.9.4"
},
"peerDependencies": {

View File

@ -50,11 +50,11 @@ interface SafxInterface {
encoding?: Encoding,
mimeType?: string,
append?: boolean,
): Promise<string>;
): Promise<void>;
createFile(uriString: string, mimeType?: String): Promise<DocumentFileDetail>;
unlink(uriString: string): Promise<boolean>;
mkdir(uriString: string): Promise<DocumentFileDetail>;
rename(uriString: string, newName: string): Promise<boolean>;
rename(uriString: string, newName: string): Promise<DocumentFileDetail>;
getPersistedUriPermissions(): Promise<Array<string>>;
releasePersistableUriPermission(uriString: string): Promise<void>;
listFiles(uriString: string): Promise<DocumentFileDetail[]>;
@ -72,8 +72,8 @@ export type DocumentFileDetail = {
name: string;
type: 'directory' | 'file';
lastModified: number;
mime?: string;
size?: number;
mime: string;
size: number;
};
export type FileOperationOptions = {

1073
yarn.lock

File diff suppressed because it is too large Load Diff