From c9e34333f5d5f900b455eaf8258c8fc8c7efa0cc Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 16 Oct 2026 13:05:57 +0800 Subject: [PATCH] fix(native): one lifecycle for the stored items on every platform What a reset to the packaged bundle and a binary upgrade delete besides the state fields differed per platform: a binary upgrade cleared the old versions' info, the first-load marker and the response cache on Android only, and only Android dropped the round breadcrumb (on both events). scripts/contract.ts storedItems now lists every persisted item next to the state fields with the events that delete it, and the generator emits each platform's lists (VersionScopedKeys.java, RCTPushyState.mm, UpdateContext.ts), which the reset and upgrade paths use. Behaviour now, on every platform: both events delete the version info, the first-load marker and the response cache (reset also the iOS package-updated marker); the uuid, the native config, the round breadcrumb and the bundle hash cache survive both. No physical key changes: nothing on disk is migrated. JS normalizes the getConstants values the platforms report differently when absent (isFirstTime to a boolean, rolledBackVersion to ''). Tests: the iOS purge-restore harness gains binary_upgrade_clears_version_keys (fails without the new upgrade clearing), a Harmony test drives UpdateContext.removeKeys with the generated lists, VersionScopedKeysTest covers both events. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/UpdateContext.java | 8 +- .../modules/update/VersionScopedKeys.java | 51 ++++++++--- .../modules/update/VersionScopedKeysTest.java | 48 +++++----- harmony/pushy/src/main/ets/UpdateContext.ts | 90 ++++++++++++------- ios/RCTPushy/RCTPushy+Launch.mm | 5 ++ ios/RCTPushy/RCTPushy.mm | 15 ++-- ios/RCTPushy/RCTPushyInternal.h | 9 ++ ios/RCTPushy/RCTPushyState.mm | 37 ++++++++ scripts/contract.ts | 83 +++++++++++++++++ scripts/generate-contract.ts | 62 +++++++++++++ scripts/tests/ios-purge-restore/extract.py | 3 + .../ios-purge-restore/purge_restore_test.mm | 33 +++++++ src/__tests__/harmonyStateLifecycle.test.ts | 80 +++++++++++++++++ src/core.ts | 6 +- 14 files changed, 448 insertions(+), 82 deletions(-) create mode 100644 src/__tests__/harmonyStateLifecycle.test.ts diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java index 1673e4d9..eb7ed521 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -136,7 +136,7 @@ private UpdateContext(Context context) { // Execute cleanUp before clearing SharedPreferences to avoid race condition cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS); SharedPreferences.Editor editor = this.sp.edit(); - removeVersionScopedKeys(editor); + removeKeys(editor, VersionScopedKeys.clearedOnUpgrade(sp.getAll().keySet())); applyState(editor, nextState); persistEditor(editor, "sync state with binary version"); clearNativeCheckCache(); @@ -441,8 +441,8 @@ private static void putNullableString( // Not editor.clear(): that also wiped the uuid (re-bucketing every gray // release on each app upgrade) and the native config (no crash hold and // no cold-start round until JS ran again). See VersionScopedKeys. - private void removeVersionScopedKeys(SharedPreferences.Editor editor) { - for (String key : VersionScopedKeys.select(sp.getAll().keySet())) { + private void removeKeys(SharedPreferences.Editor editor, java.util.List keys) { + for (String key : keys) { editor.remove(key); } } @@ -656,7 +656,7 @@ private void resetToPackagedBundleLocked() { throw new IllegalStateException("Failed to persist state for reset check cache"); } SharedPreferences.Editor editor = sp.edit(); - removeVersionScopedKeys(editor); + removeKeys(editor, VersionScopedKeys.clearedOnReset(sp.getAll().keySet())); applyState(editor, resetState); persistEditorOrThrow(editor, "reset to packaged bundle"); ignoreRollback = false; diff --git a/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java b/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java index 325be94b..95d34c69 100644 --- a/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java +++ b/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java @@ -6,31 +6,56 @@ /** * The keys of the main state file that describe installed versions rather - * than the install itself: per-version info (hash_*), the first-load marker, - * the native round's incomplete breadcrumb and the response cache's legacy - * location. A binary upgrade or a reset to the packaged bundle drops exactly - * these (plus the state fields applyState rewrites). Everything else — the - * client uuid that buckets gray releases, the persisted native config the - * cold-start round and crash hold run from, the bundle hash cache keyed by - * package version — belongs to the install and survives, as on iOS and - * Harmony. + * than the install itself. What a binary upgrade or a reset to the packaged + * bundle drops (besides the state fields applyState rewrites) is the same on + * every platform and generated from scripts/contract.ts (storedItems); the + * install's own keys — the client uuid that buckets gray releases, the + * persisted native config the cold-start round and crash hold run from, the + * round breadcrumb, the bundle hash cache keyed by package version — survive. */ final class VersionScopedKeys { static final String HASH_INFO_PREFIX = "hash_"; + // (scripts/contract.ts; bun scripts/generate-contract.ts) + // What a reset to the packaged bundle and a binary upgrade delete besides + // the state fields (scripts/contract.ts storedItems; every other item survives + // both: the uuid, the native config, the round breadcrumb, the bundle hash + // cache). + static final String[] RESET_KEYS = {UpdateContext.KEY_FIRST_LOAD_MARKED, SyncCoordinator.KEY_RESP_CACHE}; + static final String[] RESET_PREFIXES = {HASH_INFO_PREFIX}; + static final String[] UPGRADE_KEYS = {UpdateContext.KEY_FIRST_LOAD_MARKED, SyncCoordinator.KEY_RESP_CACHE}; + static final String[] UPGRADE_PREFIXES = {HASH_INFO_PREFIX}; + // + private VersionScopedKeys() { } - static List select(Collection keys) { + /** The keys a reset to the packaged bundle deletes. */ + static List clearedOnReset(Collection keys) { + return select(keys, RESET_KEYS, RESET_PREFIXES); + } + + /** The keys a binary upgrade deletes. */ + static List clearedOnUpgrade(Collection keys) { + return select(keys, UPGRADE_KEYS, UPGRADE_PREFIXES); + } + + private static List select( + Collection keys, String[] exact, String[] prefixes + ) { List doomed = new ArrayList<>(); for (String key : keys) { if (key == null) { continue; } - if (key.startsWith(HASH_INFO_PREFIX) - || key.equals(UpdateContext.KEY_FIRST_LOAD_MARKED) - || key.equals(SyncCoordinator.KEY_ROUND_INCOMPLETE) - || key.equals(SyncCoordinator.KEY_RESP_CACHE)) { + boolean match = false; + for (String name : exact) { + match |= key.equals(name); + } + for (String prefix : prefixes) { + match |= key.startsWith(prefix); + } + if (match) { doomed.add(key); } } diff --git a/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java b/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java index 64b5491d..a3f781ef 100644 --- a/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java @@ -8,29 +8,33 @@ import org.junit.Test; public class VersionScopedKeysTest { + private static final List STORED = Arrays.asList( + "uuid", + "nativeConfig", + "bundleHashCache", + "packageVersion", + "currentVersion", + "hash_abc", + "hash_def", + "firstLoadMarked", + "nativeCheckIncomplete", + "nativeCheckResp", + null + ); + + // The round breadcrumb survives both: it only decides whether the next + // launch resumes the round without the start delay. + private static final HashSet VERSION_KEYS = new HashSet<>(Arrays.asList( + "hash_abc", "hash_def", "firstLoadMarked", "nativeCheckResp")); + + @Test + public void resetDropsVersionKeysAndKeepsInstallKeys() { + assertEquals(VERSION_KEYS, new HashSet<>(VersionScopedKeys.clearedOnReset(STORED))); + } + @Test - public void dropsVersionKeysAndKeepsInstallKeys() { - List doomed = VersionScopedKeys.select(Arrays.asList( - "uuid", - "nativeConfig", - "bundleHashCache", - "packageVersion", - "currentVersion", - "hash_abc", - "hash_def", - "firstLoadMarked", - "nativeCheckIncomplete", - "nativeCheckResp", - null - )); - assertEquals( - new HashSet<>(Arrays.asList( - "hash_abc", - "hash_def", - "firstLoadMarked", - "nativeCheckIncomplete", - "nativeCheckResp")), - new HashSet<>(doomed)); + public void upgradeDropsVersionKeysAndKeepsInstallKeys() { + assertEquals(VERSION_KEYS, new HashSet<>(VersionScopedKeys.clearedOnUpgrade(STORED))); } @Test diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index d575d09c..158802f2 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -56,11 +56,34 @@ function nativeConfigsEquivalent(a: string | undefined, b: string | undefined): return NativePatchCore.configsEquivalent(a, b); } +// Per-version info key prefix and the first-load marker key. +const HASH_INFO_PREFIX = 'hash_'; +const FIRST_LOAD_MARKED_KEY = 'firstLoadMarked'; + +interface ClearedKeys { + keys: string[]; + prefixes: string[]; +} + +// (scripts/contract.ts; bun scripts/generate-contract.ts) +// What a reset to the packaged bundle and a binary upgrade delete besides +// the state fields (scripts/contract.ts storedItems; every other item survives +// both: the uuid, the native config, the round breadcrumb, the bundle hash +// cache). +const RESET_CLEARED: ClearedKeys = { + keys: [FIRST_LOAD_MARKED_KEY, KEY_RESP_CACHE], + prefixes: [HASH_INFO_PREFIX], +}; +const UPGRADE_CLEARED: ClearedKeys = { + keys: [FIRST_LOAD_MARKED_KEY, KEY_RESP_CACHE], + prefixes: [HASH_INFO_PREFIX], +}; +// + interface PersistOptions { removeStaleHash?: boolean; cleanUp?: boolean; markFirstLoadMarker?: boolean; - clearFirstLoadMarker?: boolean; } export class UpdateContext { @@ -141,7 +164,7 @@ export class UpdateContext { ` cv=${snap.currentVersion} lv=${snap.lastVersion}` + ` ft=${snap.firstTime} fto=${snap.firstTimeOk}` + ` rb=${snap.rolledBackVersion}` + - ` flm=${this.readString('firstLoadMarked')}` + + ` flm=${this.readString(FIRST_LOAD_MARKED_KEY)}` + ` uuidSet=${!!this.readString('uuid')}`, ); } @@ -372,10 +395,7 @@ export class UpdateContext { this.preferences.deleteSync(`hash_${state.staleVersionToDelete}`); } if (options.markFirstLoadMarker) { - this.preferences.putSync('firstLoadMarked', 'true'); - } - if (options.clearFirstLoadMarker) { - this.preferences.deleteSync('firstLoadMarked'); + this.preferences.putSync(FIRST_LOAD_MARKED_KEY, 'true'); } if (options.cleanUp) { this.cleanUp(); @@ -499,6 +519,27 @@ export class UpdateContext { }); } + // Deletes `cleared` from the store (in memory; the caller flushes). A + // failed read leaves harmless orphans: none of them affects the state. + private removeKeys(cleared: ClearedKeys, event: string): void { + for (const key of cleared.keys) { + this.preferences.deleteSync(key); + } + if (cleared.prefixes.length === 0) { + return; + } + try { + const all = this.preferences.getAllSync(); + for (const key of Object.keys(all)) { + if (cleared.prefixes.some(prefix => key.startsWith(prefix))) { + this.preferences.deleteSync(key); + } + } + } catch (e) { + logger.error(TAG, `Failed to clear keys on ${event}: ${getErrorMessage(e)}`); + } + } + public syncStateWithBinaryVersion( packageVersion: string, buildTime: string, @@ -522,10 +563,11 @@ export class UpdateContext { ); UpdateContext.ignoreRollback = false; this.cleanUp(); - // 仅重置状态机字段(currentVersion / lastVersion / firstTime / firstTimeOk / - // rolledBackVersion)。不再 clearExisting,避免连带清除 uuid / firstLoadMarked / - // hash_* 等与 binary 版本无关的 KV —— 它们在多实例场景下本就脆弱,连带清除会 - // 让 getConstants() 永远读到空,从而 isFirstTime=false、markSuccess 永不执行。 + // 旧二进制的版本已不在状态里:删掉它们的元信息、首启标记与响应缓存,与 + // 各端一致(scripts/contract.ts storedItems)。uuid、原生配置等属于安装本身的 + // 键保留——不走 clearExisting(那会让 getConstants 读到空的 uuid)。升级后 + // 没有运行中的热更版本,首启标记不会在本次启动里先写后删。 + this.removeKeys(UPGRADE_CLEARED, 'binary upgrade'); this.writeState(nextState, {}); this.flushInBackground('sync binary version'); } @@ -629,25 +671,11 @@ export class UpdateContext { this.beginFlushBatch(); let flushed: Promise = Promise.resolve(); try { - // 删除已下载版本的 hash_* 元信息(不走 clear():它是异步的,且会连带清掉 - // uuid —— 见 syncStateWithBinaryVersion 的注释)。读取失败时残留的 - // hash_* 只是无害孤儿数据,不影响 reset 语义。 - try { - const all = this.preferences.getAllSync(); - for (const key of Object.keys(all)) { - if (key.startsWith('hash_')) { - this.preferences.deleteSync(key); - } - } - } catch (e) { - logger.error( - TAG, - `Failed to clear hash info on reset: ${getErrorMessage(e)}`, - ); - } - this.writeState(resetState, { clearFirstLoadMarker: true }); - // 缓存里的响应仍在宣告本次 reset 刚删掉的版本,一并丢弃,避免 JS 侧复用。 - this.preferences.deleteSync(KEY_RESP_CACHE); + // 删除版本元信息、首启标记与响应缓存(缓存仍在宣告本次 reset 刚删掉的 + // 版本),与各端一致(scripts/contract.ts storedItems)。不走 clear(): + // 它是异步的,且会连带清掉 uuid。 + this.removeKeys(RESET_CLEARED, 'reset'); + this.writeState(resetState, {}); } finally { flushed = this.endFlushBatch('reset to packaged bundle'); } @@ -843,10 +871,10 @@ export class UpdateContext { } public consumeFirstLoadMarker(): boolean { - const marked = this.readString('firstLoadMarked') === 'true'; + const marked = this.readString(FIRST_LOAD_MARKED_KEY) === 'true'; this.trace(`consumeFirstLoadMarker:marked=${marked}`); if (marked) { - this.preferences.deleteSync('firstLoadMarked'); + this.preferences.deleteSync(FIRST_LOAD_MARKED_KEY); this.flushInBackground('clear first load marker'); } return marked; diff --git a/ios/RCTPushy/RCTPushy+Launch.mm b/ios/RCTPushy/RCTPushy+Launch.mm index 99c7299f..2bcf3992 100644 --- a/ios/RCTPushy/RCTPushy+Launch.mm +++ b/ios/RCTPushy/RCTPushy+Launch.mm @@ -65,6 +65,11 @@ + (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut PushyToStdString(curBuildTime) ); if (sync.changed) { + // The old binary's versions are gone from the state: drop + // their info, the first-load marker and the response cache, + // as every platform does (scripts/contract.ts storedItems). + PushyRemoveKeys(defaults, PushyKeysClearedOnUpgrade(), + PushyKeyPrefixesClearedOnUpgrade()); [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked()]; state = sync.state; PushyApplyStateToDefaults(defaults, state); diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 935bec8c..59afbc9c 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -445,16 +445,11 @@ - (NSDictionary *)constantsToExport state.build_time = PushyToStdString([RCTPushy buildTime]); PushyApplyStateToDefaults(defaults, state); - for (NSString *key in [defaults dictionaryRepresentation].allKeys) { - if ([key hasPrefix:keyHashInfo]) { - [defaults removeObjectForKey:key]; - } - } - [defaults removeObjectForKey:keyFirstLoadMarked]; - [defaults removeObjectForKey:KeyPackageUpdatedMarked()]; - // A cached response still advertises the version this reset just - // removed; dropping it stops the JS side from reusing that answer. - [defaults removeObjectForKey:keyNativeCheckCache]; + // Version info, the first-load and package-updated markers and the + // response cache (it still advertises the versions this reset just + // removed), the same on every platform (scripts/contract.ts). + PushyRemoveKeys(defaults, PushyKeysClearedOnReset(), + PushyKeyPrefixesClearedOnReset()); ignoreRollback = false; }); } @catch (...) { diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h index 9d18d4c3..0f2001bc 100644 --- a/ios/RCTPushy/RCTPushyInternal.h +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -55,6 +55,15 @@ extern PUSHY_HIDDEN std::atomic ignoreRollback; extern PUSHY_HIDDEN std::atomic pushyResetGeneration; PUSHY_HIDDEN NSString *KeyPackageUpdatedMarked(void); +// What a reset / a binary upgrade deletes besides the state fields +// (generated from scripts/contract.ts storedItems), and the removal itself: +// every key in `keys` and every stored key starting with one of `prefixes`. +PUSHY_HIDDEN NSArray *PushyKeysClearedOnReset(void); +PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnReset(void); +PUSHY_HIDDEN NSArray *PushyKeysClearedOnUpgrade(void); +PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnUpgrade(void); +PUSHY_HIDDEN void PushyRemoveKeys(NSUserDefaults *defaults, NSArray *keys, + NSArray *prefixes); PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash); // Immutable host-facing snapshot; activated always means NEXT launch. PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, diff --git a/ios/RCTPushy/RCTPushyState.mm b/ios/RCTPushy/RCTPushyState.mm index 904928a6..d37e173e 100644 --- a/ios/RCTPushy/RCTPushyState.mm +++ b/ios/RCTPushy/RCTPushyState.mm @@ -44,6 +44,43 @@ // to reuse (§10.3), scoped to the request and config that produced it. PUSHY_HIDDEN NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY"; +// (scripts/contract.ts; bun scripts/generate-contract.ts) +// What a reset to the packaged bundle and a binary upgrade delete besides +// the state fields (scripts/contract.ts storedItems; every other item survives +// both: the uuid, the native config, the round breadcrumb, the bundle hash +// cache). +PUSHY_HIDDEN NSArray *PushyKeysClearedOnReset(void) { + return @[keyFirstLoadMarked, KeyPackageUpdatedMarked(), keyNativeCheckCache]; +} +PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnReset(void) { + return @[keyHashInfo]; +} +PUSHY_HIDDEN NSArray *PushyKeysClearedOnUpgrade(void) { + return @[keyFirstLoadMarked, keyNativeCheckCache]; +} +PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnUpgrade(void) { + return @[keyHashInfo]; +} +// + +PUSHY_HIDDEN void PushyRemoveKeys(NSUserDefaults *defaults, NSArray *keys, + NSArray *prefixes) { + for (NSString *key in keys) { + [defaults removeObjectForKey:key]; + } + if (prefixes.count == 0) { + return; + } + for (NSString *key in [defaults dictionaryRepresentation].allKeys) { + for (NSString *prefix in prefixes) { + if ([key hasPrefix:prefix]) { + [defaults removeObjectForKey:key]; + break; + } + } + } +} + // error def — messages are human-readable; the stable cross-platform codes // live in cpp/patch_core/error_codes.h and travel in RCTPushyErrorCodeKey. PUSHY_HIDDEN NSString *const ERROR_OPTIONS = @"options error"; diff --git a/scripts/contract.ts b/scripts/contract.ts index fafd0a2c..7fc9f290 100644 --- a/scripts/contract.ts +++ b/scripts/contract.ts @@ -412,3 +412,86 @@ export const servicePresets = { ], }, }; + +export type Lifecycle = 'reset' | 'binaryUpgrade'; + +export interface StoredItem { + name: string; + doc: string; + /** The key is a prefix (one entry per version). */ + prefix?: boolean; + /** The events that delete it; everything else survives both. */ + clearedOn: Lifecycle[]; + /** The key (or prefix) as an expression in each platform's own code. */ + keys: { objc?: string; java?: string; arkts?: string }; +} + +// The persisted items next to the state fields (which the shared state core +// rewrites itself) and what a reset to the packaged bundle or a binary +// upgrade does to them, the same on every platform. The physical keys stay +// as each platform has always stored them (no format migration). +export const storedItems: StoredItem[] = [ + { + name: 'HashInfo', + prefix: true, + doc: 'Per-version info: name, description, metaInfo and the rescue flags.', + clearedOn: ['reset', 'binaryUpgrade'], + keys: { objc: 'keyHashInfo', java: 'HASH_INFO_PREFIX', arkts: 'HASH_INFO_PREFIX' }, + }, + { + name: 'FirstLoadMarker', + doc: 'Set when a launch consumes first_time; getConstants reports and clears it.', + clearedOn: ['reset', 'binaryUpgrade'], + keys: { + objc: 'keyFirstLoadMarked', + java: 'UpdateContext.KEY_FIRST_LOAD_MARKED', + arkts: 'FIRST_LOAD_MARKED_KEY', + }, + }, + { + name: 'PackageUpdatedMarker', + doc: 'iOS: set by a binary upgrade, consumed by getConstants (old version cleanup).', + clearedOn: ['reset'], + keys: { objc: 'KeyPackageUpdatedMarked()' }, + }, + { + name: 'CheckCache', + doc: + "The native round's response cache: it describes versions the event just\n" + + 'removed (Android: its legacy location here; its own file is cleared too).', + clearedOn: ['reset', 'binaryUpgrade'], + keys: { + objc: 'keyNativeCheckCache', + java: 'SyncCoordinator.KEY_RESP_CACHE', + arkts: 'KEY_RESP_CACHE', + }, + }, + { + name: 'RoundBreadcrumb', + doc: 'Only decides whether the next launch resumes the round without the 5s delay.', + clearedOn: [], + keys: { + objc: 'keyNativeCheckIncomplete', + java: 'SyncCoordinator.KEY_ROUND_INCOMPLETE', + arkts: 'KEY_ROUND_INCOMPLETE', + }, + }, + { + name: 'Uuid', + doc: 'Identifies the install for gray-release bucketing: must never change.', + clearedOn: [], + keys: {}, + }, + { + name: 'NativeConfig', + doc: 'The host or JS config the cold-start round and crash hold run from.', + clearedOn: [], + keys: {}, + }, + { + name: 'BundleHashCache', + doc: 'Keyed by the binary identity, so it invalidates itself on upgrade.', + clearedOn: [], + keys: {}, + }, +]; diff --git a/scripts/generate-contract.ts b/scripts/generate-contract.ts index 4fbcd6c8..e9b28b48 100644 --- a/scripts/generate-contract.ts +++ b/scripts/generate-contract.ts @@ -19,7 +19,9 @@ import { roundStatuses, servicePresets, stateOperations, + storedItems, syncStorageKeys, + type Lifecycle, type TextHost, vendoredSources, } from './contract'; @@ -658,6 +660,66 @@ region('src/client.ts', 'service-presets', [ '};', ]); +// Stored-item lifecycle: what a reset / binary upgrade deletes. +const cleared = ( + event: Lifecycle, + platform: 'objc' | 'java' | 'arkts', + prefix: boolean +) => + storedItems + .filter( + (item) => + item.clearedOn.includes(event) && + !!item.prefix === prefix && + item.keys[platform] !== undefined + ) + .map((item) => item.keys[platform] as string); +const lifecycleDoc = [ + 'What a reset to the packaged bundle and a binary upgrade delete besides', + 'the state fields (scripts/contract.ts storedItems; every other item survives', + 'both: the uuid, the native config, the round breadcrumb, the bundle hash', + 'cache).', +]; +const events: [Lifecycle, string, string][] = [ + ['reset', 'Reset', 'RESET'], + ['binaryUpgrade', 'Upgrade', 'UPGRADE'], +]; +region( + `${javaDir}/VersionScopedKeys.java`, + 'lifecycle', + [ + ...lifecycleDoc.map((line) => `// ${line}`), + ...events.flatMap(([event, , upper]) => [ + `static final String[] ${upper}_KEYS = {${cleared(event, 'java', false).join(', ')}};`, + `static final String[] ${upper}_PREFIXES = {${cleared(event, 'java', true).join(', ')}};`, + ]), + ] +); +region( + 'ios/RCTPushy/RCTPushyState.mm', + 'lifecycle', + [ + ...lifecycleDoc.map((line) => `// ${line}`), + ...events.flatMap(([event, camel]) => [ + `PUSHY_HIDDEN NSArray *PushyKeysClearedOn${camel}(void) {`, + ` return @[${cleared(event, 'objc', false).join(', ')}];`, + '}', + `PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOn${camel}(void) {`, + ` return @[${cleared(event, 'objc', true).join(', ')}];`, + '}', + ]), + ] +); +region(`${ets}/UpdateContext.ts`, 'lifecycle', [ + ...lifecycleDoc.map((line) => `// ${line}`), + ...events.flatMap(([event, , upper]) => [ + `const ${upper}_CLEARED: ClearedKeys = {`, + ` keys: [${cleared(event, 'arkts', false).join(', ')}],`, + ` prefixes: [${cleared(event, 'arkts', true).join(', ')}],`, + '};', + ]), +]); + // --- Apply ----------------------------------------------------------------- const check = process.argv.includes('--check'); diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index a1230aef..3bc9c3ea 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -95,6 +95,9 @@ def declaration(source: str, name: str) -> tuple[int, str]: # The exception boundary every entry point uses (CODE_AUDIT 4.4). 'PushyCaughtReason', 'PushyGuard', 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', 'KeyPackageUpdatedMarked', + # What reset / binary upgrade delete (generated from scripts/contract.ts). + 'PushyKeysClearedOnReset', 'PushyKeyPrefixesClearedOnReset', 'PushyKeysClearedOnUpgrade', + 'PushyKeyPrefixesClearedOnUpgrade', 'PushyRemoveKeys', 'PushyJsonDictionary', 'PushyJsonString', 'PushyRejectCaught', 'PushyFileQueue', 'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue', 'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked', diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 3436b9b3..878dc9f2 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -740,6 +740,38 @@ static void RoundCommitVerifies(void) { Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); } +// A binary upgrade drops what belonged to the old binary's versions — their +// info, the first-load marker, the response cache — and keeps the install's +// own keys, the same on every platform (scripts/contract.ts storedItems). +static void BinaryUpgradeClearsVersionKeys(void) { + SetUp(Order::ResetFirst); + NSString *hashInfoKey = PushyHashInfoKey(@"B"); + NSString *breadcrumb = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; + PushyWithStateLock(^{ + pushy::state::State state; + state.package_version = "0.9"; // the previous binary + state.build_time = "100"; + state.current_version = "B"; + PushyApplyStateToDefaults(testDefaults, state); + [testDefaults setObject:@"{\"name\":\"B\"}" forKey:hashInfoKey]; + [testDefaults setObject:@YES forKey:keyFirstLoadMarked]; + [testDefaults setObject:@"{}" forKey:keyNativeCheckCache]; + [testDefaults setObject:@YES forKey:breadcrumb]; + }); + NSString *rolledBack = nil; + NSString *purged = nil; + [RCTPushy resolveLaunchBundleURL:&rolledBack purgedVersion:&purged]; + Expect(State().package_version == "1.0", "the state takes the new binary identity"); + Expect(State().current_version.empty(), "the old binary's version is not launched"); + Expect(Value(hashInfoKey) == nil, "an upgrade drops the old versions' info"); + Expect(Value(keyFirstLoadMarked) == nil, "an upgrade drops the first-load marker"); + Expect(Value(keyNativeCheckCache) == nil, "an upgrade drops the response cache"); + Expect([Value(KeyPackageUpdatedMarked()) boolValue], "an upgrade is reported to getConstants"); + Expect([Value(keyUuid) isEqual:@"installation-uuid"], "an upgrade keeps the install identity"); + Expect([Value(breadcrumb) boolValue], "an upgrade keeps the round breadcrumb"); + Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); +} + // CODE_AUDIT 4.4: a C++ exception from the shared cores is caught at the // entry point, with its reason, and never leaves the state lock held. The // launch falls back to the packaged bundle and still schedules the round. @@ -812,6 +844,7 @@ int main(int argc, char **argv) { {"install_record_activation", InstallRecordActivation}, {"switch_version_records", SwitchVersionRecords}, {"round_commit_verifies", RoundCommitVerifies}, + {"binary_upgrade_clears_version_keys", BinaryUpgradeClearsVersionKeys}, {"cpp_exception_boundary", CppExceptionBoundary}, }; int ran = 0; diff --git a/src/__tests__/harmonyStateLifecycle.test.ts b/src/__tests__/harmonyStateLifecycle.test.ts new file mode 100644 index 00000000..a22bcb54 --- /dev/null +++ b/src/__tests__/harmonyStateLifecycle.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test'; +import { storedItems } from '../../scripts/contract'; +import { evalHarmonyModule } from './nativeSources'; + +// What a reset to the packaged bundle and a binary upgrade delete on Harmony: +// the generated lists (scripts/contract.ts storedItems) through the actual +// UpdateContext.removeKeys, against a store holding every kind of key. + +interface ClearedKeys { + keys: string[]; + prefixes: string[]; +} + +function lifecycleHarness() { + const values = new Map([ + ['uuid', 'installation-id'], + ['nativeConfig', '{"appKey":"k"}'], + ['bundleHashCache', '1.0|1|sha'], + ['nativeCheckIncomplete', '1'], + ['nativeCheckResp', '{}'], + ['firstLoadMarked', 'true'], + ['hash_a', '{"name":"a"}'], + ['hash_b', '{"name":"b"}'], + ['currentVersion', 'a'], + ]); + const preferences = { + getSync: (key: string, fallback: string) => values.get(key) ?? fallback, + putSync: (key: string, value: string) => values.set(key, value), + deleteSync: (key: string) => values.delete(key), + getAllSync: () => Object.fromEntries(values), + }; + const module = evalHarmonyModule( + 'UpdateContext.ts', + `const store = Object.create(UpdateContext.prototype); + store.preferences = preferences; + ({ store, RESET_CLEARED, UPGRADE_CLEARED });`, + { + preferences, + KEY_CONFIG: 'nativeConfig', + KEY_RESP_CACHE: 'nativeCheckResp', + logger: { error() {} }, + getErrorMessage: (error: unknown) => String(error), + } + ) as { + store: { removeKeys: (cleared: ClearedKeys, event: string) => void }; + RESET_CLEARED: ClearedKeys; + UPGRADE_CLEARED: ClearedKeys; + }; + return { values, ...module }; +} + +// The install's own keys and the state fields (rewritten by the state core). +const survivors = [ + 'uuid', + 'nativeConfig', + 'bundleHashCache', + 'nativeCheckIncomplete', + 'currentVersion', +]; + +describe('harmony stored-item lifecycle', () => { + for (const event of ['reset', 'binaryUpgrade'] as const) { + test(`${event} drops the version keys and keeps the install's`, () => { + const h = lifecycleHarness(); + h.store.removeKeys( + event === 'reset' ? h.RESET_CLEARED : h.UPGRADE_CLEARED, + event + ); + expect([...h.values.keys()].sort()).toEqual([...survivors].sort()); + }); + } + + test('the contract keeps the breadcrumb, uuid and config on both events', () => { + for (const name of ['RoundBreadcrumb', 'Uuid', 'NativeConfig']) { + expect(storedItems.find((item) => item.name === name)?.clearedOn).toEqual( + [] + ); + } + }); +}); diff --git a/src/core.ts b/src/core.ts index 2540ab4f..4718c0fe 100644 --- a/src/core.ts +++ b/src/core.ts @@ -126,8 +126,10 @@ export const currentBundleSha256: string = ? PushyConstants.currentBundleSha256 : ''; -export const isFirstTime: boolean = PushyConstants.isFirstTime; -export const rolledBackVersion: string = PushyConstants.rolledBackVersion; +// The platforms report an absent value differently (iOS nil, Android +// false/null, Harmony false/''): normalize at the boundary. +export const isFirstTime: boolean = !!PushyConstants.isFirstTime; +export const rolledBackVersion: string = PushyConstants.rolledBackVersion || ''; export const isRolledBack: boolean = !!rolledBackVersion; export const buildTime: string = PushyConstants.buildTime;