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
14 changes: 11 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,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.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ 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.Interceptor
import okhttp3.MediaType
import okhttp3.OkHttpClient
Expand All @@ -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<Set<String>>(emptySet())

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

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 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.
*/
private 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
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<NSString *> *_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<NSString *> *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
};
}

Expand All @@ -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<NSString *> *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;
Expand All @@ -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<NSString *> *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<NSString *> *nextHosts =

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: [Host updates can only grow, never replace or clear]

This merges the new host array into process-global state instead of replacing the previous set. After a caller once registers a.example.com, later updates like setConfig({ throttleUrlHosts: ['b.example.com'] }) or setConfig({ throttleUrlHosts: [] }) still leave a.example.com throttled until the app restarts.

The result is that settings changes are not actually applied, and stale domains keep getting slowed unexpectedly. The stored allowlist needs replacement semantics for a single runtime, or a real ownership model with explicit deregistration if multiple runtimes must coexist.

[_oneKeyNetworkThrottleHosts mutableCopy] ?: [NSMutableSet set];
[nextHosts unionSet:normalizedHosts];
_oneKeyNetworkThrottleHosts = [nextHosts copy];
}
}
atomic_store_explicit(
&_oneKeyNetworkThrottleLatencyMicros,
(long long)llround(normalizedLatencyMs * 1000.0),
Expand Down Expand Up @@ -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"];
}
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion 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.82",
"version": "3.0.84-alpha.0",
"description": "react-native-network-throttle",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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);
});
});

// 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);
});
});
Loading