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
56 changes: 28 additions & 28 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,31 +54,31 @@
"@formatjs/intl-pluralrules": "^4.3.3",
"@notifee/react-native": "9.1.8",
"@onekeyfe/react-native-animated-charts": "2.0.1",
"@onekeyfe/react-native-app-update": "3.0.36",
"@onekeyfe/react-native-auto-size-input": "3.0.36",
"@onekeyfe/react-native-background-thread": "3.0.36",
"@onekeyfe/react-native-app-update": "3.0.37",
"@onekeyfe/react-native-auto-size-input": "3.0.37",
"@onekeyfe/react-native-background-thread": "3.0.37",
"@onekeyfe/react-native-ble-utils": "0.1.4",
"@onekeyfe/react-native-bundle-update": "3.0.36",
"@onekeyfe/react-native-check-biometric-auth-changed": "3.0.36",
"@onekeyfe/react-native-cloud-kit-module": "3.0.36",
"@onekeyfe/react-native-device-utils": "3.0.36",
"@onekeyfe/react-native-keychain-module": "3.0.36",
"@onekeyfe/react-native-lite-card": "3.0.36",
"@onekeyfe/react-native-native-logger": "3.0.36",
"@onekeyfe/react-native-perf-memory": "3.0.36",
"@onekeyfe/react-native-perf-stats": "3.0.36",
"@onekeyfe/react-native-scroll-guard": "3.0.36",
"@onekeyfe/react-native-skeleton": "3.0.36",
"@onekeyfe/react-native-bundle-update": "3.0.37",
"@onekeyfe/react-native-check-biometric-auth-changed": "3.0.37",
"@onekeyfe/react-native-cloud-kit-module": "3.0.37",
"@onekeyfe/react-native-device-utils": "3.0.37",
"@onekeyfe/react-native-keychain-module": "3.0.37",
"@onekeyfe/react-native-lite-card": "3.0.37",
"@onekeyfe/react-native-native-logger": "3.0.37",
"@onekeyfe/react-native-perf-memory": "3.0.37",
"@onekeyfe/react-native-perf-stats": "3.0.37",
"@onekeyfe/react-native-scroll-guard": "3.0.37",
"@onekeyfe/react-native-skeleton": "3.0.37",
"@onekeyfe/react-native-sni-connect": "1.1.0",
"@onekeyfe/react-native-splash-screen": "3.0.36",
"@onekeyfe/react-native-split-bundle-loader": "3.0.36",
"@onekeyfe/react-native-tab-view": "3.0.36",
"@onekeyfe/react-native-splash-screen": "3.0.37",
"@onekeyfe/react-native-split-bundle-loader": "3.0.37",
"@onekeyfe/react-native-tab-view": "3.0.37",
"@onekeyfe/react-native-text-input": "0.3.0",
"@onekeyhq/components": "*",
"@onekeyhq/kit": "*",
"@onekeyhq/shared": "*",
"@phantom/react-native-juicebox-sdk": "0.3.17",
"@react-native-async-storage/async-storage": "npm:@onekeyfe/react-native-async-storage@3.0.36",
"@react-native-async-storage/async-storage": "npm:@onekeyfe/react-native-async-storage@3.0.37",
"@react-native-community/netinfo": "11.4.1",
"@react-native-community/slider": "5.0.1",
"@react-native-documents/picker": "^12.0.1",
Expand Down Expand Up @@ -126,47 +126,47 @@
"path-browserify": "^1.0.1",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-aes-crypto": "npm:@onekeyfe/react-native-aes-crypto@3.0.36",
"react-native-aes-crypto": "npm:@onekeyfe/react-native-aes-crypto@3.0.37",
"react-native-awesome-slider": "^2.9.0",
"react-native-ble-plx": "3.5.1",
"react-native-camera-kit": "17.0.1",
"react-native-canvas": "^0.1.39",
"react-native-capture-protection": "2.3.0",
"react-native-cloud-fs": "npm:@onekeyfe/react-native-cloud-fs@3.0.36",
"react-native-cloud-fs": "npm:@onekeyfe/react-native-cloud-fs@3.0.37",
"react-native-collapsible-tab-view": "8.0.1",
"react-native-crypto": "^2.2.0",
"react-native-dns-lookup": "npm:@onekeyfe/react-native-dns-lookup@3.0.36",
"react-native-fast-pbkdf2": "npm:@onekeyfe/react-native-pbkdf2@3.0.36",
"react-native-dns-lookup": "npm:@onekeyfe/react-native-dns-lookup@3.0.37",
"react-native-fast-pbkdf2": "npm:@onekeyfe/react-native-pbkdf2@3.0.37",
"react-native-fs": "npm:@dr.pogodin/react-native-fs@2.34.0",
"react-native-gesture-handler": "2.30.0",
"react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.36",
"react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.37",
"react-native-image-colors": "^2.5.0",
"react-native-image-crop-picker": "0.51.1",
"react-native-keyboard-controller": "1.20.7",
"react-native-level-fs": "3.0.1",
"react-native-mmkv": "4.1.0",
"react-native-modal": "^13.0.1",
"react-native-network-info": "npm:@onekeyfe/react-native-network-info@3.0.36",
"react-native-network-info": "npm:@onekeyfe/react-native-network-info@3.0.37",
"react-native-network-logger": "2.0.1",
"react-native-nitro-modules": "0.33.2",
"react-native-pager-view": "npm:@onekeyfe/react-native-pager-view@3.0.36",
"react-native-pager-view": "npm:@onekeyfe/react-native-pager-view@3.0.37",
"react-native-passkeys": "0.3.3",
"react-native-permissions": "5.4.4",
"react-native-ping": "npm:@onekeyfe/react-native-ping@3.0.36",
"react-native-ping": "npm:@onekeyfe/react-native-ping@3.0.37",
"react-native-purchases": "8.11.9",
"react-native-qrcode-styled": "0.4.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.23.0",
"react-native-svg": "15.15.1",
"react-native-svg-transformer": "^1.5.3",
"react-native-tcp-socket": "npm:@onekeyfe/react-native-tcp-socket@3.0.36",
"react-native-tcp-socket": "npm:@onekeyfe/react-native-tcp-socket@3.0.37",
"react-native-video": "6.18.0",
"react-native-view-shot": "4.0.3",
"react-native-webview": "13.15.0",
"react-native-webview-cleaner": "npm:@onekeyfe/react-native-webview-cleaner@1.0.0",
"react-native-worklets": "0.7.1",
"react-native-zip-archive": "npm:@onekeyfe/react-native-zip-archive@3.0.36",
"react-native-zip-archive": "npm:@onekeyfe/react-native-zip-archive@3.0.37",
"readable-stream": "^3.6.0",
"realm": "20.2.0",
"realm-flipper-plugin-device": "^1.1.0",
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@
"react-native": "0.81.5",
"react-native-confirmation-code-field": "^7.4.0",
"react-native-draggable-flatlist": "4.0.3",
"react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.36",
"react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.37",
"react-native-reanimated": "4.2.1",
"react-native-screens": "4.23.0",
"react-native-web": "0.21.2",
Expand Down Expand Up @@ -450,7 +450,7 @@
"react-native-reanimated": "4.2.1",
"react-native-worklets": "0.7.1",
"react-native-screens": "4.23.0",
"react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.36",
"react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.37",
"@isaacs/brace-expansion": "5.0.1",
"fast-xml-parser": "4.5.4"
}
Expand Down
4 changes: 2 additions & 2 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"private": true,
"main": "src/index.tsx",
"dependencies": {
"@onekeyfe/react-native-scroll-guard": "3.0.36",
"@onekeyfe/react-native-tab-view": "3.0.36",
"@onekeyfe/react-native-scroll-guard": "3.0.37",
"@onekeyfe/react-native-tab-view": "3.0.37",
"@react-navigation/bottom-tabs": "7.10.1",
"@react-navigation/elements": "2.9.5",
"@react-navigation/native": "7.1.28",
Expand Down
120 changes: 100 additions & 20 deletions packages/kit/src/components/AppUpdate/updateRetry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { globalNetInfo } from '@onekeyhq/components/src/hooks/useNetInfo';
import { OneKeyLocalError } from '@onekeyhq/shared/src/errors';
import { defaultLogger } from '@onekeyhq/shared/src/logger/logger';
import timerUtils from '@onekeyhq/shared/src/utils/timerUtils';
Expand All @@ -7,57 +8,136 @@ import {
isUnrecoverableDownloadError,
} from './updateErrorTaxonomy';

const DOWNLOAD_RETRY_MAX_ATTEMPTS = 3;
const DOWNLOAD_RETRY_MAX_ATTEMPTS = 5;
const DOWNLOAD_RETRY_BASE_DELAY_MS = 1500;
const DOWNLOAD_RETRY_MAX_DELAY_MS = 60_000;
// Cap on how long we'll camp on the NetInfo listener before falling back to
// the regular backoff. 5 minutes is long enough to cover Wi-Fi → cellular
// handover, captive-portal re-auth, and most train-tunnel blackouts without
// leaving the retry loop wedged forever on a user who just put the phone down.
const DOWNLOAD_RETRY_OFFLINE_WAIT_MS = 5 * 60 * 1000;
// Brief grace after the network is reported back so we don't fire the next
// request while the OS is still negotiating DNS / probing the captive portal.
const DOWNLOAD_RETRY_ONLINE_GRACE_MS = 1500;

// Visible for testing; main callers go through runDownloadWithRetry.
export function computeDownloadRetryDelayMs(attempt: number): number {
return (
const exp =
DOWNLOAD_RETRY_BASE_DELAY_MS * 2 ** attempt +
Math.floor(Math.random() * 500)
Math.floor(Math.random() * 500);
return Math.min(exp, DOWNLOAD_RETRY_MAX_DELAY_MS);
}

/**
* Wait until either `timeoutMs` elapses OR globalNetInfo reports the device
* is online (isInternetReachable !== false), whichever comes first. The
* `null` (unknown) state is treated as "not offline" — we never block on a
* NetInfo that hasn't booted yet, since blocking on `null` would wedge the
* retry loop on environments where the reachability probe never runs.
*/
async function waitForOnlineOrTimeout(
timeoutMs: number,
context: string,
): Promise<void> {
if (globalNetInfo.currentState().isInternetReachable !== false) return;
const startedAt = Date.now();
defaultLogger.app.appUpdate.log(
`${context}: offline-wait start, cap=${timeoutMs}ms`,
);
// Default to 'timeout'; only the listener path overrides to 'online'.
let exitReason: 'online' | 'timeout' = 'timeout';
await new Promise<void>((resolve) => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let unsubscribe: (() => void) | null = null;
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
if (timeoutId !== null) clearTimeout(timeoutId);
unsubscribe?.();
resolve();
};
timeoutId = setTimeout(finish, timeoutMs);
unsubscribe = globalNetInfo.addEventListener((state) => {
if (state.isInternetReachable !== false) {
exitReason = 'online';
finish();
}
});
});
defaultLogger.app.appUpdate.log(
`${context}: offline-wait end reason=${exitReason} elapsed=${
Date.now() - startedAt
}ms`,
);
}

