Skip to content

Commit

Permalink
Write a file in a single transaction
Browse files Browse the repository at this point in the history
  • Loading branch information
Amphiluke committed Nov 29, 2022
1 parent e1f9d83 commit 58b6db8
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 105 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cordova-plugin-save-dialog",
"version": "1.1.1",
"version": "2.0.0",
"description": "Cordova plugin for opening the native Save dialog and storing a file in the user-selected location",
"main": "index.js",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion plugin.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<plugin xmlns="http://apache.org/cordova/ns/plugins/1.0" id="cordova-plugin-save-dialog" version="1.1.1">
<plugin xmlns="http://apache.org/cordova/ns/plugins/1.0" id="cordova-plugin-save-dialog" version="2.0.0">
<name>Save Dialog</name>
<description>Cordova plugin for opening the native Save dialog and storing a file in the user-selected location</description>
<license>MIT</license>
Expand Down
53 changes: 30 additions & 23 deletions src/android/SaveDialog.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,39 @@
import android.net.Uri;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
// import android.provider.DocumentsContract;
import android.util.Base64;

import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.CordovaArgs;

import org.json.JSONArray;
import org.json.JSONException;

import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.nio.channels.FileChannel;

public class SaveDialog extends CordovaPlugin {
private static final int LOCATE_FILE = 1;

private CallbackContext callbackContext;
private final ByteArrayOutputStream fileByteStream = new ByteArrayOutputStream();

@Override
public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException {
public boolean execute(String action, CordovaArgs args, CallbackContext callbackContext) throws JSONException {
this.callbackContext = callbackContext;
if (action.equals("locateFile")) {
this.locateFile(args.getString(0), args.getString(1));
} else if (action.equals("saveFile")) {
this.saveFile(Uri.parse(args.getString(0)), args.getString(1), args.getBoolean(2));
} else {
return false;
switch (action) {
case "locateFile":
this.locateFile(args.getString(0), args.getString(1));
this.fileByteStream.reset();
break;
case "addChunk":
this.addChunk(args.getArrayBuffer(0));
break;
case "saveFile":
this.saveFile(Uri.parse(args.getString(0)), this.fileByteStream.toByteArray());
this.fileByteStream.reset();
break;
default:
return false;
}
return true;
}
Expand All @@ -40,9 +47,6 @@ private void locateFile(String type, String name) {
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(type);
intent.putExtra(Intent.EXTRA_TITLE, name);
// TODO Optionally, specify a URI for the directory that should be opened in
// the system file picker when your app creates the document.
// intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri);
cordova.startActivityForResult(this, intent, SaveDialog.LOCATE_FILE);
}

Expand All @@ -64,18 +68,21 @@ public void onRestoreStateForActivityResult(Bundle state, CallbackContext callba
this.callbackContext = callbackContext;
}

private void saveFile(Uri uri, String data, boolean clearFile) {
private void addChunk(byte[] chunk) {
try {
byte[] rawData = Base64.decode(data, Base64.DEFAULT);
ParcelFileDescriptor pfd = cordova.getActivity().getContentResolver().openFileDescriptor(uri, "wa");
FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor());
this.fileByteStream.write(chunk);
this.callbackContext.success();
} catch (Exception e) {
this.callbackContext.error(e.getMessage());
e.printStackTrace();
}
}

private void saveFile(Uri uri, byte[] rawData) {
try {
ParcelFileDescriptor pfd = cordova.getActivity().getContentResolver().openFileDescriptor(uri, "w");
FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor());
try {
if (clearFile) {
FileChannel fChan = fileOutputStream.getChannel();
fChan.truncate(0);
}

fileOutputStream.write(rawData);
this.callbackContext.success(uri.toString());
} catch (Exception e) {
Expand Down
125 changes: 58 additions & 67 deletions www/android/SaveDialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,90 +3,81 @@ let {keep: keepBlob, get: getBlob, clear: clearBlob} = require("./BlobKeeper");
let moduleMapper = require("cordova/modulemapper");
let FileReader = moduleMapper.getOriginalSymbol(window, "FileReader") || window.FileReader;

let locateFile = (type, name) => new Promise((resolve, reject) => {
exec(resolve, reject, "SaveDialog", "locateFile", [type || "application/octet-stream", name]);
});

let saveFile = (uri, blob, clearFile) => new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => {
exec(resolve, reject, "SaveDialog", "saveFile", [uri, reader.result, clearFile]);
};
reader.onerror = () => {
reject(reader.error);
};
reader.onabort = () => {
reject("Blob reading has been aborted");
};
reader.readAsArrayBuffer(blob);
});

let saveFileInChunks = (uri, blob) => {
const BLOCK_SIZE = 1024 * 1024;
let writtenSize = 0;

function saveNextChunk(clearFile) {
const size = Math.min(BLOCK_SIZE, blob.size - writtenSize);
const chunk = blob.slice(writtenSize, writtenSize + size);

writtenSize += size;

return saveFile(uri, chunk, clearFile);
}

return new Promise(async (resolve, reject) => {
let i = 0;
let uri = '';
let error = null;

while(writtenSize < blob.size) {
[uri, error] = await saveNextChunk(i === 0).then((result) => [result, null]).catch((err) => [null, err]);
function blobToArrayBuffer(blob) {
// Using FileReader.readAsArrayBuffer() until Blob.arrayBuffer() is widely supported
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = () => {
reject(reader.error);
};
reader.onabort = () => {
reject("Blob reading has been aborted");
};
reader.readAsArrayBuffer(blob);
});
}

if (error !== null) {
reject(error);
return;
}
function asyncExec(action, ...args) {
return new Promise((resolve, reject) => {
exec(resolve, reject, "SaveDialog", action, args);
});
}

i++;
// Transfer file contents to the plugin chunk by chunk to overcome the limitation on the size of data that can be
// converted to an array buffer, serialized and passed to the native Java component (see PR #2).
async function addChunks(blob) {
let getBlobChunks = function* () {
const CHUNK_SIZE = 1024 ** 2; // 1 MB
let transferredLength = 0;
while (transferredLength < blob.size) {
yield blob.slice(transferredLength, transferredLength + CHUNK_SIZE);
transferredLength += CHUNK_SIZE;
}

resolve(uri);
});
return null;
};
for (let blobChunk of getBlobChunks()) {
let arrayBufferChunk = await blobToArrayBuffer(blobChunk);
await asyncExec("addChunk", arrayBufferChunk);
}
}

module.exports = {
saveFile(blob, name = "") {
return keepBlob(blob) // see the “resume” event handler below
.then(() => locateFile(blob.type, name))
.then(uri => saveFileInChunks(uri, blob))
.then(uri => {
clearBlob();
return uri;
})
.catch(reason => {
clearBlob();
return Promise.reject(reason);
});
async saveFile(blob, name = "") {
try {
await keepBlob(blob); // see the “resume” event handler below
let uri = await asyncExec("locateFile", blob.type || "application/octet-stream", name);
await addChunks(blob);
return await asyncExec("saveFile", uri);
} catch (reason) {
return Promise.reject(reason);
} finally {
clearBlob();
}
}
};

// If Android OS has destroyed the Cordova Activity in background, try to complete the Save operation
// using the URI passed in the payload of the “resume” event and the blob stored by the BlobKeeper.
// https://cordova.apache.org/docs/en/10.x/guide/platforms/android/plugin.html#launching-other-activities
document.addEventListener("resume", ({pendingResult = {}}) => {
// https://cordova.apache.org/docs/en/11.x/guide/platforms/android/plugin.html#launching-other-activities
document.addEventListener("resume", async ({pendingResult = {}}) => {
if (pendingResult.pluginServiceName !== "SaveDialog") {
return;
}
if (pendingResult.pluginStatus !== "OK" || !pendingResult.result) {
clearBlob();
return;
}
getBlob().then(blob => {
if (blob instanceof Blob) {
saveFile(pendingResult.result, blob).catch(reason => {
console.warn("[SaveDialog]", reason);
});
let blob = await getBlob();
if (blob instanceof Blob) {
try {
await addChunks(blob);
await asyncExec("saveFile", pendingResult.result);
} catch (reason) {
console.warn("[SaveDialog]", reason);
}
clearBlob();
});
}
clearBlob();
}, false);
29 changes: 16 additions & 13 deletions www/ios/SaveDialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@ let exec = require("cordova/exec");
let moduleMapper = require("cordova/modulemapper");
let FileReader = moduleMapper.getOriginalSymbol(window, "FileReader") || window.FileReader;

let saveFile = (blob, name) => new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => {
exec(resolve, reject, "SaveDialog", "saveFile", [reader.result, name]);
};
reader.onerror = () => {
reject(reader.error);
};
reader.onabort = () => {
reject("Blob reading has been aborted");
};
reader.readAsArrayBuffer(blob);
});
function saveFile(blob, name) {
// Using FileReader.readAsArrayBuffer() until Blob.arrayBuffer() is widely supported
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => {
exec(resolve, reject, "SaveDialog", "saveFile", [reader.result, name]);
};
reader.onerror = () => {
reject(reader.error);
};
reader.onabort = () => {
reject("Blob reading has been aborted");
};
reader.readAsArrayBuffer(blob);
});
}

module.exports = {
saveFile(blob, name = "untitled") {
Expand Down

0 comments on commit 58b6db8

Please sign in to comment.