Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
de9e160
feat(android): add native update result contract
sunnylqm Sep 19, 2026
92d0255
feat(android): expose a bridge-free native update entry point
sunnylqm Sep 19, 2026
3184da7
feat(ios): declare native check-and-update API for Objective-C and Swift
sunnylqm Sep 19, 2026
1761fb9
feat(harmony): add native update results and shared round gate
sunnylqm Sep 19, 2026
6387065
chore: stage guarded native host API source transformation
sunnylqm Sep 19, 2026
3d78778
chore: apply guarded native API edits on the feature branch
sunnylqm Sep 19, 2026
155ccba
feat: connect native host APIs to the shared update round
github-actions[bot] Sep 19, 2026
9dd20c4
test: cover native round deduplication and result snapshots
sunnylqm Sep 19, 2026
a4acb13
chore: remove completed one-off source preparation workflow
sunnylqm Sep 19, 2026
d496e48
chore: remove applied native API transformation script
sunnylqm Sep 19, 2026
b9b7d30
test: exercise native host orchestration with mocked platform IO
sunnylqm Sep 19, 2026
b0927f6
test: align native API regression tests with repository formatting
sunnylqm Sep 19, 2026
ef2ad10
feat(harmony): validate and normalize native host configuration
sunnylqm Sep 19, 2026
5764c96
feat(android): normalize native configuration before persistence
sunnylqm Sep 19, 2026
ab44038
feat(ios): declare native configuration validation helper
sunnylqm Sep 19, 2026
2d427eb
feat(ios): validate and normalize native host configuration
sunnylqm Sep 19, 2026
9752f53
chore: stage guarded native configuration integration
sunnylqm Sep 19, 2026
799c678
chore: apply and validate native configuration integration
sunnylqm Sep 19, 2026
b1c6dcf
feat: configure native updates before JS with explicit configuration …
github-actions[bot] Sep 19, 2026
acc11d5
test: cover native provisioning validation, ownership and stale-commi…
sunnylqm Sep 19, 2026
a036f9d
chore: stage native-first identity and provisioning regression coverage
sunnylqm Sep 19, 2026
1ec02ad
chore: verify native-first configuration and its regression tests
sunnylqm Sep 19, 2026
9ee744a
feat: preserve native-first identity and invalidate superseded config…
github-actions[bot] Sep 19, 2026
11de3d4
chore: remove completed native configuration preparation helpers
sunnylqm Sep 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
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,52 @@ final class NativeCheckOrchestrator {
// (markJsCheckCompleted). Process-scoped by design: the next launch
// starts with no signal and the cold-start round runs again.
private static volatile String sJsCompletedConfig;
// Published after the launch rollback snapshot, before host calls are accepted.
private static volatile boolean nativeReady;
private static volatile NativeUpdateResult roundResult =
NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed");
private static volatile long roundGeneration = -1;
private static volatile long roundConfigGeneration = -1;
private static volatile String roundConfigJson;

/** Blocking only on the host API's worker; never call on the UI thread. */
static NativeUpdateResult checkAndUpdate(UpdateContext context) throws InterruptedException {
if (UpdateContext.DEBUG) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "debug");
}
if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_initialized");
}
String configJson = context.getKv(KEY_CONFIG);
if (configJson == null || configJson.isEmpty()) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured");
}
try {
JSONObject config = new JSONObject(configJson);
if (config.optBoolean("disabled", false)) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled");
}
if (config.optString("appKey", "").isEmpty()) {
return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
}
} catch (JSONException e) {
return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
}
startRound(0);
if (!roundStarted.get()) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed");
}
roundDone.await();
if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration()
|| !configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {
return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "config_changed");
}
if (roundGeneration != UpdateContext.getResetGeneration()) {
return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset");
}
return roundResult;
}


