@bravemobile/react-native-code-push 12.4.1 → 13.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CodePush.podspec +47 -2
  2. package/README.md +118 -3
  3. package/THIRD-PARTY-NOTICES +96 -0
  4. package/android/app/build.gradle +26 -0
  5. package/android/app/proguard-rules.pro +5 -0
  6. package/android/app/src/main/cpp/CMakeLists.txt +38 -0
  7. package/android/app/src/main/cpp/binarypatch_jni.c +181 -0
  8. package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchAttempt.java +92 -0
  9. package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchResult.java +64 -0
  10. package/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +46 -11
  11. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +293 -0
  12. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +25 -0
  13. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +79 -88
  14. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +159 -4
  15. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +13 -0
  16. package/android/app/src/main/java/com/microsoft/codepush/react/HDiffPatchNative.java +52 -0
  17. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +393 -0
  18. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java +207 -0
  19. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +449 -0
  20. package/android/codepush-export.gradle +115 -0
  21. package/cli/commands/bundleCommand/bundleCodePush.ts +39 -7
  22. package/cli/commands/bundleCommand/index.ts +9 -1
  23. package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
  24. package/cli/commands/releaseCommand/index.test.ts +118 -0
  25. package/cli/commands/releaseCommand/index.ts +20 -2
  26. package/cli/commands/releaseCommand/release.test.ts +582 -0
  27. package/cli/commands/releaseCommand/release.ts +232 -15
  28. package/cli/dist/commands/bundleCommand/bundleCodePush.js +26 -7
  29. package/cli/dist/commands/bundleCommand/index.js +5 -1
  30. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
  31. package/cli/dist/commands/releaseCommand/index.js +8 -1
  32. package/cli/dist/commands/releaseCommand/index.test.js +96 -0
  33. package/cli/dist/commands/releaseCommand/release.js +165 -17
  34. package/cli/dist/commands/releaseCommand/release.test.js +367 -0
  35. package/cli/dist/functions/makeBinaryPatchBundle.js +248 -0
  36. package/cli/dist/functions/makeBinaryPatchBundle.test.js +321 -0
  37. package/cli/dist/functions/makeCodePushBundle.js +4 -2
  38. package/cli/dist/functions/runHermesEmitBinaryCommand.js +98 -27
  39. package/cli/dist/functions/runHermesEmitBinaryCommand.test.js +100 -0
  40. package/cli/dist/jest.globalSetup.js +38 -0
  41. package/cli/dist/utils/binaryPatch.js +86 -0
  42. package/cli/dist/utils/binaryPatch.test.js +190 -0
  43. package/cli/functions/makeBinaryPatchBundle.test.ts +410 -0
  44. package/cli/functions/makeBinaryPatchBundle.ts +367 -0
  45. package/cli/functions/makeCodePushBundle.ts +4 -2
  46. package/cli/functions/runHermesEmitBinaryCommand.test.ts +124 -0
  47. package/cli/functions/runHermesEmitBinaryCommand.ts +112 -26
  48. package/cli/jest.globalSetup.ts +43 -0
  49. package/cli/utils/binaryPatch.test.ts +234 -0
  50. package/cli/utils/binaryPatch.ts +103 -0
  51. package/cpp/binarypatch/binarypatch_zstd_decompressor.c +187 -0
  52. package/cpp/binarypatch/binarypatch_zstd_decompressor.h +47 -0
  53. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/checksum_plugin.h +52 -0
  54. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/hpatch_mt/hpatch_mt.h +147 -0
  55. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.c +3315 -0
  56. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.h +268 -0
  57. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_private.h +297 -0
  58. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_types.h +434 -0
  59. package/cpp/binarypatch/vendor/zstd/common/allocations.h +55 -0
  60. package/cpp/binarypatch/vendor/zstd/common/bits.h +205 -0
  61. package/cpp/binarypatch/vendor/zstd/common/bitstream.h +454 -0
  62. package/cpp/binarypatch/vendor/zstd/common/compiler.h +464 -0
  63. package/cpp/binarypatch/vendor/zstd/common/cpu.h +249 -0
  64. package/cpp/binarypatch/vendor/zstd/common/debug.c +30 -0
  65. package/cpp/binarypatch/vendor/zstd/common/debug.h +107 -0
  66. package/cpp/binarypatch/vendor/zstd/common/entropy_common.c +340 -0
  67. package/cpp/binarypatch/vendor/zstd/common/error_private.c +64 -0
  68. package/cpp/binarypatch/vendor/zstd/common/error_private.h +158 -0
  69. package/cpp/binarypatch/vendor/zstd/common/fse.h +625 -0
  70. package/cpp/binarypatch/vendor/zstd/common/fse_decompress.c +315 -0
  71. package/cpp/binarypatch/vendor/zstd/common/huf.h +277 -0
  72. package/cpp/binarypatch/vendor/zstd/common/mem.h +422 -0
  73. package/cpp/binarypatch/vendor/zstd/common/pool.c +371 -0
  74. package/cpp/binarypatch/vendor/zstd/common/pool.h +81 -0
  75. package/cpp/binarypatch/vendor/zstd/common/portability_macros.h +171 -0
  76. package/cpp/binarypatch/vendor/zstd/common/threading.c +182 -0
  77. package/cpp/binarypatch/vendor/zstd/common/threading.h +142 -0
  78. package/cpp/binarypatch/vendor/zstd/common/xxhash.c +18 -0
  79. package/cpp/binarypatch/vendor/zstd/common/xxhash.h +7094 -0
  80. package/cpp/binarypatch/vendor/zstd/common/zstd_common.c +48 -0
  81. package/cpp/binarypatch/vendor/zstd/common/zstd_deps.h +123 -0
  82. package/cpp/binarypatch/vendor/zstd/common/zstd_internal.h +324 -0
  83. package/cpp/binarypatch/vendor/zstd/common/zstd_trace.h +156 -0
  84. package/cpp/binarypatch/vendor/zstd/decompress/huf_decompress.c +1944 -0
  85. package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.c +244 -0
  86. package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.h +44 -0
  87. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress.c +2410 -0
  88. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.c +2209 -0
  89. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.h +73 -0
  90. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_internal.h +240 -0
  91. package/cpp/binarypatch/vendor/zstd/zstd.h +3206 -0
  92. package/cpp/binarypatch/vendor/zstd/zstd_errors.h +107 -0
  93. package/ios/CodePush/CodePush.h +58 -9
  94. package/ios/CodePush/{CodePush.m → CodePush.mm} +77 -63
  95. package/ios/CodePush/CodePushBinaryPatch.h +80 -0
  96. package/ios/CodePush/CodePushBinaryPatch.m +473 -0
  97. package/ios/CodePush/CodePushPackage.m +183 -2
  98. package/ios/CodePush/CodePushUpdateUtils.m +49 -1
  99. package/ios/CodePush.xcodeproj/project.pbxproj +6 -6
  100. package/package.json +28 -4
  101. package/react-native.config.js +3 -1
  102. package/scripts/binary-patch/build-hdiffpatch.sh +86 -0
  103. package/scripts/export-embedded-bundle.sh +76 -0
  104. package/src/CodePush.js +190 -96
  105. package/src/CodePush.test.js +382 -0
  106. package/src/native/NativeCodePush.ts +162 -0
  107. package/src/package-mixins.js +24 -11
  108. package/src/specs/NativeCodePush.ts +43 -0
  109. package/typings/react-native-code-push.d.ts +77 -0
  110. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushDialog.java +0 -102
  111. package/src/AlertAdapter.js +0 -24
