Skip to content
Open
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
11 changes: 8 additions & 3 deletions native-modules/react-native-network-throttle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ 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
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
Expand All @@ -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<Set<String>>(emptySet())

fun install(context: Context) {
if (!installed.compareAndSet(false, true)) {
Expand Down Expand Up @@ -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())
Expand All @@ -104,13 +122,31 @@ 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
}

private fun getLatencyNanos(): Long = if (enabled.get()) latencyNanos.get() else 0L
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
Expand Down Expand Up @@ -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)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: [Android bypass policy does not follow redirects]

When this URL redirects across origins, this check evaluates only the initial request. ThrottleInterceptor is installed as an OkHttp application interceptor, so chain.proceed() follows redirects without invoking this bypass check again.

As a result, a request starting on a bypassed origin can redirect to a non-bypassed origin and still skip all throttling; the reverse direction can keep throttling a bypassed target. Please evaluate the bypass set for each network exchange or redirect target, and cover redirects both into and out of bypassed origins.

return chain.proceed(request)
}
val requestStartNanos = System.nanoTime()
val delayNanos = getLatencyNanos()
val request = chain.request()
val requestBody = request.body
val activeUploadBps = getUploadBps()
val throttledRequest =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<NSString *> *_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<NSString *> *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
};
}

Expand All @@ -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;
Expand All @@ -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<NSString *> *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<NSString *> *nextOrigins =
[_oneKeyNetworkThrottleBypassOrigins mutableCopy] ?: [NSMutableSet set];
[nextOrigins unionSet:normalizedOrigins];
_oneKeyNetworkThrottleBypassOrigins = [nextOrigins copy];
}
}
atomic_store_explicit(
&_oneKeyNetworkThrottleLatencyMicros,
(long long)llround(normalizedLatencyMs * 1000.0),
Expand Down Expand Up @@ -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"];
}
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions native-modules/react-native-network-throttle/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -143,5 +143,5 @@
}
}
},
"stableVersion": "3.0.80"
"stableVersion": "3.0.81"
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading