From d92abbb2982a221fa61da065ae8d0294fe3abb30 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:30:45 +0700 Subject: [PATCH 1/2] feat: bypass internet censorship tks https://github.com/UsagiApp/Usagi Co-authored-by: OpenAI Codex --- .../NativeNetwork/DpiBypass.kt | 446 ++++++++++++++++++ .../NativeNetwork/LocalAddressClassifier.kt | 63 +++ .../NativeNetwork/LocalAwareDns.kt | 32 +- .../NativeNetwork/NativeNetworkModule.kt | 24 +- .../NativeNetwork/NetworkClientFactory.kt | 23 +- .../NativeNetwork/NetworkMode.kt | 20 + specs/NativeNetwork.ts | 2 + .../settings/SettingsAdvancedScreen.tsx | 23 +- src/services/network/settings.ts | 10 + strings/languages/en/strings.json | 2 + strings/languages/vi_VN/strings.json | 2 + strings/types/index.ts | 2 + 12 files changed, 610 insertions(+), 39 deletions(-) create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/DpiBypass.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/LocalAddressClassifier.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NetworkMode.kt create mode 100644 src/services/network/settings.ts diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/DpiBypass.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/DpiBypass.kt new file mode 100644 index 0000000000..541ba0b107 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/DpiBypass.kt @@ -0,0 +1,446 @@ +package com.rajarsheechatterjee.NativeNetwork + +import android.content.Context +import java.io.ByteArrayOutputStream +import java.io.FilterOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketAddress +import java.nio.channels.SocketChannel +import javax.net.SocketFactory +import kotlin.math.min +import kotlin.random.Random + +internal class RoutingSocketFactory(context: Context) : SocketFactory() { + private val applicationContext = context.applicationContext + private val direct = getDefault() + private val bypass = BypassSocketFactory() + + private fun current(): SocketFactory = + if (readNetworkMode(applicationContext) == NetworkMode.DPI_BYPASS) { + bypass + } else { + direct + } + + override fun createSocket(): Socket = current().createSocket() + + override fun createSocket(host: String, port: Int): Socket = current().createSocket(host, port) + + override fun createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int, + ): Socket = current().createSocket(host, port, localHost, localPort) + + override fun createSocket(address: InetAddress, port: Int): Socket = + current().createSocket(address, port) + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = current().createSocket(address, port, localAddress, localPort) +} + +internal class BypassSocketFactory : SocketFactory() { + private val delegate = getDefault() + + override fun createSocket(): Socket = BypassSocket(delegate.createSocket()) + + override fun createSocket(host: String, port: Int): Socket = + BypassSocket( + delegate.createSocket(host, port), + LocalAddressClassifier.isLocalHostname(host), + ) + + override fun createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int, + ): Socket = BypassSocket( + delegate.createSocket(host, port, localHost, localPort), + LocalAddressClassifier.isLocalHostname(host), + ) + + override fun createSocket(address: InetAddress, port: Int): Socket = + BypassSocket( + delegate.createSocket(address, port), + LocalAddressClassifier.isLocalAddress(address), + ) + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = BypassSocket( + delegate.createSocket(address, port, localAddress, localPort), + LocalAddressClassifier.isLocalAddress(address), + ) +} + +private class BypassSocket( + private val delegate: Socket, + private val forceDirect: Boolean = false, +) : Socket() { + private var bypassOutputStream: OutputStream? = null + private var bypassEnabled = !forceDirect && shouldBypass(delegate.inetAddress) + + override fun connect(endpoint: SocketAddress?) { + delegate.connect(endpoint) + bypassEnabled = !forceDirect && shouldBypass(delegate.inetAddress, endpoint) + } + + override fun connect(endpoint: SocketAddress?, timeout: Int) { + delegate.connect(endpoint, timeout) + bypassEnabled = !forceDirect && shouldBypass(delegate.inetAddress, endpoint) + } + + override fun bind(bindpoint: SocketAddress?) = delegate.bind(bindpoint) + override fun getInetAddress(): InetAddress? = delegate.inetAddress + override fun getLocalAddress(): InetAddress? = delegate.localAddress + override fun getPort(): Int = delegate.port + override fun getLocalPort(): Int = delegate.localPort + override fun getRemoteSocketAddress(): SocketAddress? = delegate.remoteSocketAddress + override fun getLocalSocketAddress(): SocketAddress? = delegate.localSocketAddress + override fun getChannel(): SocketChannel? = delegate.channel + override fun getInputStream(): InputStream = delegate.getInputStream() + + override fun getOutputStream(): OutputStream { + val output = delegate.getOutputStream() + if (!bypassEnabled) return output + return bypassOutputStream ?: BypassOutputStream(output).also { + bypassOutputStream = it + } + } + + override fun setTcpNoDelay(on: Boolean) { + delegate.tcpNoDelay = on + } + + override fun getTcpNoDelay(): Boolean = delegate.tcpNoDelay + + override fun setSoLinger(on: Boolean, linger: Int) = delegate.setSoLinger(on, linger) + override fun getSoLinger(): Int = delegate.soLinger + override fun sendUrgentData(data: Int) = delegate.sendUrgentData(data) + + override fun setOOBInline(on: Boolean) { + delegate.oobInline = on + } + + override fun getOOBInline(): Boolean = delegate.oobInline + + override fun setSoTimeout(timeout: Int) { + delegate.soTimeout = timeout + } + + override fun getSoTimeout(): Int = delegate.soTimeout + + override fun setSendBufferSize(size: Int) { + delegate.sendBufferSize = size + } + + override fun getSendBufferSize(): Int = delegate.sendBufferSize + + override fun setReceiveBufferSize(size: Int) { + delegate.receiveBufferSize = size + } + + override fun getReceiveBufferSize(): Int = delegate.receiveBufferSize + + override fun setKeepAlive(on: Boolean) { + delegate.keepAlive = on + } + + override fun getKeepAlive(): Boolean = delegate.keepAlive + + override fun setTrafficClass(tc: Int) { + delegate.trafficClass = tc + } + + override fun getTrafficClass(): Int = delegate.trafficClass + + override fun setReuseAddress(on: Boolean) { + delegate.reuseAddress = on + } + + override fun getReuseAddress(): Boolean = delegate.reuseAddress + override fun close() = delegate.close() + override fun shutdownInput() = delegate.shutdownInput() + override fun shutdownOutput() = delegate.shutdownOutput() + override fun toString(): String = delegate.toString() + override fun isConnected(): Boolean = delegate.isConnected + override fun isBound(): Boolean = delegate.isBound + override fun isClosed(): Boolean = delegate.isClosed + override fun isInputShutdown(): Boolean = delegate.isInputShutdown + override fun isOutputShutdown(): Boolean = delegate.isOutputShutdown + + private fun shouldBypass(address: InetAddress?, endpoint: SocketAddress? = null): Boolean { + val socketAddress = endpoint as? InetSocketAddress + if (socketAddress != null && + LocalAddressClassifier.isLocalHostname(socketAddress.hostString) + ) { + return false + } + val resolvedAddress = address ?: socketAddress?.address + return resolvedAddress?.let { + !LocalAddressClassifier.isLocalAddress(it) + } ?: true + } +} + +private class BypassOutputStream( + private val delegate: OutputStream, +) : FilterOutputStream(delegate) { + private val buffer = ByteArrayOutputStream() + private var firstWrite = true + + override fun write(value: Int) { + if (!firstWrite) { + delegate.write(value) + return + } + buffer.write(value) + flushFirstWriteIfReady() + } + + override fun write(bytes: ByteArray, offset: Int, length: Int) { + if (!firstWrite) { + delegate.write(bytes, offset, length) + return + } + buffer.write(bytes, offset, length) + flushFirstWriteIfReady() + } + + override fun flush() { + if (firstWrite && buffer.size() > 0) processFirstWrite() + delegate.flush() + } + + private fun flushFirstWriteIfReady() { + val data = buffer.toByteArray() + when { + looksLikeTlsRecord(data) && data.size < 9 -> Unit + + isTlsClientHello(data) -> { + val recordSize = if (data.size >= 5) unsignedShort(data, 3) + 5 else Int.MAX_VALUE + if (data.size >= recordSize || data.size >= MAX_FIRST_WRITE) processFirstWrite() + } + + looksLikeHttpRequest(data) -> { + if (data.indexOf(HTTP_HEADERS_END) >= 0 || data.size >= MAX_FIRST_WRITE) { + processFirstWrite() + } + } + + data.size >= TLS_HEADER_SIZE -> processFirstWrite() + } + } + + private fun processFirstWrite() { + if (!firstWrite) return + firstWrite = false + val data = buffer.toByteArray() + buffer.reset() + + when { + isTlsClientHello(data) -> writeTls(data) + isHttpRequest(data) -> simpleSplit(transformHttpRequest(data)) + else -> simpleSplit(data) + } + } + + private fun writeTls(data: ByteArray) { + val payloadLength = unsignedShort(data, 3) + val recordEnd = TLS_HEADER_SIZE + payloadLength + if (recordEnd > data.size) { + simpleSplit(data) + return + } + + val sni = findSni(data) ?: run { + simpleSplit(data) + return + } + val splitPoints = splitPoints(sni, payloadLength) + var offset = TLS_HEADER_SIZE + splitPoints.forEachIndexed { index, end -> + val length = end - offset + if (length <= 0) return@forEachIndexed + delegate.write(0x16) + delegate.write(data[1].toInt() and 0xFF) + delegate.write(data[2].toInt() and 0xFF) + delegate.write((length shr 8) and 0xFF) + delegate.write(length and 0xFF) + delegate.write(data, offset, length) + delegate.flush() + offset = end + if (index < splitPoints.lastIndex) delay() + } + if (recordEnd < data.size) { + delegate.write(data, recordEnd, data.size - recordEnd) + delegate.flush() + } + } + + private fun splitPoints(sni: SniInfo, payloadLength: Int): List { + val payloadEnd = TLS_HEADER_SIZE + payloadLength + val points = mutableListOf() + val before = sni.offset + if (before > TLS_HEADER_SIZE + MIN_FRAGMENT_SIZE) points.add(before) + + val middle = sni.offset + sni.length / 2 + if (middle > (points.lastOrNull() ?: TLS_HEADER_SIZE) + MIN_FRAGMENT_SIZE && + middle < payloadEnd - MIN_FRAGMENT_SIZE + ) { + points.add(middle) + } + + val after = sni.offset + sni.length + if (after > (points.lastOrNull() ?: TLS_HEADER_SIZE) + MIN_FRAGMENT_SIZE && + after < payloadEnd - MIN_FRAGMENT_SIZE + ) { + points.add(after) + } + points.add(payloadEnd) + + if (points.size == 1) { + val early = TLS_HEADER_SIZE + min(MIN_FRAGMENT_SIZE, payloadLength / 3) + if (early < payloadEnd - MIN_FRAGMENT_SIZE) points.add(0, early) + } + return points + } + + private fun simpleSplit(data: ByteArray) { + if (data.size <= 2) { + delegate.write(data) + delegate.flush() + return + } + delegate.write(data, 0, 1) + delegate.flush() + delay() + delegate.write(data, 1, data.size - 1) + delegate.flush() + } + + private fun transformHttpRequest(data: ByteArray): ByteArray { + val request = String(data, Charsets.ISO_8859_1) + val transformed = request.replace(HOST_HEADER_REGEX) { match -> + val header = mixCase(match.groupValues[1]) + val host = match.groupValues[2].trimEnd() + val normalizedHost = if (!host.contains(':') && !host.endsWith('.')) "$host." else host + "$header: $normalizedHost" + } + return transformed.toByteArray(Charsets.ISO_8859_1) + } + + private fun findSni(data: ByteArray): SniInfo? { + if (!isTlsClientHello(data) || data.size < 9) return null + val recordLength = unsignedShort(data, 3) + if (recordLength < 42 || TLS_HEADER_SIZE + recordLength > data.size) return null + + var position = TLS_HEADER_SIZE + if (data[position].toInt() and 0xFF != 0x01) return null + position += 38 + if (position >= data.size) return null + position += 1 + (data[position].toInt() and 0xFF) + if (position + 2 > data.size) return null + position += 2 + unsignedShort(data, position) + if (position >= data.size) return null + position += 1 + (data[position].toInt() and 0xFF) + if (position + 2 > data.size) return null + + val extensionsLength = unsignedShort(data, position) + position += 2 + val extensionsEnd = position + extensionsLength + if (extensionsEnd > data.size) return null + + while (position + 4 <= extensionsEnd) { + val type = unsignedShort(data, position) + val length = unsignedShort(data, position + 2) + val dataStart = position + 4 + if (dataStart + length > extensionsEnd) return null + if (type == 0x0000 && dataStart + 2 <= dataStart + length) { + var namePosition = dataStart + 2 + while (namePosition + 3 <= dataStart + length) { + val nameLength = unsignedShort(data, namePosition + 1) + if (data[namePosition].toInt() and 0xFF == 0x00 && + namePosition + 3 + nameLength <= dataStart + length + ) { + return SniInfo(namePosition + 3, nameLength) + } + namePosition += 3 + nameLength + } + } + position = dataStart + length + } + return null + } + + private fun isTlsClientHello(data: ByteArray): Boolean = + data.size >= 9 && + data[0] == 0x16.toByte() && + data[1] == 0x03.toByte() && + data[5].toInt() and 0xFF == 0x01 + + private fun looksLikeTlsRecord(data: ByteArray): Boolean = + data.isNotEmpty() && + data[0] == 0x16.toByte() && + (data.size == 1 || data[1] == 0x03.toByte()) + + private fun looksLikeHttpRequest(data: ByteArray): Boolean { + val prefix = String(data, Charsets.ISO_8859_1).uppercase() + return HTTP_METHODS.any { method -> "$method ".startsWith(prefix) || prefix.startsWith("$method ") } + } + + private fun isHttpRequest(data: ByteArray): Boolean { + val firstLine = String(data, Charsets.ISO_8859_1).substringBefore("\r\n") + return firstLine.substringBefore(' ').uppercase() in HTTP_METHODS + } + + private fun unsignedShort(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + + private fun delay() { + try { + Thread.sleep(Random.nextLong(30L, 81L)) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + private fun mixCase(value: String): String { + val mixed = value.map { character -> + if (Random.nextBoolean()) character.uppercaseChar() else character.lowercaseChar() + }.joinToString("") + return if (mixed == value) value.replaceFirstChar(Char::lowercaseChar) else mixed + } + + private fun ByteArray.indexOf(needle: ByteArray): Int { + if (needle.isEmpty() || size < needle.size) return -1 + for (index in 0..size - needle.size) { + if (needle.indices.all { offset -> this[index + offset] == needle[offset] }) return index + } + return -1 + } + + private data class SniInfo(val offset: Int, val length: Int) + + private companion object { + const val MAX_FIRST_WRITE = 32 * 1024 + const val MIN_FRAGMENT_SIZE = 4 + const val TLS_HEADER_SIZE = 5 + val HTTP_HEADERS_END = "\r\n\r\n".toByteArray(Charsets.ISO_8859_1) + val HTTP_METHODS = setOf("GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS", "CONNECT", "PATCH") + val HOST_HEADER_REGEX = Regex("(?im)^\\s*(Host):\\s*([^\\r\\n]+)") + } +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/LocalAddressClassifier.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/LocalAddressClassifier.kt new file mode 100644 index 0000000000..fbade5a1f7 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/LocalAddressClassifier.kt @@ -0,0 +1,63 @@ +package com.rajarsheechatterjee.NativeNetwork + +import java.net.Inet6Address +import java.net.InetAddress + +internal object LocalAddressClassifier { + private val localDnsSuffixes = listOf( + ".localhost", + ".local", + ".lan", + ".home", + ".home.arpa", + ".internal", + ) + + fun isLocalHostname(value: String): Boolean { + val host = value.trim().trimEnd('.').lowercase() + return host == "localhost" || + host.isNotEmpty() && '.' !in host && ':' !in host || + localDnsSuffixes.any(host::endsWith) + } + + fun isIpLiteral(value: String): Boolean { + val host = value.trim().removePrefix("[").removeSuffix("]") + return ':' in host || host.isIpv4Address() + } + + fun isLocalAddress(address: InetAddress): Boolean { + if (address.isAnyLocalAddress || + address.isLoopbackAddress || + address.isLinkLocalAddress || + address.isSiteLocalAddress + ) { + return true + } + + val bytes = address.address + if (address is Inet6Address) { + return bytes.isNotEmpty() && (bytes[0].toInt() and 0xFE) == 0xFC + } + if (bytes.size != 4) return false + + val first = bytes[0].toInt() and 0xFF + val second = bytes[1].toInt() and 0xFF + return first == 0 || + first == 10 || + first == 127 || + first == 169 && second == 254 || + first == 172 && second in 16..31 || + first == 192 && second == 168 || + first == 100 && second in 64..127 + } + + private fun String.isIpv4Address(): Boolean { + val octets = split('.') + return octets.size == 4 && octets.all { octet -> + octet.isNotEmpty() && + octet.length <= 3 && + octet.all { it in '0'..'9' } && + octet.toInt() in 0..255 + } + } +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/LocalAwareDns.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/LocalAwareDns.kt index 334e68db8e..702db4bd02 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/LocalAwareDns.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/LocalAwareDns.kt @@ -8,37 +8,11 @@ internal class LocalAwareDns( private val delegate: Dns, ) : Dns { override fun lookup(hostname: String): List = - if (hostname.isLocalHost()) { + if (LocalAddressClassifier.isLocalHostname(hostname) || + LocalAddressClassifier.isIpLiteral(hostname) + ) { Dns.SYSTEM.lookup(hostname) } else { delegate.lookup(hostname) } } - -private fun String.isLocalHost(): Boolean { - val host = trimEnd('.').lowercase() - return host == "localhost" || - '.' !in host || - ':' in host || - host.isIpv4Address() || - LOCAL_DNS_SUFFIXES.any(host::endsWith) -} - -private fun String.isIpv4Address(): Boolean { - val octets = split('.') - return octets.size == 4 && octets.all { octet -> - octet.isNotEmpty() && - octet.length <= 3 && - octet.all { it in '0'..'9' } && - octet.toInt() in 0..255 - } -} - -private val LOCAL_DNS_SUFFIXES = listOf( - ".localhost", - ".local", - ".lan", - ".home", - ".home.arpa", - ".internal", -) diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NativeNetworkModule.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NativeNetworkModule.kt index ba9f605d57..56a67b5b8e 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NativeNetworkModule.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NativeNetworkModule.kt @@ -7,22 +7,40 @@ import android.webkit.WebView import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.modules.network.OkHttpClientProvider import com.lnreader.spec.NativeNetworkSpec import java.io.File import okhttp3.Headers class NativeNetworkModule(context: ReactApplicationContext) : NativeNetworkSpec(context) { + private val networkPreferences = reactApplicationContext.getSharedPreferences( + NETWORK_PREFERENCES, + Context.MODE_PRIVATE, + ) + override fun setDohProvider(providerId: String) { require(DohProvider.isSupported(providerId)) { "Unsupported DNS-over-HTTPS provider: $providerId" } - reactApplicationContext - .getSharedPreferences(NETWORK_PREFERENCES, Context.MODE_PRIVATE) - .edit() + networkPreferences.edit() .putString(DOH_PROVIDER_KEY, providerId) .apply() } + override fun setNetworkMode(mode: String) { + require(NetworkMode.isSupported(mode)) { "Unsupported network mode: $mode" } + networkPreferences.edit() + .putString(NETWORK_MODE_KEY, mode) + .apply() + refreshRouting() + } + + override fun getNetworkMode(): String = readNetworkMode(reactApplicationContext).id + + private fun refreshRouting() { + OkHttpClientProvider.getOkHttpClient().connectionPool.evictAll() + } + override fun isUserAgentValid(value: String): Boolean { if (value.isBlank()) return false return runCatching { diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NetworkClientFactory.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NetworkClientFactory.kt index 44a3e6e046..24c34522c8 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NetworkClientFactory.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NetworkClientFactory.kt @@ -4,6 +4,7 @@ import android.content.Context import com.facebook.react.modules.network.OkHttpClientFactory import com.facebook.react.modules.network.OkHttpClientProvider import java.io.File +import java.net.Proxy import okhttp3.Cache import okhttp3.OkHttpClient @@ -23,14 +24,24 @@ class NetworkClientFactory(context: Context) : OkHttpClientFactory { val baseClient = OkHttpClientProvider.createClientBuilder() .cache(cache) .build() - val providerId = applicationContext - .getSharedPreferences(NETWORK_PREFERENCES, Context.MODE_PRIVATE) + val preferences = applicationContext.getSharedPreferences( + NETWORK_PREFERENCES, + Context.MODE_PRIVATE, + ) + val providerId = preferences .getString(DOH_PROVIDER_KEY, DISABLED_DOH_PROVIDER) .orEmpty() - val provider = DohProvider.fromId(providerId) ?: return baseClient + val provider = DohProvider.fromId(providerId) + val clientBuilder = baseClient.newBuilder() + .socketFactory(RoutingSocketFactory(applicationContext)) - return baseClient.newBuilder() - .dns(provider.createDns(baseClient)) - .build() + if (provider != null) { + val bootstrapClient = baseClient.newBuilder() + .proxy(Proxy.NO_PROXY) + .build() + clientBuilder.dns(provider.createDns(bootstrapClient)) + } + + return clientBuilder.build() } } diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NetworkMode.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NetworkMode.kt new file mode 100644 index 0000000000..016055c220 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/NetworkMode.kt @@ -0,0 +1,20 @@ +package com.rajarsheechatterjee.NativeNetwork + +import android.content.Context + +internal const val NETWORK_MODE_KEY = "network_mode" + +internal enum class NetworkMode(val id: String) { + DIRECT("direct"), + DPI_BYPASS("dpi_bypass"); + + companion object { + fun fromId(id: String?) = entries.find { it.id == id } ?: DIRECT + fun isSupported(id: String) = entries.any { it.id == id } + } +} + +internal fun readNetworkMode(context: Context): NetworkMode = NetworkMode.fromId( + context.getSharedPreferences(NETWORK_PREFERENCES, Context.MODE_PRIVATE) + .getString(NETWORK_MODE_KEY, null), +) diff --git a/specs/NativeNetwork.ts b/specs/NativeNetwork.ts index ff612d3bc6..dbc67b25db 100644 --- a/specs/NativeNetwork.ts +++ b/specs/NativeNetwork.ts @@ -3,6 +3,8 @@ import { TurboModuleRegistry } from 'react-native'; export interface Spec extends TurboModule { setDohProvider(providerId: string): void; + setNetworkMode(mode: string): void; + getNetworkMode(): string; isUserAgentValid(value: string): boolean; clearWebViewData(): Promise; } diff --git a/src/screens/settings/SettingsAdvancedScreen.tsx b/src/screens/settings/SettingsAdvancedScreen.tsx index 0bc89890b6..c54319cce0 100644 --- a/src/screens/settings/SettingsAdvancedScreen.tsx +++ b/src/screens/settings/SettingsAdvancedScreen.tsx @@ -16,13 +16,15 @@ import { NOVEL_UPDATE_RANDOM_KEY } from '@hooks/persisted/useUpdates'; import { AdvancedSettingsScreenProps } from '@navigators/types'; import { store } from '@plugins/helpers/storage'; import CookieManager from '@preeternal/react-native-cookie-manager'; +import { useFocusEffect } from '@react-navigation/native'; import { getDohProviderName } from '@services/network/doh'; +import { getNetworkMode, setNetworkMode } from '@services/network/settings'; import NativeLocalServer from '@specs/NativeLocalServer'; import NativeNetwork from '@specs/NativeNetwork'; import { getString } from '@strings/translations'; import { MMKVStorage } from '@utils/mmkv/mmkv'; import { showToast } from '@utils/showToast'; -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import { Portal, Text, TextInput } from 'react-native-paper'; @@ -37,6 +39,13 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => { const appSettings = useAppSettings(); const { verboseLogging, setAppSettings } = appSettings; const [userAgentInput, setUserAgentInput] = useState(userAgent); + const [networkMode, setCurrentNetworkMode] = useState(getNetworkMode); + + useFocusEffect( + useCallback(() => { + setCurrentNetworkMode(getNetworkMode()); + }, []), + ); /** * Confirm Clear Database Dialog @@ -201,6 +210,18 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => { onPress={showDohProviderModal} theme={theme} /> + { + const mode = + networkMode === 'dpi_bypass' ? 'direct' : 'dpi_bypass'; + setNetworkMode(mode); + setCurrentNetworkMode(mode); + }} + theme={theme} + /> + NativeNetwork.getNetworkMode() === 'dpi_bypass' ? 'dpi_bypass' : 'direct'; + +export const setNetworkMode = (mode: NetworkMode) => { + NativeNetwork.setNetworkMode(mode); +}; diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index b65bb13909..a8e6cffa83 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -46,6 +46,8 @@ "webViewDataClearFailed": "Failed to clear WebView data", "dnsOverHttps": "DNS over HTTPS (DoH)", "disabled": "Disabled", + "bypassDpi": "Bypass internet censorship", + "bypassDpiDesc": "(Experimental) Split initial HTTP and TLS traffic to bypass DPI filtering.", "resetUserAgent": "Reset default User Agent", "invalidUserAgent": "Invalid User-Agent value", "storageUsage": "Storage usage", diff --git a/strings/languages/vi_VN/strings.json b/strings/languages/vi_VN/strings.json index 016322b049..bedce8319e 100644 --- a/strings/languages/vi_VN/strings.json +++ b/strings/languages/vi_VN/strings.json @@ -46,6 +46,8 @@ "webViewDataClearFailed": "Không thể xóa dữ liệu WebView", "dnsOverHttps": "DNS qua HTTPS (DoH)", "disabled": "Đã tắt", + "bypassDpi": "Vượt kiểm duyệt Internet", + "bypassDpiDesc": "(Thử nghiệm) Chia nhỏ lưu lượng HTTP và TLS ban đầu để vượt bộ lọc DPI.", "resetUserAgent": "Đặt lại User Agent mặc định", "invalidUserAgent": "Giá trị User-Agent không hợp lệ", "storageUsage": "Kho chứa chiếm dụng", diff --git a/strings/types/index.ts b/strings/types/index.ts index 429a759a0e..2b8d61fe31 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -47,6 +47,8 @@ export interface StringMap { 'advancedSettingsScreen.webViewDataClearFailed': 'string'; 'advancedSettingsScreen.dnsOverHttps': 'string'; 'advancedSettingsScreen.disabled': 'string'; + 'advancedSettingsScreen.bypassDpi': 'string'; + 'advancedSettingsScreen.bypassDpiDesc': 'string'; 'advancedSettingsScreen.resetUserAgent': 'string'; 'advancedSettingsScreen.invalidUserAgent': 'string'; 'advancedSettingsScreen.storageUsage': 'string'; From 7aa7482211242159b66728dcc8a16b4b9fa82613 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:59:41 +0700 Subject: [PATCH 2/2] perf: O(n^2) --- .../NativeNetwork/DpiBypass.kt | 114 +++++++++++++----- strings/languages/en/strings.json | 4 +- strings/languages/vi_VN/strings.json | 4 +- 3 files changed, 85 insertions(+), 37 deletions(-) diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/DpiBypass.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/DpiBypass.kt index 541ba0b107..d24e3c26ab 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/DpiBypass.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeNetwork/DpiBypass.kt @@ -199,8 +199,9 @@ private class BypassSocket( private class BypassOutputStream( private val delegate: OutputStream, ) : FilterOutputStream(delegate) { - private val buffer = ByteArrayOutputStream() + private val buffer = FirstWriteBuffer() private var firstWrite = true + private var httpScanOffset = 0 override fun write(value: Int) { if (!firstWrite) { @@ -212,36 +213,49 @@ private class BypassOutputStream( } override fun write(bytes: ByteArray, offset: Int, length: Int) { + if (offset < 0 || length < 0 || offset > bytes.size - length) { + throw IndexOutOfBoundsException() + } + if (length == 0) return if (!firstWrite) { delegate.write(bytes, offset, length) return } - buffer.write(bytes, offset, length) - flushFirstWriteIfReady() + + val bufferedLength = min(length, MAX_FIRST_WRITE - buffer.length) + if (bufferedLength > 0) { + buffer.write(bytes, offset, bufferedLength) + flushFirstWriteIfReady() + } + if (firstWrite && bufferedLength < length) processFirstWrite() + if (bufferedLength < length) { + delegate.write(bytes, offset + bufferedLength, length - bufferedLength) + } } override fun flush() { - if (firstWrite && buffer.size() > 0) processFirstWrite() + if (firstWrite && buffer.length > 0) processFirstWrite() delegate.flush() } private fun flushFirstWriteIfReady() { - val data = buffer.toByteArray() + val data = buffer.data + val size = buffer.length when { - looksLikeTlsRecord(data) && data.size < 9 -> Unit + looksLikeTlsRecord(data, size) && size < 9 -> Unit - isTlsClientHello(data) -> { - val recordSize = if (data.size >= 5) unsignedShort(data, 3) + 5 else Int.MAX_VALUE - if (data.size >= recordSize || data.size >= MAX_FIRST_WRITE) processFirstWrite() + isTlsClientHello(data, size) -> { + val recordSize = unsignedShort(data, 3) + TLS_HEADER_SIZE + if (size >= recordSize || size >= MAX_FIRST_WRITE) processFirstWrite() } - looksLikeHttpRequest(data) -> { - if (data.indexOf(HTTP_HEADERS_END) >= 0 || data.size >= MAX_FIRST_WRITE) { + looksLikeHttpRequest(data, size) -> { + if (hasHttpHeadersEnd(data, size) || size >= MAX_FIRST_WRITE) { processFirstWrite() } } - data.size >= TLS_HEADER_SIZE -> processFirstWrite() + size >= TLS_HEADER_SIZE -> processFirstWrite() } } @@ -253,7 +267,7 @@ private class BypassOutputStream( when { isTlsClientHello(data) -> writeTls(data) - isHttpRequest(data) -> simpleSplit(transformHttpRequest(data)) + looksLikeHttpRequest(data, data.size) -> simpleSplit(transformHttpRequest(data)) else -> simpleSplit(data) } } @@ -386,25 +400,49 @@ private class BypassOutputStream( return null } - private fun isTlsClientHello(data: ByteArray): Boolean = - data.size >= 9 && + private fun isTlsClientHello(data: ByteArray, size: Int = data.size): Boolean = + size >= 9 && data[0] == 0x16.toByte() && data[1] == 0x03.toByte() && data[5].toInt() and 0xFF == 0x01 - private fun looksLikeTlsRecord(data: ByteArray): Boolean = - data.isNotEmpty() && + private fun looksLikeTlsRecord(data: ByteArray, size: Int): Boolean = + size > 0 && data[0] == 0x16.toByte() && - (data.size == 1 || data[1] == 0x03.toByte()) + (size == 1 || data[1] == 0x03.toByte()) + + private fun looksLikeHttpRequest(data: ByteArray, size: Int): Boolean { + if (size == 0) return false + return HTTP_METHOD_PREFIXES.any { method -> + val comparedLength = min(size, method.size) + (0 until comparedLength).all { index -> + data[index].uppercaseAscii() == method[index] + } + } + } - private fun looksLikeHttpRequest(data: ByteArray): Boolean { - val prefix = String(data, Charsets.ISO_8859_1).uppercase() - return HTTP_METHODS.any { method -> "$method ".startsWith(prefix) || prefix.startsWith("$method ") } + private fun hasHttpHeadersEnd(data: ByteArray, size: Int): Boolean { + val lastStart = size - HTTP_HEADERS_END.size + if (lastStart < httpScanOffset) return false + for (start in httpScanOffset..lastStart) { + if (HTTP_HEADERS_END.indices.all { offset -> + data[start + offset] == HTTP_HEADERS_END[offset] + } + ) { + return true + } + } + httpScanOffset = maxOf(0, size - HTTP_HEADERS_END.size + 1) + return false } - private fun isHttpRequest(data: ByteArray): Boolean { - val firstLine = String(data, Charsets.ISO_8859_1).substringBefore("\r\n") - return firstLine.substringBefore(' ').uppercase() in HTTP_METHODS + private fun Byte.uppercaseAscii(): Byte { + val value = toInt() and 0xFF + return if (value in 'a'.code..'z'.code) { + (value - ASCII_CASE_OFFSET).toByte() + } else { + this + } } private fun unsignedShort(data: ByteArray, offset: Int): Int = @@ -425,22 +463,32 @@ private class BypassOutputStream( return if (mixed == value) value.replaceFirstChar(Char::lowercaseChar) else mixed } - private fun ByteArray.indexOf(needle: ByteArray): Int { - if (needle.isEmpty() || size < needle.size) return -1 - for (index in 0..size - needle.size) { - if (needle.indices.all { offset -> this[index + offset] == needle[offset] }) return index - } - return -1 - } - private data class SniInfo(val offset: Int, val length: Int) + private class FirstWriteBuffer : ByteArrayOutputStream(INITIAL_BUFFER_SIZE) { + val data: ByteArray + get() = buf + val length: Int + get() = count + } + private companion object { + const val ASCII_CASE_OFFSET = 'a'.code - 'A'.code + const val INITIAL_BUFFER_SIZE = 1024 const val MAX_FIRST_WRITE = 32 * 1024 const val MIN_FRAGMENT_SIZE = 4 const val TLS_HEADER_SIZE = 5 val HTTP_HEADERS_END = "\r\n\r\n".toByteArray(Charsets.ISO_8859_1) - val HTTP_METHODS = setOf("GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS", "CONNECT", "PATCH") + val HTTP_METHOD_PREFIXES = arrayOf( + "GET ", + "POST ", + "HEAD ", + "PUT ", + "DELETE ", + "OPTIONS ", + "CONNECT ", + "PATCH ", + ).map { it.toByteArray(Charsets.US_ASCII) } val HOST_HEADER_REGEX = Regex("(?im)^\\s*(Host):\\s*([^\\r\\n]+)") } } diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index a8e6cffa83..de02f7e53a 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -68,11 +68,11 @@ "recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.", "developer": "Developer", "allowCloudflareBypass": "Allow plugins to use Cloudflare bypass API", - "allowCloudflareBypassDesc": "Enables the Cloudflare Turnstile bypass API for plugins. Requires app restart.", + "allowCloudflareBypassDesc": "Enables the Cloudflare Turnstile bypass API for plugins.", "hideCloudflareOverlay": "Hide WebView overlay when bypassing CF", "hideCloudflareOverlayDesc": "Hides the Cloudflare WebView while bypassing. Uncheck to see the solving process.", "allowProxyAPI": "Allow plugins to use Proxy API", - "allowProxyAPIDesc": "Enables the LocalHttpServer Proxy API used by plugins to bypass CORS. Takes effect immediately.", + "allowProxyAPIDesc": "Enables the LocalHttpServer Proxy API used by plugins to bypass CORS.", "verboseLogging": "Verbose Logging", "verboseLoggingDesc": "Print detailed logs including system logs (reduces performance)", "restartRequiredToast": "Please restart the app to apply changes" diff --git a/strings/languages/vi_VN/strings.json b/strings/languages/vi_VN/strings.json index bedce8319e..5aef7014fe 100644 --- a/strings/languages/vi_VN/strings.json +++ b/strings/languages/vi_VN/strings.json @@ -68,11 +68,11 @@ "recreateDBIndexesDesc": "Tạo lại DB indexes. Việc này có thể cải thiện hiệu năng trên các thiết bị chậm hơn.", "developer": "Nhà phát triển", "allowCloudflareBypass": "Cho phép plugin sử dụng API bypass Cloudflare", - "allowCloudflareBypassDesc": "Bật tính năng vượt qua Cloudflare Turnstile cho các plugin. Yêu cầu khởi động lại ứng dụng.", + "allowCloudflareBypassDesc": "Bật tính năng vượt qua Cloudflare Turnstile cho các plugin.", "hideCloudflareOverlay": "Ẩn overlay WebView khi bypass CF", "hideCloudflareOverlayDesc": "Ẩn giao diện giải Cloudflare. Tắt đi nếu bạn muốn xem quá trình giải.", "allowProxyAPI": "Cho phép plugin sử dụng Proxy API", - "allowProxyAPIDesc": "Bật tính năng Proxy API cho LocalHttpServer để giúp plugin vượt qua CORS. Áp dụng ngay lập tức.", + "allowProxyAPIDesc": "Bật tính năng Proxy API cho LocalHttpServer để giúp plugin vượt qua CORS.", "verboseLogging": "Ghi nhật ký chi tiết", "verboseLoggingDesc": "In các ghi chép tường tận đến cả hệ thống (giảm hiệu suất)", "restartRequiredToast": "Vui lòng khởi động lại ứng dụng để áp dụng thay đổi"