Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 9 additions & 6 deletions Example/e2etest/e2e/bundle-hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { device } from 'detox';
// 就会确信 pdiff 可用而实际 hpatch 源校验失败,比 buildTime 启发式更糟。
//
// 无需 app 配合:JS 层(core.ts)在模块加载时预取 getBundleHash,原生算完后
// 缓存进 NSUserDefaults / SharedPreferences;宿主机直读缓存末段,与 Node 侧
// 缓存进状态存储(三端同名的 pushy_state:iOS 独立 suite、Android
// SharedPreferences 文件,见 docs/STORED_STATE_LAYOUT.md);宿主机直读缓存末段,与 Node 侧
// 对包内 bundle 算的 sha256 比对。
//
// Android 有独立的验证价值:它的哈希实现(MessageDigest)与被哈希字节的来源
Expand All @@ -23,8 +24,10 @@ const ANDROID_APK_PATH = path.resolve(
__dirname,
'../android/app/build/outputs/apk/release/app-release.apk'
);
const IOS_DEFAULTS_KEY = 'REACTNATIVECN_PUSHY_BUNDLEHASH_KEY';
const ANDROID_PREFS_KEY = 'bundleHashCache';
// scripts/contract.ts storeSchema: the state store and the cache's key, the
// same on every platform.
const STATE_STORE = 'pushy_state';
const BUNDLE_HASH_KEY = 'bundleHashCache';
const POLL_TIMEOUT_MS = 60000;
const POLL_INTERVAL_MS = 1000;

