1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-06 09:19:22 +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
19 changed files with 1502 additions and 1862 deletions

View File

@@ -3,11 +3,12 @@ buildscript {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
jcenter()
} }
dependencies { 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() google()
mavenCentral() mavenCentral()
jcenter()
} }
dependencies { dependencies {

View File

@@ -6,7 +6,6 @@ import android.os.Build.VERSION_CODES;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
import androidx.documentfile.provider.DocumentFile;
import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise; 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.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.module.annotations.ReactModule;
import com.reactnativesafx.utils.DocumentHelper; import com.reactnativesafx.utils.EfficientDocumentHelper;
import com.reactnativesafx.utils.GeneralHelper;
import com.reactnativesafx.utils.UriHelper; 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) @RequiresApi(api = VERSION_CODES.Q)
@ReactModule(name = SafXModule.NAME) @ReactModule(name = SafXModule.NAME)
public class SafXModule extends ReactContextBaseJavaModule { public class SafXModule extends ReactContextBaseJavaModule {
public static final String NAME = "SafX"; public static final String NAME = "SafX";
public static Pattern trailingSlash = Pattern.compile("[/\\\\]$"); private final EfficientDocumentHelper efficientDocumentHelper;
private final DocumentHelper documentHelper;
public SafXModule(ReactApplicationContext reactContext) { public SafXModule(ReactApplicationContext reactContext) {
super(reactContext); super(reactContext);
this.documentHelper = new DocumentHelper(reactContext); this.efficientDocumentHelper = new EfficientDocumentHelper(reactContext);
} }
@Override @Override
@@ -46,183 +36,84 @@ public class SafXModule extends ReactContextBaseJavaModule {
@ReactMethod @ReactMethod
public void openDocumentTree(final boolean persist, final Promise promise) { public void openDocumentTree(final boolean persist, final Promise promise) {
this.documentHelper.openDocumentTree(persist, promise); this.efficientDocumentHelper.openDocumentTree(persist, promise);
} }
@ReactMethod @ReactMethod
public void openDocument(final boolean persist, final boolean multiple, final Promise promise) { 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 @ReactMethod
public void createDocument( public void createDocument(
final String data, final String data,
final String encoding, final String encoding,
final String initialName, final String initialName,
final String mimeType, final String mimeType,
final Promise promise) { final Promise promise) {
this.documentHelper.createDocument(data, encoding, initialName, mimeType, promise); this.efficientDocumentHelper.createDocument(data, encoding, initialName, mimeType, promise);
} }
@ReactMethod @ReactMethod
public void hasPermission(String uriString, final Promise promise) { public void hasPermission(String uriString, final Promise promise) {
if (this.documentHelper.hasPermission(uriString)) { this.efficientDocumentHelper.hasPermission(uriString, promise);
promise.resolve(true);
} else {
promise.resolve(false);
}
} }
@ReactMethod @ReactMethod
public void exists(String uriString, final Promise promise) { public void exists(String uriString, final Promise promise) {
try { this.efficientDocumentHelper.exists(uriString, promise);
promise.resolve(this.documentHelper.exists(uriString));
} catch (SecurityException e) {
promise.reject("EPERM", e.getLocalizedMessage());
} catch (Exception e) {
promise.reject("ERROR", e.getLocalizedMessage());
}
} }
@ReactMethod @ReactMethod
public void readFile(String uriString, String encoding, final Promise promise) { public void readFile(String uriString, String encoding, final Promise promise) {
try { this.efficientDocumentHelper.readFile(uriString, encoding, promise);
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());
}
} }
@ReactMethod @ReactMethod
public void writeFile( public void writeFile(
String uriString, String uriString,
String data, String data,
String encoding, String encoding,
String mimeType, String mimeType,
boolean append, boolean append,
final Promise promise) { final Promise promise) {
try { this.efficientDocumentHelper.writeFile(
DocumentFile file; uriString, data, encoding, mimeType, append, promise
);
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());
}
} }
@ReactMethod @ReactMethod
public void transferFile( public void transferFile(
String srcUri, String destUri, boolean replaceIfDestExists, boolean copy, Promise promise) { 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 @ReactMethod
public void rename(String uriString, String newName, final Promise promise) { public void rename(String uriString, String newName, final Promise promise) {
try { this.efficientDocumentHelper.renameTo(uriString, newName, promise);
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());
}
} }
@ReactMethod @ReactMethod
public void unlink(String uriString, final Promise promise) { public void unlink(String uriString, final Promise promise) {
try { this.efficientDocumentHelper.unlink(uriString, promise);
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());
}
} }
@ReactMethod @ReactMethod
public void mkdir(String uriString, final Promise promise) { public void mkdir(String uriString, final Promise promise) {
try { this.efficientDocumentHelper.mkdir(uriString, promise);
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());
}
} }
@ReactMethod @ReactMethod
public void createFile(String uriString, String mimeType, final Promise promise) { public void createFile(String uriString, String mimeType, final Promise promise) {
try { this.efficientDocumentHelper.createFile(uriString, mimeType, promise);
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());
}
} }
@ReactMethod @ReactMethod
public void getPersistedUriPermissions(final Promise promise) { public void getPersistedUriPermissions(final Promise promise) {
String[] uriList = String[] uriList =
getReactApplicationContext().getContentResolver().getPersistedUriPermissions().stream() getReactApplicationContext().getContentResolver().getPersistedUriPermissions().stream()
.map(uriPermission -> uriPermission.getUri().toString()) .map(uriPermission -> uriPermission.getUri().toString())
.toArray(String[]::new); .toArray(String[]::new);
WritableArray wa = Arguments.fromArray(uriList); WritableArray wa = Arguments.fromArray(uriList);
promise.resolve(wa); promise.resolve(wa);
@@ -232,52 +123,20 @@ public class SafXModule extends ReactContextBaseJavaModule {
public void releasePersistableUriPermission(String uriString, final Promise promise) { public void releasePersistableUriPermission(String uriString, final Promise promise) {
Uri uriToRevoke = Uri.parse(UriHelper.normalize(uriString)); Uri uriToRevoke = Uri.parse(UriHelper.normalize(uriString));
final int takeFlags = final int takeFlags =
(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
this.getReactApplicationContext() this.getReactApplicationContext()
.getContentResolver() .getContentResolver()
.releasePersistableUriPermission(uriToRevoke, takeFlags); .releasePersistableUriPermission(uriToRevoke, takeFlags);
promise.resolve(null); promise.resolve(null);
} }
@ReactMethod @ReactMethod
public void listFiles(String uriString, final Promise promise) { public void listFiles(String uriString, final Promise promise) {
try { efficientDocumentHelper.listFiles(uriString, promise);
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());
}
} }
@ReactMethod @ReactMethod
public void stat(String uriString, final Promise promise) { public void stat(String uriString, final Promise promise) {
try { efficientDocumentHelper.stat(uriString, promise);
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());
}
} }
} }

View File

@@ -12,17 +12,17 @@ import java.util.Collections;
import java.util.List; import java.util.List;
public class SafXPackage implements ReactPackage { public class SafXPackage implements ReactPackage {
@NonNull @NonNull
@Override @Override
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) { public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>(); List<NativeModule> modules = new ArrayList<>();
modules.add(new SafXModule(reactContext)); modules.add(new SafXModule(reactContext));
return modules; return modules;
} }
@NonNull @NonNull
@Override @Override
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) { public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
return Collections.emptyList(); return Collections.emptyList();
} }
} }

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,13 +1,14 @@
package com.reactnativesafx.utils; package com.reactnativesafx.utils;
import android.util.Base64; import android.util.Base64;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
public class GeneralHelper { public class GeneralHelper {
/** /**
* String to byte converter method * String to byte converter method
* *
* @param data Raw data in string format * @param data Raw data in string format
* @param encoding Decoder name * @param encoding Decoder name
* @return Converted data byte array * @return Converted data byte array
*/ */

View File

@@ -2,61 +2,92 @@ package com.reactnativesafx.utils;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.net.Uri; import android.net.Uri;
import android.os.Build.VERSION_CODES; import android.os.Build;
import android.provider.DocumentsContract;
import androidx.annotation.RequiresApi; 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 class UriHelper {
public static final String CONTENT_URI_PREFIX = "content://"; private static final String PATH_TREE = "tree";
public static final Pattern DOCUMENT_TREE_PREFIX = private static final String PATH_DOCUMENT = "document";
Pattern.compile("^content://.*?/tree/.+?", Pattern.CASE_INSENSITIVE);
public static String getLastSegment(String uriString) { 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");
}
return Uri.parse(Uri.decode(uriString)).getLastPathSegment(); return fileName;
}
public static boolean isContentDocumentTreeUri(String uriString) {
return DOCUMENT_TREE_PREFIX.matcher(uriString).matches();
} }
public static String normalize(String uriString) { 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: // an abnormal uri example:
// content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin/locks/2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json // content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin/locks/2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
// normalized: // normalized:
// content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin%2Flocks%2F2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json // content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin%2Flocks%2F2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
// uri parts: if (uri.getPath().indexOf(":") != -1) {
String[] parts = Uri.decode(uriString).split(":"); // uri parts:
return parts[0] + ":" + parts[1] + Uri.encode(":" + parts[2]); 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) { public static String denormalize(Uri uri) {
if (isContentDocumentTreeUri(uriString)) { if (DocumentsContract.isTreeUri(uri)) {
// an normalized uri example: // an normalized uri example:
// content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin%2Flocks%2F2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json // content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin%2Flocks%2F2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
// denormalized: // denormalized:
// content://com.android.externalstorage.documents/tree/1707-3F0B/Ajoplin/locks/2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json // content://com.android.externalstorage.documents/tree/1707-3F0B/Ajoplin/locks/2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
return Uri.decode(normalize(uriString)); return Uri.decode(normalize(uri));
}
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.toString(); 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

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

View File

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

1073
yarn.lock

File diff suppressed because it is too large Load Diff