@@ -0,0 +1,449 @@
1
+ package com.microsoft.codepush.react;
2
+
3
+ import static org.junit.Assert.assertArrayEquals;
4
+ import static org.junit.Assert.assertEquals;
5
+ import static org.junit.Assert.assertFalse;
6
+ import static org.junit.Assert.assertTrue;
7
+
8
+ import org.json.JSONObject;
9
+ import org.junit.After;
10
+ import org.junit.Before;
11
+ import org.junit.Rule;
12
+ import org.junit.Test;
13
+ import org.junit.rules.TemporaryFolder;
14
+
15
+ import java.io.BufferedReader;
16
+ import java.io.ByteArrayOutputStream;
17
+ import java.io.File;
18
+ import java.io.FileInputStream;
19
+ import java.io.FileOutputStream;
20
+ import java.io.IOException;
21
+ import java.io.InputStream;
22
+ import java.io.InputStreamReader;
23
+ import java.io.OutputStream;
24
+ import java.net.InetAddress;
25
+ import java.net.ServerSocket;
26
+ import java.net.Socket;
27
+ import java.nio.charset.Charset;
28
+ import java.security.MessageDigest;
29
+ import java.security.NoSuchAlgorithmException;
30
+ import java.util.ArrayList;
31
+ import java.util.Arrays;
32
+ import java.util.Collections;
33
+ import java.util.HashMap;
34
+ import java.util.LinkedHashMap;
35
+ import java.util.List;
36
+ import java.util.Map;
37
+ import java.util.zip.ZipEntry;
38
+ import java.util.zip.ZipOutputStream;
39
+
40
+ /**
41
+ * Downloading a real archive over HTTP and installing what comes out of it.
42
+ *
43
+ * These exercise the update manager end to end - download, unzip, restore, folder hash,
44
+ * metadata - with only the two seams of the applier stubbed, because the wiring between
45
+ * those steps is where an archive that is not what it claims has to be caught.
46
+ */
47
+ public class CodePushUpdateManagerDownloadTest {
48
+
49
+ private static final String BUNDLE_FILE_NAME = "index.android.bundle";
50
+ /** Every archive wraps its files in one directory, which the manifest paths are relative to. */
51
+ private static final String CONTENTS_DIR_NAME = "CodePush";
52
+ private static final String ASSET_PATH = "assets/logo.png";
53
+
54
+ private static final byte[] BASE_BUNDLE = bytes("the bundle inside the app binary");
55
+ private static final byte[] TARGET_BUNDLE = bytes("the bundle the update wants to run");
56
+ private static final byte[] PATCH = bytes("the difference between the two");
57
+ private static final byte[] ASSET = bytes("an image the update ships with");
58
+ private static final byte[] ERROR_PAGE = bytes("<html><body>404 Not Found</body></html>");
59
+
60
+ @Rule
61
+ public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
62
+
63
+ private TestArchiveServer mServer;
64
+
65
+ private String mDocumentsDirectory;
66
+ private String mPackageHash;
67
+ private File mPackageFolder;
68
+
69
+ @Before
70
+ public void setUp() throws IOException {
71
+ mDocumentsDirectory = mTemporaryFolder.getRoot().getAbsolutePath();
72
+ mPackageHash = packageHashOf(fullArchiveContents());
73
+ mPackageFolder = new File(new File(mDocumentsDirectory, CodePushConstants.CODE_PUSH_FOLDER_PREFIX), mPackageHash);
74
+
75
+ mServer = new TestArchiveServer();
76
+ }
77
+
78
+ @After
79
+ public void tearDown() throws IOException {
80
+ mServer.close();
81
+ }
82
+
83
+ @Test
84
+ public void installsAnUpdateFromItsBinaryPatchArchive() throws IOException {
85
+ String patchUrl = serve("/patch.zip", zipOf(patchArchiveContents()));
86
+ String fullUrl = serve("/full.zip", zipOf(fullArchiveContents()));
87
+
88
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
89
+ .downloadPackage(updatePackage(fullUrl, patchUrl), BUNDLE_FILE_NAME, ignoreProgress());
90
+
91
+ assertEquals("the full archive is not downloaded when the patch installs",
92
+ Arrays.asList("/patch.zip"), mServer.requestedPaths());
93
+ assertInstalledContents();
94
+ assertEquals("applied", patchResult.optString("status", null));
95
+ assertFalse("an applied patch has nothing to report a reason for", patchResult.has("fallbackReason"));
96
+ assertTrue("the apply is timed", patchResult.optLong("applyDurationMs", -1) >= 0);
97
+ }
98
+
99
+ @Test
100
+ public void fallsBackToTheFullArchiveWhenThePatchUrlDoesNotServeAnArchive() throws IOException {
101
+ // A CDN that answers an error page with a 200 is the realistic way this happens.
102
+ String patchUrl = serve("/patch.zip", ERROR_PAGE);
103
+ String fullUrl = serve("/full.zip", zipOf(fullArchiveContents()));
104
+
105
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
106
+ .downloadPackage(updatePackage(fullUrl, patchUrl), BUNDLE_FILE_NAME, ignoreProgress());
107
+
108
+ assertEquals(Arrays.asList("/patch.zip", "/full.zip"), mServer.requestedPaths());
109
+ assertInstalledContents();
110
+ assertFallbackResult(patchResult, BinaryPatchResult.REASON_INVALID_MANIFEST);
111
+ }
112
+
113
+ @Test
114
+ public void reportsAnInvalidManifestWhenThePatchUrlDoesNotServeAnArchive() throws IOException {
115
+ String patchUrl = serve("/patch.zip", ERROR_PAGE);
116
+
117
+ BinaryPatchResult result = updateManager(applierWriting(TARGET_BUNDLE)).downloadAndInstallPackage(
118
+ updatePackage("https://example.test/unused.zip", patchUrl), BUNDLE_FILE_NAME, ignoreProgress(),
119
+ patchUrl, true, new BinaryPatchAttempt());
120
+
121
+ assertFalse(result.succeeded());
122
+ assertEquals(BinaryPatchResult.REASON_INVALID_MANIFEST, result.getFailureReason());
123
+ assertFalse("bytes that are not an update must not reach the package folder", mPackageFolder.exists());
124
+ }
125
+
126
+ @Test
127
+ public void fallsBackToTheFullArchiveWhenApplyingThePatchRunsOutOfMemory() throws IOException {
128
+ String patchUrl = serve("/patch.zip", zipOf(patchArchiveContents()));
129
+ String fullUrl = serve("/full.zip", zipOf(fullArchiveContents()));
130
+ CodePushBinaryPatch.PatchApplier outOfMemoryApplier = new CodePushBinaryPatch.PatchApplier() {
131
+ @Override
132
+ public int apply(byte[] base, byte[] patch, String outputPath, long expectedTargetSize) {
133
+ throw new OutOfMemoryError("Failed to allocate the restored bundle");
134
+ }
135
+ };
136
+
137
+ JSONObject patchResult = updateManager(outOfMemoryApplier)
138
+ .downloadPackage(updatePackage(fullUrl, patchUrl), BUNDLE_FILE_NAME, ignoreProgress());
139
+
140
+ assertEquals(Arrays.asList("/patch.zip", "/full.zip"), mServer.requestedPaths());
141
+ assertInstalledContents();
142
+ // Running out of memory is not one of the outcomes the appliers have a word for, and
143
+ // no word is invented for it here.
144
+ assertFallbackResult(patchResult, null);
145
+ }
146
+
147
+ @Test
148
+ public void reportsAPackageVerificationFailureWhenTheRestoredUpdateIsNotTheOnePublished() throws IOException {
149
+ // The patch archive carries an asset the release was not published with, so the
150
+ // bundle it restores is the promised one while the update it makes is not.
151
+ Map<String, byte[]> contents = patchArchiveContents();
152
+ contents.put(CONTENTS_DIR_NAME + "/" + ASSET_PATH, bytes("an image from some other release"));
153
+ String patchUrl = serve("/patch.zip", zipOf(contents));
154
+ String fullUrl = serve("/full.zip", zipOf(fullArchiveContents()));
155
+
156
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
157
+ .downloadPackage(updatePackage(fullUrl, patchUrl), BUNDLE_FILE_NAME, ignoreProgress());
158
+
159
+ assertEquals(Arrays.asList("/patch.zip", "/full.zip"), mServer.requestedPaths());
160
+ assertInstalledContents();
161
+ assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
162
+ }
163
+
164
+ /** The result of a download the update had to be downloaded in full for. */
165
+ private static void assertFallbackResult(JSONObject patchResult, String expectedReason) {
166
+ assertEquals("fallback", patchResult.optString("status", null));
167
+ assertEquals(expectedReason, patchResult.optString("fallbackReason", null));
168
+ assertTrue("the attempt is timed", patchResult.optLong("applyDurationMs", -1) >= 0);
169
+ }
170
+
171
+ /** The installed update is the full archive's contents, whichever archive it came from. */
172
+ private void assertInstalledContents() throws IOException {
173
+ File contentsFolder = new File(mPackageFolder, CONTENTS_DIR_NAME);
174
+ assertArrayEquals(TARGET_BUNDLE, readFile(new File(contentsFolder, BUNDLE_FILE_NAME)));
175
+ assertArrayEquals(ASSET, readFile(new File(contentsFolder, ASSET_PATH)));
176
+ assertFalse(new File(contentsFolder, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME).exists());
177
+ assertFalse(new File(contentsFolder, BUNDLE_FILE_NAME + ".patch").exists());
178
+
179
+ // Written last, so its presence also says the folder hash check passed.
180
+ JSONObject metadata = CodePushUtils.getJsonObjectFromFile(
181
+ new File(mPackageFolder, CodePushConstants.PACKAGE_FILE_NAME).getAbsolutePath());
182
+ String bundlePath = metadata.optString(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, null);
183
+ assertTrue("the metadata points at the restored bundle, but says " + bundlePath,
184
+ bundlePath != null && bundlePath.endsWith(CONTENTS_DIR_NAME + "/" + BUNDLE_FILE_NAME));
185
+ // How the update was downloaded says nothing about the update, so it is not part of
186
+ // what the update is installed from and outlives the download in no file.
187
+ assertFalse("the patch attempt reached the stored metadata",
188
+ metadata.has(CodePushConstants.BINARY_PATCH_RESULT_KEY));
189
+
190
+ File binaryPatchFolder = new File(
191
+ new File(mDocumentsDirectory, CodePushConstants.CODE_PUSH_FOLDER_PREFIX),
192
+ CodePushConstants.BINARY_PATCH_FOLDER_NAME);
193
+ assertFalse(binaryPatchFolder.exists());
194
+ }
195
+
196
+ private CodePushUpdateManager updateManager(CodePushBinaryPatch.PatchApplier applier) {
197
+ CodePushBinaryPatch binaryPatch = new CodePushBinaryPatch(new CodePushBinaryPatch.BaseBundleProvider() {
198
+ @Override
199
+ public byte[] readBaseBundle(String bundleFileName) {
200
+ return BASE_BUNDLE;
201
+ }
202
+ }, applier);
203
+
204
+ return new CodePushUpdateManager(mDocumentsDirectory, binaryPatch);
205
+ }
206
+
207
+ private static CodePushBinaryPatch.PatchApplier applierWriting(final byte[] restoredBundle) {
208
+ return new CodePushBinaryPatch.PatchApplier() {
209
+ @Override
210
+ public int apply(byte[] base, byte[] patch, String outputPath, long expectedTargetSize) {
211
+ assertArrayEquals(BASE_BUNDLE, base);
212
+ assertArrayEquals(PATCH, patch);
213
+ try {
214
+ writeFile(new File(outputPath), restoredBundle);
215
+ } catch (IOException e) {
216
+ return RESULT_IO_ERROR;
217
+ }
218
+
219
+ return RESULT_OK;
220
+ }
221
+ };
222
+ }
223
+
224
+ private Map<String, byte[]> fullArchiveContents() {
225
+ Map<String, byte[]> contents = new LinkedHashMap<>();
226
+ contents.put(CONTENTS_DIR_NAME + "/" + BUNDLE_FILE_NAME, TARGET_BUNDLE);
227
+ contents.put(CONTENTS_DIR_NAME + "/" + ASSET_PATH, ASSET);
228
+ return contents;
229
+ }
230
+
231
+ private Map<String, byte[]> patchArchiveContents() {
232
+ JSONObject manifest = new JSONObject();
233
+ CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_FORMAT_VERSION_KEY,
234
+ CodePushConstants.BINARY_PATCH_FORMAT_VERSION);
235
+ CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_ALGORITHM_KEY,
236
+ CodePushConstants.BINARY_PATCH_ALGORITHM);
237
+ CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_BUNDLE_PATH_KEY, BUNDLE_FILE_NAME);
238
+ CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_FILE_KEY, BUNDLE_FILE_NAME + ".patch");
239
+ CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_BASE_BUNDLE_HASH_KEY, sha256(BASE_BUNDLE));
240
+ CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_HASH_KEY, sha256(TARGET_BUNDLE));
241
+ CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_SIZE_KEY, TARGET_BUNDLE.length);
242
+
243
+ Map<String, byte[]> contents = new LinkedHashMap<>();
244
+ contents.put(CONTENTS_DIR_NAME + "/" + CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME, bytes(manifest.toString()));
245
+ contents.put(CONTENTS_DIR_NAME + "/" + BUNDLE_FILE_NAME + ".patch", PATCH);
246
+ contents.put(CONTENTS_DIR_NAME + "/" + ASSET_PATH, ASSET);
247
+ return contents;
248
+ }
249
+
250
+ private JSONObject updatePackage(String downloadUrl, String binaryPatchDownloadUrl) {
251
+ JSONObject updatePackage = new JSONObject();
252
+ CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.PACKAGE_HASH_KEY, mPackageHash);
253
+ CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.DOWNLOAD_URL_KEY, downloadUrl);
254
+ CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.BINARY_PATCH_DOWNLOAD_URL_KEY, binaryPatchDownloadUrl);
255
+ return updatePackage;
256
+ }
257
+
258
+ private String serve(String path, byte[] body) {
259
+ return mServer.serve(path, body);
260
+ }
261
+
262
+ /**
263
+ * Serves the archives over a loopback socket, so the download really goes through
264
+ * `HttpURLConnection` the way it does on a device. Written on a plain socket rather than
265
+ * against an HTTP library because a unit test here has neither the JDK's server nor a
266
+ * dependency that could stand in for it.
267
+ */
268
+ private static class TestArchiveServer {
269
+
270
+ private final ServerSocket mSocket;
271
+ private final Map<String, byte[]> mBodies = new HashMap<>();
272
+ private final List<String> mRequestedPaths = Collections.synchronizedList(new ArrayList<String>());
273
+
274
+ TestArchiveServer() throws IOException {
275
+ mSocket = new ServerSocket(0, 0, InetAddress.getByName("127.0.0.1"));
276
+ Thread serverThread = new Thread(new Runnable() {
277
+ @Override
278
+ public void run() {
279
+ serveUntilClosed();
280
+ }
281
+ });
282
+ serverThread.setDaemon(true);
283
+ serverThread.start();
284
+ }
285
+
286
+ synchronized String serve(String path, byte[] body) {
287
+ mBodies.put(path, body);
288
+ return "http://127.0.0.1:" + mSocket.getLocalPort() + path;
289
+ }
290
+
291
+ List<String> requestedPaths() {
292
+ return new ArrayList<>(mRequestedPaths);
293
+ }
294
+
295
+ void close() throws IOException {
296
+ mSocket.close();
297
+ }
298
+
299
+ private void serveUntilClosed() {
300
+ while (!mSocket.isClosed()) {
301
+ Socket connection;
302
+ try {
303
+ connection = mSocket.accept();
304
+ } catch (IOException e) {
305
+ // The socket was closed while waiting, which is how the test ends.
306
+ return;
307
+ }
308
+
309
+ try {
310
+ try {
311
+ respond(connection);
312
+ } finally {
313
+ connection.close();
314
+ }
315
+ } catch (IOException e) {
316
+ // A broken connection is the client's business, not the server's.
317
+ }
318
+ }
319
+ }
320
+
321
+ private void respond(Socket connection) throws IOException {
322
+ BufferedReader request = new BufferedReader(
323
+ new InputStreamReader(connection.getInputStream(), Charset.forName("UTF-8")));
324
+ String requestLine = request.readLine();
325
+ for (String header = request.readLine(); header != null && !header.isEmpty(); header = request.readLine()) {
326
+ // The headers are read to the blank line so the request is fully consumed.
327
+ }
328
+
329
+ String path = requestLine == null ? "" : requestLine.split(" ")[1];
330
+ mRequestedPaths.add(path);
331
+ byte[] body = bodyFor(path);
332
+
333
+ OutputStream response = connection.getOutputStream();
334
+ if (body == null) {
335
+ response.write(bytes("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"));
336
+ } else {
337
+ response.write(bytes("HTTP/1.1 200 OK\r\nContent-Length: " + body.length
338
+ + "\r\nConnection: close\r\n\r\n"));
339
+ response.write(body);
340
+ }
341
+ response.flush();
342
+ // Half-close and wait for the client to hang up, so the response is not cut off
343
+ // by closing the socket underneath it.
344
+ connection.shutdownOutput();
345
+ while (connection.getInputStream().read() >= 0) {
346
+ // Drains whatever the client sends before it closes.
347
+ }
348
+ }
349
+
350
+ private synchronized byte[] bodyFor(String path) {
351
+ return mBodies.get(path);
352
+ }
353
+ }
354
+
355
+ private static byte[] zipOf(Map<String, byte[]> contents) throws IOException {
356
+ ByteArrayOutputStream archive = new ByteArrayOutputStream();
357
+ ZipOutputStream zipStream = new ZipOutputStream(archive);
358
+ try {
359
+ for (Map.Entry<String, byte[]> entry : contents.entrySet()) {
360
+ zipStream.putNextEntry(new ZipEntry(entry.getKey()));
361
+ zipStream.write(entry.getValue());
362
+ zipStream.closeEntry();
363
+ }
364
+ } finally {
365
+ zipStream.close();
366
+ }
367
+
368
+ return archive.toByteArray();
369
+ }
370
+
371
+ /**
372
+ * The package hash of update contents, computed the way the CLI computes it: the sorted
373
+ * `<relative path>:<sha256>` entries, stringified as a JSON array, hashed.
374
+ */
375
+ private static String packageHashOf(Map<String, byte[]> contents) {
376
+ List<String> manifest = new ArrayList<>();
377
+ for (Map.Entry<String, byte[]> entry : contents.entrySet()) {
378
+ manifest.add(entry.getKey() + ":" + sha256(entry.getValue()));
379
+ }
380
+ Collections.sort(manifest);
381
+
382
+ StringBuilder entries = new StringBuilder("[");
383
+ for (int i = 0; i < manifest.size(); i++) {
384
+ if (i > 0) {
385
+ entries.append(",");
386
+ }
387
+ entries.append('"').append(manifest.get(i)).append('"');
388
+ }
389
+ entries.append("]");
390
+
391
+ return sha256(bytes(entries.toString()));
392
+ }
393
+
394
+ private static DownloadProgressCallback ignoreProgress() {
395
+ return new DownloadProgressCallback() {
396
+ @Override
397
+ public void call(DownloadProgress downloadProgress) {
398
+ }
399
+ };
400
+ }
401
+
402
+ private static void writeFile(File file, byte[] contents) throws IOException {
403
+ OutputStream output = new FileOutputStream(file);
404
+ try {
405
+ output.write(contents);
406
+ } finally {
407
+ output.close();
408
+ }
409
+ }
410
+
411
+ private static byte[] readFile(File file) throws IOException {
412
+ byte[] contents = new byte[(int) file.length()];
413
+ InputStream input = new FileInputStream(file);
414
+ try {
415
+ int offset = 0;
416
+ while (offset < contents.length) {
417
+ int bytesRead = input.read(contents, offset, contents.length - offset);
418
+ if (bytesRead < 0) {
419
+ throw new IOException("Unexpected end of " + file);
420
+ }
421
+ offset += bytesRead;
422
+ }
423
+ } finally {
424
+ input.close();
425
+ }
426
+
427
+ return contents;
428
+ }
429
+
430
+ private static byte[] bytes(String text) {
431
+ return text.getBytes(Charset.forName("UTF-8"));
432
+ }
433
+
434
+ private static String sha256(byte[] data) {
435
+ MessageDigest digest;
436
+ try {
437
+ digest = MessageDigest.getInstance("SHA-256");
438
+ } catch (NoSuchAlgorithmException e) {
439
+ throw new IllegalStateException(e);
440
+ }
441
+
442
+ StringBuilder hex = new StringBuilder();
443
+ for (byte hashByte : digest.digest(data)) {
444
+ hex.append(String.format("%02x", hashByte));
445
+ }
446
+
447
+ return hex.toString();
448
+ }
449
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Exports the JS bundle a release build embeds in the app, so a later CodePush release
3
+ * can compute a binary patch against exactly the bytes the store binary ships.
4
+ *
5
+ * Apply it from the app module's build.gradle, below the React Native plugin:
6
+ *
7
+ * apply from: "../../node_modules/@bravemobile/react-native-code-push/android/codepush-export.gradle"
8
+ *
9
+ * Every variant that bundles JS gets a `codePushExport<Variant>Bundle` task that runs
10
+ * right after `createBundle<Variant>JsAndAssets`. Variants the React Native plugin lists
11
+ * in `debuggableVariants` never bundle - they have no bundle task - so they get no export
12
+ * task and the build behaves exactly as it did without this script.
13
+ *
14
+ * Each export writes the bundle and a `binary-patch-base.json` record next to it, under
15
+ * `<buildDir>/codepush/embedded-bundle/<variant>/`. Set the `codePushExportDir` Gradle
16
+ * property to export somewhere else; the `<variant>` directory is appended to it either
17
+ * way, so an `assemble` that builds several variants never has them overwrite each other.
18
+ */
19
+
20
+ import groovy.json.JsonOutput
21
+
22
+ import java.nio.file.Files
23
+ import java.nio.file.StandardCopyOption
24
+ import java.security.MessageDigest
25
+
26
+ /**
27
+ * Name and field names of the record are a contract shared with the CodePush CLI
28
+ * (`cli/functions/makeBinaryPatchBundle.ts`) and the iOS export script
29
+ * (`scripts/export-embedded-bundle.sh`). Keep the three in step.
30
+ */
31
+ def exportRecordName = 'binary-patch-base.json'
32
+
33
+ def exportRootDir = project.hasProperty('codePushExportDir')
34
+ ? project.file(project.property('codePushExportDir'))
35
+ : new File(project.layout.buildDirectory.get().asFile, 'codepush/embedded-bundle')
36
+
37
+ // The variant API is the only place the version of the artifact being built is known.
38
+ def variantOutputs = [:]
39
+ androidComponents.onVariants(androidComponents.selector().all()) { variant ->
40
+ variantOutputs[variant.name] = variant.outputs.find { it.outputType.toString() == 'SINGLE' } ?: variant.outputs.first()
41
+ }
42
+
43
+ // Runs after the Android plugin has created the variants and React Native has registered
44
+ // its bundle tasks, so the tasks to hook onto are all there to be found.
45
+ project.afterEvaluate {
46
+ def gitDir = project.rootDir
47
+
48
+ variantOutputs.each { variantName, variantOutput ->
49
+ def capitalizedName = variantName.capitalize()
50
+
51
+ // The React Native plugin registers this task only for variants that bundle JS, so
52
+ // a missing task means the variant embeds no bundle and there is nothing to export.
53
+ def bundleTask = project.tasks.findByName("createBundle${capitalizedName}JsAndAssets")
54
+ if (bundleTask == null) {
55
+ return
56
+ }
57
+
58
+ // Taken from the bundle task's own output properties rather than a fixed path, so a
59
+ // custom `bundleAssetName` or a new React Native output layout still resolves.
60
+ def bundleFile = bundleTask.jsBundleDir.file(bundleTask.bundleAssetName)
61
+ def exportDir = new File(exportRootDir, variantName)
62
+ def binaryVersion = variantOutput.versionName
63
+ def buildNumber = variantOutput.versionCode
64
+
65
+ def exportTask = project.tasks.register("codePushExport${capitalizedName}Bundle") { task ->
66
+ task.group = 'codepush'
67
+ task.description = "Exports the ${variantName} JS bundle embedded in the app, with the record describing it, to ${exportDir}."
68
+ task.inputs.file(bundleFile)
69
+ // The versions are part of what the record says, so bumping one has to rewrite
70
+ // it even when the bundle itself came out unchanged.
71
+ task.inputs.property('binaryVersion', binaryVersion)
72
+ task.inputs.property('buildNumber', buildNumber)
73
+ task.outputs.dir(exportDir)
74
+ task.doLast {
75
+ def embeddedBundle = bundleFile.get().asFile
76
+ exportDir.mkdirs()
77
+ def exportedBundle = new File(exportDir, embeddedBundle.name)
78
+ Files.copy(embeddedBundle.toPath(), exportedBundle.toPath(), StandardCopyOption.REPLACE_EXISTING)
79
+
80
+ def digest = MessageDigest.getInstance('SHA-256')
81
+ exportedBundle.eachByte(64 * 1024) { buffer, length -> digest.update(buffer, 0, length) }
82
+
83
+ def baseRecord = [baseBundleHash: digest.digest().encodeHex().toString()]
84
+ if (binaryVersion.getOrNull() != null) {
85
+ baseRecord.binaryVersion = binaryVersion.get()
86
+ }
87
+ if (buildNumber.getOrNull() != null) {
88
+ baseRecord.buildNumber = buildNumber.get().toString()
89
+ }
90
+
91
+ // A build outside a git checkout still exports a usable record, just without
92
+ // the commit, so a missing or failing git is never a build failure.
93
+ try {
94
+ def git = new ProcessBuilder('git', 'rev-parse', 'HEAD')
95
+ .directory(gitDir)
96
+ .redirectError(ProcessBuilder.Redirect.DISCARD)
97
+ .start()
98
+ def gitSha = git.inputStream.getText('UTF-8').trim()
99
+ if (git.waitFor() == 0 && !gitSha.isEmpty()) {
100
+ baseRecord.gitSha = gitSha
101
+ }
102
+ } catch (Exception ignored) {
103
+ }
104
+
105
+ new File(exportDir, exportRecordName).write(JsonOutput.prettyPrint(JsonOutput.toJson(baseRecord)), 'UTF-8')
106
+ // A bare `logger` here resolves against the enclosing script, so it is the
107
+ // Project's logger and drags the Project into the task action, which the
108
+ // configuration cache refuses. The action's own task carries one.
109
+ task.logger.lifecycle("CodePush: exported the embedded ${variantName} bundle to ${exportedBundle}")
110
+ }
111
+ }
112
+
113
+ bundleTask.finalizedBy(exportTask)
114
+ }
115
+ }
@@ -4,29 +4,49 @@ import { prepareToBundleJS } from "../../functions/prepareToBundleJS.js";
4
4
  import { runReactNativeBundleCommand } from "../../functions/runReactNativeBundleCommand.js";
5
5
  import { runExpoBundleCommand } from "../../functions/runExpoBundleCommand.js";
6
6
  import { getReactTempDir } from "../../functions/getReactTempDir.js";
7
- import { runHermesEmitBinaryCommand } from "../../functions/runHermesEmitBinaryCommand.js";
7
+ import { resolveBaseBytecodeHermesFlags, runHermesEmitBinaryCommand } from "../../functions/runHermesEmitBinaryCommand.js";
8
8
  import { makeCodePushBundle } from "../../functions/makeCodePushBundle.js";
9
+ import { hashBundleFile, writeBinaryPatchBaseRecord } from "../../functions/makeBinaryPatchBundle.js";
9
10
  import { ROOT_OUTPUT_DIR, ENTRY_FILE } from "../../constant.js";
10
11
 
12
+ export type CodePushBundleResult = {
13
+ /** CodePush bundle file name (equals to packageHash) */
14
+ bundleFileName: string;
15
+ /** Directory holding the files that were packed into the CodePush bundle file */
16
+ contentsPath: string;
17
+ /** JS bundle file name inside the contents directory */
18
+ jsBundleName: string;
19
+ };
20
+
11
21
  /**
12
- * @return {Promise<string>} CodePush bundle file name (equals to packageHash)
22
+ * JS bundle file name react-native writes, which is also the name the app looks for
23
+ * inside an update, so it has to be decided the same way everywhere.
24
+ */
25
+ export function resolveJsBundleName(platform: 'ios' | 'android', jsBundleName?: string): string {
26
+ const DEFAULT_JS_BUNDLE_NAME = platform === 'ios' ? 'main.jsbundle' : 'index.android.bundle';
27
+ return jsBundleName || DEFAULT_JS_BUNDLE_NAME;
28
+ }
29
+
30
+ /**
31
+ * @param baseBundlePath {string} JS bundle from the target binary. When given, the compilation is aligned with it and the base is recorded for a later `release`.
32
+ * @return {Promise<CodePushBundleResult>} CodePush bundle file name (equals to packageHash) and the contents it was made of
13
33
  */
14
34
  export async function bundleCodePush(
15
35
  framework: 'expo' | undefined,
16
36
  platform: 'ios' | 'android' = 'ios',
17
37
  outputRootPath: string = ROOT_OUTPUT_DIR,
18
38
  entryFile: string = ENTRY_FILE,
19
- jsBundleName: string, // JS bundle file name (not CodePush bundle file)
39
+ jsBundleName: string | undefined, // JS bundle file name (not CodePush bundle file)
20
40
  bundleDirectory: string, // CodePush bundle output directory
21
41
  outputMetroDir?: string,
22
- ): Promise<string> {
42
+ baseBundlePath?: string,
43
+ ): Promise<CodePushBundleResult> {
23
44
  if (fs.existsSync(outputRootPath)) {
24
45
  fs.rmSync(outputRootPath, { recursive: true });
25
46
  }
26
47
 
27
48
  const OUTPUT_CONTENT_PATH = `${outputRootPath}/CodePush`;
28
- const DEFAULT_JS_BUNDLE_NAME = platform === 'ios' ? 'main.jsbundle' : 'index.android.bundle';
29
- const _jsBundleName = jsBundleName || DEFAULT_JS_BUNDLE_NAME; // react-native JS bundle output name
49
+ const _jsBundleName = resolveJsBundleName(platform, jsBundleName); // react-native JS bundle output name
30
50
  const SOURCEMAP_OUTPUT = `${outputRootPath}/${_jsBundleName}.map`;
31
51
 
32
52
  prepareToBundleJS({ deleteDirs: [outputRootPath, getReactTempDir()], makeDir: OUTPUT_CONTENT_PATH });
@@ -57,13 +77,25 @@ export async function bundleCodePush(
57
77
  _jsBundleName,
58
78
  OUTPUT_CONTENT_PATH,
59
79
  SOURCEMAP_OUTPUT,
80
+ baseBundlePath ? resolveBaseBytecodeHermesFlags(baseBundlePath) : [],
60
81
  );
61
82
  console.log('log: Hermes compilation complete');
62
83
 
63
84
  const { bundleFileName: codePushBundleFileName } = await makeCodePushBundle(OUTPUT_CONTENT_PATH, bundleDirectory);
64
85
  console.log(`log: CodePush bundle created (file path: ./${bundleDirectory}/${codePushBundleFileName})`);
65
86
 
66
- return codePushBundleFileName;
87
+ if (baseBundlePath) {
88
+ // Written after the bundle file, and to the output root instead of the update
89
+ // contents, so recording the base cannot change what was just packed or its hash.
90
+ const recordPath = writeBinaryPatchBaseRecord(outputRootPath, hashBundleFile(baseBundlePath));
91
+ console.log(`log: Binary patch base recorded (file path: ${recordPath})`);
92
+ }
93
+
94
+ return {
95
+ bundleFileName: codePushBundleFileName,
96
+ contentsPath: OUTPUT_CONTENT_PATH,
97
+ jsBundleName: _jsBundleName,
98
+ };
67
99
  }
68
100
 
69
101
  function copyMetroOutputsIfNeeded(