diff --git a/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/JSBundleLoader.java b/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/JSBundleLoader.java index 7b4d6e499..6615c75c8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/JSBundleLoader.java +++ b/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/JSBundleLoader.java @@ -14,6 +14,8 @@ import android.content.Context; import com.facebook.react.devsupport.DebugServerException; import com.facebook.react.devsupport.DevServerHelper; +import java.io.File; + /** * A class that stores JS bundle information and allows {@link CatalystInstance} to load a correct * bundle through {@link ReactBridge}. @@ -99,6 +101,19 @@ public abstract class JSBundleLoader { }; } + public static JSBundleLoader createUnpackingBundleLoader( + final Context context, + final String sourceURL, + final String bundleName) { + return UnpackingJSBundleLoader.newBuilder() + .setContext(context) + .setSourceURL(sourceURL) + .setDestinationPath(new File(context.getFilesDir(), "optimized-bundle")) + .checkAndUnpackFile(bundleName + ".meta", "bundle.meta") + .unpackFile(bundleName, "bundle.js") + .build(); + } + public abstract void loadScript(CatalystInstanceImpl instance); public abstract String getSourceUrl(); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/UnpackingJSBundleLoader.java b/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/UnpackingJSBundleLoader.java new file mode 100644 index 000000000..ed65860ff --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/UnpackingJSBundleLoader.java @@ -0,0 +1,342 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * 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.facebook.react.cxxbridge; + +import android.content.Context; +import android.content.res.AssetManager; + +import com.facebook.infer.annotation.Assertions; +import com.facebook.soloader.FileLocker; +import com.facebook.soloader.SysUtil; +import com.facebook.systrace.Systrace; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.util.ArrayList; +import java.util.Arrays; + +import javax.annotation.Nullable; + +import static com.facebook.systrace.Systrace.TRACE_TAG_REACT_JAVA_BRIDGE; + +/** + * JSBundleLoader capable of unpacking specified files necessary for executing + * JS bundle stored in optimized format. + */ +public class UnpackingJSBundleLoader extends JSBundleLoader { + + /** + * Name of the lock files. Multiple processes can be spawned off the same app + * and we need to guarantee that at most one unpacks files at any time. To + * make that work any process is required to hold file system lock on + * LOCK_FILE when checking whether files should be unpacked and unpacking + * them. + */ + static final String LOCK_FILE = "unpacking-bundle-loader.lock"; + + /** + * Existence of this file indicates that the last unpacking operation finished + * before the app was killed or crashed. File with this name is created in the + * destination directory as the last one. If it is present it means that + * all the files that needed to be fsynced were fsynced and their content is + * what it should be. + */ + static final String DOT_UNPACKED_FILE = ".unpacked"; + + private static final int IO_BUFFER_SIZE = 16 * 1024; + + /** + * Where all the files should go to. + */ + private final File mDirectoryPath; + + private final String mSourceURL; + private final Context mContext; + + /** + * Description of what needs to be unpacked. + */ + private final Unpacker[] mUnpackers; + + /* package */ UnpackingJSBundleLoader(Builder builder) { + mContext = Assertions.assertNotNull(builder.context); + mDirectoryPath = Assertions.assertNotNull(builder.destinationPath); + mSourceURL = Assertions.assertNotNull(builder.sourceURL); + mUnpackers = builder.unpackers.toArray(new Unpacker[builder.unpackers.size()]); + } + + /** + * Checks if any file needs to be extracted again, and if so, clears the destination + * directory and unpacks everything again. + */ + /* package */ void prepare() { + final File lockFilePath = new File(mContext.getFilesDir(), LOCK_FILE); + Systrace.beginSection(TRACE_TAG_REACT_JAVA_BRIDGE, "UnpackingJSBundleLoader.prepare"); + try (FileLocker lock = FileLocker.lock(lockFilePath)) { + prepareLocked(); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } finally { + Systrace.endSection(TRACE_TAG_REACT_JAVA_BRIDGE); + } + } + + private void prepareLocked() throws IOException { + final File dotFinishedFilePath = new File(mDirectoryPath, DOT_UNPACKED_FILE); + boolean shouldReconstruct = !mDirectoryPath.exists() || !dotFinishedFilePath.exists(); + + byte[] buffer = new byte[IO_BUFFER_SIZE]; + for (int i = 0; i < mUnpackers.length && !shouldReconstruct; ++i) { + shouldReconstruct = mUnpackers[i].shouldReconstructDir(mContext, buffer); + } + + if (!shouldReconstruct) { + return; + } + + boolean succeeded = false; + try { + SysUtil.dumbDeleteRecursive(mDirectoryPath); + if (!mDirectoryPath.mkdirs()) { + throw new IOException("Coult not create the destination directory"); + } + + for (Unpacker unpacker : mUnpackers) { + unpacker.unpack(mContext, buffer); + } + + if (!dotFinishedFilePath.createNewFile()) { + throw new IOException("Could not create .unpacked file"); + } + + // It would be nice to fsync a few directories and files here. The thing is, if we crash and + // lose some data then it should be noticed on the next prepare invocation and the directory + // will be reconstructed. It is only crucial to fsync those files whose content is not + // verified on each start. Everything else is a tradeoff between perf with no crashes + // situation and perf when user experiences crashes. Fortunately Unpackers corresponding + // to files whose content is not checked handle fsyncs themselves. + + succeeded = true; + } finally { + // In case of failure do yourself a favor and remove partially initialized state. + if (!succeeded) { + SysUtil.dumbDeleteRecursive(mDirectoryPath); + } + } + } + + @Override + public void loadScript(CatalystInstanceImpl instance) { + prepare(); + // TODO(12128379): add instance method that would take bundle directory + instance.loadScriptFromFile( + new File(mDirectoryPath, "bundle.js").getPath(), + mSourceURL); + } + + @Override + public String getSourceUrl() { + return mSourceURL; + } + + static void fsync(File path) throws IOException { + try (RandomAccessFile file = new RandomAccessFile(path, "r")) { + file.getFD().sync(); + } + } + + /** + * Reads all the bytes (but no more that maxSize) from given input stream through ioBuffer + * and returns byte array containing all the read bytes. + */ + static byte[] readBytes(InputStream is, byte[] ioBuffer, int maxSize) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + copyBytes(baos, is, ioBuffer, maxSize); + return baos.toByteArray(); + } + + /** + * Pumps all the bytes (but no more that maxSize) from given input stream through ioBuffer + * to given output stream and returns number of moved bytes. + */ + static int copyBytes( + OutputStream os, + InputStream is, + byte[] ioBuffer, + int maxSize) throws IOException { + int totalSize = 0; + while (totalSize < maxSize) { + int rc = is.read(ioBuffer, 0, Math.min(maxSize - totalSize, ioBuffer.length)); + if (rc == -1) { + break; + } + os.write(ioBuffer, 0, rc); + totalSize += rc; + } + return totalSize; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static class Builder { + private @Nullable Context context; + private @Nullable File destinationPath; + private @Nullable String sourceURL; + private final ArrayList unpackers; + + public Builder() { + this.unpackers = new ArrayList(); + context = null; + destinationPath = null; + sourceURL = null; + } + + public Builder setContext(Context context) { + this.context = context; + return this; + } + + public Builder setDestinationPath(File destinationPath) { + this.destinationPath = destinationPath; + return this; + } + + public Builder setSourceURL(String sourceURL) { + this.sourceURL = sourceURL; + return this; + } + + /** + * Adds a file for unpacking. Content of extracted file is not checked on each + * start against content of the file bundled in apk. + */ + public Builder unpackFile(String nameInApk, String destFileName) { + unpackers.add(new ExistenceCheckingUnpacker(nameInApk, destFileName)); + return this; + } + + /** + * Adds a file for unpacking. Content of extracted file is compared on each + * start with content of the same file bundled in apk. It is usefull for + * detecting bundle/app changes. + */ + public Builder checkAndUnpackFile(String nameInApk, String destFileName) { + unpackers.add(new ContentCheckingUnpacker(nameInApk, destFileName)); + return this; + } + + /** + * Adds arbitrary unpacker. Usefull for injecting mocks. + */ + Builder addUnpacker(Unpacker u) { + unpackers.add(u); + return this; + } + + public UnpackingJSBundleLoader build() { + Assertions.assertNotNull(destinationPath); + for (int i = 0; i < unpackers.size(); ++i) { + unpackers.get(i).setDestinationDirectory(destinationPath); + } + return new UnpackingJSBundleLoader(this); + } + } + + /** + * Abstraction for dealing with unpacking single file from apk. + */ + static abstract class Unpacker { + protected final String mNameInApk; + private final String mFileName; + protected @Nullable File mDestinationFilePath; + + public Unpacker(String nameInApk, String fileName) { + mNameInApk = nameInApk; + mFileName = fileName; + } + + public void setDestinationDirectory(File destinationDirectoryPath) { + mDestinationFilePath = new File(destinationDirectoryPath, mFileName); + } + + public abstract boolean shouldReconstructDir(Context context, byte[] ioBuffer) + throws IOException; + + public void unpack(Context context, byte[] ioBuffer) throws IOException { + AssetManager am = context.getAssets(); + try (InputStream is = am.open(mNameInApk, AssetManager.ACCESS_STREAMING)) { + try (FileOutputStream fileOutputStream = new FileOutputStream( + Assertions.assertNotNull(mDestinationFilePath))) { + copyBytes(fileOutputStream, is, ioBuffer, Integer.MAX_VALUE); + } + } + } + } + + /** + * Deals with unpacking files whose content is not checked on each start and + * need to be fsynced after unpacking. + */ + static class ExistenceCheckingUnpacker extends Unpacker { + public ExistenceCheckingUnpacker(String nameInApk, String fileName) { + super(nameInApk, fileName); + } + + @Override + public boolean shouldReconstructDir(Context context, byte[] ioBuffer) { + return !Assertions.assertNotNull(mDestinationFilePath).exists(); + } + + @Override + public void unpack(Context context, byte[] ioBuffer) throws IOException { + super.unpack(context, ioBuffer); + fsync(Assertions.assertNotNull(mDestinationFilePath)); + } + } + + /** + * Deals with unpacking files whose content is checked on each start and thus + * do not require fsync. + */ + static class ContentCheckingUnpacker extends Unpacker { + public ContentCheckingUnpacker(String nameInApk, String fileName) { + super(nameInApk, fileName); + } + + @Override + public boolean shouldReconstructDir(Context context, byte[] ioBuffer) throws IOException { + if (!Assertions.assertNotNull(mDestinationFilePath).exists()) { + return true; + } + + AssetManager am = context.getAssets(); + final byte[] assetContent; + try (InputStream assetStream = am.open(mNameInApk, AssetManager.ACCESS_STREAMING)) { + assetContent = readBytes(assetStream, ioBuffer, Integer.MAX_VALUE); + } + + final byte[] fileContent; + try (InputStream fileStream = new FileInputStream( + Assertions.assertNotNull(mDestinationFilePath))) { + fileContent = readBytes(fileStream, ioBuffer, assetContent.length + 1); + } + + return !Arrays.equals(assetContent, fileContent); + } + } +} diff --git a/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/BUCK b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/BUCK new file mode 100644 index 000000000..22c014b06 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/BUCK @@ -0,0 +1,40 @@ +include_defs('//ReactAndroid/DEFS') + +STANDARD_TEST_SRCS = [ + '*Test.java', +] + +android_library( + name = 'testhelpers', + srcs = glob(['*.java'], excludes = STANDARD_TEST_SRCS), + deps = [ + react_native_dep('third-party/java/junit:junit'), + react_native_dep('third-party/java/mockito:mockito'), + ], + visibility = [ + 'PUBLIC' + ], +) + +robolectric3_test( + name = 'bridge', + # Please change the contact to the oncall of your team + contacts = ['oncall+fbandroid_sheriff@xmail.facebook.com'], + srcs = glob(STANDARD_TEST_SRCS), + deps = [ + ':testhelpers', + react_native_dep('libraries/fbcore/src/test/java/com/facebook/powermock:powermock'), + react_native_dep('libraries/soloader/java/com/facebook/soloader:soloader'), + react_native_dep('third-party/java/junit:junit'), + react_native_dep('third-party/java/mockito:mockito'), + react_native_dep('third-party/java/robolectric3/robolectric:robolectric'), + react_native_target('java/com/facebook/react/cxxbridge:bridge'), + ], + visibility = [ + 'PUBLIC' + ], +) + +project_config( + test_target = ':bridge', +) diff --git a/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/ContentCheckingUnpackerTest.java b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/ContentCheckingUnpackerTest.java new file mode 100644 index 000000000..23ba3ca67 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/ContentCheckingUnpackerTest.java @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * 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.facebook.react.cxxbridge; + +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.IOException; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.runner.RunWith; +import org.junit.Test; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.rule.PowerMockRule; +import org.robolectric.RobolectricTestRunner; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.times; +import static org.powermock.api.mockito.PowerMockito.mockStatic; +import static org.powermock.api.mockito.PowerMockito.verifyStatic; + +@PrepareForTest({UnpackingJSBundleLoader.class}) +@PowerMockIgnore({ "org.mockito.*", "org.robolectric.*", "android.*" }) +@RunWith(RobolectricTestRunner.class) +public class ContentCheckingUnpackerTest extends UnpackerTestBase { + @Rule + public PowerMockRule rule = new PowerMockRule(); + + private UnpackingJSBundleLoader.ContentCheckingUnpacker mUnpacker; + + @Before + public void setUp() throws IOException { + super.setUp(); + mUnpacker = new UnpackingJSBundleLoader.ContentCheckingUnpacker( + NAME_IN_APK, + DESTINATION_NAME); + mUnpacker.setDestinationDirectory(folder.getRoot()); + } + + @Test + public void testReconstructsIfFileDoesNotExist() throws IOException { + assertTrue(mUnpacker.shouldReconstructDir(mContext, mIOBuffer)); + } + + @Test + public void testReconstructsIfContentDoesNotMatch() throws IOException { + try (FileOutputStream fos = new FileOutputStream(mDestinationPath)) { + fos.write(ASSET_DATA, 0, ASSET_DATA.length - 1); + fos.write((byte) (ASSET_DATA[ASSET_DATA.length - 1] + 1)); + } + assertTrue(mUnpacker.shouldReconstructDir(mContext, mIOBuffer)); + } + + @Test + public void testDoesNotReconstructIfContentMatches() throws IOException { + try (FileOutputStream fos = new FileOutputStream(mDestinationPath)) { + fos.write(ASSET_DATA); + } + assertFalse(mUnpacker.shouldReconstructDir(mContext, mIOBuffer)); + } + + @Test + public void testUnpacksFile() throws IOException { + mUnpacker.unpack(mContext, mIOBuffer); + assertTrue(mDestinationPath.exists()); + try (InputStream is = new FileInputStream(mDestinationPath)) { + byte[] storedData = UnpackingJSBundleLoader.readBytes(is, mIOBuffer, Integer.MAX_VALUE); + assertArrayEquals(ASSET_DATA, storedData); + } + } +} diff --git a/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/ExistenceCheckingUnpackerTest.java b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/ExistenceCheckingUnpackerTest.java new file mode 100644 index 000000000..2608bcab3 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/ExistenceCheckingUnpackerTest.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * 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.facebook.react.cxxbridge; + +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.IOException; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.runner.RunWith; +import org.junit.Test; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.rule.PowerMockRule; +import org.robolectric.RobolectricTestRunner; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.times; +import static org.powermock.api.mockito.PowerMockito.mockStatic; +import static org.powermock.api.mockito.PowerMockito.verifyStatic; + +@PrepareForTest({UnpackingJSBundleLoader.class}) +@PowerMockIgnore({ "org.mockito.*", "org.robolectric.*", "android.*" }) +@RunWith(RobolectricTestRunner.class) +public class ExistenceCheckingUnpackerTest extends UnpackerTestBase { + @Rule + public PowerMockRule rule = new PowerMockRule(); + + private UnpackingJSBundleLoader.ExistenceCheckingUnpacker mUnpacker; + + @Before + public void setUp() throws IOException { + super.setUp(); + mUnpacker = new UnpackingJSBundleLoader.ExistenceCheckingUnpacker( + NAME_IN_APK, + DESTINATION_NAME); + mUnpacker.setDestinationDirectory(folder.getRoot()); + } + + @Test + public void testReconstructsIfFileDoesNotExist() { + assertTrue(mUnpacker.shouldReconstructDir(mContext, mIOBuffer)); + } + + @Test + public void testDoesNotReconstructIfFileExists() throws IOException { + mDestinationPath.createNewFile(); + assertFalse(mUnpacker.shouldReconstructDir(mContext, mIOBuffer)); + } + + @Test + public void testUnpacksFile() throws IOException { + mUnpacker.unpack(mContext, mIOBuffer); + assertTrue(mDestinationPath.exists()); + try (InputStream is = new FileInputStream(mDestinationPath)) { + byte[] storedData = UnpackingJSBundleLoader.readBytes(is, mIOBuffer, Integer.MAX_VALUE); + assertArrayEquals(ASSET_DATA, storedData); + } + } + + @Test + public void testFsyncsAfterUnpacking() throws IOException { + mockStatic(UnpackingJSBundleLoader.class); + mUnpacker.unpack(mContext, mIOBuffer); + verifyStatic(times(1)); + UnpackingJSBundleLoader.fsync(mDestinationPath); + } +} diff --git a/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/UnpackerTestBase.java b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/UnpackerTestBase.java new file mode 100644 index 000000000..a2865a612 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/UnpackerTestBase.java @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * 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.facebook.react.cxxbridge; + +import android.content.Context; +import android.content.res.AssetManager; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.IOException; + +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class UnpackerTestBase { + static final String NAME_IN_APK = "nameInApk"; + static final String DESTINATION_NAME = "destination"; + static final byte[] ASSET_DATA = new byte[]{(byte) 1, (byte) 101, (byte) 50}; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + File mDestinationPath; + byte[] mIOBuffer; + + Context mContext; + AssetManager mAssetManager; + + public void setUp() throws IOException { + mDestinationPath = new File(folder.getRoot(), DESTINATION_NAME); + mIOBuffer = new byte[16 * 1024]; + + mContext = mock(Context.class); + mAssetManager = mock(AssetManager.class); + + when(mContext.getAssets()).thenReturn(mAssetManager); + when(mAssetManager.open(eq(NAME_IN_APK), anyInt())) + .then(new Answer() { + @Override + public FileInputStream answer(InvocationOnMock invocation) throws Throwable { + final ByteArrayInputStream bais = new ByteArrayInputStream(ASSET_DATA); + final FileInputStream fis = mock(FileInputStream.class); + when(fis.read()) + .then(new Answer() { + @Override + public Integer answer(InvocationOnMock invocation) throws Throwable { + return bais.read(); + } + }); + when(fis.read(any(byte[].class))) + .then(new Answer() { + @Override + public Integer answer(InvocationOnMock invocation) throws Throwable { + return bais.read((byte[]) invocation.getArguments()[0]); + } + }); + when(fis.read(any(byte[].class), any(int.class), any(int.class))) + .then(new Answer() { + @Override + public Integer answer(InvocationOnMock invocation) throws Throwable { + return bais.read( + (byte[]) invocation.getArguments()[0], + (int) invocation.getArguments()[1], + (int) invocation.getArguments()[2]); + } + }); + when(fis.available()).then(new Answer() { + @Override + public Integer answer(InvocationOnMock invocation) throws Throwable { + return bais.available(); + } + }); + return fis; + } + }); + } +} diff --git a/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/UnpackingJSBundleLoaderTest.java b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/UnpackingJSBundleLoaderTest.java new file mode 100644 index 000000000..d6a2b9c66 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/UnpackingJSBundleLoaderTest.java @@ -0,0 +1,183 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * 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.facebook.react.cxxbridge; + +import android.content.Context; + +import com.facebook.soloader.SoLoader; + +import java.io.File; +import java.io.IOException; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.Test; +import org.robolectric.RobolectricTestRunner; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.same; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@RunWith(RobolectricTestRunner.class) +public class UnpackingJSBundleLoaderTest { + static { + SoLoader.setInTestMode(); + } + + private static final String URL = "http://this.is.an.url"; + private static final int MOCK_UNPACKERS_NUM = 2; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private File mDestinationPath; + private File mFilesPath; + + private UnpackingJSBundleLoader.Builder mBuilder; + private Context mContext; + private CatalystInstanceImpl mCatalystInstanceImpl; + private UnpackingJSBundleLoader.Unpacker[] mMockUnpackers; + + @Before + public void setUp() throws IOException { + mDestinationPath = folder.newFolder("destination"); + mFilesPath = folder.newFolder("files"); + + mContext = mock(Context.class); + when(mContext.getFilesDir()).thenReturn(mFilesPath); + + mCatalystInstanceImpl = mock(CatalystInstanceImpl.class); + + mBuilder = UnpackingJSBundleLoader.newBuilder() + .setDestinationPath(mDestinationPath) + .setSourceURL(URL) + .setContext(mContext); + + mMockUnpackers = new UnpackingJSBundleLoader.Unpacker[MOCK_UNPACKERS_NUM]; + for (int i = 0; i < mMockUnpackers.length; ++i) { + mMockUnpackers[i] = mock(UnpackingJSBundleLoader.Unpacker.class); + } + } + + private void addUnpackers() { + for (UnpackingJSBundleLoader.Unpacker unpacker : mMockUnpackers) { + mBuilder.addUnpacker(unpacker); + } + } + + @Test + public void testGetSourceUrl() { + assertEquals(URL, mBuilder.build().getSourceUrl()); + } + + @Test + public void testCreatesDotUnpackedFile() throws IOException { + mBuilder.build().prepare(); + assertTrue(new File(mDestinationPath, UnpackingJSBundleLoader.DOT_UNPACKED_FILE).exists()); + } + + @Test + public void testCreatesLockFile() throws IOException { + mBuilder.build().prepare(); + assertTrue(new File(mFilesPath, UnpackingJSBundleLoader.LOCK_FILE).exists()); + } + + @Test + public void testCallsAppropriateInstanceMethod() throws IOException { + mBuilder.build().loadScript(mCatalystInstanceImpl); + verify(mCatalystInstanceImpl).loadScriptFromFile( + eq(new File(mDestinationPath, "bundle.js").getPath()), + eq(URL)); + verifyNoMoreInteractions(mCatalystInstanceImpl); + } + + @Test + public void testLoadScriptUnpacks() { + mBuilder.build().loadScript(mCatalystInstanceImpl); + assertTrue(new File(mDestinationPath, UnpackingJSBundleLoader.DOT_UNPACKED_FILE).exists()); + } + + @Test + public void testPrepareCallDoesNotRecreateDirIfNotNecessary() throws IOException { + mBuilder.build().prepare(); + + addUnpackers(); + mBuilder.build().prepare(); + for (UnpackingJSBundleLoader.Unpacker unpacker : mMockUnpackers) { + verify(unpacker).setDestinationDirectory(mDestinationPath); + verify(unpacker).shouldReconstructDir( + same(mContext), + any(byte[].class)); + verifyNoMoreInteractions(unpacker); + } + } + + @Test + public void testShouldReconstructDirForcesRecreation() throws IOException { + mBuilder.build().prepare(); + + addUnpackers(); + when(mMockUnpackers[0].shouldReconstructDir( + same(mContext), + any(byte[].class))) + .thenReturn(true); + mBuilder.build().prepare(); + + verify(mMockUnpackers[0]).shouldReconstructDir( + same(mContext), + any(byte[].class)); + for (UnpackingJSBundleLoader.Unpacker unpacker : mMockUnpackers) { + verify(unpacker).setDestinationDirectory(mDestinationPath); + verify(unpacker).unpack( + same(mContext), + any(byte[].class)); + verifyNoMoreInteractions(unpacker); + } + } + + @Test + public void testDirectoryReconstructionRemovesDir() throws IOException { + mBuilder.build().prepare(); + final File aFile = new File(mDestinationPath, "a_file"); + aFile.createNewFile(); + + when(mMockUnpackers[0].shouldReconstructDir( + same(mContext), + any(byte[].class))) + .thenReturn(true); + addUnpackers(); + mBuilder.build().prepare(); + + assertFalse(aFile.exists()); + } + + @Test(expected = RuntimeException.class) + public void testDropsDirectoryOnException() throws IOException { + doThrow(new IOException("An expected IOException")) + .when(mMockUnpackers[0]).unpack( + same(mContext), + any(byte[].class)); + try { + mBuilder.addUnpacker(mMockUnpackers[0]).build().prepare(); + } finally { + assertFalse(mDestinationPath.exists()); + } + } +}