Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a219064
chore: vendor HDiffPatch and zstd sources for the binary patch applier
floyd-soomgo Aug 5, 2026
52863b2
feat: add zstd decompressor and host harness for the native patch app…
floyd-soomgo Aug 5, 2026
511ce06
test: validate binary patch codec contract
floyd-soomgo Aug 5, 2026
001593d
fix(cli): keep the CodePush bundle in an absolute output directory
floyd-soomgo Aug 5, 2026
c5eb9f7
feat(cli): generate platform binary patch artifacts
floyd-soomgo Aug 5, 2026
2ed5bb2
fix(cli): pass --js-bundle-name through to release
floyd-soomgo Aug 5, 2026
bb6c517
fix(cli): provision hdiffz tools once in jest global setup
floyd-soomgo Aug 6, 2026
7b2564e
feat(cli): add --on-oversized-patch policy
floyd-soomgo Aug 6, 2026
9ce2c8c
test: transform the JSX of the CodePush module for jest
floyd-soomgo Aug 7, 2026
2dac760
feat: add optional binary patch metadata
floyd-soomgo Aug 7, 2026
3aa4268
docs: describe the binary patch url of a release
floyd-soomgo Aug 7, 2026
e918fa3
feat(android): apply binary patch updates
floyd-soomgo Aug 10, 2026
27254a2
docs: describe how android installs a binary patch update
floyd-soomgo Aug 10, 2026
457dd61
fix(android): fall back when a patch download is not a patch
floyd-soomgo Aug 10, 2026
21d4b12
build: pin the zstd revision the patch generator is built from
floyd-soomgo Aug 13, 2026
e885a89
chore: ignore local tool and build outputs
floyd-soomgo Aug 13, 2026
916aad1
chore(cli): ship and document the patch generator build script
floyd-soomgo Aug 13, 2026
c482e60
fix(android): tighten the patch target size bound
floyd-soomgo Aug 18, 2026
61f7f2d
docs: drop the NDK bullet from requirements
floyd-soomgo Aug 18, 2026
ebf27ef
Merge branch 'feature/binary-patch-codec-contract' into feature/binar…
floyd-soomgo Aug 18, 2026
e8fc028
Merge branch 'feature/binary-patch-cli-artifacts' into feature/binary…
floyd-soomgo Aug 18, 2026
9763d01
Merge branch 'feature/binary-patch-metadata' into feature/binary-patc…
floyd-soomgo Aug 18, 2026
32720b5
Merge remote-tracking branch 'origin/master' into feature/binary-patc…
floyd-soomgo Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,27 @@ android {
defaultConfig {
consumerProguardFiles 'proguard-rules.pro'
}

// Builds the binary patch applier. Its sources are the shared ones at the root of the
// repository, which the CMake file points at; nothing is compiled twice.
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
}
}

testOptions {
// The unit tests exercise plain logic, so the android framework classes they touch
// in passing (logging above all) are allowed to do nothing instead of throwing.
unitTests.returnDefaultValues = true
}
}

dependencies {
implementation "com.facebook.react:react-native:+"

testImplementation "junit:junit:4.13.2"
// org.json ships as stubs in the android framework jar, and the unit tests parse real
// manifests, so they need an implementation that actually parses.
testImplementation "org.json:json:20231013"
}
5 changes: 5 additions & 0 deletions android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,8 @@

# Can't find referenced class org.bouncycastle.**
-dontwarn com.nimbusds.jose.**

# The binary patch applier is found by name from the native library.
-keepclasseswithmembernames class com.microsoft.codepush.react.HDiffPatchNative {
native <methods>;
}
38 changes: 38 additions & 0 deletions android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
cmake_minimum_required(VERSION 3.13)

project(codepush-binarypatch C)

# The applier sources live outside the platform directories because the host build and
# the other platform compile the very same files. Referencing them where they are is
# what keeps the appliers of the platforms from drifting apart, so they are deliberately
# not copied into this directory.
set(BINARY_PATCH_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../cpp/binarypatch)

