diff --git a/parse/src/main/java/com/parse/ParseCountingUriHttpBody.java b/parse/src/main/java/com/parse/ParseCountingUriHttpBody.java new file mode 100644 index 000000000..87bf355aa --- /dev/null +++ b/parse/src/main/java/com/parse/ParseCountingUriHttpBody.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2015-present, Parse, LLC. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +package com.parse; + +import android.net.Uri; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +class ParseCountingUriHttpBody extends ParseUriHttpBody { + + private static final int DEFAULT_CHUNK_SIZE = 4096; + private static final int EOF = -1; + + private final ProgressCallback progressCallback; + + public ParseCountingUriHttpBody(Uri uri, ProgressCallback progressCallback) { + this(uri, null, progressCallback); + } + + public ParseCountingUriHttpBody( + Uri uri, String contentType, ProgressCallback progressCallback) { + super(uri, contentType); + this.progressCallback = progressCallback; + } + + @Override + public void writeTo(OutputStream output) throws IOException { + if (output == null) { + throw new IllegalArgumentException("Output stream may not be null"); + } + + final InputStream fileInput = Parse.getApplicationContext().getContentResolver().openInputStream(uri); + try { + byte[] buffer = new byte[DEFAULT_CHUNK_SIZE]; + int n; + long totalLength = getContentLength(); + long position = 0; + while (EOF != (n = fileInput.read(buffer))) { + output.write(buffer, 0, n); + position += n; + + if (progressCallback != null) { + int progress = (int) (100 * position / totalLength); + progressCallback.done(progress); + } + } + } finally { + ParseIOUtils.closeQuietly(fileInput); + } + } +} diff --git a/parse/src/main/java/com/parse/ParseFile.java b/parse/src/main/java/com/parse/ParseFile.java index f9d167e0d..f79291ce1 100644 --- a/parse/src/main/java/com/parse/ParseFile.java +++ b/parse/src/main/java/com/parse/ParseFile.java @@ -8,6 +8,7 @@ */ package com.parse; +import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; import com.parse.boltsinternal.Continuation; @@ -64,6 +65,7 @@ public ParseFile[] newArray(int size) { */ /* package for tests */ byte[] data; /* package for tests */ File file; + /* package for tests */ Uri uri; private State state; /** @@ -102,6 +104,21 @@ public ParseFile(String name, byte[] data, String contentType) { this.data = data; } + /** + * Creates a new file from a content uri, file name, and content type. Content type will be used + * instead of auto-detection by file extension. + * + * @param name The file's name, ideally with extension. The file name must begin with an + * alphanumeric character, and consist of alphanumeric characters, periods, spaces, + * underscores, or dashes. + * @param uri The file uri. + * @param contentType The file's content type. + */ + public ParseFile(String name, Uri uri, String contentType) { + this(new State.Builder().name(name).mimeType(contentType).build()); + this.uri = uri; + } + /** * Creates a new file from a byte array. * @@ -263,28 +280,38 @@ private Task saveAsync( return Task.cancelled(); } - Task saveTask; - if (data != null) { - saveTask = - getFileController() - .saveAsync( - state, - data, - sessionToken, - progressCallbackOnMainThread( - uploadProgressCallback), - cancellationToken); - } else { - saveTask = - getFileController() - .saveAsync( - state, - file, - sessionToken, - progressCallbackOnMainThread( - uploadProgressCallback), - cancellationToken); - } + Task saveTask; + if (data != null) { + saveTask = + getFileController() + .saveAsync( + state, + data, + sessionToken, + progressCallbackOnMainThread( + uploadProgressCallback), + cancellationToken); + } else if (uri != null) { + saveTask = + getFileController() + .saveAsync( + state, + uri, + sessionToken, + progressCallbackOnMainThread( + uploadProgressCallback), + cancellationToken); + } else { + saveTask = + getFileController() + .saveAsync( + state, + file, + sessionToken, + progressCallbackOnMainThread( + uploadProgressCallback), + cancellationToken); + } return saveTask.onSuccessTask( task1 -> { diff --git a/parse/src/main/java/com/parse/ParseFileController.java b/parse/src/main/java/com/parse/ParseFileController.java index 084ac7e40..44118bcd6 100644 --- a/parse/src/main/java/com/parse/ParseFileController.java +++ b/parse/src/main/java/com/parse/ParseFileController.java @@ -8,6 +8,8 @@ */ package com.parse; +import android.net.Uri; + import com.parse.boltsinternal.Task; import com.parse.http.ParseHttpRequest; import java.io.File; @@ -163,6 +165,49 @@ public Task saveAsync( ParseExecutors.io()); } + public Task saveAsync( + final ParseFile.State state, + final Uri uri, + String sessionToken, + ProgressCallback uploadProgressCallback, + Task cancellationToken) { + if (state.url() != null) { // !isDirty + return Task.forResult(state); + } + if (cancellationToken != null && cancellationToken.isCancelled()) { + return Task.cancelled(); + } + + final ParseRESTCommand command = + new ParseRESTFileCommand.Builder() + .fileName(state.name()) + .uri(uri) + .contentType(state.mimeType()) + .sessionToken(sessionToken) + .build(); + + return command.executeAsync(restClient, uploadProgressCallback, null, cancellationToken) + .onSuccess( + task -> { + JSONObject result = task.getResult(); + ParseFile.State newState = + new ParseFile.State.Builder(state) + .name(result.getString("name")) + .url(result.getString("url")) + .build(); + + // Write data to cache + try { + ParseFileUtils.writeUriToFile(getCacheFile(newState), uri); + } catch (IOException e) { + // do nothing + } + + return newState; + }, + ParseExecutors.io()); + } + public Task fetchAsync( final ParseFile.State state, @SuppressWarnings("UnusedParameters") String sessionToken, diff --git a/parse/src/main/java/com/parse/ParseFileUtils.java b/parse/src/main/java/com/parse/ParseFileUtils.java index 50af66982..b7198feee 100644 --- a/parse/src/main/java/com/parse/ParseFileUtils.java +++ b/parse/src/main/java/com/parse/ParseFileUtils.java @@ -16,7 +16,13 @@ */ package com.parse; +import android.net.Uri; + import androidx.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -27,8 +33,6 @@ import java.nio.channels.FileChannel; import java.nio.charset.Charset; import java.util.List; -import org.json.JSONException; -import org.json.JSONObject; /** General file manipulation utilities. */ public class ParseFileUtils { @@ -115,6 +119,30 @@ public static void writeByteArrayToFile(File file, byte[] data) throws IOExcepti } } + /** + * Writes a content uri to a file creating the file if it does not exist. + * + *

NOTE: As from v1.3, the parent directories of the file will be created if they do not + * exist. + * + * @param file the file to write to + * @param uri the content uri with data to write to the file + * @throws IOException in case of an I/O error + * @since Commons IO 1.1 + */ + public static void writeUriToFile(File file, Uri uri) throws IOException { + OutputStream out = null; + InputStream in = null; + try { + in = Parse.getApplicationContext().getContentResolver().openInputStream(uri); + out = openOutputStream(file); + ParseIOUtils.copyLarge(in, out); + } finally { + ParseIOUtils.closeQuietly(out); + ParseIOUtils.closeQuietly(in); + } + } + // ----------------------------------------------------------------------- /** diff --git a/parse/src/main/java/com/parse/ParseRESTFileCommand.java b/parse/src/main/java/com/parse/ParseRESTFileCommand.java index e933509ba..1826747ef 100644 --- a/parse/src/main/java/com/parse/ParseRESTFileCommand.java +++ b/parse/src/main/java/com/parse/ParseRESTFileCommand.java @@ -8,6 +8,8 @@ */ package com.parse; +import android.net.Uri; + import com.parse.http.ParseHttpBody; import com.parse.http.ParseHttpRequest; import java.io.File; @@ -18,15 +20,23 @@ class ParseRESTFileCommand extends ParseRESTCommand { private final byte[] data; private final String contentType; private final File file; + private final Uri uri; public ParseRESTFileCommand(Builder builder) { super(builder); if (builder.file != null && builder.data != null) { throw new IllegalArgumentException("File and data can not be set at the same time"); } + if (builder.uri != null && builder.data != null) { + throw new IllegalArgumentException("URI and data can not be set at the same time"); + } + if (builder.file != null && builder.uri != null) { + throw new IllegalArgumentException("File and URI can not be set at the same time"); + } this.data = builder.data; this.contentType = builder.contentType; this.file = builder.file; + this.uri = builder.uri; } @Override @@ -35,13 +45,21 @@ protected ParseHttpBody newBody(final ProgressCallback progressCallback) { // file // in ParseFileController if (progressCallback == null) { - return data != null - ? new ParseByteArrayHttpBody(data, contentType) - : new ParseFileHttpBody(file, contentType); + if (data != null) { + return new ParseByteArrayHttpBody(data, contentType); + } else if (uri != null) { + return new ParseUriHttpBody(uri, contentType); + } else { + return new ParseFileHttpBody(file, contentType); + } + } + if (data != null) { + return new ParseCountingByteArrayHttpBody(data, contentType, progressCallback); + } else if (uri != null) { + return new ParseCountingUriHttpBody(uri, contentType, progressCallback); + } else { + return new ParseCountingFileHttpBody(file, contentType, progressCallback); } - return data != null - ? new ParseCountingByteArrayHttpBody(data, contentType, progressCallback) - : new ParseCountingFileHttpBody(file, contentType, progressCallback); } public static class Builder extends Init { @@ -49,6 +67,7 @@ public static class Builder extends Init { private byte[] data = null; private String contentType = null; private File file; + private Uri uri; public Builder() { // We only ever use ParseRESTFileCommand for file uploads, so default to POST. @@ -74,6 +93,11 @@ public Builder file(File file) { return this; } + public Builder uri(Uri uri) { + this.uri = uri; + return this; + } + @Override /* package */ Builder self() { return this; diff --git a/parse/src/main/java/com/parse/ParseUriHttpBody.java b/parse/src/main/java/com/parse/ParseUriHttpBody.java new file mode 100644 index 000000000..cd169399b --- /dev/null +++ b/parse/src/main/java/com/parse/ParseUriHttpBody.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2015-present, Parse, LLC. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +package com.parse; + +import static com.parse.Parse.getApplicationContext; + +import android.content.res.AssetFileDescriptor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import com.parse.http.ParseHttpBody; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +class ParseUriHttpBody extends ParseHttpBody { + + /* package */ final Uri uri; + + public ParseUriHttpBody(Uri uri) { + this(uri, null); + } + + public ParseUriHttpBody(Uri uri, String contentType) { + super(contentType, getUriLength(uri)); + this.uri = uri; + } + + private static long getUriLength(Uri uri) { + long length = -1; + try { + ParcelFileDescriptor parcelFileDescriptor = getApplicationContext().getContentResolver().openFileDescriptor(uri, "r"); + if (parcelFileDescriptor != null) { + length = parcelFileDescriptor.getStatSize(); + parcelFileDescriptor.close(); + } + } catch (IOException ignored) { + } + if (length == -1) { + try { + AssetFileDescriptor assetFileDescriptor = getApplicationContext().getContentResolver().openAssetFileDescriptor(uri, "r"); + if (assetFileDescriptor != null) { + length = assetFileDescriptor.getLength(); + assetFileDescriptor.close(); + } + } catch (IOException ignored) { + } + } + return length; + } + + @Override + public InputStream getContent() throws IOException { + return getApplicationContext().getContentResolver().openInputStream(uri); + } + + @Override + public void writeTo(OutputStream out) throws IOException { + if (out == null) { + throw new IllegalArgumentException("Output stream can not be null"); + } + + final InputStream fileInput = getApplicationContext().getContentResolver().openInputStream(uri); + try { + ParseIOUtils.copy(fileInput, out); + } finally { + ParseIOUtils.closeQuietly(fileInput); + } + } +} diff --git a/parse/src/test/java/com/parse/ParseCountingUriHttpBodyTest.java b/parse/src/test/java/com/parse/ParseCountingUriHttpBodyTest.java new file mode 100644 index 000000000..8175a6448 --- /dev/null +++ b/parse/src/test/java/com/parse/ParseCountingUriHttpBodyTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2015-present, Parse, LLC. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +package com.parse; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.net.Uri; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Arrays; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +public class ParseCountingUriHttpBodyTest { + + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private static String getData() { + char[] chars = new char[64 << 14]; // 1MB + Arrays.fill(chars, '1'); + return new String(chars); + } + + private static Uri makeTestUri(File root) throws IOException { + File file = new File(root, "test"); + FileWriter writer = new FileWriter(file); + writer.write(getData()); + writer.close(); + return Uri.fromFile(file); + } + + @Test + public void testWriteTo() throws Exception { + final Semaphore didReportIntermediateProgress = new Semaphore(0); + final Semaphore finish = new Semaphore(0); + + ParseCountingUriHttpBody body = + new ParseCountingUriHttpBody( + makeTestUri(temporaryFolder.getRoot()), + new ProgressCallback() { + Integer maxProgressSoFar = 0; + + @Override + public void done(Integer percentDone) { + if (percentDone > maxProgressSoFar) { + maxProgressSoFar = percentDone; + assertTrue(percentDone >= 0 && percentDone <= 100); + + if (percentDone < 100 && percentDone > 0) { + didReportIntermediateProgress.release(); + } else if (percentDone == 100) { + finish.release(); + } else if (percentDone == 0) { + // do nothing + } else { + fail("percentDone should be within 0 - 100"); + } + } + } + }); + + // Check content + ByteArrayOutputStream output = new ByteArrayOutputStream(); + body.writeTo(output); + assertArrayEquals(getData().getBytes(), output.toByteArray()); + // Check progress callback + assertTrue(didReportIntermediateProgress.tryAcquire(5, TimeUnit.SECONDS)); + assertTrue(finish.tryAcquire(5, TimeUnit.SECONDS)); + } + + @Test(expected = IllegalArgumentException.class) + public void testWriteToWithNullOutput() throws Exception { + ParseCountingUriHttpBody body = + new ParseCountingUriHttpBody(makeTestUri(temporaryFolder.getRoot()), null); + body.writeTo(null); + } +} diff --git a/parse/src/test/java/com/parse/ParseFileControllerTest.java b/parse/src/test/java/com/parse/ParseFileControllerTest.java index 1dd65e151..108cc8d2c 100644 --- a/parse/src/test/java/com/parse/ParseFileControllerTest.java +++ b/parse/src/test/java/com/parse/ParseFileControllerTest.java @@ -20,6 +20,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.net.Uri; + import com.parse.boltsinternal.Task; import com.parse.http.ParseHttpRequest; import com.parse.http.ParseHttpResponse; @@ -197,6 +199,43 @@ public void testSaveAsyncSuccessWithFile() throws Exception { assertEquals("content", ParseFileUtils.readFileToString(cachedFile, "UTF-8")); } + @Test + public void testSaveAsyncSuccessWithUri() throws Exception { + JSONObject json = new JSONObject(); + json.put("name", "new_file_name"); + json.put("url", "http://example.com"); + String content = json.toString(); + + ParseHttpResponse mockResponse = + new ParseHttpResponse.Builder() + .setStatusCode(200) + .setTotalSize((long) content.length()) + .setContent(new ByteArrayInputStream(content.getBytes())) + .build(); + + ParseHttpClient restClient = mock(ParseHttpClient.class); + when(restClient.execute(any(ParseHttpRequest.class))).thenReturn(mockResponse); + + File root = temporaryFolder.getRoot(); + ParseFileController controller = new ParseFileController(restClient, root); + + File file = new File(root, "test"); + ParseFileUtils.writeStringToFile(file, "content", "UTF-8"); + Uri uri = Uri.fromFile(file); + ParseFile.State state = + new ParseFile.State.Builder().name("file_name").mimeType("mime_type").build(); + Task task = controller.saveAsync(state, uri, null, null, null); + ParseFile.State result = ParseTaskUtils.wait(task); + + verify(restClient, times(1)).execute(any(ParseHttpRequest.class)); + assertEquals("new_file_name", result.name()); + assertEquals("http://example.com", result.url()); + File cachedFile = new File(root, "new_file_name"); + assertTrue(cachedFile.exists()); + assertTrue(file.exists()); + assertEquals("content", ParseFileUtils.readFileToString(cachedFile, "UTF-8")); + } + @Test public void testSaveAsyncFailureWithByteArray() throws Exception { // TODO(grantland): Remove once we no longer rely on retry logic. diff --git a/parse/src/test/java/com/parse/ParseFileTest.java b/parse/src/test/java/com/parse/ParseFileTest.java index 11c13a5ef..0a415cfbb 100644 --- a/parse/src/test/java/com/parse/ParseFileTest.java +++ b/parse/src/test/java/com/parse/ParseFileTest.java @@ -21,6 +21,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.net.Uri; import android.os.Parcel; import com.parse.boltsinternal.Task; import java.io.File; @@ -58,6 +59,7 @@ public void testConstructor() throws Exception { byte[] data = "hello".getBytes(); String contentType = "content_type"; File file = temporaryFolder.newFile(name); + Uri uri = Uri.fromFile(file); // TODO(mengyan): After we have proper staging strategy, we should verify the staging file's // content is the same with the original file. @@ -90,6 +92,10 @@ public void testConstructor() throws Exception { parseFile = new ParseFile(file, contentType); assertEquals(name, parseFile.getName()); // Default assertEquals("content_type", parseFile.getState().mimeType()); + + parseFile = new ParseFile(name, uri, contentType); + assertEquals(name, parseFile.getName()); + assertEquals("content_type", parseFile.getState().mimeType()); } @Test @@ -248,6 +254,46 @@ public void testSaveAsyncSuccessWithFile() throws Exception { assertEquals(url, parseFile.getUrl()); } + @Test + public void testSaveAsyncSuccessWithUri() throws Exception { + String name = "name"; + File file = temporaryFolder.newFile(name); + Uri uri = Uri.fromFile(file); + String contentType = "content_type"; + String url = "url"; + ParseFile.State state = new ParseFile.State.Builder().url(url).build(); + ParseFileController controller = mock(ParseFileController.class); + when(controller.saveAsync( + any(ParseFile.State.class), + any(File.class), + nullable(String.class), + nullable(ProgressCallback.class), + nullable(Task.class))) + .thenReturn(Task.forResult(state)); + ParseCorePlugins.getInstance().registerFileController(controller); + + ParseFile parseFile = new ParseFile(name, uri, contentType); + ParseTaskUtils.wait(parseFile.saveAsync(null, null, null)); + + // Verify controller get the correct data + ArgumentCaptor stateCaptor = + ArgumentCaptor.forClass(ParseFile.State.class); + ArgumentCaptor fileCaptor = ArgumentCaptor.forClass(File.class); + verify(controller, times(1)) + .saveAsync( + stateCaptor.capture(), + fileCaptor.capture(), + nullable(String.class), + nullable(ProgressCallback.class), + nullable(Task.class)); + assertNull(stateCaptor.getValue().url()); + assertEquals(name, stateCaptor.getValue().name()); + assertEquals(contentType, stateCaptor.getValue().mimeType()); + assertEquals(file, fileCaptor.getValue()); + // Verify the state of ParseFile has been updated + assertEquals(url, parseFile.getUrl()); + } + // TODO(grantland): testSaveAsyncNotDirtyAfterQueueAwait // TODO(grantland): testSaveAsyncSuccess // TODO(grantland): testSaveAsyncFailure diff --git a/parse/src/test/java/com/parse/ParseUriHttpBodyTest.java b/parse/src/test/java/com/parse/ParseUriHttpBodyTest.java new file mode 100644 index 000000000..e0e837b26 --- /dev/null +++ b/parse/src/test/java/com/parse/ParseUriHttpBodyTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2015-present, Parse, LLC. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +package com.parse; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import android.net.Uri; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + +public class ParseUriHttpBodyTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void testInitializeWithUri() throws IOException { + byte[] content = {1, 1, 1, 1, 1}; + String contentType = "application/json"; + File file = temporaryFolder.newFile("name"); + ParseFileUtils.writeByteArrayToFile(file, content); + Uri uri = Uri.fromFile(file); + ParseUriHttpBody body = new ParseUriHttpBody(uri, contentType); + assertArrayEquals(content, ParseIOUtils.toByteArray(body.getContent())); + assertEquals(contentType, body.getContentType()); + assertEquals(5, body.getContentLength()); + } + + @Test + public void testWriteTo() throws IOException { + String content = "content"; + String contentType = "application/json"; + File file = temporaryFolder.newFile("name"); + ParseFileUtils.writeStringToFile(file, content, "UTF-8"); + Uri uri = Uri.fromFile(file); + ParseUriHttpBody body = new ParseUriHttpBody(uri, contentType); + + // Check content + ByteArrayOutputStream output = new ByteArrayOutputStream(); + body.writeTo(output); + String contentAgain = output.toString(); + assertEquals(content, contentAgain); + + // No need to check whether content input stream is closed since it is a + // ByteArrayInputStream + } +}