diff --git a/native-modules/react-native-network-throttle/README.md b/native-modules/react-native-network-throttle/README.md index 3405c8d3..a3694688 100644 --- a/native-modules/react-native-network-throttle/README.md +++ b/native-modules/react-native-network-throttle/README.md @@ -2,8 +2,16 @@ 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. + +`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. + +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/build.gradle b/native-modules/react-native-network-throttle/android/build.gradle index 2556adcb..a79813cf 100644 --- a/native-modules/react-native-network-throttle/android/build.gradle +++ b/native-modules/react-native-network-throttle/android/build.gradle @@ -62,4 +62,5 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + testImplementation "junit:junit:4.13.2" } 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..544db09b 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,8 @@ 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.Interceptor import okhttp3.MediaType import okhttp3.OkHttpClient @@ -36,6 +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 throttleUrlHosts = AtomicReference>(emptySet()) fun install(context: Context) { if (!installed.compareAndSet(false, true)) { @@ -85,6 +89,19 @@ internal object NetworkThrottle { if (nextUploadBps <= 0) { nextUploadBps = DEFAULT_THROUGHPUT_BPS.toLong() } + 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) + } + } + } + } + throttleUrlHosts.updateAndGet { current -> current + normalizedHosts } + } enabled.set(nextEnabled) latencyNanos.set((nextLatencyMs * 1_000_000.0).toLong()) @@ -104,6 +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 hosts = Arguments.createArray() + throttleUrlHosts.get().sorted().forEach(hosts::pushString) + map.putArray("throttleUrlHosts", hosts) return map } @@ -111,6 +131,33 @@ 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 normalizeHost(value: String?): String? = + value?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } + + /** + * 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. + */ + internal fun matchesHost(host: String, pattern: String): Boolean = + if (pattern.startsWith("*.")) { + host.endsWith(pattern.substring(1)) + } else { + host == pattern + } + + 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 + } + val host = requestUrl.host.lowercase() + return hosts.any { matchesHost(host, it) } + } + private fun sleepNanos(delayNanos: Long) { if (delayNanos <= 0) { return @@ -201,9 +248,12 @@ internal object NetworkThrottle { private class ThrottleInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (!shouldThrottle(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/android/src/test/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottleHostMatchingTest.kt b/native-modules/react-native-network-throttle/android/src/test/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottleHostMatchingTest.kt new file mode 100644 index 00000000..e5cf0471 --- /dev/null +++ b/native-modules/react-native-network-throttle/android/src/test/java/com/onekeyfe/reactnativenetworkthrottle/NetworkThrottleHostMatchingTest.kt @@ -0,0 +1,46 @@ +package com.onekeyfe.reactnativenetworkthrottle + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NetworkThrottleHostMatchingTest { + + @Test + fun matchesSubDomainsAtAnyDepth() { + assertTrue(NetworkThrottle.matchesHost("wallet.onekeycn.com", "*.onekeycn.com")) + assertTrue(NetworkThrottle.matchesHost("swap.onekeycn.com", "*.onekeycn.com")) + assertTrue(NetworkThrottle.matchesHost("a.b.onekeycn.com", "*.onekeycn.com")) + assertTrue(NetworkThrottle.matchesHost("uni.onekey-asset.com", "*.onekey-asset.com")) + } + + @Test + fun matchesExactHosts() { + assertTrue(NetworkThrottle.matchesHost("app-assets.onekey.so", "app-assets.onekey.so")) + assertFalse(NetworkThrottle.matchesHost("other.onekey.so", "app-assets.onekey.so")) + } + + @Test + fun doesNotMatchTheBareApex() { + // The desktop URLPattern wildcard behaves the same way, so a bare apex must + // stay untouched on both platforms. + assertFalse(NetworkThrottle.matchesHost("onekeycn.com", "*.onekeycn.com")) + assertFalse(NetworkThrottle.matchesHost("onekey-asset.com", "*.onekey-asset.com")) + } + + @Test + fun doesNotMatchLookAlikeHosts() { + // A suffix check without the leading dot would wrongly match these, which + // would throttle traffic that is not OneKey's. + assertFalse(NetworkThrottle.matchesHost("evil-onekeycn.com", "*.onekeycn.com")) + assertFalse(NetworkThrottle.matchesHost("notonekeycn.com", "*.onekeycn.com")) + } + + @Test + fun doesNotMatchUnrelatedHosts() { + for (host in listOf("mainnet.infura.io", "api.hyperliquid.xyz", "localhost", "127.0.0.1")) { + assertFalse(NetworkThrottle.matchesHost(host, "*.onekeycn.com")) + assertFalse(NetworkThrottle.matchesHost(host, "app-assets.onekey.so")) + } + } +} diff --git a/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m b/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m index 6a80114e..61d1ebd4 100644 --- a/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m +++ b/native-modules/react-native-network-throttle/ios/OneKeyNetworkThrottle.m @@ -10,16 +10,35 @@ static const NSInteger OneKeyNetworkThrottleDefaultThroughputBps = 102 * 1024; static const NSUInteger OneKeyNetworkThrottleMaxPendingDownloadBytes = 256 * 1024; +static NSString *OneKeyNetworkThrottleNormalizedHost(NSString *value) +{ + 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]]; + } + return [host isEqualToString:pattern]; +} + @interface OneKeyNetworkThrottleState : NSObject + (NSDictionary *)currentConfig; + (BOOL)isEnabled; + (NSTimeInterval)latencyMs; + (NSInteger)downloadBps; + (NSInteger)uploadBps; ++ (BOOL)shouldThrottleURL:(NSURL *)url; + (NSDictionary *)setEnabled:(BOOL)enabled latencyMs:(NSTimeInterval)latencyMs downloadBps:(NSInteger)downloadBps - uploadBps:(NSInteger)uploadBps; + uploadBps:(NSInteger)uploadBps + throttleUrlHosts:(NSArray *)throttleUrlHosts; @end @implementation OneKeyNetworkThrottleState @@ -28,18 +47,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 *_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 *throttleUrlHosts = nil; + @synchronized (self) { + throttleUrlHosts = [[_oneKeyNetworkThrottleHosts ?: [NSSet set] allObjects] + sortedArrayUsingSelector:@selector(compare:)]; + } return @{ @"enabled": @(enabled), @"profile": OneKeyNetworkThrottleProfileSlow4G, @"latencyMs": @(latencyMs), @"downloadBps": @([self downloadBps]), - @"uploadBps": @([self uploadBps]) + @"uploadBps": @([self uploadBps]), + @"throttleUrlHosts": throttleUrlHosts }; } @@ -63,6 +89,25 @@ + (NSInteger)uploadBps return (NSInteger)atomic_load_explicit(&_oneKeyNetworkThrottleUploadBps, memory_order_relaxed); } ++ (BOOL)shouldThrottleURL:(NSURL *)url +{ + NSString *host = OneKeyNetworkThrottleNormalizedHost(url.host); + if (host == nil) { + return NO; + } + // An empty allowlist throttles nothing. + NSSet *hosts = nil; + @synchronized (self) { + hosts = _oneKeyNetworkThrottleHosts; + } + for (NSString *pattern in hosts) { + if (OneKeyNetworkThrottleHostMatches(host, pattern)) { + return YES; + } + } + return NO; +} + + (NSInteger)normalizeThroughputBps:(NSInteger)throughputBps { return throughputBps > 0 ? throughputBps : OneKeyNetworkThrottleDefaultThroughputBps; @@ -72,10 +117,29 @@ + (NSDictionary *)setEnabled:(BOOL)enabled latencyMs:(NSTimeInterval)latencyMs downloadBps:(NSInteger)downloadBps uploadBps:(NSInteger)uploadBps + throttleUrlHosts:(NSArray *)throttleUrlHosts { NSTimeInterval normalizedLatencyMs = latencyMs > 0 ? latencyMs : OneKeyNetworkThrottleDefaultLatencyMs; NSInteger normalizedDownloadBps = [self normalizeThroughputBps:downloadBps]; NSInteger normalizedUploadBps = [self normalizeThroughputBps:uploadBps]; + if ([throttleUrlHosts isKindOfClass:[NSArray class]]) { + NSMutableSet *normalizedHosts = [NSMutableSet set]; + for (id value in throttleUrlHosts) { + if (![value isKindOfClass:[NSString class]]) { + continue; + } + NSString *host = OneKeyNetworkThrottleNormalizedHost((NSString *)value); + if (host != nil) { + [normalizedHosts addObject:host]; + } + } + @synchronized (self) { + NSMutableSet *nextHosts = + [_oneKeyNetworkThrottleHosts mutableCopy] ?: [NSMutableSet set]; + [nextHosts unionSet:normalizedHosts]; + _oneKeyNetworkThrottleHosts = [nextHosts copy]; + } + } atomic_store_explicit( &_oneKeyNetworkThrottleLatencyMicros, (long long)llround(normalizedLatencyMs * 1000.0), @@ -137,6 +201,9 @@ + (BOOL)canInitWithRequest:(NSURLRequest *)request if ([NSURLProtocol propertyForKey:OneKeyNetworkThrottleHandledKey inRequest:request]) { return NO; } + if (![OneKeyNetworkThrottleState shouldThrottleURL:request.URL]) { + return NO; + } NSString *scheme = request.URL.scheme.lowercaseString; return [scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"]; } @@ -517,7 +584,14 @@ + (BOOL)requiresMainQueueSetup uploadBpsValue != nil && uploadBpsValue != [NSNull null] ? [uploadBpsValue integerValue] : [OneKeyNetworkThrottleState uploadBps]; - resolve([OneKeyNetworkThrottleState setEnabled:enabled latencyMs:latencyMs downloadBps:downloadBps uploadBps:uploadBps]); + id throttleUrlHostsValue = config[@"throttleUrlHosts"]; + NSArray *throttleUrlHosts = [throttleUrlHostsValue isKindOfClass:[NSArray class]] ? throttleUrlHostsValue : nil; + resolve([OneKeyNetworkThrottleState + setEnabled:enabled + latencyMs:latencyMs + downloadBps:downloadBps + uploadBps:uploadBps + throttleUrlHosts:throttleUrlHosts]); } @end diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index a00997c4..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.82", + "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 new file mode 100644 index 00000000..b09f6a33 --- /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, + throttleUrlHosts: [], + }); + }); + + it('forwards the throttle allowlist with a complete native config', async () => { + const throttleUrlHosts = ['*.onekeycn.com']; + const expectedConfig: NetworkThrottleConfig = { + ...baseConfig, + throttleUrlHosts, + }; + mockGetConfig.mockResolvedValue({ + ...baseConfig, + throttleUrlHosts: [], + }); + mockSetConfig.mockResolvedValue(expectedConfig); + + await expect( + NetworkThrottle.setConfig({ throttleUrlHosts }) + ).resolves.toEqual(expectedConfig); + expect(mockSetConfig).toHaveBeenCalledWith(expectedConfig); + }); + + it('preserves the registered allowlist for unrelated config updates', async () => { + const currentConfig: NetworkThrottleConfig = { + ...baseConfig, + throttleUrlHosts: ['*.onekeycn.com'], + }; + 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..67209419 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; + throttleUrlHosts: 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, + 'throttleUrlHosts' +> & { + throttleUrlHosts?: 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, + throttleUrlHosts: config.throttleUrlHosts ?? [], + }; +} + 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, + throttleUrlHosts: + config.throttleUrlHosts ?? currentConfig.throttleUrlHosts ?? [], }); + return normalizeNativeConfig(nativeConfig); }, } : (new Proxy(