add_library(codepush-binarypatch SHARED
${CMAKE_CURRENT_SOURCE_DIR}/binarypatch_jni.c
${BINARY_PATCH_DIR}/binarypatch_zstd_decompressor.c
${BINARY_PATCH_DIR}/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.c
${BINARY_PATCH_DIR}/vendor/zstd/common/debug.c
${BINARY_PATCH_DIR}/vendor/zstd/common/entropy_common.c
${BINARY_PATCH_DIR}/vendor/zstd/common/error_private.c
${BINARY_PATCH_DIR}/vendor/zstd/common/fse_decompress.c
${BINARY_PATCH_DIR}/vendor/zstd/common/xxhash.c
${BINARY_PATCH_DIR}/vendor/zstd/common/zstd_common.c
${BINARY_PATCH_DIR}/vendor/zstd/decompress/huf_decompress.c
${BINARY_PATCH_DIR}/vendor/zstd/decompress/zstd_ddict.c
${BINARY_PATCH_DIR}/vendor/zstd/decompress/zstd_decompress.c
${BINARY_PATCH_DIR}/vendor/zstd/decompress/zstd_decompress_block.c)

target_include_directories(codepush-binarypatch PRIVATE
${BINARY_PATCH_DIR}
${BINARY_PATCH_DIR}/vendor/HDiffPatch
${BINARY_PATCH_DIR}/vendor/zstd)

# ZSTD_DISABLE_ASM: the assembly fast path is intentionally not vendored.
# _IS_USED_MULTITHREAD=0: patches are applied on the thread that downloads them.
target_compile_definitions(codepush-binarypatch PRIVATE
ZSTD_DISABLE_ASM=1
_IS_USED_MULTITHREAD=0)

find_library(log-lib log)
target_link_libraries(codepush-binarypatch ${log-lib})
181 changes: 181 additions & 0 deletions android/app/src/main/cpp/binarypatch_jni.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
* JNI entry point of the CodePush binary patch applier.
*
* The applier itself is the shared C code one directory tree up, which the host build and
* the other platform compile as well; this file only moves data across the JNI boundary
* and turns a failure into the result code `HDiffPatchNative` hands back to Java.
*
* Memory contract, the same one the host build documents:
* - the base bundle is held whole, because the patches are produced with `hdiffz -m`,
* which patches with random access to the base data
* - the patch is held whole, being far smaller than either bundle
* - the restored bundle is written sequentially to a file, so a patch session never
* holds two bundles at once
*
* A patch carries no checksum of the base data and its zstd streams carry no content
* checksum, so a successful apply here is not proof of a correct result. The caller
* verifies the base and target hashes; this file cannot.
*/

#include <jni.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

#include <android/log.h>

#include "binarypatch_zstd_decompressor.h"
#include "libHDiffPatch/HPatch/patch.h"

#define LOG_TAG "ReactNative"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

/* Mirrored by CodePushBinaryPatch.PatchApplier; the two lists have to stay in step. */
#define RESULT_OK 0
#define RESULT_INVALID_ARGUMENT 1
#define RESULT_IO_ERROR 2
#define RESULT_INVALID_HEADER 3
#define RESULT_UNSUPPORTED_COMPRESSION 4
#define RESULT_SIZE_MISMATCH 5
#define RESULT_APPLY_FAILED 6

/* Scratch buffer handed to patch_decompress_with_cache() to reduce stream reads. */
#define APPLY_CACHE_SIZE (1024 * 1024)

typedef struct {
FILE* file;
hpatch_StreamPos_t writtenSize;
} TSequentialFileWriter;

static hpatch_BOOL _write_sequential(const hpatch_TStreamOutput* stream,
hpatch_StreamPos_t writeToPos,
const unsigned char* data,
const unsigned char* data_end) {
TSequentialFileWriter* self = (TSequentialFileWriter*)stream->streamImport;
const size_t length = (size_t)(data_end - data);
/* patch_decompress_with_cache() only ever appends; anything else is a bug. */
if (writeToPos != self->writtenSize) {
return hpatch_FALSE;
}
if (fwrite(data, 1, length, self->file) != length) {
return hpatch_FALSE;
}
self->writtenSize += length;
return hpatch_TRUE;
}

JNIEXPORT jint JNICALL
Java_com_microsoft_codepush_react_HDiffPatchNative_applyPatch(JNIEnv* env,
jclass clazz,
jbyteArray base,
jbyteArray patch,
jstring outputPath,
jlong expectedTargetSize) {
jbyte* baseData = NULL;
jbyte* patchData = NULL;
const char* outputPathChars = NULL;
unsigned char* cache = NULL;
jsize baseSize = 0;
jsize patchSize = 0;
hpatch_compressedDiffInfo diffInfo;
hpatch_TDecompress decompressor;
hpatch_TStreamInput baseStream;
hpatch_TStreamInput patchStream;
hpatch_TStreamOutput targetStream;
TSequentialFileWriter writer;
hpatch_BOOL applied;
int result = RESULT_OK;

(void)clazz;

if ((base == NULL) || (patch == NULL) || (outputPath == NULL) || (expectedTargetSize <= 0)) {
return RESULT_INVALID_ARGUMENT;
}

baseSize = (*env)->GetArrayLength(env, base);
patchSize = (*env)->GetArrayLength(env, patch);
baseData = (*env)->GetByteArrayElements(env, base, NULL);
patchData = (*env)->GetByteArrayElements(env, patch, NULL);
outputPathChars = (*env)->GetStringUTFChars(env, outputPath, NULL);
if ((baseData == NULL) || (patchData == NULL) || (outputPathChars == NULL)) {
LOGE("[CodePush] out of memory while reading the binary patch inputs");
result = RESULT_IO_ERROR;
goto cleanup;
}

binarypatch_zstd_decompressor_init(&decompressor);

if (!getCompressedDiffInfo_mem(&diffInfo, (const unsigned char*)patchData,
(const unsigned char*)patchData + patchSize)) {
LOGE("[CodePush] the binary patch header could not be read");
result = RESULT_INVALID_HEADER;
goto cleanup;
}
if ((strlen(diffInfo.compressType) > 0) && !decompressor.is_can_open(diffInfo.compressType)) {
LOGE("[CodePush] the binary patch uses an unsupported codec: %s", diffInfo.compressType);
result = RESULT_UNSUPPORTED_COMPRESSION;
goto cleanup;
}
if (diffInfo.oldDataSize != (hpatch_StreamPos_t)baseSize) {
LOGE("[CodePush] the binary patch expects a %llu byte base bundle, this one is %llu bytes",
(unsigned long long)diffInfo.oldDataSize, (unsigned long long)baseSize);
result = RESULT_SIZE_MISMATCH;
goto cleanup;
}
if (diffInfo.newDataSize != (hpatch_StreamPos_t)expectedTargetSize) {
LOGE("[CodePush] the binary patch produces %llu bytes, the manifest promises %llu",
(unsigned long long)diffInfo.newDataSize, (unsigned long long)expectedTargetSize);
result = RESULT_SIZE_MISMATCH;
goto cleanup;
}

cache = (unsigned char*)malloc(APPLY_CACHE_SIZE);
if (cache == NULL) {
LOGE("[CodePush] out of memory while allocating the binary patch cache");
result = RESULT_IO_ERROR;
goto cleanup;
}

writer.file = fopen(outputPathChars, "wb");
writer.writtenSize = 0;
if (writer.file == NULL) {
LOGE("[CodePush] the restored bundle could not be opened for writing");
result = RESULT_IO_ERROR;
goto cleanup;
}

mem_as_hStreamInput(&baseStream, (const unsigned char*)baseData, (const unsigned char*)baseData + baseSize);
mem_as_hStreamInput(&patchStream, (const unsigned char*)patchData, (const unsigned char*)patchData + patchSize);
memset(&targetStream, 0, sizeof(targetStream));
targetStream.streamImport = &writer;
targetStream.streamSize = diffInfo.newDataSize;
targetStream.write = _write_sequential;

applied = patch_decompress_with_cache(&targetStream, &baseStream, &patchStream, &decompressor,
cache, cache + APPLY_CACHE_SIZE);
if (fclose(writer.file) != 0) {
LOGE("[CodePush] the restored bundle could not be flushed to disk");
result = RESULT_IO_ERROR;
goto cleanup;
}
if (!applied) {
LOGE("[CodePush] applying the binary patch failed (decError=%d)", (int)decompressor.decError);
result = RESULT_APPLY_FAILED;
goto cleanup;
}

cleanup:
free(cache);
if (outputPathChars != NULL) {
(*env)->ReleaseStringUTFChars(env, outputPath, outputPathChars);
}
/* JNI_ABORT: neither array is written to, so nothing has to be copied back. */
if (patchData != NULL) {
(*env)->ReleaseByteArrayElements(env, patch, patchData, JNI_ABORT);
}
if (baseData != NULL) {
(*env)->ReleaseByteArrayElements(env, base, baseData, JNI_ABORT);
}

return (jint)result;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.microsoft.codepush.react;

/**
* Outcome of restoring an update from its binary patch archive.
*
* A failed result is not an error the user hears about: it is the signal to download the
* update's full archive instead. The reason strings are the vocabulary the appliers of
* every platform report, and the logs a rollout is judged from are read for exactly these
* words, so they must not be reworded.
*/
public class BinaryPatchResult {

/** The bundle inside the app binary could not be opened or read. */
public static final String REASON_BASE_BUNDLE_UNAVAILABLE = "base_bundle_unavailable";

/** The bundle inside the app binary is not the one the patch was computed against. */
public static final String REASON_BASE_HASH_MISMATCH = "base_hash_mismatch";

/** The manifest is missing, malformed, points outside the archive, or asks for too much. */
public static final String REASON_INVALID_MANIFEST = "invalid_manifest";

/** The patch was produced by a format or a codec this client cannot apply. */
public static final String REASON_UNSUPPORTED_FORMAT = "unsupported_format";

/** The applier refused the patch, or the restored bundle could not be written. */
public static final String REASON_PATCH_APPLY_FAILED = "patch_apply_failed";

/** The restored bundle is not the one the manifest promised. */
public static final String REASON_TARGET_VERIFICATION_FAILED = "target_verification_failed";

private final boolean mSucceeded;
private final String mFailureReason;

private BinaryPatchResult(boolean succeeded, String failureReason) {
mSucceeded = succeeded;
mFailureReason = failureReason;
}

public static BinaryPatchResult success() {
return new BinaryPatchResult(true, null);
}

public static BinaryPatchResult failure(String failureReason) {
return new BinaryPatchResult(false, failureReason);
}

public boolean succeeded() {
return mSucceeded;
}

/** Why the full archive has to be downloaded instead, or null when the patch was applied. */
public String getFailureReason() {
return mFailureReason;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public static synchronized CodePush getInstance(Context context, boolean isDebug
private CodePush(Context context, boolean isDebugMode) {
mContext = context.getApplicationContext();

mUpdateManager = new CodePushUpdateManager(context.getFilesDir().getAbsolutePath());
mUpdateManager = new CodePushUpdateManager(context.getFilesDir().getAbsolutePath(), mContext);
mTelemetryManager = new CodePushTelemetryManager(mContext);
mDeploymentKey = "deprecated_deployment_key";
mIsDebugMode = isDebugMode;
Expand Down
Loading
Loading