Commit 38a69a94 authored by Evan W. Patton's avatar Evan W. Patton Committed by Susan Rati Lane

Fix processing of // in File and add unit tests

Fixes #1457

Change-Id: I73ff292d87e8ba22a5900eac774d8ae576f05ef2
parent b99cfc47
......@@ -25,6 +25,7 @@ import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.support.annotation.VisibleForTesting;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.util.Log;
......@@ -74,6 +75,7 @@ import com.google.appinventor.components.runtime.util.SdkLevel;
import com.google.appinventor.components.runtime.util.ViewUtil;
import org.json.JSONException;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
......@@ -2530,13 +2532,7 @@ public class Form extends AppInventorCompatActivity
*/
@SuppressWarnings({"WeakerAccess"}) // May be called by extensions
public InputStream openAsset(String asset) throws IOException {
String path = getAssetPath(asset);
if (path.startsWith(ASSETS_PREFIX)) {
final AssetManager am = getAssets();
return am.open(path.substring(ASSETS_PREFIX.length()));
} else {
return FileUtil.openFile(URI.create(path));
}
return openAssetInternal(getAssetPath(asset));
}
/**
......@@ -2563,13 +2559,21 @@ public class Form extends AppInventorCompatActivity
* stream to prevent resource leaking.
* @throws IOException if the asset is not found or cannot be read
*/
@SuppressWarnings("unused") // May be called by extensions
public InputStream openAssetForExtension(Component component, String asset) throws IOException {
String path = getAssetPathForExtension(component, asset);
return openAssetInternal(getAssetPathForExtension(component, asset));
}
@SuppressWarnings("WeakerAccess") // Visible for testing
@VisibleForTesting
InputStream openAssetInternal(String path) throws IOException {
if (path.startsWith(ASSETS_PREFIX)) {
final AssetManager am = getAssets();
return am.open(path.substring(ASSETS_PREFIX.length()));
} else {
} else if (path.startsWith("file:")) {
return FileUtil.openFile(URI.create(path));
} else {
return FileUtil.openFile(path);
}
}
}
......@@ -62,7 +62,7 @@ public class ReplForm extends Form {
private static final String LOG_TAG = ReplForm.class.getSimpleName();
private AppInvHTTPD httpdServer = null;
public static ReplForm topform;
private static final String REPL_ASSET_DIR =
public static final String REPL_ASSET_DIR =
Environment.getExternalStorageDirectory().getAbsolutePath() +
"/AppInventor/assets/";
private static final String REPL_COMP_DIR = REPL_ASSET_DIR + "external_comps/";
......@@ -402,7 +402,7 @@ public class ReplForm extends Form {
@Override
public String getAssetPath(String asset) {
return REPL_ASSET_DIR + asset;
return "file://" + REPL_ASSET_DIR + asset;
}
@Override
......
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright © 2018 Massachusetts Institute of Technology, All rights reserved.
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.components.runtime;
import android.Manifest;
import android.os.Environment;
import com.google.appinventor.components.runtime.shadows.ShadowActivityCompat;
import com.google.appinventor.components.runtime.shadows.ShadowAsynchUtil;
import com.google.appinventor.components.runtime.shadows.ShadowEventDispatcher;
import com.google.appinventor.components.runtime.util.IOUtils;
import org.junit.Before;
import org.junit.Test;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.Charset;
/**
* Tests for the File component.
*
* @author ewpatton@mit.edu (Evan W. Patton)
*/
@Config(shadows = {ShadowActivityCompat.class})
public class FileTest extends RobolectricTestBase {
private static final String TAG = FileTest.class.getSimpleName();
private static final String DATA = "test data";
protected static final String TARGET_FILE = "test.txt";
protected File file;
@Before
public void setUp() {
super.setUp();
file = new File(getForm());
}
/**
* Tests that the File component can read files when given the special file
* path "//". In a compiled app, this reads from the app's compiled assets.
* For the REPL, this reads from a special directory AppInventor/assets/
* on the external storage.
*/
@Test
public void testFileDoubleSlash() {
grantFilePermissions();
file.ReadFrom("//" + TARGET_FILE);
ShadowAsynchUtil.runAllPendingRunnables();
runAllEvents();
ShadowEventDispatcher.assertEventFired(file, "GotText", "Hello, world!\n");
}
/**
* Tests that the File component can read files when given a relative file
* name. In a compiled app, this reads from the app's private data directory.
* For the REPL, this reads from a special directory AppInventor/data/
* on the external storage.
*/
@Test
public void testFileRelative() {
grantFilePermissions();
writeTempFile(TARGET_FILE, DATA, false);
testReadFile(TARGET_FILE, DATA);
}
/**
* Tests that the File component can read files when given an absolute file
* name. In both compiled apps and the REPL, this reads from the given file
* from the root of the external storage.
*/
@Test
public void testFileAbsolute() {
grantFilePermissions();
writeTempFile(TARGET_FILE, DATA, true);
testReadFile("/" + TARGET_FILE, DATA);
}
/**
* Tests that the file component will report a PermissionDenied event when
* the user denies a request for READ_EXTERNAL_STORAGE.
*/
@Test
public void testFilePermissionDenied() {
denyFilePermissions();
file.ReadFrom("/" + TARGET_FILE);
ShadowActivityCompat.denyLastRequestedPermissions();
runAllEvents();
ShadowEventDispatcher.assertPermissionDenied(Manifest.permission.READ_EXTERNAL_STORAGE);
}
/// Helper functions
/**
* Helper function to grant read/write permissions to the app.
*/
public void grantFilePermissions() {
Shadows.shadowOf(getForm()).grantPermissions(Manifest.permission.READ_EXTERNAL_STORAGE);
Shadows.shadowOf(getForm()).grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE);
}
/**
* Helper function to deny read/write permissions to the app.
*/
public void denyFilePermissions() {
Shadows.shadowOf(getForm()).denyPermissions(Manifest.permission.READ_EXTERNAL_STORAGE);
Shadows.shadowOf(getForm()).denyPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE);
}
/**
* Write a temporary file to the file of the given {@code name} with the given
* {@code content}.
*
* @param name the name of the file to write
* @param content the content of the file
* @param external true if the file should be written to external storage,
* otherwise false.
* @return the absolute path of the file
*/
public String writeTempFile(String name, String content, boolean external) {
String target;
if (external) {
target = Environment.getExternalStorageDirectory().getAbsolutePath();
} else if (getForm().isRepl()) {
target = Environment.getExternalStorageDirectory().getAbsolutePath() +
"/AppInventor/data";
} else {
target = getForm().getFilesDir().getAbsolutePath();
}
target += "/" + name;
FileOutputStream out = null;
try {
java.io.File targetFile = new java.io.File(target);
targetFile.deleteOnExit();
if (!targetFile.getParentFile().exists()) {
if (!targetFile.getParentFile().mkdirs()) {
throw new IOException();
}
}
out = new FileOutputStream(target);
out.write(content.getBytes(Charset.forName("UTF-8")));
return targetFile.getAbsolutePath();
} catch (IOException e) {
throw new IllegalStateException("Unable to prepare test", e);
} finally {
IOUtils.closeQuietly(TAG, out);
}
}
private void testReadFile(String filename, String expectedData) {
file.ReadFrom(filename);
ShadowAsynchUtil.runAllPendingRunnables();
runAllEvents();
ShadowEventDispatcher.assertEventFired(file, "GotText", expectedData);
}
}
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright © 2018 Massachusetts Institute of Technology, All rights reserved.
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.components.runtime;
import com.google.appinventor.components.runtime.test.TestExtension;
import com.google.appinventor.components.runtime.util.IOUtils;
import org.junit.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import static org.junit.Assert.assertEquals;
/**
* Tests for the Form component.
*
* @author ewpatton@mit.edu (Evan W. Patton)
*/
public class FormTest extends RobolectricTestBase {
private static final int BUFSIZE = 4096;
static final String TARGET_FILE = "test.txt";
/**
* Tests the ability of the Form to open an asset.
*
* @throws IOException if the asset cannot be found.
*/
@Test
public void testOpenAsset() throws IOException {
InputStream is = null;
try {
is = getForm().openAsset(TARGET_FILE);
assertEquals("Hello, world!\n", readStream(is));
} finally {
IOUtils.closeQuietly("test", is);
}
}
/**
* Tests the ability of the Form to open an asset associated with an
* extension.
*
* @throws IOException if the asset cannot be found.
*/
@Test
public void testOpenAssetExtension() throws IOException {
InputStream is = null;
try {
is = getForm().openAssetForExtension(new TestExtension(getForm()), TARGET_FILE);
assertEquals("Sample extension asset\n", readStream(is));
} finally {
IOUtils.closeQuietly("test", is);
}
}
/// Helper functions
/**
* Read the contents of a stream as a string.
*
* @param is the input stream to read
* @return the contents of the input stream as a string
* @throws IOException if the file cannot be read
*/
public static String readStream(InputStream is) throws IOException {
byte[] data = new byte[BUFSIZE];
int read;
StringBuilder sb = new StringBuilder();
while ((read = is.read(data, 0, BUFSIZE)) > 0) {
sb.append(new String(data, 0, read, Charset.forName("UTF-8")));
}
return sb.toString();
}
}
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright © 2018 Massachusetts Institute of Technology, All rights reserved.
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.components.runtime;
import android.Manifest;
import com.google.appinventor.components.runtime.util.IOUtils;
import org.junit.Before;
import org.junit.Test;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowApplication;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import static org.junit.Assert.assertEquals;
/**
* Tests for the ReplForm.
*
* @author ewpatton@mit.edu (Evan W. Patton)
*/
public class ReplFormTest extends FormTest {
private static final String TAG = "test";
private static final int BUFSIZE = 4096;
@Before
public void setUp() {
super.setUpAsRepl();
Shadows.shadowOf(getForm()).grantPermissions(Manifest.permission.READ_EXTERNAL_STORAGE);
Shadows.shadowOf(getForm()).grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE);
copyAssetToReplAssets(TARGET_FILE, TARGET_FILE);
copyAssetToReplAssets("com.google.appinventor.components.runtime.test/" + TARGET_FILE,
"external_comps/com.google.appinventor.components.runtime.test/assets/" + TARGET_FILE);
}
/**
* Test that the form can open naked file paths.
*
* In version 2.48, ReplForm did not follow the contract for {@link Form#getAssetPath(String)}.
* It returned a string containing a file path, not a file URI. This would result in developers
* getting an error "URI is not absolute" when live testing. We changed the ReplForm
* implementation to behave correctly by returning a string starting with file:///, but we also
* test here that we can handle non-URI versions in case someone ends up overriding the method in
* a subclass (somehow...).
*/
@Test
public void testOpenAssetInternal() throws IOException {
InputStream is = null;
try {
is = getForm().openAssetInternal(ReplForm.REPL_ASSET_DIR + TARGET_FILE);
assertEquals("Hello, world!\n", readStream(is));
} finally {
IOUtils.closeQuietly(TAG, is);
}
}
/// Helper functions
/**
* Copy an asset from the given source name to the given target name in the
* /sdcard/AppInventor/assets directory.
*
* @param source the source asset path
* @param target the target asset path
*/
public static void copyAssetToReplAssets(String source, String target) {
InputStream in = null;
OutputStream out = null;
try {
in = ShadowApplication.getInstance().getApplicationContext().getAssets().open(source);
java.io.File targetFile = new java.io.File(ReplForm.REPL_ASSET_DIR + target);
if (!targetFile.getParentFile().exists()) {
if (!targetFile.getParentFile().mkdirs()) {
throw new IllegalStateException("Could not configure REPL assets in setup");
}
}
out = new FileOutputStream(targetFile);
byte[] buffer = new byte[BUFSIZE];
int bytesRead;
while ((bytesRead = in.read(buffer, 0, BUFSIZE)) > 0) {
out.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
throw new IllegalStateException("Could not configure REPL assets in test setup", e);
} finally {
IOUtils.closeQuietly(TAG, in);
IOUtils.closeQuietly(TAG, out);
}
}
}
......@@ -28,7 +28,7 @@ import java.util.concurrent.TimeUnit;
* @author ewpatton@mit.edu (Evan W. Patton)
*/
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 22, manifest="tests/AndroidManifest.xml",
@Config(sdk = 23, manifest="tests/AndroidManifest.xml",
shadows = {ShadowStorageUtils.class, ShadowEventDispatcher.class, ShadowAsynchUtil.class})
public class RobolectricTestBase {
......@@ -45,14 +45,33 @@ public class RobolectricTestBase {
}
}
private static class FakeReplForm extends ReplForm {
@Override
protected void $define() {}
@Override
public void dispatchErrorOccurredEvent(Component component, String functionName, int errorCode, Object... args) {
String message = ErrorMessages.formatMessage(errorCode, args);
ShadowEventDispatcher.dispatchEvent(this.$form(), "ErrorOccurred", component, functionName, errorCode, message);
}
}
public Form getForm() {
return form;
}
@Before
public void setUp() {
setUpForm(FakeForm.class);
}
public void setUpAsRepl() {
setUpForm(FakeReplForm.class);
}
private <T extends Form> void setUpForm(Class<T> clazz) {
ShadowLooper.getShadowMainLooper().getScheduler().setIdleState(IdleState.PAUSED);
ActivityController<FakeForm> activityController = Robolectric.buildActivity(FakeForm.class)
ActivityController<T> activityController = Robolectric.buildActivity(clazz)
.create().start().resume().visible();
form = activityController.get();
// Unfortunately Robolectric won't handle laying out the view hierarchy and because of how
......
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright © 2018 Massachusetts Institute of Technology, All rights reserved.
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.components.runtime.repltest;
import com.google.appinventor.components.runtime.File;
import com.google.appinventor.components.runtime.FileTest;
import com.google.appinventor.components.runtime.ReplFormTest;
import org.junit.Before;
/**
* Tests for the File Component, but run as if it is in the REPL rather than
* in a compiled application. See {@link FileTest} for the test definitions.
*
* @author ewpatton@mit.edu (Evan W. Patton)
*/
public class FileReplTest extends FileTest {
@Before
public void setUp() {
setUpAsRepl();
file = new File(getForm());
ReplFormTest.copyAssetToReplAssets(TARGET_FILE, TARGET_FILE);
}
}
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright © 2018 Massachusetts Institute of Technology, All rights reserved.
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.components.runtime.shadows;
import android.app.Activity;
import android.content.pm.PackageManager;
import android.support.v4.app.ActivityCompat;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
/**
* A shadow implementation of ActivityCompat that allows one to
* grant/deny permissions requested when the app calls
* {@link ActivityCompat#requestPermission(Activity,String,String)}.
*
* @author ewpatton@mit.edu (Evan W. Patton)
*/
@Implements(ActivityCompat.class)
public class ShadowActivityCompat {
private static Activity activity;
private static String[] permissions;
private static int requestCode;
@Implementation
public static void requestPermissions(final Activity activity, final String[] permissions, final int requestCode) {
ShadowActivityCompat.activity = activity;
ShadowActivityCompat.permissions = permissions;
ShadowActivityCompat.requestCode = requestCode;
}
/**
* Grants the last set of permissions requested by the app.
*/
public static void grantLastRequestedPermissions() {
int[] result = new int[permissions.length];
for (int i = 0; i < result.length; i++) {
result[i] = PackageManager.PERMISSION_GRANTED;
}
activity.onRequestPermissionsResult(requestCode, permissions, result);
}
/**
* Denies the last set of permissions requested by the app.
*/
public static void denyLastRequestedPermissions() {
int[] result = new int[permissions.length];
for (int i = 0; i < result.length; i++) {
result[i] = PackageManager.PERMISSION_DENIED;
}
activity.onRequestPermissionsResult(requestCode, permissions, result);
}
}
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright © 2017 Massachusetts Institute of Technology, All rights reserved.
// Copyright © 2017-2018 Massachusetts Institute of Technology, All rights reserved.
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
......@@ -111,4 +111,23 @@ public class ShadowEventDispatcher {
}
throw new AssertionError(String.format("Form did not receive ErrorOccurred event with code %d.", errorCode));
}
/**
* Asserts that the EventDispatcher saw a PermissionDenied event for the given permission name.
*
* @param permission the permission to test for denial
*/
public static void assertPermissionDenied(String permission) {
if (permission.startsWith("android.permission.")) {
permission = permission.replace("android.permission.", "");
}
for (Set<EventWithArgs> events: firedEvents.values()) {
for (EventWithArgs event : events) {
if ("PermissionDenied".equals(event.eventName) && event.args[2].equals(permission)) {
return;
}
}
}
throw new AssertionError(String.format("Form did not receive PermissionDenied event for permission %s.", permission));
}
}
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright © 2018 Massachusetts Institute of Technology, All rights reserved.
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.components.runtime.test;
import com.google.appinventor.components.annotations.SimpleObject;
import com.google.appinventor.components.runtime.AndroidNonvisibleComponent;
import com.google.appinventor.components.runtime.Form;
/**
* TestExtension is a barebones extension class that can be used for testing
* different features of the App Inventor extensions mechanism in the REPL.
*
* @author ewpatton@mit.edu (Evan W. Patton)
*/
@SimpleObject(external = true)
public class TestExtension extends AndroidNonvisibleComponent {
public TestExtension(Form form) {
super(form);
}
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment