From a534deb57cc5e8bd87b501e7e505c27b6d3cfd48 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 11 Aug 2026 20:41:44 +0800 Subject: [PATCH 1/3] fix: support network throttle bypass origins --- .../react-native-network-throttle/README.md | 11 ++- .../NetworkThrottle.kt | 41 +++++++++- .../ios/OneKeyNetworkThrottle.m | 69 +++++++++++++++- .../src/__tests__/index.test.tsx | 79 +++++++++++++++++++ .../src/index.tsx | 35 ++++++-- 5 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 native-modules/react-native-network-throttle/src/__tests__/index.test.tsx diff --git a/native-modules/react-native-network-throttle/README.md b/native-modules/react-native-network-throttle/README.md index 3405c8d3..e9552584 100644 --- a/native-modules/react-native-network-throttle/README.md +++ b/native-modules/react-native-network-throttle/README.md @@ -2,8 +2,13 @@ React Native native network throttle for OneKey iOS and Android development settings. -Current scope is an RN HTTP response latency gate. It does not emulate download -throughput, upload throughput, offline mode, WebView traffic, or third-party -native networking stacks. +Current scope is RN HTTP(S) latency and upload/download throughput. It does not +emulate offline mode, WebView traffic, or third-party native networking stacks. + +`bypassUrlOrigins` excludes exact HTTP(S) origins from all throttling. Origins +are canonicalized with their effective port and registered additively for the +lifetime of the native process. This allows independently initialized React +Native runtimes to register local development servers without clearing each +other's configuration. This package only owns native request throttling. Product settings, persistence, and UI controls should remain in the host app. diff --git a/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt b/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt index dd3df650..7559e27a 100644 --- a/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt +++ b/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.network.OkHttpClientProvider import java.io.IOException @@ -11,6 +12,9 @@ import java.io.InterruptedIOException import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.MediaType import okhttp3.OkHttpClient @@ -36,6 +40,7 @@ internal object NetworkThrottle { private val downloadBps = AtomicLong(DEFAULT_THROUGHPUT_BPS.toLong()) private val uploadBps = AtomicLong(DEFAULT_THROUGHPUT_BPS.toLong()) private val installed = AtomicBoolean(false) + private val bypassUrlOrigins = AtomicReference>(emptySet()) fun install(context: Context) { if (!installed.compareAndSet(false, true)) { @@ -85,6 +90,19 @@ internal object NetworkThrottle { if (nextUploadBps <= 0) { nextUploadBps = DEFAULT_THROUGHPUT_BPS.toLong() } + if (config.hasKey("bypassUrlOrigins") && !config.isNull("bypassUrlOrigins")) { + val origins = config.getArray("bypassUrlOrigins") + val normalizedOrigins = buildSet { + if (origins != null) { + for (index in 0 until origins.size()) { + if (origins.getType(index) == ReadableType.String) { + normalizeOrigin(origins.getString(index))?.let(::add) + } + } + } + } + bypassUrlOrigins.updateAndGet { current -> current + normalizedOrigins } + } enabled.set(nextEnabled) latencyNanos.set((nextLatencyMs * 1_000_000.0).toLong()) @@ -104,6 +122,9 @@ internal object NetworkThrottle { map.putDouble("latencyMs", latencyNanos.get() / 1_000_000.0) map.putDouble("downloadBps", downloadBps.get().toDouble()) map.putDouble("uploadBps", uploadBps.get().toDouble()) + val origins = Arguments.createArray() + bypassUrlOrigins.get().sorted().forEach(origins::pushString) + map.putArray("bypassUrlOrigins", origins) return map } @@ -111,6 +132,21 @@ internal object NetworkThrottle { private fun getDownloadBps(): Long = if (enabled.get()) downloadBps.get() else 0L private fun getUploadBps(): Long = if (enabled.get()) uploadBps.get() else 0L + private fun canonicalOrigin(url: HttpUrl): String = + HttpUrl.Builder() + .scheme(url.scheme) + .host(url.host) + .port(url.port) + .build() + .toString() + .removeSuffix("/") + + private fun normalizeOrigin(value: String?): String? = + value?.toHttpUrlOrNull()?.let(::canonicalOrigin) + + private fun shouldBypass(requestUrl: HttpUrl): Boolean = + bypassUrlOrigins.get().contains(canonicalOrigin(requestUrl)) + private fun sleepNanos(delayNanos: Long) { if (delayNanos <= 0) { return @@ -201,9 +237,12 @@ internal object NetworkThrottle { private class ThrottleInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (shouldBypass(request.url)) { + return chain.proceed(request) + } val requestStartNanos = System.nanoTime() val delayNanos = getLatencyNanos() - val request = chain.request() val requestBody = request.body val activeUploadBps = getUploadBps() val throttledRequest = diff --git a/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m b/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m index 6a80114e..ed836d79 100644 --- a/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m +++ b/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m @@ -10,16 +10,32 @@ static const NSInteger OneKeyNetworkThrottleDefaultThroughputBps = 102 * 1024; static const NSUInteger OneKeyNetworkThrottleMaxPendingDownloadBytes = 256 * 1024; +static NSString *OneKeyNetworkThrottleCanonicalOrigin(NSURL *url) +{ + NSString *scheme = url.scheme.lowercaseString; + NSString *host = url.host.lowercaseString; + if ((!([scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"])) || host.length == 0) { + return nil; + } + NSURLComponents *components = [[NSURLComponents alloc] init]; + components.scheme = scheme; + components.host = host; + components.port = url.port ?: @([scheme isEqualToString:@"https"] ? 443 : 80); + return components.string; +} + @interface OneKeyNetworkThrottleState : NSObject + (NSDictionary *)currentConfig; + (BOOL)isEnabled; + (NSTimeInterval)latencyMs; + (NSInteger)downloadBps; + (NSInteger)uploadBps; ++ (BOOL)shouldBypassURL:(NSURL *)url; + (NSDictionary *)setEnabled:(BOOL)enabled latencyMs:(NSTimeInterval)latencyMs downloadBps:(NSInteger)downloadBps - uploadBps:(NSInteger)uploadBps; + uploadBps:(NSInteger)uploadBps + bypassUrlOrigins:(NSArray *)bypassUrlOrigins; @end @implementation OneKeyNetworkThrottleState @@ -28,18 +44,25 @@ @implementation OneKeyNetworkThrottleState static atomic_llong _oneKeyNetworkThrottleLatencyMicros = ATOMIC_VAR_INIT(562500); static atomic_llong _oneKeyNetworkThrottleDownloadBps = ATOMIC_VAR_INIT(102 * 1024); static atomic_llong _oneKeyNetworkThrottleUploadBps = ATOMIC_VAR_INIT(102 * 1024); +static NSSet *_oneKeyNetworkThrottleBypassOrigins; + (NSDictionary *)currentConfig { BOOL enabled = atomic_load_explicit(&_oneKeyNetworkThrottleEnabled, memory_order_acquire); NSTimeInterval latencyMs = ((NSTimeInterval)atomic_load_explicit(&_oneKeyNetworkThrottleLatencyMicros, memory_order_relaxed)) / 1000.0; + NSArray *bypassUrlOrigins = nil; + @synchronized (self) { + bypassUrlOrigins = [[_oneKeyNetworkThrottleBypassOrigins ?: [NSSet set] allObjects] + sortedArrayUsingSelector:@selector(compare:)]; + } return @{ @"enabled": @(enabled), @"profile": OneKeyNetworkThrottleProfileSlow4G, @"latencyMs": @(latencyMs), @"downloadBps": @([self downloadBps]), - @"uploadBps": @([self uploadBps]) + @"uploadBps": @([self uploadBps]), + @"bypassUrlOrigins": bypassUrlOrigins }; } @@ -63,6 +86,17 @@ + (NSInteger)uploadBps return (NSInteger)atomic_load_explicit(&_oneKeyNetworkThrottleUploadBps, memory_order_relaxed); } ++ (BOOL)shouldBypassURL:(NSURL *)url +{ + NSString *origin = OneKeyNetworkThrottleCanonicalOrigin(url); + if (origin == nil) { + return NO; + } + @synchronized (self) { + return [_oneKeyNetworkThrottleBypassOrigins containsObject:origin]; + } +} + + (NSInteger)normalizeThroughputBps:(NSInteger)throughputBps { return throughputBps > 0 ? throughputBps : OneKeyNetworkThrottleDefaultThroughputBps; @@ -72,10 +106,29 @@ + (NSDictionary *)setEnabled:(BOOL)enabled latencyMs:(NSTimeInterval)latencyMs downloadBps:(NSInteger)downloadBps uploadBps:(NSInteger)uploadBps + bypassUrlOrigins:(NSArray *)bypassUrlOrigins { NSTimeInterval normalizedLatencyMs = latencyMs > 0 ? latencyMs : OneKeyNetworkThrottleDefaultLatencyMs; NSInteger normalizedDownloadBps = [self normalizeThroughputBps:downloadBps]; NSInteger normalizedUploadBps = [self normalizeThroughputBps:uploadBps]; + if ([bypassUrlOrigins isKindOfClass:[NSArray class]]) { + NSMutableSet *normalizedOrigins = [NSMutableSet set]; + for (id value in bypassUrlOrigins) { + if (![value isKindOfClass:[NSString class]]) { + continue; + } + NSString *origin = OneKeyNetworkThrottleCanonicalOrigin([NSURL URLWithString:(NSString *)value]); + if (origin != nil) { + [normalizedOrigins addObject:origin]; + } + } + @synchronized (self) { + NSMutableSet *nextOrigins = + [_oneKeyNetworkThrottleBypassOrigins mutableCopy] ?: [NSMutableSet set]; + [nextOrigins unionSet:normalizedOrigins]; + _oneKeyNetworkThrottleBypassOrigins = [nextOrigins copy]; + } + } atomic_store_explicit( &_oneKeyNetworkThrottleLatencyMicros, (long long)llround(normalizedLatencyMs * 1000.0), @@ -137,6 +190,9 @@ + (BOOL)canInitWithRequest:(NSURLRequest *)request if ([NSURLProtocol propertyForKey:OneKeyNetworkThrottleHandledKey inRequest:request]) { return NO; } + if ([OneKeyNetworkThrottleState shouldBypassURL:request.URL]) { + return NO; + } NSString *scheme = request.URL.scheme.lowercaseString; return [scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"]; } @@ -517,7 +573,14 @@ + (BOOL)requiresMainQueueSetup uploadBpsValue != nil && uploadBpsValue != [NSNull null] ? [uploadBpsValue integerValue] : [OneKeyNetworkThrottleState uploadBps]; - resolve([OneKeyNetworkThrottleState setEnabled:enabled latencyMs:latencyMs downloadBps:downloadBps uploadBps:uploadBps]); + id bypassUrlOriginsValue = config[@"bypassUrlOrigins"]; + NSArray *bypassUrlOrigins = [bypassUrlOriginsValue isKindOfClass:[NSArray class]] ? bypassUrlOriginsValue : nil; + resolve([OneKeyNetworkThrottleState + setEnabled:enabled + latencyMs:latencyMs + downloadBps:downloadBps + uploadBps:uploadBps + bypassUrlOrigins:bypassUrlOrigins]); } @end diff --git a/native-modules/react-native-network-throttle/src/__tests__/index.test.tsx b/native-modules/react-native-network-throttle/src/__tests__/index.test.tsx new file mode 100644 index 00000000..b12bebf1 --- /dev/null +++ b/native-modules/react-native-network-throttle/src/__tests__/index.test.tsx @@ -0,0 +1,79 @@ +jest.mock('react-native', () => ({ + NativeModules: { + OneKeyNetworkThrottle: { + getConfig: jest.fn(), + setConfig: jest.fn(), + }, + }, + Platform: { + select: (options: { default: string }) => options.default, + }, +})); + +import { NativeModules } from 'react-native'; + +import { NetworkThrottle, type NetworkThrottleConfig } from '../index'; + +const { getConfig: mockGetConfig, setConfig: mockSetConfig } = + NativeModules.OneKeyNetworkThrottle as { + getConfig: jest.Mock; + setConfig: jest.Mock; + }; + +const baseConfig = { + enabled: true, + profile: 'slow4g' as const, + latencyMs: 562.5, + downloadBps: 102 * 1024, + uploadBps: 102 * 1024, +}; + +describe('NetworkThrottle', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('normalizes config from an older native binary', async () => { + mockGetConfig.mockResolvedValue(baseConfig); + + await expect(NetworkThrottle.getConfig()).resolves.toEqual({ + ...baseConfig, + bypassUrlOrigins: [], + }); + }); + + it('forwards exact bypass origins with a complete native config', async () => { + const bypassUrlOrigins = ['http://localhost:8081']; + const expectedConfig: NetworkThrottleConfig = { + ...baseConfig, + bypassUrlOrigins, + }; + mockGetConfig.mockResolvedValue({ + ...baseConfig, + bypassUrlOrigins: [], + }); + mockSetConfig.mockResolvedValue(expectedConfig); + + await expect( + NetworkThrottle.setConfig({ bypassUrlOrigins }) + ).resolves.toEqual(expectedConfig); + expect(mockSetConfig).toHaveBeenCalledWith(expectedConfig); + }); + + it('preserves registered origins for unrelated config updates', async () => { + const currentConfig: NetworkThrottleConfig = { + ...baseConfig, + bypassUrlOrigins: ['http://10.0.2.2:8081'], + }; + const expectedConfig = { + ...currentConfig, + enabled: false, + }; + mockGetConfig.mockResolvedValue(currentConfig); + mockSetConfig.mockResolvedValue(expectedConfig); + + await NetworkThrottle.setConfig({ enabled: false }); + + expect(mockSetConfig).toHaveBeenCalledWith(expectedConfig); + }); +}); diff --git a/native-modules/react-native-network-throttle/src/index.tsx b/native-modules/react-native-network-throttle/src/index.tsx index 872e8e6c..2dbef6bb 100644 --- a/native-modules/react-native-network-throttle/src/index.tsx +++ b/native-modules/react-native-network-throttle/src/index.tsx @@ -8,6 +8,7 @@ export type NetworkThrottleConfig = { latencyMs: number; downloadBps: number; uploadBps: number; + bypassUrlOrigins: string[]; }; export const NETWORK_THROTTLE_SLOW_4G_LATENCY_MS = 562.5; @@ -16,9 +17,18 @@ export const NETWORK_THROTTLE_SLOW_4G_DOWNLOAD_BPS = NETWORK_THROTTLE_102_KIB_BPS; export const NETWORK_THROTTLE_SLOW_4G_UPLOAD_BPS = NETWORK_THROTTLE_102_KIB_BPS; +type NativeNetworkThrottleConfig = Omit< + NetworkThrottleConfig, + 'bypassUrlOrigins' +> & { + bypassUrlOrigins?: string[]; +}; + type NativeNetworkThrottleModule = { - getConfig: () => Promise; - setConfig: (config: NetworkThrottleConfig) => Promise; + getConfig: () => Promise; + setConfig: ( + config: NativeNetworkThrottleConfig + ) => Promise; }; export type NetworkThrottleModule = { @@ -37,18 +47,33 @@ const nativeModule = NativeModules.OneKeyNetworkThrottle as | NativeNetworkThrottleModule | undefined; +function normalizeNativeConfig( + config: NativeNetworkThrottleConfig +): NetworkThrottleConfig { + return { + ...config, + bypassUrlOrigins: config.bypassUrlOrigins ?? [], + }; +} + export const NetworkThrottle: NetworkThrottleModule = nativeModule ? { - getConfig: () => nativeModule.getConfig(), + getConfig: async () => + normalizeNativeConfig(await nativeModule.getConfig()), setConfig: async (config) => { - const currentConfig = await nativeModule.getConfig(); - return nativeModule.setConfig({ + const currentConfig = normalizeNativeConfig( + await nativeModule.getConfig() + ); + const nativeConfig = await nativeModule.setConfig({ enabled: config.enabled ?? currentConfig.enabled, profile: config.profile ?? currentConfig.profile, latencyMs: config.latencyMs ?? currentConfig.latencyMs, downloadBps: config.downloadBps ?? currentConfig.downloadBps, uploadBps: config.uploadBps ?? currentConfig.uploadBps, + bypassUrlOrigins: + config.bypassUrlOrigins ?? currentConfig.bypassUrlOrigins ?? [], }); + return normalizeNativeConfig(nativeConfig); }, } : (new Proxy( From 191982f7f4d9f01874d5bbdfd6a5ed34b236cfe8 Mon Sep 17 00:00:00 2001 From: Leon Date: Sat, 15 Aug 2026 11:26:47 +0800 Subject: [PATCH 2/3] fix: address bypass-origin review feedback - bump to 3.0.83-alpha.0: npm already has a stable 3.0.82 (published from main without bypassUrlOrigins) that sorts higher than 3.0.82-alpha.0, so the next release with this feature must be >= 3.0.83 - short-circuit shouldBypass when no origin is registered to avoid per-request canonicalization allocations in production builds - document the Android redirect limitation of exact-origin bypasses --- .../react-native-network-throttle/README.md | 6 ++++++ .../reactnativenetworkthrottle/NetworkThrottle.kt | 11 +++++++++-- .../react-native-network-throttle/package.json | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/native-modules/react-native-network-throttle/README.md b/native-modules/react-native-network-throttle/README.md index e9552584..2a561fbb 100644 --- a/native-modules/react-native-network-throttle/README.md +++ b/native-modules/react-native-network-throttle/README.md @@ -11,4 +11,10 @@ lifetime of the native process. This allows independently initialized React Native runtimes to register local development servers without clearing each other's configuration. +Known limitation: on Android the bypass decision is made once per logical +request (OkHttp application interceptor), so a cross-origin redirect keeps the +initial request's bypass decision; iOS re-evaluates each request. Exact-origin +bypasses target local development servers, which do not redirect across +origins. + This package only owns native request throttling. Product settings, persistence, and UI controls should remain in the host app. diff --git a/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt b/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt index 7559e27a..0d356156 100644 --- a/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt +++ b/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt @@ -144,8 +144,15 @@ internal object NetworkThrottle { private fun normalizeOrigin(value: String?): String? = value?.toHttpUrlOrNull()?.let(::canonicalOrigin) - private fun shouldBypass(requestUrl: HttpUrl): Boolean = - bypassUrlOrigins.get().contains(canonicalOrigin(requestUrl)) + private fun shouldBypass(requestUrl: HttpUrl): Boolean { + // The interceptor is installed in every build; skip the per-request + // canonicalization allocation while no origin is registered. + val origins = bypassUrlOrigins.get() + if (origins.isEmpty()) { + return false + } + return origins.contains(canonicalOrigin(requestUrl)) + } private fun sleepNanos(delayNanos: Long) { if (delayNanos <= 0) { diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index a00997c4..7dcd9519 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.82", + "version": "3.0.83-alpha.0", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", From 8e39a87da774acb1848be55ef9b5e9936e327041 Mon Sep 17 00:00:00 2001 From: Leon Date: Sat, 15 Aug 2026 17:52:20 +0800 Subject: [PATCH 3/3] feat: throttle only allowlisted hosts Replaces the bypass-origin exclusion with a host allowlist: when throttleUrlHosts is non-empty, only matching requests are throttled and everything else is left untouched. Entries are exact hosts or '*.example.com', which matches sub-domains at any depth but not the bare apex, mirroring the URL patterns the desktop app installs so both platforms throttle the same traffic. This lets the host app limit the weak-network simulation to its own API and CDN traffic, leaving DApp pages, third-party RPC endpoints, and the local development server at full speed. The dev-server bypass is no longer needed: Metro is simply not on the allowlist. Hosts stay additive across the independently initialized main and bg runtimes, and an empty allowlist throttles nothing. --- .../react-native-network-throttle/README.md | 17 ++-- .../NetworkThrottle.kt | 64 ++++++++------- .../ios/OneKeyNetworkThrottle.m | 81 +++++++++++-------- .../package.json | 2 +- .../src/__tests__/index.test.tsx | 40 +++++++-- .../src/index.tsx | 12 +-- 6 files changed, 126 insertions(+), 90 deletions(-) diff --git a/native-modules/react-native-network-throttle/README.md b/native-modules/react-native-network-throttle/README.md index 2a561fbb..a3694688 100644 --- a/native-modules/react-native-network-throttle/README.md +++ b/native-modules/react-native-network-throttle/README.md @@ -5,16 +5,13 @@ React Native native network throttle for OneKey iOS and Android development sett Current scope is RN HTTP(S) latency and upload/download throughput. It does not emulate offline mode, WebView traffic, or third-party native networking stacks. -`bypassUrlOrigins` excludes exact HTTP(S) origins from all throttling. Origins -are canonicalized with their effective port and registered additively for the -lifetime of the native process. This allows independently initialized React -Native runtimes to register local development servers without clearing each -other's configuration. +`throttleUrlHosts` is an allowlist: when it is non-empty, only requests whose +host matches are throttled, and everything else is left untouched. An entry is +either an exact host or `*.example.com`, which matches sub-domains at any depth +but not the bare apex. An empty allowlist throttles nothing. -Known limitation: on Android the bypass decision is made once per logical -request (OkHttp application interceptor), so a cross-origin redirect keeps the -initial request's bypass decision; iOS re-evaluates each request. Exact-origin -bypasses target local development servers, which do not redirect across -origins. +Hosts are registered additively for the lifetime of the native process, so +independently initialized React Native runtimes cannot clear each other's +configuration. This package only owns native request throttling. Product settings, persistence, and UI controls should remain in the host app. diff --git a/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt b/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt index 0d356156..b345dcea 100644 --- a/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt +++ b/native-modules/react-native-network-throttle/android/src/main/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottle.kt @@ -14,7 +14,6 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.MediaType import okhttp3.OkHttpClient @@ -40,7 +39,7 @@ internal object NetworkThrottle { private val downloadBps = AtomicLong(DEFAULT_THROUGHPUT_BPS.toLong()) private val uploadBps = AtomicLong(DEFAULT_THROUGHPUT_BPS.toLong()) private val installed = AtomicBoolean(false) - private val bypassUrlOrigins = AtomicReference>(emptySet()) + private val throttleUrlHosts = AtomicReference>(emptySet()) fun install(context: Context) { if (!installed.compareAndSet(false, true)) { @@ -90,18 +89,18 @@ internal object NetworkThrottle { if (nextUploadBps <= 0) { nextUploadBps = DEFAULT_THROUGHPUT_BPS.toLong() } - if (config.hasKey("bypassUrlOrigins") && !config.isNull("bypassUrlOrigins")) { - val origins = config.getArray("bypassUrlOrigins") - val normalizedOrigins = buildSet { - if (origins != null) { - for (index in 0 until origins.size()) { - if (origins.getType(index) == ReadableType.String) { - normalizeOrigin(origins.getString(index))?.let(::add) + if (config.hasKey("throttleUrlHosts") && !config.isNull("throttleUrlHosts")) { + val hosts = config.getArray("throttleUrlHosts") + val normalizedHosts = buildSet { + if (hosts != null) { + for (index in 0 until hosts.size()) { + if (hosts.getType(index) == ReadableType.String) { + normalizeHost(hosts.getString(index))?.let(::add) } } } } - bypassUrlOrigins.updateAndGet { current -> current + normalizedOrigins } + throttleUrlHosts.updateAndGet { current -> current + normalizedHosts } } enabled.set(nextEnabled) @@ -122,9 +121,9 @@ internal object NetworkThrottle { map.putDouble("latencyMs", latencyNanos.get() / 1_000_000.0) map.putDouble("downloadBps", downloadBps.get().toDouble()) map.putDouble("uploadBps", uploadBps.get().toDouble()) - val origins = Arguments.createArray() - bypassUrlOrigins.get().sorted().forEach(origins::pushString) - map.putArray("bypassUrlOrigins", origins) + val hosts = Arguments.createArray() + throttleUrlHosts.get().sorted().forEach(hosts::pushString) + map.putArray("throttleUrlHosts", hosts) return map } @@ -132,26 +131,31 @@ internal object NetworkThrottle { private fun getDownloadBps(): Long = if (enabled.get()) downloadBps.get() else 0L private fun getUploadBps(): Long = if (enabled.get()) uploadBps.get() else 0L - private fun canonicalOrigin(url: HttpUrl): String = - HttpUrl.Builder() - .scheme(url.scheme) - .host(url.host) - .port(url.port) - .build() - .toString() - .removeSuffix("/") + private fun normalizeHost(value: String?): String? = + value?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } - private fun normalizeOrigin(value: String?): String? = - value?.toHttpUrlOrNull()?.let(::canonicalOrigin) + /** + * Hosts are matched as exact names, or as `*.example.com` which matches + * sub-domains at any depth but not the bare apex. This mirrors the URL + * patterns the desktop app installs, so both platforms throttle the same + * traffic. + */ + private fun matchesHost(host: String, pattern: String): Boolean = + if (pattern.startsWith("*.")) { + host.endsWith(pattern.substring(1)) + } else { + host == pattern + } - private fun shouldBypass(requestUrl: HttpUrl): Boolean { - // The interceptor is installed in every build; skip the per-request - // canonicalization allocation while no origin is registered. - val origins = bypassUrlOrigins.get() - if (origins.isEmpty()) { + private fun shouldThrottle(requestUrl: HttpUrl): Boolean { + // The interceptor is installed in every build, so keep the empty case + // allocation-free. An empty allowlist throttles nothing. + val hosts = throttleUrlHosts.get() + if (hosts.isEmpty()) { return false } - return origins.contains(canonicalOrigin(requestUrl)) + val host = requestUrl.host.lowercase() + return hosts.any { matchesHost(host, it) } } private fun sleepNanos(delayNanos: Long) { @@ -245,7 +249,7 @@ internal object NetworkThrottle { private class ThrottleInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() - if (shouldBypass(request.url)) { + if (!shouldThrottle(request.url)) { return chain.proceed(request) } val requestStartNanos = System.nanoTime() diff --git a/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m b/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m index ed836d79..61d1ebd4 100644 --- a/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m +++ b/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m @@ -10,18 +10,21 @@ static const NSInteger OneKeyNetworkThrottleDefaultThroughputBps = 102 * 1024; static const NSUInteger OneKeyNetworkThrottleMaxPendingDownloadBytes = 256 * 1024; -static NSString *OneKeyNetworkThrottleCanonicalOrigin(NSURL *url) +static NSString *OneKeyNetworkThrottleNormalizedHost(NSString *value) { - NSString *scheme = url.scheme.lowercaseString; - NSString *host = url.host.lowercaseString; - if ((!([scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"])) || host.length == 0) { - return nil; + NSString *host = [value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]].lowercaseString; + return host.length > 0 ? host : nil; +} + +// Hosts match as exact names, or as `*.example.com` which matches sub-domains +// at any depth but not the bare apex. This mirrors the URL patterns the +// desktop app installs, so both platforms throttle the same traffic. +static BOOL OneKeyNetworkThrottleHostMatches(NSString *host, NSString *pattern) +{ + if ([pattern hasPrefix:@"*."]) { + return [host hasSuffix:[pattern substringFromIndex:1]]; } - NSURLComponents *components = [[NSURLComponents alloc] init]; - components.scheme = scheme; - components.host = host; - components.port = url.port ?: @([scheme isEqualToString:@"https"] ? 443 : 80); - return components.string; + return [host isEqualToString:pattern]; } @interface OneKeyNetworkThrottleState : NSObject @@ -30,12 +33,12 @@ + (BOOL)isEnabled; + (NSTimeInterval)latencyMs; + (NSInteger)downloadBps; + (NSInteger)uploadBps; -+ (BOOL)shouldBypassURL:(NSURL *)url; ++ (BOOL)shouldThrottleURL:(NSURL *)url; + (NSDictionary *)setEnabled:(BOOL)enabled latencyMs:(NSTimeInterval)latencyMs downloadBps:(NSInteger)downloadBps uploadBps:(NSInteger)uploadBps - bypassUrlOrigins:(NSArray *)bypassUrlOrigins; + throttleUrlHosts:(NSArray *)throttleUrlHosts; @end @implementation OneKeyNetworkThrottleState @@ -44,16 +47,16 @@ @implementation OneKeyNetworkThrottleState static atomic_llong _oneKeyNetworkThrottleLatencyMicros = ATOMIC_VAR_INIT(562500); static atomic_llong _oneKeyNetworkThrottleDownloadBps = ATOMIC_VAR_INIT(102 * 1024); static atomic_llong _oneKeyNetworkThrottleUploadBps = ATOMIC_VAR_INIT(102 * 1024); -static NSSet *_oneKeyNetworkThrottleBypassOrigins; +static NSSet *_oneKeyNetworkThrottleHosts; + (NSDictionary *)currentConfig { BOOL enabled = atomic_load_explicit(&_oneKeyNetworkThrottleEnabled, memory_order_acquire); NSTimeInterval latencyMs = ((NSTimeInterval)atomic_load_explicit(&_oneKeyNetworkThrottleLatencyMicros, memory_order_relaxed)) / 1000.0; - NSArray *bypassUrlOrigins = nil; + NSArray *throttleUrlHosts = nil; @synchronized (self) { - bypassUrlOrigins = [[_oneKeyNetworkThrottleBypassOrigins ?: [NSSet set] allObjects] + throttleUrlHosts = [[_oneKeyNetworkThrottleHosts ?: [NSSet set] allObjects] sortedArrayUsingSelector:@selector(compare:)]; } return @{ @@ -62,7 +65,7 @@ + (NSDictionary *)currentConfig @"latencyMs": @(latencyMs), @"downloadBps": @([self downloadBps]), @"uploadBps": @([self uploadBps]), - @"bypassUrlOrigins": bypassUrlOrigins + @"throttleUrlHosts": throttleUrlHosts }; } @@ -86,15 +89,23 @@ + (NSInteger)uploadBps return (NSInteger)atomic_load_explicit(&_oneKeyNetworkThrottleUploadBps, memory_order_relaxed); } -+ (BOOL)shouldBypassURL:(NSURL *)url ++ (BOOL)shouldThrottleURL:(NSURL *)url { - NSString *origin = OneKeyNetworkThrottleCanonicalOrigin(url); - if (origin == nil) { + NSString *host = OneKeyNetworkThrottleNormalizedHost(url.host); + if (host == nil) { return NO; } + // An empty allowlist throttles nothing. + NSSet *hosts = nil; @synchronized (self) { - return [_oneKeyNetworkThrottleBypassOrigins containsObject:origin]; + hosts = _oneKeyNetworkThrottleHosts; } + for (NSString *pattern in hosts) { + if (OneKeyNetworkThrottleHostMatches(host, pattern)) { + return YES; + } + } + return NO; } + (NSInteger)normalizeThroughputBps:(NSInteger)throughputBps @@ -106,27 +117,27 @@ + (NSDictionary *)setEnabled:(BOOL)enabled latencyMs:(NSTimeInterval)latencyMs downloadBps:(NSInteger)downloadBps uploadBps:(NSInteger)uploadBps - bypassUrlOrigins:(NSArray *)bypassUrlOrigins + throttleUrlHosts:(NSArray *)throttleUrlHosts { NSTimeInterval normalizedLatencyMs = latencyMs > 0 ? latencyMs : OneKeyNetworkThrottleDefaultLatencyMs; NSInteger normalizedDownloadBps = [self normalizeThroughputBps:downloadBps]; NSInteger normalizedUploadBps = [self normalizeThroughputBps:uploadBps]; - if ([bypassUrlOrigins isKindOfClass:[NSArray class]]) { - NSMutableSet *normalizedOrigins = [NSMutableSet set]; - for (id value in bypassUrlOrigins) { + if ([throttleUrlHosts isKindOfClass:[NSArray class]]) { + NSMutableSet *normalizedHosts = [NSMutableSet set]; + for (id value in throttleUrlHosts) { if (![value isKindOfClass:[NSString class]]) { continue; } - NSString *origin = OneKeyNetworkThrottleCanonicalOrigin([NSURL URLWithString:(NSString *)value]); - if (origin != nil) { - [normalizedOrigins addObject:origin]; + NSString *host = OneKeyNetworkThrottleNormalizedHost((NSString *)value); + if (host != nil) { + [normalizedHosts addObject:host]; } } @synchronized (self) { - NSMutableSet *nextOrigins = - [_oneKeyNetworkThrottleBypassOrigins mutableCopy] ?: [NSMutableSet set]; - [nextOrigins unionSet:normalizedOrigins]; - _oneKeyNetworkThrottleBypassOrigins = [nextOrigins copy]; + NSMutableSet *nextHosts = + [_oneKeyNetworkThrottleHosts mutableCopy] ?: [NSMutableSet set]; + [nextHosts unionSet:normalizedHosts]; + _oneKeyNetworkThrottleHosts = [nextHosts copy]; } } atomic_store_explicit( @@ -190,7 +201,7 @@ + (BOOL)canInitWithRequest:(NSURLRequest *)request if ([NSURLProtocol propertyForKey:OneKeyNetworkThrottleHandledKey inRequest:request]) { return NO; } - if ([OneKeyNetworkThrottleState shouldBypassURL:request.URL]) { + if (![OneKeyNetworkThrottleState shouldThrottleURL:request.URL]) { return NO; } NSString *scheme = request.URL.scheme.lowercaseString; @@ -573,14 +584,14 @@ + (BOOL)requiresMainQueueSetup uploadBpsValue != nil && uploadBpsValue != [NSNull null] ? [uploadBpsValue integerValue] : [OneKeyNetworkThrottleState uploadBps]; - id bypassUrlOriginsValue = config[@"bypassUrlOrigins"]; - NSArray *bypassUrlOrigins = [bypassUrlOriginsValue isKindOfClass:[NSArray class]] ? bypassUrlOriginsValue : nil; + id throttleUrlHostsValue = config[@"throttleUrlHosts"]; + NSArray *throttleUrlHosts = [throttleUrlHostsValue isKindOfClass:[NSArray class]] ? throttleUrlHostsValue : nil; resolve([OneKeyNetworkThrottleState setEnabled:enabled latencyMs:latencyMs downloadBps:downloadBps uploadBps:uploadBps - bypassUrlOrigins:bypassUrlOrigins]); + throttleUrlHosts:throttleUrlHosts]); } @end diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index 7dcd9519..2233da87 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.83-alpha.0", + "version": "3.0.84-alpha.0", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/src/__tests__/index.test.tsx b/native-modules/react-native-network-throttle/src/__tests__/index.test.tsx index b12bebf1..6ac9e78e 100644 --- a/native-modules/react-native-network-throttle/src/__tests__/index.test.tsx +++ b/native-modules/react-native-network-throttle/src/__tests__/index.test.tsx @@ -38,32 +38,32 @@ describe('NetworkThrottle', () => { await expect(NetworkThrottle.getConfig()).resolves.toEqual({ ...baseConfig, - bypassUrlOrigins: [], + throttleUrlHosts: [], }); }); - it('forwards exact bypass origins with a complete native config', async () => { - const bypassUrlOrigins = ['http://localhost:8081']; + it('forwards the throttle allowlist with a complete native config', async () => { + const throttleUrlHosts = ['*.onekeycn.com']; const expectedConfig: NetworkThrottleConfig = { ...baseConfig, - bypassUrlOrigins, + throttleUrlHosts, }; mockGetConfig.mockResolvedValue({ ...baseConfig, - bypassUrlOrigins: [], + throttleUrlHosts: [], }); mockSetConfig.mockResolvedValue(expectedConfig); await expect( - NetworkThrottle.setConfig({ bypassUrlOrigins }) + NetworkThrottle.setConfig({ throttleUrlHosts }) ).resolves.toEqual(expectedConfig); expect(mockSetConfig).toHaveBeenCalledWith(expectedConfig); }); - it('preserves registered origins for unrelated config updates', async () => { + it('preserves the registered allowlist for unrelated config updates', async () => { const currentConfig: NetworkThrottleConfig = { ...baseConfig, - bypassUrlOrigins: ['http://10.0.2.2:8081'], + throttleUrlHosts: ['*.onekeycn.com'], }; const expectedConfig = { ...currentConfig, @@ -77,3 +77,27 @@ describe('NetworkThrottle', () => { expect(mockSetConfig).toHaveBeenCalledWith(expectedConfig); }); }); + +// The native side implements this matching in Kotlin and Objective-C; this +// pins the contract both must satisfy, and mirrors the desktop URL patterns. +describe('throttle host matching contract', () => { + const matches = (host: string, pattern: string) => + pattern.startsWith('*.') + ? host.endsWith(pattern.slice(1)) + : host === pattern; + + it.each([ + ['wallet.onekeycn.com', '*.onekeycn.com', true], + ['a.b.onekeycn.com', '*.onekeycn.com', true], + ['uni.onekey-asset.com', '*.onekey-asset.com', true], + ['app-assets.onekey.so', 'app-assets.onekey.so', true], + // the bare apex is not a sub-domain + ['onekeycn.com', '*.onekeycn.com', false], + // look-alike hosts must not match + ['evil-onekeycn.com', '*.onekeycn.com', false], + ['mainnet.infura.io', '*.onekeycn.com', false], + ['localhost', '*.onekeycn.com', false], + ])('%s vs %s -> %s', (host, pattern, expected) => { + expect(matches(host, pattern)).toBe(expected); + }); +}); diff --git a/native-modules/react-native-network-throttle/src/index.tsx b/native-modules/react-native-network-throttle/src/index.tsx index 2dbef6bb..67209419 100644 --- a/native-modules/react-native-network-throttle/src/index.tsx +++ b/native-modules/react-native-network-throttle/src/index.tsx @@ -8,7 +8,7 @@ export type NetworkThrottleConfig = { latencyMs: number; downloadBps: number; uploadBps: number; - bypassUrlOrigins: string[]; + throttleUrlHosts: string[]; }; export const NETWORK_THROTTLE_SLOW_4G_LATENCY_MS = 562.5; @@ -19,9 +19,9 @@ export const NETWORK_THROTTLE_SLOW_4G_UPLOAD_BPS = NETWORK_THROTTLE_102_KIB_BPS; type NativeNetworkThrottleConfig = Omit< NetworkThrottleConfig, - 'bypassUrlOrigins' + 'throttleUrlHosts' > & { - bypassUrlOrigins?: string[]; + throttleUrlHosts?: string[]; }; type NativeNetworkThrottleModule = { @@ -52,7 +52,7 @@ function normalizeNativeConfig( ): NetworkThrottleConfig { return { ...config, - bypassUrlOrigins: config.bypassUrlOrigins ?? [], + throttleUrlHosts: config.throttleUrlHosts ?? [], }; } @@ -70,8 +70,8 @@ export const NetworkThrottle: NetworkThrottleModule = nativeModule latencyMs: config.latencyMs ?? currentConfig.latencyMs, downloadBps: config.downloadBps ?? currentConfig.downloadBps, uploadBps: config.uploadBps ?? currentConfig.uploadBps, - bypassUrlOrigins: - config.bypassUrlOrigins ?? currentConfig.bypassUrlOrigins ?? [], + throttleUrlHosts: + config.throttleUrlHosts ?? currentConfig.throttleUrlHosts ?? [], }); return normalizeNativeConfig(nativeConfig); },