diff --git a/packages/capacitor-plugin/README.md b/packages/capacitor-plugin/README.md index 53ae388..be555e0 100644 --- a/packages/capacitor-plugin/README.md +++ b/packages/capacitor-plugin/README.md @@ -572,17 +572,17 @@ Copy a file or directory #### Directory -| Members | Value | Description | Since | -| --------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| **`Documents`** | 'DOCUMENTS' | The Documents directory. On iOS it's the app's documents directory. Use this directory to store user-generated content. On Android it's the Public Documents folder, so it's accessible from other apps. It's not accesible on Android 10 unless the app enables legacy External Storage by adding `android:requestLegacyExternalStorage="true"` in the `application` tag in the `AndroidManifest.xml`. On Android 11 or newer the app can only access the files/folders the app created. | 1.0.0 | -| **`Data`** | 'DATA' | The Data directory. On iOS it will use the Documents directory. On Android it's the directory holding application files. Files will be deleted when the application is uninstalled. | 1.0.0 | -| **`Library`** | 'LIBRARY' | The Library directory. On iOS it will use the Library directory. On Android it's the directory holding application files. Files will be deleted when the application is uninstalled. | 1.1pn.0 | -| **`Cache`** | 'CACHE' | The Cache directory. Can be deleted in cases of low memory, so use this directory to write app-specific files. that your app can re-create easily. | 1.0.0 | -| **`External`** | 'EXTERNAL' | The external directory. On iOS it will use the Documents directory. On Android it's the directory on the primary shared/external storage device where the application can place persistent files it owns. These files are internal to the applications, and not typically visible to the user as media. Files will be deleted when the application is uninstalled. | 1.0.0 | -| **`ExternalStorage`** | 'EXTERNAL_STORAGE' | The external storage directory. On iOS it will use the Documents directory. On Android it's the primary shared/external storage directory. It's not accesible on Android 10 unless the app enables legacy External Storage by adding `android:requestLegacyExternalStorage="true"` in the `application` tag in the `AndroidManifest.xml`. It's not accesible on Android 11 or newer. | 1.0.0 | -| **`ExternalCache`** | 'EXTERNAL_CACHE' | The external cache directory. Android ONly On Android it's the primary shared/external cache. It's not accesible on Android 10 unless the app enables legacy External Storage by adding `android:requestLegacyExternalStorage="true"` in the `application` tag in the `AndroidManifest.xml`. It's not accesible on Android 11 or newer. | 7.1.0 | -| **`LibraryNoCloud`** | 'LIBRARY_NO_CLOUD' | iOS only It maps to Library/NoCloud directory Files will be deleted when the application is uninstalled. | 7.1.0 | -| **`Temporary`** | 'TEMPORARY' | iOS only The tmp/ directory. Files will be deleted when the application is uninstalled. | 7.1.0 | +| Members | Value | Description | Since | +| --------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`Documents`** | 'DOCUMENTS' | The Documents directory. On iOS it's the app's documents directory. Use this directory to store user-generated content. On Android it's the Public Documents folder, so it's accessible from other apps. It's not accesible on Android 10 unless the app enables legacy External Storage by adding `android:requestLegacyExternalStorage="true"` in the `application` tag in the `AndroidManifest.xml`. On Android 11 or newer the app can only access the files/folders the app created. | 1.0.0 | +| **`Data`** | 'DATA' | The Data directory. On iOS it will use the Documents directory. On Android it's the directory holding application files. Files will be deleted when the application is uninstalled. | 1.0.0 | +| **`Library`** | 'LIBRARY' | The Library directory. On iOS it will use the Library directory. On Android it's the directory holding application files. Files will be deleted when the application is uninstalled. | 1.1.0 | +| **`Cache`** | 'CACHE' | The Cache directory. Can be deleted in cases of low memory, so use this directory to write app-specific files. that your app can re-create easily. | 1.0.0 | +| **`External`** | 'EXTERNAL' | The external directory. On iOS it will use the Documents directory. On Android it's the directory on the primary shared/external storage device where the application can place persistent files it owns. These files are internal to the applications, and not typically visible to the user as media. Files will be deleted when the application is uninstalled. | 1.0.0 | +| **`ExternalStorage`** | 'EXTERNAL_STORAGE' | The external storage directory. On iOS it will use the Documents directory. On Android it's the primary shared/external storage directory. It's not accesible on Android 10 unless the app enables legacy External Storage by adding `android:requestLegacyExternalStorage="true"` in the `application` tag in the `AndroidManifest.xml`. It's not accesible on Android 11 or newer. | 1.0.0 | +| **`ExternalCache`** | 'EXTERNAL_CACHE' | The external cache directory. On iOS it will use the Documents directory. On Android it's the primary shared/external cache. It's not accesible on Android 10 unless the app enables legacy External Storage by adding `android:requestLegacyExternalStorage="true"` in the `application` tag in the `AndroidManifest.xml`. It's not accesible on Android 11 or newer. | 7.1.0 | +| **`LibraryNoCloud`** | 'LIBRARY_NO_CLOUD' | The Library directory without cloud backup. Used in iOS On Android it's the directory holding application files. | 7.1.0 | +| **`Temporary`** | 'TEMPORARY' | A temporary directory for iOS. Om Android it's the directory holding the application cache. | 7.1.0 | #### Encoding diff --git a/packages/capacitor-plugin/android/build.gradle b/packages/capacitor-plugin/android/build.gradle index fa1022f..de26288 100644 --- a/packages/capacitor-plugin/android/build.gradle +++ b/packages/capacitor-plugin/android/build.gradle @@ -59,6 +59,7 @@ repositories { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) //implementation("io.ionic.libs:ionfilesystem-android:1.0.0") + implementation project(':tmp-native-lib-android') implementation project(':capacitor-android') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" diff --git a/packages/capacitor-plugin/android/settings.gradle b/packages/capacitor-plugin/android/settings.gradle index b6cd38d..d5d1113 100644 --- a/packages/capacitor-plugin/android/settings.gradle +++ b/packages/capacitor-plugin/android/settings.gradle @@ -1,2 +1,6 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +// this is here until native library is published; TODO remove this once that happens. +include ':tmp-native-lib-android' +project(':tmp-native-lib-android').projectDir = new File('../../tmp_native_lib_android') \ No newline at end of file diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/Filesystem.java b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/Filesystem.java deleted file mode 100644 index 82fd848..0000000 --- a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/Filesystem.java +++ /dev/null @@ -1,412 +0,0 @@ -package com.capacitorjs.plugins.filesystem; - -import android.content.Context; -import android.net.Uri; -import android.os.Environment; -import android.os.Handler; -import android.os.Looper; -import android.util.Base64; -import com.capacitorjs.plugins.filesystem.exceptions.CopyFailedException; -import com.capacitorjs.plugins.filesystem.exceptions.DirectoryExistsException; -import com.capacitorjs.plugins.filesystem.exceptions.DirectoryNotFoundException; -import com.getcapacitor.Bridge; -import com.getcapacitor.JSObject; -import com.getcapacitor.PluginCall; -import com.getcapacitor.plugin.util.CapacitorHttpUrlConnection; -import com.getcapacitor.plugin.util.HttpRequestHandler; -import java.io.BufferedWriter; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStreamWriter; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.channels.FileChannel; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Locale; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import org.json.JSONException; - -public class Filesystem { - - private Context context; - - Filesystem(Context context) { - this.context = context; - } - - public String readFile(String path, String directory, Charset charset) throws IOException { - InputStream is = getInputStream(path, directory); - String dataStr; - if (charset != null) { - dataStr = readFileAsString(is, charset.name()); - } else { - dataStr = readFileAsBase64EncodedData(is); - } - return dataStr; - } - - public void saveFile(File file, String data, Charset charset, Boolean append) throws IOException { - // if charset is not null assume its a plain text file the user wants to save - if (charset != null) { - BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file, append), charset)); - writer.write(data); - writer.close(); - } else { - //remove header from dataURL - if (data.contains(",")) { - data = data.split(",")[1]; - } - FileOutputStream fos = new FileOutputStream(file, append); - fos.write(Base64.decode(data, Base64.NO_WRAP)); - fos.close(); - } - } - - public boolean deleteFile(String file, String directory) throws FileNotFoundException { - File fileObject = getFileObject(file, directory); - if (!fileObject.exists()) { - throw new FileNotFoundException("File does not exist"); - } - return fileObject.delete(); - } - - public boolean mkdir(String path, String directory, Boolean recursive) throws DirectoryExistsException { - File fileObject = getFileObject(path, directory); - - if (fileObject.exists()) { - throw new DirectoryExistsException("Directory exists"); - } - - boolean created = false; - if (recursive) { - created = fileObject.mkdirs(); - } else { - created = fileObject.mkdir(); - } - return created; - } - - public File[] readdir(String path, String directory) throws DirectoryNotFoundException { - File[] files = null; - File fileObject = getFileObject(path, directory); - if (fileObject != null && fileObject.exists()) { - files = fileObject.listFiles(); - } else { - throw new DirectoryNotFoundException("Directory does not exist"); - } - return files; - } - - public File copy(String from, String directory, String to, String toDirectory, boolean doRename) - throws IOException, CopyFailedException { - if (toDirectory == null) { - toDirectory = directory; - } - - File fromObject = getFileObject(from, directory); - File toObject = getFileObject(to, toDirectory); - - if (fromObject == null) { - throw new CopyFailedException("from file is null"); - } - if (toObject == null) { - throw new CopyFailedException("to file is null"); - } - - if (toObject.equals(fromObject)) { - return toObject; - } - - if (!fromObject.exists()) { - throw new CopyFailedException("The source object does not exist"); - } - - if (toObject.getParentFile().isFile()) { - throw new CopyFailedException("The parent object of the destination is a file"); - } - - if (!toObject.getParentFile().exists()) { - throw new CopyFailedException("The parent object of the destination does not exist"); - } - - if (toObject.isDirectory()) { - throw new CopyFailedException("Cannot overwrite a directory"); - } - - toObject.delete(); - - if (doRename) { - boolean modified = fromObject.renameTo(toObject); - if (!modified) { - throw new CopyFailedException("Unable to rename, unknown reason"); - } - } else { - copyRecursively(fromObject, toObject); - } - - return toObject; - } - - public InputStream getInputStream(String path, String directory) throws IOException { - if (directory == null) { - Uri u = Uri.parse(path); - if (u.getScheme().equals("content")) { - return this.context.getContentResolver().openInputStream(u); - } else { - return new FileInputStream(new File(u.getPath())); - } - } - - File androidDirectory = this.getDirectory(directory); - - if (androidDirectory == null) { - throw new IOException("Directory not found"); - } - - return new FileInputStream(new File(androidDirectory, path)); - } - - public String readFileAsString(InputStream is, String encoding) throws IOException { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - - byte[] buffer = new byte[1024]; - int length = 0; - - while ((length = is.read(buffer)) != -1) { - outputStream.write(buffer, 0, length); - } - - return outputStream.toString(encoding); - } - - public String readFileAsBase64EncodedData(InputStream is) throws IOException { - FileInputStream fileInputStreamReader = (FileInputStream) is; - ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); - - byte[] buffer = new byte[1024]; - - int c; - while ((c = fileInputStreamReader.read(buffer)) != -1) { - byteStream.write(buffer, 0, c); - } - fileInputStreamReader.close(); - - return Base64.encodeToString(byteStream.toByteArray(), Base64.NO_WRAP); - } - - @SuppressWarnings("deprecation") - public File getDirectory(String directory) { - Context c = this.context; - switch (directory) { - case "DOCUMENTS": - return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS); - case "DATA": - case "LIBRARY": - return c.getFilesDir(); - case "CACHE": - return c.getCacheDir(); - case "EXTERNAL": - return c.getExternalFilesDir(null); - case "EXTERNAL_STORAGE": - return Environment.getExternalStorageDirectory(); - } - return null; - } - - public File getFileObject(String path, String directory) { - if (directory == null) { - Uri u = Uri.parse(path); - if (u.getScheme() == null || u.getScheme().equals("file")) { - return new File(u.getPath()); - } - } - - File androidDirectory = this.getDirectory(directory); - - if (androidDirectory == null) { - return null; - } else { - if (!androidDirectory.exists()) { - androidDirectory.mkdir(); - } - } - - return new File(androidDirectory, path); - } - - public Charset getEncoding(String encoding) { - if (encoding == null) { - return null; - } - - switch (encoding) { - case "utf8": - return StandardCharsets.UTF_8; - case "utf16": - return StandardCharsets.UTF_16; - case "ascii": - return StandardCharsets.US_ASCII; - } - return null; - } - - /** - * Helper function to recursively delete a directory - * - * @param file The file or directory to recursively delete - * @throws IOException - */ - public void deleteRecursively(File file) throws IOException { - if (file.isFile()) { - file.delete(); - return; - } - - for (File f : file.listFiles()) { - deleteRecursively(f); - } - - file.delete(); - } - - /** - * Helper function to recursively copy a directory structure (or just a file) - * - * @param src The source location - * @param dst The destination location - * @throws IOException - */ - public void copyRecursively(File src, File dst) throws IOException { - if (src.isDirectory()) { - dst.mkdir(); - - for (String file : src.list()) { - copyRecursively(new File(src, file), new File(dst, file)); - } - - return; - } - - if (!dst.getParentFile().exists()) { - dst.getParentFile().mkdirs(); - } - - if (!dst.exists()) { - dst.createNewFile(); - } - - try (FileChannel source = new FileInputStream(src).getChannel(); FileChannel destination = new FileOutputStream(dst).getChannel()) { - destination.transferFrom(source, 0, source.size()); - } - } - - public void downloadFile( - PluginCall call, - Bridge bridge, - HttpRequestHandler.ProgressEmitter emitter, - FilesystemDownloadCallback callback - ) { - String urlString = call.getString("url", ""); - ExecutorService executor = Executors.newSingleThreadExecutor(); - Handler handler = new Handler(Looper.getMainLooper()); - - executor.execute(() -> { - try { - JSObject result = doDownloadInBackground(urlString, call, bridge, emitter); - handler.post(() -> callback.onSuccess(result)); - } catch (Exception error) { - handler.post(() -> callback.onError(error)); - } finally { - executor.shutdown(); - } - }); - } - - private JSObject doDownloadInBackground(String urlString, PluginCall call, Bridge bridge, HttpRequestHandler.ProgressEmitter emitter) - throws IOException, URISyntaxException, JSONException { - JSObject headers = call.getObject("headers", new JSObject()); - JSObject params = call.getObject("params", new JSObject()); - Integer connectTimeout = call.getInt("connectTimeout"); - Integer readTimeout = call.getInt("readTimeout"); - Boolean disableRedirects = call.getBoolean("disableRedirects"); - Boolean shouldEncode = call.getBoolean("shouldEncodeUrlParams", true); - Boolean progress = call.getBoolean("progress", false); - - String method = call.getString("method", "GET").toUpperCase(Locale.ROOT); - String path = call.getString("path"); - String directory = call.getString("directory", Environment.DIRECTORY_DOWNLOADS); - - final URL url = new URL(urlString); - final File file = getFileObject(path, directory); - - HttpRequestHandler.HttpURLConnectionBuilder connectionBuilder = new HttpRequestHandler.HttpURLConnectionBuilder() - .setUrl(url) - .setMethod(method) - .setHeaders(headers) - .setUrlParams(params, shouldEncode) - .setConnectTimeout(connectTimeout) - .setReadTimeout(readTimeout) - .setDisableRedirects(disableRedirects) - .openConnection(); - - CapacitorHttpUrlConnection connection = connectionBuilder.build(); - - connection.setSSLSocketFactory(bridge); - - InputStream connectionInputStream = connection.getInputStream(); - FileOutputStream fileOutputStream = new FileOutputStream(file, false); - - String contentLength = connection.getHeaderField("content-length"); - int bytes = 0; - int maxBytes = 0; - - try { - maxBytes = contentLength != null ? Integer.parseInt(contentLength) : 0; - } catch (NumberFormatException ignored) {} - - byte[] buffer = new byte[1024]; - int len; - - // Throttle emitter to 100ms so it doesn't slow down app - long lastEmitTime = System.currentTimeMillis(); - long minEmitIntervalMillis = 100; - - while ((len = connectionInputStream.read(buffer)) > 0) { - fileOutputStream.write(buffer, 0, len); - - bytes += len; - - if (progress && null != emitter) { - long currentTime = System.currentTimeMillis(); - if (currentTime - lastEmitTime > minEmitIntervalMillis) { - emitter.emit(bytes, maxBytes); - lastEmitTime = currentTime; - } - } - } - - if (progress && null != emitter) { - emitter.emit(bytes, maxBytes); - } - - connectionInputStream.close(); - fileOutputStream.close(); - - JSObject ret = new JSObject(); - ret.put("path", file.getAbsolutePath()); - return ret; - } - - public interface FilesystemDownloadCallback { - void onSuccess(JSObject result); - - void onError(Exception error); - } -} diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemErrors.kt b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemErrors.kt new file mode 100644 index 0000000..306700e --- /dev/null +++ b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemErrors.kt @@ -0,0 +1,101 @@ +package com.capacitorjs.plugins.filesystem + +import io.ionic.libs.ionfilesystemlib.model.IONFILEExceptions + +object FilesystemErrors { + private fun formatErrorCode(number: Int): String { + return "OS-PLUG-FILE-" + number.toString().padStart(4, '0') + } + + data class ErrorInfo( + val code: String, + val message: String + ) + + fun invalidInputMethod(methodName: String): ErrorInfo = ErrorInfo( + code = formatErrorCode(5), + message = "The '$methodName' input. parameters aren't valid." + ) + + fun invalidPath(path: String): ErrorInfo = ErrorInfo( + code = formatErrorCode(6), + message = "Invalid ${if (path.isNotBlank()) "'$path' " else ""}path." + ) + + val filePermissionsDenied: ErrorInfo = ErrorInfo( + code = formatErrorCode(7), + message = "Unable to do file operation, user denied permission request." + ) + + fun doesNotExist(methodName: String, path: String): ErrorInfo = ErrorInfo( + code = formatErrorCode(8), + message = "'$methodName' failed because file ${if (path.isNotBlank()) "at '$path' " else ""}does not exist." + ) + + fun notAllowed(methodName: String, notAllowedFor: String): ErrorInfo = ErrorInfo( + code = formatErrorCode(9), + message = "'$methodName' not supported for $notAllowedFor." + ) + + fun directoryCreationAlreadyExists(path: String): ErrorInfo = ErrorInfo( + code = formatErrorCode(10), + message = "Directory ${if (path.isNotBlank()) "at '$path' " else ""}already exists, cannot be overwritten." + ) + + val missingParentDirectories: ErrorInfo = ErrorInfo( + code = formatErrorCode(11), + message = "Missing parent directory – possibly recursive=false was passed or parent directory creation failed." + ) + + val cannotDeleteChildren: ErrorInfo = ErrorInfo( + code = formatErrorCode(12), + message = "Cannot delete directory with children; received recursive=false but directory has contents." + ) + + fun operationFailed(methodName: String, errorMessage: String): ErrorInfo = ErrorInfo( + code = formatErrorCode(13), + message = "'$methodName' failed with${if (errorMessage.isNotBlank()) ": $errorMessage" else "an unknown error."}" + ) +} + +fun Throwable.toFilesystemError(methodName: String): FilesystemErrors.ErrorInfo = when (this) { + + is IONFILEExceptions.UnresolvableUri -> FilesystemErrors.invalidPath(this.uri) + + is IONFILEExceptions.DoesNotExist -> FilesystemErrors.doesNotExist(methodName, this.path) + + is IONFILEExceptions.NotSupportedForContentScheme -> FilesystemErrors.notAllowed( + methodName, + notAllowedFor = "content:// URIs" + ) + + is IONFILEExceptions.NotSupportedForDirectory -> FilesystemErrors.notAllowed( + methodName, + notAllowedFor = "directories" + ) + + is IONFILEExceptions.NotSupportedForFiles -> FilesystemErrors.notAllowed( + methodName, + notAllowedFor = "files, only directories are supported" + ) + + is IONFILEExceptions.CreateFailed.AlreadyExists -> + FilesystemErrors.directoryCreationAlreadyExists(this.path) + + is IONFILEExceptions.CreateFailed.NoParentDirectory -> FilesystemErrors.missingParentDirectories + + is IONFILEExceptions.DeleteFailed.CannotDeleteChildren -> FilesystemErrors.cannotDeleteChildren + + is IONFILEExceptions.CopyRenameFailed.MixingFilesAndDirectories, + is IONFILEExceptions.CopyRenameFailed.LocalToContent, + is IONFILEExceptions.CopyRenameFailed.SourceAndDestinationContent -> + FilesystemErrors.notAllowed(methodName, "the provided source and destinations") + + is IONFILEExceptions.CopyRenameFailed.DestinationDirectoryExists -> + FilesystemErrors.directoryCreationAlreadyExists(this.path) + + is IONFILEExceptions.CopyRenameFailed.NoParentDirectory -> + FilesystemErrors.missingParentDirectories + + else -> FilesystemErrors.operationFailed(methodName, this.localizedMessage ?: "") +} \ No newline at end of file diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemMethodOptions.kt b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemMethodOptions.kt new file mode 100644 index 0000000..6af0d76 --- /dev/null +++ b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemMethodOptions.kt @@ -0,0 +1,134 @@ +package com.capacitorjs.plugins.filesystem + +import com.getcapacitor.PluginCall +import io.ionic.libs.ionfilesystemlib.model.IONFILEEncoding +import io.ionic.libs.ionfilesystemlib.model.IONFILEFolderType +import io.ionic.libs.ionfilesystemlib.model.IONFILEReadInChunksOptions +import io.ionic.libs.ionfilesystemlib.model.IONFILEReadOptions +import io.ionic.libs.ionfilesystemlib.model.IONFILESaveMode +import io.ionic.libs.ionfilesystemlib.model.IONFILESaveOptions +import io.ionic.libs.ionfilesystemlib.model.IONFILEUri + +internal val INPUT_APPEND = "append" +private val INPUT_PATH = "path" +private val INPUT_DIRECTORY = "directory" +private val INPUT_ENCODING = "encoding" +private val INPUT_CHUNK_SIZE = "chunkSize" +private val INPUT_DATA = "data" +private val INPUT_RECURSIVE = "recursive" +private val INPUT_FROM = "from" +private val INPUT_FROM_DIRECTORY = "directory" +private val INPUT_TO = "to" +private val INPUT_TO_DIRECTORY = "toDirectory" + +internal data class ReadFileOptions( + val uri: IONFILEUri.Unresolved, + val options: IONFILEReadOptions +) + +internal data class ReadFileInChunksOptions( + val uri: IONFILEUri.Unresolved, + val options: IONFILEReadInChunksOptions +) + +internal data class WriteFileOptions( + val uri: IONFILEUri.Unresolved, + val options: IONFILESaveOptions +) + +internal data class SingleUriWithRecursiveOptions( + val uri: IONFILEUri.Unresolved, + val recursive: Boolean +) + +internal data class DoubleUri( + val fromUri: IONFILEUri.Unresolved, + val toUri: IONFILEUri.Unresolved, +) + +/** + * @return [ReadFileOptions] from JSON inside [PluginCall], or null if input is invalid + */ +internal fun PluginCall.getReadFileOptions(): ReadFileOptions? { + val uri = getSingleIONFILEUri() ?: return null + val encodingName = getString(INPUT_ENCODING) + return ReadFileOptions( + uri = uri, + options = IONFILEReadOptions(IONFILEEncoding.fromEncodingName(encodingName)) + ) +} + +/** + * @return [ReadFileInChunksOptions] from JSON inside [PluginCall], or null if input is invalid + */ +internal fun PluginCall.getReadFileInChunksOptions(): ReadFileInChunksOptions? { + val uri = getSingleIONFILEUri() ?: return null + val encodingName = getString(INPUT_ENCODING) + val chunkSize = getInt(INPUT_CHUNK_SIZE)?.takeIf { it > 0 } ?: return null + return ReadFileInChunksOptions( + uri = uri, + options = IONFILEReadInChunksOptions( + IONFILEEncoding.fromEncodingName(encodingName), + chunkSize + ) + ) +} + +/** + * @return [ReadFileOptions] from JSON inside [PluginCall], or null if input is invalid + */ +internal fun PluginCall.getWriteFileOptions(): WriteFileOptions? { + val uri = getSingleIONFILEUri() ?: return null + val data = getString(INPUT_DATA) ?: return null + val recursive = getBoolean(INPUT_RECURSIVE) ?: false + val append = getBoolean(INPUT_APPEND) ?: false + val encodingName = getString(INPUT_ENCODING) + return WriteFileOptions( + uri = uri, + options = IONFILESaveOptions( + data = data, + encoding = IONFILEEncoding.fromEncodingName(encodingName), + mode = if (append) IONFILESaveMode.APPEND else IONFILESaveMode.WRITE, + createFileRecursive = recursive + ) + ) +} + +/** + * @return [SingleUriWithRecursiveOptions] from JSON inside [PluginCall], or null if input is invalid + */ +internal fun PluginCall.getSingleUriWithRecursiveOptions(): SingleUriWithRecursiveOptions? { + val uri = getSingleIONFILEUri() ?: return null + val recursive = getBoolean(INPUT_RECURSIVE) ?: false + return SingleUriWithRecursiveOptions(uri = uri, recursive = recursive) +} + +/** + * @return two uris in form of [DoubleUri] from JSON inside [PluginCall], or null if input is invalid + */ +internal fun PluginCall.getDoubleIONFILEUri(): DoubleUri? { + val fromPath = getString(INPUT_FROM) ?: return null + val fromFolder = IONFILEFolderType.fromStringAlias(getString(INPUT_FROM_DIRECTORY)) + val toPath = getString(INPUT_TO) ?: return null + val toFolder = getString(INPUT_TO_DIRECTORY)?.let { toDirectory -> + IONFILEFolderType.fromStringAlias(toDirectory) + } ?: fromFolder + return DoubleUri( + fromUri = IONFILEUri.Unresolved(fromFolder, fromPath), + toUri = IONFILEUri.Unresolved(toFolder, toPath), + ) +} + +/** + * return a single [IONFILEUri.Unresolved] from JSON inside [PluginCall], or null if input is invalid + */ +internal fun PluginCall.getSingleIONFILEUri(): IONFILEUri.Unresolved? { + val path = getString(INPUT_PATH) ?: return null + val directoryAlias = getString(INPUT_DIRECTORY) + return unresolvedUri(path, directoryAlias) +} + +private fun unresolvedUri(path: String, directoryAlias: String?) = IONFILEUri.Unresolved( + parentFolder = IONFILEFolderType.fromStringAlias(directoryAlias), + uriPath = path +) diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemMethodResults.kt b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemMethodResults.kt new file mode 100644 index 0000000..20a5295 --- /dev/null +++ b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemMethodResults.kt @@ -0,0 +1,69 @@ +package com.capacitorjs.plugins.filesystem + +import android.net.Uri +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import io.ionic.libs.ionfilesystemlib.model.IONFILEFileType +import io.ionic.libs.ionfilesystemlib.model.IONFILEMetadataResult +import io.ionic.libs.ionfilesystemlib.model.IONFILESaveMode +import io.ionic.libs.ionfilesystemlib.model.IONFILEUri + +private val OUTPUT_DATA = "data" +private val OUTPUT_NAME = "name" +private val OUTPUT_TYPE = "type" +private val OUTPUT_SIZE = "size" +private val OUTPUT_MODIFIED_TIME = "mtime" +private val OUTPUT_CREATED_TIME = "ctime" +private val OUTPUT_URI = "uri" +private val OUTPUT_FILES = "files" + +/** + * @return a result [JSObject] for reading a file + */ +fun createReadResultObject(readData: String): JSObject = + JSObject().also { it.putOpt(OUTPUT_DATA, readData) } + + +/** + * @return a result [JSObject] for writing/append a file + */ +fun createWriteResultObject(uri: Uri, mode: IONFILESaveMode): JSObject? = + if (mode == IONFILESaveMode.APPEND) { + null + } else { + createUriResultObject(uri) + } + +/** + * @return a result [JSObject] for the list of a directories contents + */ +fun createReadDirResultObject(list: List): JSObject = JSObject().also { + it.put(OUTPUT_FILES, JSArray(list.map { child -> child.toResultObject() })) +} + +/** + * @return a result [JSObject] for stat, from the [IONFILEMetadataResult] object + */ +fun IONFILEMetadataResult.toResultObject(): JSObject = JSObject().apply { + put(OUTPUT_NAME, name) + put(OUTPUT_TYPE, if (type is IONFILEFileType.Directory) "directory" else "file") + put(OUTPUT_SIZE, size) + put(OUTPUT_MODIFIED_TIME, lastModifiedTimestamp) + if (createdTimestamp != null) { + put(OUTPUT_CREATED_TIME, createdTimestamp) + } else { + put(OUTPUT_CREATED_TIME, null) + } + put(OUTPUT_URI, uri) +} + +/** + * @return a result [JSObject] based on a resolved uri [IONFILEUri.Resolved] + */ +fun IONFILEUri.Resolved.toResultObject(): JSObject = createUriResultObject(this.uri) + +/** + * @return a result [JSObject] for an Android [Uri] + */ +fun createUriResultObject(uri: Uri): JSObject = + JSObject().also { it.put(OUTPUT_URI, uri.toString()) } \ No newline at end of file diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemPlugin.java b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemPlugin.java deleted file mode 100644 index fb33906..0000000 --- a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemPlugin.java +++ /dev/null @@ -1,551 +0,0 @@ -package com.capacitorjs.plugins.filesystem; - -import android.Manifest; -import android.media.MediaScannerConnection; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import com.capacitorjs.plugins.filesystem.exceptions.CopyFailedException; -import com.capacitorjs.plugins.filesystem.exceptions.DirectoryExistsException; -import com.capacitorjs.plugins.filesystem.exceptions.DirectoryNotFoundException; -import com.getcapacitor.JSArray; -import com.getcapacitor.JSObject; -import com.getcapacitor.Logger; -import com.getcapacitor.PermissionState; -import com.getcapacitor.Plugin; -import com.getcapacitor.PluginCall; -import com.getcapacitor.PluginMethod; -import com.getcapacitor.annotation.CapacitorPlugin; -import com.getcapacitor.annotation.Permission; -import com.getcapacitor.annotation.PermissionCallback; -import com.getcapacitor.plugin.util.HttpRequestHandler; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.nio.charset.Charset; -import java.nio.file.Files; -import java.nio.file.attribute.BasicFileAttributes; -import org.json.JSONException; - -@CapacitorPlugin( - name = "Filesystem", - permissions = { - @Permission( - strings = { Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE }, - alias = "publicStorage" - ) - } -) -public class FilesystemPlugin extends Plugin { - - static final String PUBLIC_STORAGE = "publicStorage"; - private Filesystem implementation; - - @Override - public void load() { - implementation = new Filesystem(getContext()); - } - - private static final String PERMISSION_DENIED_ERROR = "Unable to do file operation, user denied permission request"; - - @PluginMethod - public void readFile(PluginCall call) { - String path = call.getString("path"); - String directory = getDirectoryParameter(call); - String encoding = call.getString("encoding"); - - Charset charset = implementation.getEncoding(encoding); - if (encoding != null && charset == null) { - call.reject("Unsupported encoding provided: " + encoding); - return; - } - - if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - } else { - try { - String dataStr = implementation.readFile(path, directory, charset); - JSObject ret = new JSObject(); - ret.putOpt("data", dataStr); - call.resolve(ret); - } catch (FileNotFoundException ex) { - call.reject("File does not exist", ex); - } catch (IOException ex) { - call.reject("Unable to read file", ex); - } catch (JSONException ex) { - call.reject("Unable to return value for reading file", ex); - } - } - } - - @PluginMethod - public void writeFile(PluginCall call) { - String path = call.getString("path"); - String data = call.getString("data"); - Boolean recursive = call.getBoolean("recursive", false); - - if (path == null) { - Logger.error(getLogTag(), "No path or filename retrieved from call", null); - call.reject("NO_PATH"); - return; - } - - if (data == null) { - Logger.error(getLogTag(), "No data retrieved from call", null); - call.reject("NO_DATA"); - return; - } - - String directory = getDirectoryParameter(call); - if (directory != null) { - if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - } else { - // create directory because it might not exist - File androidDir = implementation.getDirectory(directory); - if (androidDir != null) { - if (androidDir.exists() || androidDir.mkdirs()) { - // path might include directories as well - File fileObject = new File(androidDir, path); - if (fileObject.getParentFile().exists() || (recursive && fileObject.getParentFile().mkdirs())) { - saveFile(call, fileObject, data); - } else { - call.reject("Parent folder doesn't exist"); - } - } else { - Logger.error(getLogTag(), "Not able to create '" + directory + "'!", null); - call.reject("NOT_CREATED_DIR"); - } - } else { - Logger.error(getLogTag(), "Directory ID '" + directory + "' is not supported by plugin", null); - call.reject("INVALID_DIR"); - } - } - } else { - // check file:// or no scheme uris - Uri u = Uri.parse(path); - if (u.getScheme() == null || u.getScheme().equals("file")) { - File fileObject = new File(u.getPath()); - // do not know where the file is being store so checking the permission to be secure - // TODO to prevent permission checking we need a property from the call - if (!isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - } else { - if ( - fileObject.getParentFile() == null || - fileObject.getParentFile().exists() || - (recursive && fileObject.getParentFile().mkdirs()) - ) { - saveFile(call, fileObject, data); - } else { - call.reject("Parent folder doesn't exist"); - } - } - } else { - call.reject(u.getScheme() + " scheme not supported"); - } - } - } - - private void saveFile(PluginCall call, File file, String data) { - String encoding = call.getString("encoding"); - boolean append = call.getBoolean("append", false); - - Charset charset = implementation.getEncoding(encoding); - if (encoding != null && charset == null) { - call.reject("Unsupported encoding provided: " + encoding); - return; - } - - try { - implementation.saveFile(file, data, charset, append); - // update mediaStore index only if file was written to external storage - if (isPublicDirectory(getDirectoryParameter(call))) { - MediaScannerConnection.scanFile(getContext(), new String[] { file.getAbsolutePath() }, null, null); - } - Logger.debug(getLogTag(), "File '" + file.getAbsolutePath() + "' saved!"); - JSObject result = new JSObject(); - result.put("uri", Uri.fromFile(file).toString()); - call.resolve(result); - } catch (IOException ex) { - Logger.error( - getLogTag(), - "Creating file '" + file.getPath() + "' with charset '" + charset + "' failed. Error: " + ex.getMessage(), - ex - ); - call.reject("FILE_NOTCREATED"); - } catch (IllegalArgumentException ex) { - call.reject("The supplied data is not valid base64 content."); - } - } - - @PluginMethod - public void appendFile(PluginCall call) { - try { - call.getData().putOpt("append", true); - } catch (JSONException ex) {} - - this.writeFile(call); - } - - @PluginMethod - public void deleteFile(PluginCall call) { - String file = call.getString("path"); - String directory = getDirectoryParameter(call); - if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - } else { - try { - boolean deleted = implementation.deleteFile(file, directory); - if (!deleted) { - call.reject("Unable to delete file"); - } else { - call.resolve(); - } - } catch (FileNotFoundException ex) { - call.reject(ex.getMessage()); - } - } - } - - @PluginMethod - public void mkdir(PluginCall call) { - String path = call.getString("path"); - String directory = getDirectoryParameter(call); - boolean recursive = call.getBoolean("recursive", false).booleanValue(); - if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - } else { - try { - boolean created = implementation.mkdir(path, directory, recursive); - if (!created) { - call.reject("Unable to create directory, unknown reason"); - } else { - call.resolve(); - } - } catch (DirectoryExistsException ex) { - call.reject(ex.getMessage()); - } - } - } - - @PluginMethod - public void rmdir(PluginCall call) { - String path = call.getString("path"); - String directory = getDirectoryParameter(call); - Boolean recursive = call.getBoolean("recursive", false); - - File fileObject = implementation.getFileObject(path, directory); - - if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - } else { - if (!fileObject.exists()) { - call.reject("Directory does not exist"); - return; - } - - if (fileObject.isDirectory() && fileObject.listFiles().length != 0 && !recursive) { - call.reject("Directory is not empty"); - return; - } - - boolean deleted = false; - - try { - implementation.deleteRecursively(fileObject); - deleted = true; - } catch (IOException ignored) {} - - if (!deleted) { - call.reject("Unable to delete directory, unknown reason"); - } else { - call.resolve(); - } - } - } - - @PluginMethod - public void readdir(PluginCall call) { - String path = call.getString("path"); - String directory = getDirectoryParameter(call); - - if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - } else { - try { - File[] files = implementation.readdir(path, directory); - JSArray filesArray = new JSArray(); - if (files != null) { - for (var i = 0; i < files.length; i++) { - File fileObject = files[i]; - JSObject data = new JSObject(); - data.put("name", fileObject.getName()); - data.put("type", fileObject.isDirectory() ? "directory" : "file"); - data.put("size", fileObject.length()); - data.put("mtime", fileObject.lastModified()); - data.put("uri", Uri.fromFile(fileObject).toString()); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - try { - BasicFileAttributes attr = Files.readAttributes(fileObject.toPath(), BasicFileAttributes.class); - - // use whichever is the oldest between creationTime and lastAccessTime - if (attr.creationTime().toMillis() < attr.lastAccessTime().toMillis()) { - data.put("ctime", attr.creationTime().toMillis()); - } else { - data.put("ctime", attr.lastAccessTime().toMillis()); - } - } catch (Exception ex) {} - } else { - data.put("ctime", null); - } - filesArray.put(data); - } - - JSObject ret = new JSObject(); - ret.put("files", filesArray); - call.resolve(ret); - } else { - call.reject("Unable to read directory"); - } - } catch (DirectoryNotFoundException ex) { - call.reject(ex.getMessage()); - } - } - } - - @PluginMethod - public void getUri(PluginCall call) { - String path = call.getString("path"); - String directory = getDirectoryParameter(call); - - File fileObject = implementation.getFileObject(path, directory); - - if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - } else { - JSObject data = new JSObject(); - data.put("uri", Uri.fromFile(fileObject).toString()); - call.resolve(data); - } - } - - @PluginMethod - public void stat(PluginCall call) { - String path = call.getString("path"); - String directory = getDirectoryParameter(call); - - File fileObject = implementation.getFileObject(path, directory); - - if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - } else { - if (!fileObject.exists()) { - call.reject("File does not exist"); - return; - } - - JSObject data = new JSObject(); - data.put("type", fileObject.isDirectory() ? "directory" : "file"); - data.put("size", fileObject.length()); - data.put("mtime", fileObject.lastModified()); - data.put("uri", Uri.fromFile(fileObject).toString()); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - try { - BasicFileAttributes attr = Files.readAttributes(fileObject.toPath(), BasicFileAttributes.class); - - // use whichever is the oldest between creationTime and lastAccessTime - if (attr.creationTime().toMillis() < attr.lastAccessTime().toMillis()) { - data.put("ctime", attr.creationTime().toMillis()); - } else { - data.put("ctime", attr.lastAccessTime().toMillis()); - } - } catch (Exception ex) {} - } else { - data.put("ctime", null); - } - - call.resolve(data); - } - } - - @PluginMethod - public void rename(PluginCall call) { - this._copy(call, true); - } - - @PluginMethod - public void copy(PluginCall call) { - this._copy(call, false); - } - - @PluginMethod - public void downloadFile(PluginCall call) { - try { - String directory = call.getString("directory", Environment.DIRECTORY_DOWNLOADS); - - if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - return; - } - - HttpRequestHandler.ProgressEmitter emitter = (bytes, contentLength) -> { - JSObject ret = new JSObject(); - ret.put("url", call.getString("url")); - ret.put("bytes", bytes); - ret.put("contentLength", contentLength); - notifyListeners("progress", ret); - }; - - implementation.downloadFile( - call, - bridge, - emitter, - new Filesystem.FilesystemDownloadCallback() { - @Override - public void onSuccess(JSObject response) { - // update mediaStore index only if file was written to external storage - if (isPublicDirectory(directory)) { - MediaScannerConnection.scanFile(getContext(), new String[] { response.getString("path") }, null, null); - } - call.resolve(response); - } - - @Override - public void onError(Exception error) { - call.reject("Error downloading file: " + error.getLocalizedMessage(), error); - } - } - ); - } catch (Exception ex) { - call.reject("Error downloading file: " + ex.getLocalizedMessage(), ex); - } - } - - private void _copy(PluginCall call, Boolean doRename) { - String from = call.getString("from"); - String to = call.getString("to"); - String directory = call.getString("directory"); - String toDirectory = call.getString("toDirectory"); - - if (from == null || from.isEmpty() || to == null || to.isEmpty()) { - call.reject("Both to and from must be provided"); - return; - } - if (isPublicDirectory(directory) || isPublicDirectory(toDirectory)) { - if (!isStoragePermissionGranted()) { - requestAllPermissions(call, "permissionCallback"); - return; - } - } - try { - File file = implementation.copy(from, directory, to, toDirectory, doRename); - if (!doRename) { - JSObject result = new JSObject(); - result.put("uri", Uri.fromFile(file).toString()); - call.resolve(result); - } else { - call.resolve(); - } - } catch (CopyFailedException ex) { - call.reject(ex.getMessage()); - } catch (IOException ex) { - call.reject("Unable to perform action: " + ex.getLocalizedMessage()); - } - } - - @PluginMethod - public void checkPermissions(PluginCall call) { - if (isStoragePermissionGranted()) { - JSObject permissionsResultJSON = new JSObject(); - permissionsResultJSON.put(PUBLIC_STORAGE, "granted"); - call.resolve(permissionsResultJSON); - } else { - super.checkPermissions(call); - } - } - - @PluginMethod - public void requestPermissions(PluginCall call) { - if (isStoragePermissionGranted()) { - JSObject permissionsResultJSON = new JSObject(); - permissionsResultJSON.put(PUBLIC_STORAGE, "granted"); - call.resolve(permissionsResultJSON); - } else { - super.requestPermissions(call); - } - } - - @PermissionCallback - private void permissionCallback(PluginCall call) { - if (!isStoragePermissionGranted()) { - Logger.debug(getLogTag(), "User denied storage permission"); - call.reject(PERMISSION_DENIED_ERROR); - return; - } - - switch (call.getMethodName()) { - case "appendFile": - case "writeFile": - writeFile(call); - break; - case "deleteFile": - deleteFile(call); - break; - case "mkdir": - mkdir(call); - break; - case "rmdir": - rmdir(call); - break; - case "rename": - rename(call); - break; - case "copy": - copy(call); - break; - case "readFile": - readFile(call); - break; - case "readdir": - readdir(call); - break; - case "getUri": - getUri(call); - break; - case "stat": - stat(call); - break; - case "downloadFile": - downloadFile(call); - break; - } - } - - /** - * Checks the the given permission is granted or not - * @return Returns true if the app is running on Android 30 or newer or if the permission is already granted - * or false if it is denied. - */ - private boolean isStoragePermissionGranted() { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || getPermissionState(PUBLIC_STORAGE) == PermissionState.GRANTED; - } - - /** - * Reads the directory parameter from the plugin call - * @param call the plugin call - */ - private String getDirectoryParameter(PluginCall call) { - return call.getString("directory"); - } - - /** - * True if the given directory string is a public storage directory, which is accessible by the user or other apps. - * @param directory the directory string. - */ - private boolean isPublicDirectory(String directory) { - return "DOCUMENTS".equals(directory) || "EXTERNAL_STORAGE".equals(directory); - } -} diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemPlugin.kt b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemPlugin.kt new file mode 100644 index 0000000..1afe2a2 --- /dev/null +++ b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/FilesystemPlugin.kt @@ -0,0 +1,356 @@ +package com.capacitorjs.plugins.filesystem + +import android.Manifest +import android.media.MediaScannerConnection +import android.os.Build +import android.util.Log +import com.getcapacitor.JSObject +import com.getcapacitor.Logger +import com.getcapacitor.PermissionState +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import com.getcapacitor.annotation.Permission +import com.getcapacitor.annotation.PermissionCallback +import io.ionic.libs.ionfilesystemlib.IONFILEController +import io.ionic.libs.ionfilesystemlib.model.IONFILECreateOptions +import io.ionic.libs.ionfilesystemlib.model.IONFILEDeleteOptions +import io.ionic.libs.ionfilesystemlib.model.IONFILEUri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.json.JSONException + +private const val PUBLIC_STORAGE = "publicStorage" +private const val PUBLIC_STORAGE_ABOVE_ANDROID_10 = "publicStorageAboveAPI29" +private const val PERMISSION_GRANTED = "granted" + +@CapacitorPlugin( + name = "Filesystem", + permissions = [ + Permission( + strings = [Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE], + alias = PUBLIC_STORAGE + ), + /* + For SDK versions 30-32 (Android 11 and Android 12) + Could be that certain files may require read permission, such as local file path to photos/videos in gallery + */ + Permission( + strings = [Manifest.permission.READ_EXTERNAL_STORAGE], + alias = PUBLIC_STORAGE_ABOVE_ANDROID_10 + ) + ] +) +class FilesystemPlugin : Plugin() { + + private val coroutineScope: CoroutineScope by lazy { CoroutineScope(Dispatchers.Main) } + private val controller: IONFILEController by lazy { IONFILEController(context.applicationContext) } + + override fun handleOnDestroy() { + super.handleOnDestroy() + coroutineScope.cancel() + } + + @PluginMethod + fun readFile(call: PluginCall) { + val input: ReadFileOptions = call.getReadFileOptions() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input.uri, call) { uri -> + controller.readFile(uri, input.options) + .onSuccess { call.sendSuccess(result = createReadResultObject(it)) } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod(returnType = PluginMethod.RETURN_CALLBACK) + fun readFileInChunks(call: PluginCall) { + val input: ReadFileInChunksOptions = call.getReadFileInChunksOptions() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input.uri, call) { uri -> + controller.readFileInChunks(uri, input.options) + .onEach { chunk -> + call.sendSuccess(result = createReadResultObject(chunk), keepCallback = true) + } + .onCompletion { error -> + if (error == null) { + call.sendSuccess(result = createReadResultObject("")) + } + } + .catch { + call.sendError(it.toFilesystemError(call.methodName)) + } + .launchIn(coroutineScope) + } + } + + @PluginMethod + fun writeFile(call: PluginCall) { + val input: WriteFileOptions = call.getWriteFileOptions() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input.uri, call) { uri -> + controller.saveFile(uri, input.options) + .onSuccess { uriSaved -> + // update mediaStore index only if file was written to external storage + if (uri.inExternalStorage) { + uriSaved.path?.let { + MediaScannerConnection.scanFile(context, arrayOf(it), null, null) + } + } + call.sendSuccess(result = createWriteResultObject(uriSaved, input.options.mode)) + } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod + fun appendFile(call: PluginCall) { + try { + call.data.putOpt(INPUT_APPEND, true) + } catch (ex: JSONException) { + Log.w(logTag, "Tried to set `append` in `PluginCall`, but got exception", ex) + call.sendError( + FilesystemErrors.operationFailed(call.methodName, ex.localizedMessage ?: "") + ) + return + } + writeFile(call) + } + + @PluginMethod + fun deleteFile(call: PluginCall) { + val input = call.getSingleIONFILEUri() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input, call) { uri -> + controller.delete(uri, IONFILEDeleteOptions(recursive = false)) + .onSuccess { call.sendSuccess() } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod + fun mkdir(call: PluginCall) { + val input = call.getSingleUriWithRecursiveOptions() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input.uri, call) { uri -> + controller.createDirectory(uri, IONFILECreateOptions(input.recursive)) + .onSuccess { call.sendSuccess() } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod + fun rmdir(call: PluginCall) { + val input = call.getSingleUriWithRecursiveOptions() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input.uri, call) { uri -> + controller.delete(uri, IONFILEDeleteOptions(input.recursive)) + .onSuccess { call.sendSuccess() } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod + fun readdir(call: PluginCall) { + val input = call.getSingleIONFILEUri() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input, call) { uri -> + controller.listDirectory(uri) + .onSuccess { call.sendSuccess(result = createReadDirResultObject(it)) } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod + fun getUri(call: PluginCall) { + val input = call.getSingleIONFILEUri() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + coroutineScope.launch { + controller.getFileUri(input) + .onSuccess { resolvedUri -> call.sendSuccess(result = resolvedUri.toResultObject()) } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod + fun stat(call: PluginCall) { + val input = call.getSingleIONFILEUri() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input, call) { uri -> + controller.getMetadata(uri) + .onSuccess { metadata -> call.sendSuccess(result = metadata.toResultObject()) } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod + fun rename(call: PluginCall) { + val input = call.getDoubleIONFILEUri() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input.fromUri, input.toUri, call) { source, destination -> + controller.move(source, destination) + .onSuccess { call.sendSuccess() } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod + fun copy(call: PluginCall) { + val input = call.getDoubleIONFILEUri() ?: run { + call.sendError(FilesystemErrors.invalidInputMethod(call.methodName)) + return + } + runWithPermission(input.fromUri, input.toUri, call) { source, destination -> + controller.copy(source, destination) + .onSuccess { call.sendSuccess(createUriResultObject(it)) } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + @PluginMethod + fun downloadFile(call: PluginCall) { + TODO("To be implemented in the near future") + } + + @PluginMethod + override fun checkPermissions(call: PluginCall) { + if (isStoragePermissionGranted(false)) { + call.sendSuccess(JSObject().also { it.put(PUBLIC_STORAGE, PERMISSION_GRANTED) }) + } else { + super.checkPermissions(call) + } + } + + @PluginMethod + override fun requestPermissions(call: PluginCall) { + if (isStoragePermissionGranted(false)) { + call.sendSuccess(JSObject().also { it.put(PUBLIC_STORAGE, PERMISSION_GRANTED) }) + } else { + super.requestPermissions(call) + } + } + + @PermissionCallback + private fun permissionCallback(call: PluginCall) { + if (!isStoragePermissionGranted(true)) { + Logger.debug(logTag, "User denied storage permission") + call.sendError(FilesystemErrors.filePermissionsDenied) + return + } + + when (call.methodName) { + "appendFile", "writeFile" -> writeFile(call) + "deleteFile" -> deleteFile(call) + "mkdir" -> mkdir(call) + "rmdir" -> rmdir(call) + "rename" -> rename(call) + "copy" -> copy(call) + "readFile" -> readFile(call) + "readFileInChunks" -> readFileInChunks(call) + "readdir" -> readdir(call) + "getUri" -> getUri(call) + "stat" -> stat(call) + "downloadFile" -> downloadFile(call) + } + } + + /** + * Runs a suspend code if the app has permission to access the uri + * + * Will ask for permission if it has not been granted. + * + * May return an error if the uri is not resolvable. + * + * @param uri the uri pointing to the file / directory + * @param call the capacitor plugin call + * @param onPermissionGranted the callback to run the suspending code + */ + private fun runWithPermission( + uri: IONFILEUri.Unresolved, + call: PluginCall, + onPermissionGranted: suspend (resolvedUri: IONFILEUri.Resolved) -> Unit + ) { + coroutineScope.launch { + controller.getFileUri(uri) + .onSuccess { resolvedUri -> + // certain files like a photo/video in gallery may require read permission on Android 11 and 12. + if ( + resolvedUri.inExternalStorage + && !isStoragePermissionGranted(shouldRequestAboveAndroid10 = uri.parentFolder == null) + ) { + requestAllPermissions(call, this@FilesystemPlugin::permissionCallback.name) + } else { + onPermissionGranted(resolvedUri) + } + } + .onFailure { call.sendError(it.toFilesystemError(call.methodName)) } + } + } + + /** + * Runs a suspend code if the app has permission to access both to and from uris + * + * Will ask for permission if it has not been granted. + * + * May return an error if the uri is not resolvable. + * + * @param fromUri the source uri pointing to the file / directory + * @param toUri the destination uri pointing to the file / directory + * @param call the capacitor plugin call + * @param onPermissionGranted the callback to run the suspending code + */ + private fun runWithPermission( + fromUri: IONFILEUri.Unresolved, + toUri: IONFILEUri.Unresolved, + call: PluginCall, + onPermissionGranted: suspend (resolvedSourceUri: IONFILEUri.Resolved, resolvedDestinationUri: IONFILEUri.Resolved) -> Unit + ) { + runWithPermission(fromUri, call) { resolvedSourceUri -> + runWithPermission(toUri, call) { resolvedDestinationUri -> + onPermissionGranted(resolvedSourceUri, resolvedDestinationUri) + } + } + } + + /** + * Checks the the given permission is granted or not + * @param shouldRequestAboveAndroid10 whether or not should check for read permission above android 10 + * May vary with the kind of file path that is provided. + * @return Returns true if the app is running on Android 13 (API 33) or newer, or if the permission is already granted + * or false if it is denied. + */ + private fun isStoragePermissionGranted(shouldRequestAboveAndroid10: Boolean): Boolean = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> true + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + !shouldRequestAboveAndroid10 || getPermissionState(PUBLIC_STORAGE_ABOVE_ANDROID_10) == PermissionState.GRANTED + + else -> getPermissionState(PUBLIC_STORAGE) == PermissionState.GRANTED + } +} \ No newline at end of file diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/PluginResultExtensions.kt b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/PluginResultExtensions.kt new file mode 100644 index 0000000..7ca43b0 --- /dev/null +++ b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/PluginResultExtensions.kt @@ -0,0 +1,25 @@ +package com.capacitorjs.plugins.filesystem + +import com.getcapacitor.JSObject +import com.getcapacitor.PluginCall + +/** + * Extension function to return a successful plugin result + * @param result JSOObject with the JSON content to return + * @param keepCallback boolean to determine if callback should be kept for future calls or not + */ +internal fun PluginCall.sendSuccess(result: JSObject? = null, keepCallback: Boolean = false) { + this.setKeepAlive(keepCallback) + if (result != null) { + this.resolve(result) + } else { + this.resolve() + } +} + +/** + * Extension function to return a unsuccessful plugin result + * @param error error class representing the error to return, containing a code and message + */ +internal fun PluginCall.sendError(error: FilesystemErrors.ErrorInfo) = + this.reject(error.message, error.code) \ No newline at end of file diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/exceptions/CopyFailedException.java b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/exceptions/CopyFailedException.java deleted file mode 100644 index d722bd0..0000000 --- a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/exceptions/CopyFailedException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.capacitorjs.plugins.filesystem.exceptions; - -public class CopyFailedException extends Exception { - - public CopyFailedException(String s) { - super(s); - } - - public CopyFailedException(Throwable t) { - super(t); - } - - public CopyFailedException(String s, Throwable t) { - super(s, t); - } -} diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/exceptions/DirectoryExistsException.java b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/exceptions/DirectoryExistsException.java deleted file mode 100644 index ff1722f..0000000 --- a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/exceptions/DirectoryExistsException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.capacitorjs.plugins.filesystem.exceptions; - -public class DirectoryExistsException extends Exception { - - public DirectoryExistsException(String s) { - super(s); - } - - public DirectoryExistsException(Throwable t) { - super(t); - } - - public DirectoryExistsException(String s, Throwable t) { - super(s, t); - } -} diff --git a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/exceptions/DirectoryNotFoundException.java b/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/exceptions/DirectoryNotFoundException.java deleted file mode 100644 index 33a56e8..0000000 --- a/packages/capacitor-plugin/android/src/main/kotlin/com/capacitorjs/plugins/filesystem/exceptions/DirectoryNotFoundException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.capacitorjs.plugins.filesystem.exceptions; - -public class DirectoryNotFoundException extends Exception { - - public DirectoryNotFoundException(String s) { - super(s); - } - - public DirectoryNotFoundException(Throwable t) { - super(t); - } - - public DirectoryNotFoundException(String s, Throwable t) { - super(s, t); - } -} diff --git a/packages/capacitor-plugin/src/definitions.ts b/packages/capacitor-plugin/src/definitions.ts index 79449f3..6808022 100644 --- a/packages/capacitor-plugin/src/definitions.ts +++ b/packages/capacitor-plugin/src/definitions.ts @@ -72,11 +72,11 @@ export enum Directory { * * @since 1.0.0 */ - ExternalStorage = 'EXTERNAL_STORAGE', + ExternalStorage = 'EXTERNAL_STORAGE', /** * The external cache directory. - * Android ONly + * On iOS it will use the Documents directory. * On Android it's the primary shared/external cache. * It's not accesible on Android 10 unless the app enables legacy External Storage * by adding `android:requestLegacyExternalStorage="true"` in the `application` tag @@ -88,18 +88,16 @@ export enum Directory { ExternalCache = 'EXTERNAL_CACHE', /** - * iOS only - * It maps to Library/NoCloud directory - * Files will be deleted when the application is uninstalled. + * The Library directory without cloud backup. Used in iOS + * On Android it's the directory holding application files. * * @since 7.1.0 */ LibraryNoCloud = 'LIBRARY_NO_CLOUD', /** - * iOS only - * The tmp/ directory. - * Files will be deleted when the application is uninstalled. + * A temporary directory for iOS. + * Om Android it's the directory holding the application cache. * * @since 7.1.0 */ diff --git a/packages/example-app-capacitor/android/app/src/main/AndroidManifest.xml b/packages/example-app-capacitor/android/app/src/main/AndroidManifest.xml index de7c0a4..74200ad 100644 --- a/packages/example-app-capacitor/android/app/src/main/AndroidManifest.xml +++ b/packages/example-app-capacitor/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> diff --git a/packages/example-app-capacitor/android/settings.gradle b/packages/example-app-capacitor/android/settings.gradle index 3b4431d..14c5496 100644 --- a/packages/example-app-capacitor/android/settings.gradle +++ b/packages/example-app-capacitor/android/settings.gradle @@ -2,4 +2,8 @@ include ':app' include ':capacitor-cordova-android-plugins' project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') +// this is here until native library is published; TODO remove this once that happens. +include ':tmp-native-lib-android' +project(':tmp-native-lib-android').projectDir = new File('../../tmp_native_lib_android') + apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/packages/tmp_native_lib_android/.gitignore b/packages/tmp_native_lib_android/.gitignore new file mode 100644 index 0000000..d163863 --- /dev/null +++ b/packages/tmp_native_lib_android/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/packages/tmp_native_lib_android/IONFilesystemLib-debug.aar b/packages/tmp_native_lib_android/IONFilesystemLib-debug.aar new file mode 100644 index 0000000..45d90b9 Binary files /dev/null and b/packages/tmp_native_lib_android/IONFilesystemLib-debug.aar differ diff --git a/packages/tmp_native_lib_android/build.gradle b/packages/tmp_native_lib_android/build.gradle new file mode 100644 index 0000000..0a9e287 --- /dev/null +++ b/packages/tmp_native_lib_android/build.gradle @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file('IONFilesystemLib-debug.aar')) \ No newline at end of file