Expand Down Expand Up @@ -52,7 +55,7 @@ function readIosCachedBundleHash(
.toString()
.trim();
const raw = execSync(
`plutil -extract ${IOS_DEFAULTS_KEY} raw -o - "${container}/Library/Preferences/${bundleId}.plist"`,
`plutil -extract ${BUNDLE_HASH_KEY} raw -o - "${container}/Library/Preferences/${STATE_STORE}.plist"`,
{ stdio: ['ignore', 'pipe', 'ignore'] }
)
.toString()
Expand Down Expand Up @@ -86,7 +89,7 @@ function readAndroidCachedBundleHash(
}
adbRooted = true;
}
const prefsPath = `/data/data/${packageName}/shared_prefs/update.xml`;
const prefsPath = `/data/data/${packageName}/shared_prefs/${STATE_STORE}.xml`;
for (const cmd of [
`adb -s ${adbName} shell cat ${prefsPath}`,
`adb -s ${adbName} shell su 0 cat ${prefsPath}`,
Expand All @@ -96,7 +99,7 @@ function readAndroidCachedBundleHash(
stdio: ['ignore', 'pipe', 'ignore'],
}).toString();
const match = xml.match(
new RegExp(`name="${ANDROID_PREFS_KEY}"[^>]*>([^<]+)<`)
new RegExp(`name="${BUNDLE_HASH_KEY}"[^>]*>([^<]+)<`)
);
if (match) {
return extractHash(match[1]);
Expand Down
Binary file modified android/lib/arm64-v8a/librnpushy.so
Binary file not shown.
Binary file modified android/lib/armeabi-v7a/librnpushy.so
Binary file not shown.
Binary file modified android/lib/x86/librnpushy.so
Binary file not shown.
Binary file modified android/lib/x86_64/librnpushy.so
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package cn.reactnative.modules.update;

import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
import org.json.JSONArray;
import org.json.JSONObject;

/**
* The one-time move from the legacy layout (SDKs up to 10.61: the
* Texts.PREFERENCES_NAME file with native booleans, its "_check" file for
* the response cache) into StoreSchema's stores. The rules are the shared
* core's (cpp/patch_core/stored_items.h); this class only does the IO:
* snapshot both stores, write the plan's puts in one commit, and only once
* that commit reached disk delete the plan's removes from the legacy files.
* Every step is safe to repeat, so a process killed anywhere in between
* finishes the move on its next launch.
*/
final class StoreMigration {
static final String TAG = Texts.LOG_TAG;

/** What run() did; for logs and tests. */
enum Result {
/** The store already held the schema (legacy leftovers were removed). */
CURRENT,
/** The legacy items were carried over and the schema written. */
MIGRATED,
/** The puts did not reach disk: the legacy keys stay, the next launch retries. */
RETRY,
/** No plan (the core failed): the uuid alone was carried, the next launch retries. */
FALLBACK,
}

interface Planner {
/**
* The plan as JSON (MigrateStoredItemsJson); "null" for an unknown
* layout, null when the core threw.
*/
@Nullable
String plan(int legacyLayout, String[] legacy, String[] current);
}

static final Planner NATIVE = StoreMigration::migrateStoredItems;

// librnpushy.so is loaded by UpdateContext's static initializer, which
// runs before the only caller (its constructor).
private static native String migrateStoredItems(
int legacyLayout, String[] legacy, String[] current);

private StoreMigration() {
}

/**
* Never throws: it runs on the launch path before anything reads the
* state store. A failure leaves the app on a fresh state store (as a new
* install) that still carries the install's uuid.
*/
static Result run(
SharedPreferences state,
SharedPreferences legacy,
SharedPreferences legacyCache,
Planner planner
) {
try {
Map<String, Object> legacyItems = new HashMap<>(legacyCache.getAll());
legacyItems.putAll(legacy.getAll());
String json = planner.plan(
StoreSchema.LEGACY_LAYOUT, flatten(legacyItems), flatten(state.getAll()));
if (json == null || "null".equals(json)) {
throw new IllegalStateException("no migration plan");
}
JSONObject plan = new JSONObject(json);
JSONArray puts = plan.getJSONArray("puts");
if (puts.length() > 0) {
SharedPreferences.Editor editor = state.edit();
for (int i = 0; i < puts.length(); i++) {
JSONArray put = puts.getJSONArray(i);
editor.putString(put.getString(0), put.getString(1));
}
// One commit, so the schema never lands without the items.
if (!editor.commit()) {
Log.e(TAG, "Failed to persist the migrated state store");
return Result.RETRY;
}
}
JSONArray removes = plan.getJSONArray("removes");
if (removes.length() > 0) {
SharedPreferences.Editor legacyEditor = legacy.edit();
SharedPreferences.Editor legacyCacheEditor = legacyCache.edit();
for (int i = 0; i < removes.length(); i++) {
legacyEditor.remove(removes.getString(i));
legacyCacheEditor.remove(removes.getString(i));
}
// A failure only leaves keys nothing reads: the next launch
// removes them again.
boolean removed = legacyEditor.commit();
if (!legacyCacheEditor.commit() || !removed) {
Log.w(TAG, "Failed to remove the legacy state keys");
}
}
return plan.optBoolean("migrated") ? Result.MIGRATED : Result.CURRENT;
} catch (Throwable e) {
Log.e(TAG, "State store migration failed", e);
carryUuid(state, legacy);
return Result.FALLBACK;
}
}

/**
* The migration could not run: start like a fresh install, but keep the
* install's identity (a new uuid would re-bucket every gray release). No
* schema is written, so the next launch runs the whole migration again.
*/
private static void carryUuid(SharedPreferences state, SharedPreferences legacy) {
try {
if (StoreSchema.readString(state, StoreSchema.KEY_UUID) != null) {
return;
}
Object uuid = legacy.getAll().get(StoreSchema.LEGACY_KEY_UUID);
if (uuid instanceof String && !((String) uuid).isEmpty()
&& !state.edit().putString(StoreSchema.KEY_UUID, (String) uuid).commit()) {
Log.e(TAG, "Failed to persist the carried uuid");
}
} catch (Throwable e) {
Log.e(TAG, "Failed to carry the uuid", e);
}
}

/**
* [key, value, key, value, ...] with every value as text: a legacy
* boolean is "1" / "0" like the new layout's, anything without a text
* form (a string set) is "" — still a key to delete, never a value.
*/
static String[] flatten(Map<String, ?> items) {
String[] flat = new String[items.size() * 2];
int i = 0;
for (Map.Entry<String, ?> entry : items.entrySet()) {
Object value = entry.getValue();
flat[i++] = entry.getKey() == null ? "" : entry.getKey();
if (value instanceof Boolean) {
flat[i++] = (Boolean) value ? StoreSchema.BOOLEAN_TRUE : StoreSchema.BOOLEAN_FALSE;
} else if (value instanceof String || value instanceof Number) {
flat[i++] = value.toString();
} else {
flat[i++] = "";
}
}
return flat;
}
}
129 changes: 129 additions & 0 deletions android/src/main/java/cn/reactnative/modules/update/StoreSchema.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package cn.reactnative.modules.update;

import android.content.SharedPreferences;
import androidx.annotation.Nullable;

/**
* The persisted layout every platform shares (scripts/contract.ts
* storeSchema): the keys, the two stores, and the State mapping, generated.
* Every value is a string; an empty one is never stored, a boolean is
* BOOLEAN_TRUE / BOOLEAN_FALSE, a marker is BOOLEAN_TRUE while set. The
* state store is written by StoreMigration's one-time move out of the
* legacy layout before anything reads it.
*/
final class StoreSchema {
// <generated:store-schema> (scripts/contract.ts; bun scripts/generate-contract.ts)
static final int VERSION = 1;
static final String KEY_SCHEMA_VERSION = "schemaVersion";
static final String STATE_STORE = "pushy_state";
static final String CACHE_STORE = "pushy_cache";
// A boolean is stored as one of these; anything else (absent) reads as the
// field's fallback.
static final String BOOLEAN_TRUE = "1";
static final String BOOLEAN_FALSE = "0";
static final int LEGACY_LAYOUT = 1;
// Carried by hand when the migration itself cannot run.
static final String LEGACY_KEY_UUID = "uuid";
// The state fields (pushy::state::State).
static final String KEY_PACKAGE_VERSION = "packageVersion";
static final String KEY_BUILD_TIME = "buildTime";
static final String KEY_CURRENT_VERSION = "currentVersion";
static final String KEY_LAST_VERSION = "lastVersion";
static final String KEY_FIRST_TIME = "firstTime";
static final String KEY_FIRST_TIME_OK = "firstTimeOk";
static final String KEY_ROLLED_BACK_VERSION = "rolledBackVersion";
// Per-version info: name, description, metaInfo and the rescue flags.
static final String KEY_HASH_INFO = "hash_";
// Set when a launch consumes first_time; getConstants reports and clears
// it.
static final String KEY_FIRST_LOAD_MARKER = "firstLoadMarked";
// The native round's raw response for the JS side to reuse (§10.3), scoped
// to the exact logical request and native config that produced it. It
// describes versions a reset or an upgrade removes.
static final String KEY_CHECK_CACHE = "nativeCheckResp";
// Set when a round starts, cleared when it ends (§11.4). Residue on the
// next launch means the previous process died mid-round: that launch
// resumes immediately instead of waiting 5s.
static final String KEY_ROUND_BREADCRUMB = "nativeCheckIncomplete";
// Identifies the install for gray-release bucketing: must never change.
static final String KEY_UUID = "uuid";
// The persisted native check config (JS syncNativeConfig or the host) the
// cold-start round and the crash hold run from.
static final String KEY_NATIVE_CONFIG = "nativeConfig";
// "<binary identity>|<sha256>" of the embedded bundle: keyed by the binary,
// so it invalidates itself on upgrade.
static final String KEY_BUNDLE_HASH_CACHE = "bundleHashCache";
// </generated:store-schema>

private StoreSchema() {
}

static StateCoreResult readState(SharedPreferences store) {
StateCoreResult state = new StateCoreResult();
// <generated:read-state> (scripts/contract.ts; bun scripts/generate-contract.ts)
state.packageVersion = readString(store, KEY_PACKAGE_VERSION);
state.buildTime = readString(store, KEY_BUILD_TIME);
state.currentVersion = readString(store, KEY_CURRENT_VERSION);
state.lastVersion = readString(store, KEY_LAST_VERSION);
state.firstTime = readBoolean(store, KEY_FIRST_TIME, false);
state.firstTimeOk = readBoolean(store, KEY_FIRST_TIME_OK, true);
state.rolledBackVersion = readString(store, KEY_ROLLED_BACK_VERSION);
// </generated:read-state>
return state;
}

static void writeState(SharedPreferences.Editor editor, StateCoreResult state) {
// <generated:write-state> (scripts/contract.ts; bun scripts/generate-contract.ts)
writeString(editor, KEY_PACKAGE_VERSION, state.packageVersion);
writeString(editor, KEY_BUILD_TIME, state.buildTime);
writeString(editor, KEY_CURRENT_VERSION, state.currentVersion);
writeString(editor, KEY_LAST_VERSION, state.lastVersion);
writeBoolean(editor, KEY_FIRST_TIME, state.firstTime);
writeBoolean(editor, KEY_FIRST_TIME_OK, state.firstTimeOk);
writeString(editor, KEY_ROLLED_BACK_VERSION, state.rolledBackVersion);
// </generated:write-state>
}

/** The value, or null when absent or empty (the two read the same). */
@Nullable
static String readString(SharedPreferences store, String key) {
String value = store.getString(key, null);
return value == null || value.isEmpty() ? null : value;
}

static boolean readBoolean(SharedPreferences store, String key, boolean fallback) {
String value = store.getString(key, null);
if (BOOLEAN_TRUE.equals(value)) {
return true;
}
if (BOOLEAN_FALSE.equals(value)) {
return false;
}
return fallback;
}

static boolean readMarker(SharedPreferences store, String key) {
return readBoolean(store, key, false);
}

/** Removes the key for a null or empty value. */
static void writeString(SharedPreferences.Editor editor, String key, @Nullable String value) {
if (value == null || value.isEmpty()) {
editor.remove(key);
} else {
editor.putString(key, value);
}
}

static void writeBoolean(SharedPreferences.Editor editor, String key, boolean value) {
editor.putString(key, value ? BOOLEAN_TRUE : BOOLEAN_FALSE);
}

static void writeMarker(SharedPreferences.Editor editor, String key, boolean set) {
if (set) {
editor.putString(key, BOOLEAN_TRUE);
} else {
editor.remove(key);
}
}
}
Loading
Loading