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/package.json b/native-modules/react-native-network-throttle/package.json index 3bc6f1fc..71b9cf9b 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.81-alpha.8", + "version": "3.0.82-alpha.0", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -143,5 +143,5 @@ } } }, - "stableVersion": "3.0.80" + "stableVersion": "3.0.81" } 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(