/**
* Retries `operation` on transient bundle-update failures (network drops,
* partial truncation, transient server 5xx) up to DOWNLOAD_RETRY_MAX_ATTEMPTS
* times with exponential backoff + jitter. The native modules persist their
* Sleep before the next attempt. When the device is currently reported
* offline, prefer "wait until online" over a fixed exponential delay —
* a 1.5s backoff in a 30s tunnel just burns retries against DNS for nothing.
* When the device is online, fall back to the original
* exp + jitter schedule capped at DOWNLOAD_RETRY_MAX_DELAY_MS.
*/
async function waitBeforeRetry(
attempt: number,
context: string,
): Promise<void> {
const baseDelay = computeDownloadRetryDelayMs(attempt);
if (globalNetInfo.currentState().isInternetReachable === false) {
await waitForOnlineOrTimeout(DOWNLOAD_RETRY_OFFLINE_WAIT_MS, context);
// After the listener fires (or the offline cap expires), give the OS a
// moment to stabilize the new path before we hammer the CDN again.
await timerUtils.wait(Math.min(baseDelay, DOWNLOAD_RETRY_ONLINE_GRACE_MS));
return;
}
await timerUtils.wait(baseDelay);
}

/**
* Retries `operation` on transient bundle / APK download failures (network
* drops, partial truncation, transient server 5xx) up to
* DOWNLOAD_RETRY_MAX_ATTEMPTS times. The native modules persist their
* resume artifact (iOS .resume / Android & Desktop .partial) on each failure,
* so each retry is a true range-resume rather than a from-byte-zero re-fetch.
*
* Wait strategy is reachability-aware: while NetInfo reports offline we camp
* on its listener (capped by DOWNLOAD_RETRY_OFFLINE_WAIT_MS) and only release
* once the link comes back; when online we use exponential backoff
* (`1500 * 2^attempt + jitter[0,500)`, capped at DOWNLOAD_RETRY_MAX_DELAY_MS).
*
* Bails immediately for unrecoverable codes (SHA mismatch, HTTP 403/404/410,
* config errors) so we don't waste backoff windows on deterministic dead
* states. Cap of 3 attempts (initial + 3 retries = 4 total round-trips).
* Backoff schedule is `1500 * 2^attempt + jitter[0,500)`, i.e. roughly
* 1.5s, 3s, 6s before the 4th attempt; total worst-case wall time
* before bubbling up is ~10.5s + ~1.5s of jitter.
* states.
*/
export async function runDownloadWithRetry<T>(
operation: () => Promise<T>,
context: string,
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= DOWNLOAD_RETRY_MAX_ATTEMPTS; attempt += 1) {
try {
return await operation();
} catch (e) {
lastError = e;
if (
isUnrecoverableDownloadError(e) ||
attempt === DOWNLOAD_RETRY_MAX_ATTEMPTS
) {
throw e;
}
const delayMs = computeDownloadRetryDelayMs(attempt);
const isOffline =
globalNetInfo.currentState().isInternetReachable === false;
const baseDelayMs = computeDownloadRetryDelayMs(attempt);
defaultLogger.app.appUpdate.log(
`${context}: retry ${attempt + 1}/${DOWNLOAD_RETRY_MAX_ATTEMPTS} in ${delayMs}ms — code=${
extractUpdateErrorCode(e) ?? '<none>'
}`,
`${context}: retry ${attempt + 1}/${DOWNLOAD_RETRY_MAX_ATTEMPTS} ${
isOffline
? `offline-wait≤${DOWNLOAD_RETRY_OFFLINE_WAIT_MS}ms`
: `in ${baseDelayMs}ms`
} — code=${extractUpdateErrorCode(e) ?? '<none>'}`,
);
await timerUtils.wait(delayMs);
await waitBeforeRetry(attempt, context);
}
}
throw new OneKeyLocalError(
lastError instanceof Error ? lastError.message : String(lastError),
);
// Unreachable: the loop either returns on success or throws on the
// attempt === MAX iteration. Keep the throw to satisfy TS control flow.
throw new OneKeyLocalError('runDownloadWithRetry: unreachable');
}
Loading
Loading