static void markJsCheckCompleted(String config) {
sJsCompletedConfig = config;
Expand Down Expand Up @@ -99,6 +145,7 @@ static void schedule(final UpdateContext context, final String launchRolledBackV
}
sContext = context;
sLaunchRolledBackVersion = launchRolledBackVersion;
nativeReady = true;
// The crash-hold rescue shares the orchestrator's rollout gate: no
// persisted config, no handler (§11.3).
if (context.getKv(KEY_CONFIG) != null) {
Expand Down Expand Up @@ -142,14 +189,44 @@ static boolean isRoundInFlight() {
* started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call
* and download phase to the remaining budget.
*/
private static boolean hasRunnableConfig(UpdateContext context) {
if (context == null) {
return false;
}
try {
String json = context.getKv(KEY_CONFIG);
if (json == null) {
return false;
}
JSONObject config = new JSONObject(json);
return !config.optBoolean("disabled", false)
&& config.opt("appKey") instanceof String
&& !config.getString("appKey").trim().isEmpty();
} catch (JSONException e) {
return false;
}
}

static void onConfigured(UpdateContext context) {
if (nativeReady && sContext == context && hasRunnableConfig(context)) {
CrashRescue.install();
}
}

private static void startRound(long deadlineNanos) {
// An automatic check before first-run provisioning must not consume
// the process's only round. Hosts may configure later in this launch.
if (!hasRunnableConfig(sContext)) {
return;
}
if (!roundStarted.compareAndSet(false, true)) {
return;
}
try {
runOnce(sContext, sLaunchRolledBackVersion, deadlineNanos);
} catch (Throwable e) {
Log.w(UpdateContext.TAG, "native check failed: " + e);
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "internal_error");
} finally {
roundCompleted = true;
roundDone.countDown();
Expand All @@ -170,7 +247,7 @@ static void runRescue(long deadlineNanos) {
}
crashRescueActive = true;
startRound(deadlineNanos);
if (!roundCompleted) {
if (roundStarted.get() && !roundCompleted) {
long remainingNanos = deadlineNanos - System.nanoTime();
if (remainingNanos > 0) {
try {
Expand Down Expand Up @@ -223,32 +300,33 @@ private static void runOnce(
String launchRolledBackVersion,
long deadlineNanos
) throws JSONException {
// Sampled before any IO: a reset landing while this round runs must
// win over the round's decision.
final long resetGeneration = UpdateContext.getResetGeneration();
roundGeneration = resetGeneration;
roundConfigGeneration = UpdateContext.getNativeConfigGeneration();
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed");
String configJson = context.getKv(KEY_CONFIG);
roundConfigJson = configJson;
if (configJson == null || configJson.isEmpty()) {
// No persisted config (old integration / first ever launch): the
// native check silently does not run — this is the rollout gate.
roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured");
return;
}
JSONObject config;
try {
config = new JSONObject(configJson);
} catch (JSONException e) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
return;
}
if (config.optBoolean("disabled", false)) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled");
return;
}
String appKey = config.optString("appKey", "");
if (appKey.isEmpty()) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
return;
}
// From here on the round does real work: leave the breadcrumb that
// the next launch reads to skip its 5s delay if we die mid-round.
// Best-effort: a lost breadcrumb costs one 5s delay, it must not
// abort the rescue round itself.
// Keep the existing interrupted-round breadcrumb and reset generation.
try {
context.setKv(KEY_ROUND_INCOMPLETE, "1");
} catch (IllegalStateException ignored) {
Expand Down Expand Up @@ -323,6 +401,7 @@ private static void runConfiguredRound(

String body = NativeUpdateFlow.buildCheckRequestBody(input.toString());
if (body == null) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_request");
return;
}

Expand All @@ -339,19 +418,24 @@ private static void runConfiguredRound(
String decisionJson = NativeUpdateFlow.handleCheckResponse(
responseText, identity.toString(), config.optString("afterDownload", ""));
if (decisionJson == null) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response");
return;
}
JSONObject decision = new JSONObject(decisionJson);
if (!"download".equals(decision.optString("action"))) {
context.commitNativeCheckResult(
boolean committed = context.commitNativeCheckResult(
resetGeneration, null, null, false,
buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
roundResult = committed
? NativeUpdateResult.of(NativeUpdateResult.NO_UPDATE, decision.optString("reason"))
: NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset");
Log.i(UpdateContext.TAG,
"native check: nothing to do (" + decision.optString("reason") + ")");
return;
}
String hash = decision.optString("hash", "");
if (!UpdateFileUtils.isSafePathComponent(hash)) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response");
return;
}

Expand All @@ -364,9 +448,12 @@ private static void runConfiguredRound(
if (!downloaded) {
// The native attempt has finished, so JS may safely reuse the
// response and retry through its own strategy chain.
context.commitNativeCheckResult(
boolean committed = context.commitNativeCheckResult(
resetGeneration, null, null, false,
buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
roundResult = NativeUpdateResult.of(
committed ? NativeUpdateResult.FAILED : NativeUpdateResult.CANCELLED,
committed ? "download_failed" : "reset");
return;
}

Expand Down Expand Up @@ -412,6 +499,7 @@ private static void runConfiguredRound(
buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
} catch (Exception e) {
Log.w(UpdateContext.TAG, "native check: commit failed: " + e);
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "commit_failed");
return;
}
if (!committed) {
Expand All @@ -428,6 +516,9 @@ private static void runConfiguredRound(
Log.i(UpdateContext.TAG,
"native check: downloaded " + hash + ", activation left to JS");
}
roundResult = committed
? NativeUpdateResult.downloaded(hash, activate)
: NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset");
}

private static String buildResponseCacheJson(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package cn.reactnative.modules.update;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.regex.Pattern;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/** Internal normalizer for the JSONObject accepted by PushyNativeUpdate.configure. */
final class NativeUpdateConfig {
private static final Set<String> KEYS = new HashSet<>(Arrays.asList(
"appKey", "endpoints", "queryUrls", "afterDownload", "disabled",
"packageVersion", "rnu", "rn"));
private static final Pattern URL = Pattern.compile(
"^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)");
private static final String[] ENDPOINTS = {
"https://update.react-native.cn/api", "https://update.reactnative.cn/api"
};
private static final String[] QUERY_URLS = {
"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json",
"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json"
};

private NativeUpdateConfig() {}

static String normalize(String json) throws JSONException {
JSONObject options = new JSONObject(json);
for (Iterator<String> keys = options.keys(); keys.hasNext();) {
String key = keys.next();
if (!KEYS.contains(key)) {
throw invalid("unknown option " + key);
}
}
String appKey = string(options.opt("appKey"), "appKey", false);
boolean customEndpoints = options.has("endpoints");
JSONArray endpoints = urls(customEndpoints ? options.get("endpoints")
: new JSONArray(Arrays.asList(ENDPOINTS)), "endpoints", true);
JSONArray queryUrls = urls(options.has("queryUrls") ? options.get("queryUrls")
: new JSONArray(Arrays.asList(customEndpoints ? new String[0] : QUERY_URLS)),
"queryUrls", false);
String afterDownload = options.has("afterDownload")
? string(options.get("afterDownload"), "afterDownload", false) : "none";
if (!"none".equals(afterDownload) && !"setNeedUpdate".equals(afterDownload)) {
throw invalid("afterDownload must be none or setNeedUpdate");
}
Object disabled = options.has("disabled") ? options.get("disabled") : Boolean.FALSE;
if (!(disabled instanceof Boolean)) {
throw invalid("disabled must be a boolean");
}
JSONObject result = new JSONObject();
result.put("appKey", appKey);
result.put("endpoints", endpoints);
result.put("queryUrls", queryUrls);
result.put("afterDownload", afterDownload);
result.put("disabled", disabled);
result.put("rnu", options.has("rnu") ? string(options.get("rnu"), "rnu", true) : "");
result.put("rn", options.has("rn") ? string(options.get("rn"), "rn", true) : "");
if (options.has("packageVersion")) {
result.put("packageVersion", string(options.get("packageVersion"), "packageVersion", false));
}
return result.toString();
}

private static String string(Object value, String name, boolean allowEmpty) {
if (!(value instanceof String) || (!allowEmpty && ((String) value).trim().isEmpty())) {
throw invalid(name + " must be a string" + (allowEmpty ? "" : " and must not be blank"));
}
return (String) value;
}

private static JSONArray urls(Object value, String name, boolean base) throws JSONException {
if (!(value instanceof JSONArray) || (base && ((JSONArray) value).length() == 0)) {
throw invalid(name + " must be " + (base ? "a non-empty" : "an") + " array");
}
JSONArray values = (JSONArray) value;
JSONArray result = new JSONArray();
Set<String> seen = new HashSet<>();
for (int i = 0; i < values.length(); i++) {
String url = string(values.get(i), name, false).trim();
if (!URL.matcher(url).find() || url.matches("(?s).*\\s.*") || url.contains("\\")
|| (base && (url.contains("?") || url.contains("#")))) {
throw invalid(name + " requires absolute HTTP(S) URLs without credentials"
+ (base ? ", queries or fragments" : ""));
}
String normalized = base ? url.replaceAll("/+$", "") : url;
if (seen.add(normalized)) {
result.put(normalized);
}
}
return result;
}

private static IllegalArgumentException invalid(String message) {
return new IllegalArgumentException("Invalid native configuration: " + message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cn.reactnative.modules.update;

/**
* Snapshot of a native check-and-update round. A downloaded result does not
* mean that the running React Native instance has been reloaded.
*/
public final class NativeUpdateResult {
public static final String SKIPPED = "skipped";
public static final String NO_UPDATE = "noUpdate";
public static final String DOWNLOADED = "downloaded";
public static final String FAILED = "failed";
public static final String CANCELLED = "cancelled";

private final String status;
private final String reason;
private final String hash;
private final boolean activated;

private NativeUpdateResult(String status, String reason, String hash, boolean activated) {
this.status = status;
this.reason = reason;
this.hash = hash;
this.activated = activated;
}

static NativeUpdateResult of(String status, String reason) {
return new NativeUpdateResult(status, reason, "", false);
}

static NativeUpdateResult downloaded(String hash, boolean activated) {
return new NativeUpdateResult(DOWNLOADED, "", hash, activated);
}

public String getStatus() {
return status;
}

public String getReason() {
return reason;
}

/** Installed update hash, or an empty string when this round installed nothing. */
public String getHash() {
return hash;
}

/** Whether this round selected the downloaded version for the next launch. */
public boolean isActivated() {
return activated;
}
}
Loading
Loading