OkHttp Dialer (#5579)
AI Add WebView dialer Co-authored-by: fish4terrisa-MSDSM <flyingfish.msdsm@gmail.com>
This commit is contained in:
1 parent
cfb6776e8f
commit
b945f4a3c7
12 files changed
+934
-16
No files matched your search
@@ -43,6 +43,15 @@ object CoreNativeManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun reconcileBrowserDialer(dialerAddr: String) {
|
||||
try {
|
||||
Libv2ray.reconcileBrowserDialer(dialerAddr)
|
||||
LogUtil.i(AppConfig.TAG, "Browser dialer reconciled successfully with address: $dialerAddr")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to reconcile browser dialer with address: $dialerAddr", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get V2Ray core version.
|
||||
|
||||
@@ -22,6 +22,9 @@ import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SpeedtestManager
|
||||
import com.v2ray.ang.service.CoreProxyOnlyService
|
||||
import com.v2ray.ang.service.CoreVpnService
|
||||
import com.v2ray.ang.service.DialerNativeService
|
||||
import com.v2ray.ang.service.DialerWebviewService
|
||||
import com.v2ray.ang.service.IDialerService
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -40,6 +43,7 @@ object CoreServiceManager {
|
||||
private val mMsgReceive = ReceiveMessageHandler()
|
||||
private var currentConfig: ProfileItem? = null
|
||||
private var processFinder: XrayProcessFinder? = null
|
||||
private var browserDialer: IDialerService? = null
|
||||
|
||||
var serviceControl: SoftReference<ServiceControl>? = null
|
||||
set(value) {
|
||||
@@ -223,17 +227,35 @@ object CoreServiceManager {
|
||||
|
||||
currentConfig = config
|
||||
var tunFd = vpnInterface?.fd ?: 0
|
||||
val dialerAddr = if (currentConfig?.browserDialerMode.isNullOrEmpty()) {
|
||||
""
|
||||
} else {
|
||||
"127.0.0.1:${Utils.findRandomFreePort()}"
|
||||
}
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
tunFd = 0
|
||||
}
|
||||
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
|
||||
if (!coreController.isRunning) {
|
||||
error("Core failed to start")
|
||||
}
|
||||
|
||||
if (browserDialer != null) {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
if (config.browserDialerMode == "OkHttp") {
|
||||
browserDialer = DialerNativeService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
} else if (config.browserDialerMode == "WebView") {
|
||||
browserDialer = DialerWebviewService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||
@@ -257,6 +279,13 @@ object CoreServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Close existing browser dialer
|
||||
CoreNativeManager.reconcileBrowserDialer("")
|
||||
if (browserDialer != null) {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
NotificationManager.cancelNotification()
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ data class ProfileItem(
|
||||
var policyGroupFilter: String? = null,
|
||||
var proxyChainProfiles: String? = null,
|
||||
|
||||
var browserDialerMode: String? = null,
|
||||
|
||||
) {
|
||||
companion object {
|
||||
fun create(configType: EConfigType): ProfileItem {
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.Buffer
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.EOFException
|
||||
import java.net.URI
|
||||
import java.net.SocketException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.io.DEFAULT_BUFFER_SIZE
|
||||
|
||||
// This class is responsible for forwarding xray's HTTP/WS requests through okhttp,
|
||||
// so that its TLS and traffic characteristics are okhttp instead of golang/utls.
|
||||
// Only WS and xhttp package-up are supported for now.
|
||||
// ws:
|
||||
// DialerNativeService connects to the control WebSocket provided by the xray core.
|
||||
// Then xray sends a task message with method "WS" and the server URL to the control WebSocket.
|
||||
// DialerNativeService opens a WebSocket connection to the server URL, and forwards messages between the control WebSocket and the target WebSocket.
|
||||
// xhttp(package-up):
|
||||
// A task message with streaming down (method == "GET" and streamResponse == true), let's call it Task A.
|
||||
// A task message with unary down (streamResponse == false), let's call it Task B.
|
||||
// 1. DialerNativeService connects to the control WebSocket provided by the xray core.
|
||||
// 2. Xray sends Task A, DialerNativeService sends "ok" and connects to the target URL, then sends the "GET" request to the server; let's call this "Connection A".
|
||||
// 3. Xray sends a Task B, called B_1. DialerNativeService sends "ok" and sends the data body to the target URL. Whatever the response is, DialerNativeService sends "ok" or "fail" back to the control WebSocket and closes the connection for Task B_1.
|
||||
// 4. The server returns the response for Task B_1 through Connection A, and DialerNativeService forwards the response body to the control WebSocket.
|
||||
// 5. Xray sends another Task B, called B_2.
|
||||
// ...
|
||||
// Finally, the xray client core sends all data through the B_1, B_2, ... tasks.
|
||||
// The server closes Connection A, and DialerNativeService closes Task A.
|
||||
// The above is a complete cycle.
|
||||
class DialerNativeService : IDialerService {
|
||||
companion object {
|
||||
private const val DEBUG_LOG = false
|
||||
private val NEXT_SOCKET_ID = AtomicLong(0L)
|
||||
|
||||
private const val CONTROL_SOCKET_IDLE = 0
|
||||
private const val CONTROL_SOCKET_OPENING = 1
|
||||
private const val CONTROL_LOOP_DELAY_MS = 1000L
|
||||
private const val UNARY_BODY_WAIT_TIMEOUT_MS = 15_000L
|
||||
private val TOKEN_REGEX = Regex("""/websocket\?token=([^"'\s]+)""")
|
||||
private val METHODS_WITHOUT_BODY = setOf("GET", "HEAD")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var serviceJob = SupervisorJob()
|
||||
@Volatile
|
||||
private var scope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
private val running = AtomicBoolean(false)
|
||||
private val controlSocketState = AtomicInteger(CONTROL_SOCKET_IDLE)
|
||||
private val controlSockets = ConcurrentHashMap.newKeySet<WebSocket>()
|
||||
|
||||
@Volatile
|
||||
private var controlUrl: String? = null
|
||||
private var loopJob: Job? = null
|
||||
private var client: OkHttpClient? = null
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
override fun start(context: Context, dialerAddr: String) {
|
||||
stop()
|
||||
serviceJob = SupervisorJob()
|
||||
scope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
if (dialerAddr.isEmpty()) return
|
||||
|
||||
val nativeClient = OkHttpClient.Builder()
|
||||
.retryOnConnectionFailure(true)
|
||||
.pingInterval(25, TimeUnit.SECONDS)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS) // Disable read timeout for long-running streams
|
||||
.build()
|
||||
|
||||
client = nativeClient
|
||||
running.set(true)
|
||||
loopJob = scope.launch {
|
||||
val resolvedControlUrl = resolveControlWsUrl(dialerAddr, nativeClient)
|
||||
if (resolvedControlUrl == null) {
|
||||
debug("BrowserDialer: failed to resolve control url from dialer endpoint: $dialerAddr"
|
||||
)
|
||||
running.set(false)
|
||||
return@launch
|
||||
}
|
||||
controlUrl = resolvedControlUrl
|
||||
debug("BrowserDialer: started dialerAddr=$dialerAddr controlUrl=$resolvedControlUrl idleGate=${controlSocketState.get()}")
|
||||
maintainControlSocketPool()
|
||||
while (isActive && running.get()) {
|
||||
maintainControlSocketPool()
|
||||
delay(CONTROL_LOOP_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running.set(false)
|
||||
loopJob?.cancel()
|
||||
loopJob = null
|
||||
serviceJob.cancel()
|
||||
|
||||
debug("BrowserDialer: stopping ${poolState()} controlUrl=$controlUrl")
|
||||
|
||||
controlSockets.toTypedArray().forEach { socket ->
|
||||
runCatching { socket.close(1000, "stopped") }
|
||||
}
|
||||
controlSockets.clear()
|
||||
|
||||
controlSocketState.set(CONTROL_SOCKET_IDLE)
|
||||
controlUrl = null
|
||||
|
||||
serviceJob = SupervisorJob()
|
||||
scope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
|
||||
val oldClient = client
|
||||
client = null
|
||||
oldClient?.dispatcher?.cancelAll()
|
||||
oldClient?.connectionPool?.evictAll()
|
||||
}
|
||||
|
||||
private fun maintainControlSocketPool() {
|
||||
debug("BrowserDialer: maintaining single idle control socket ${poolState()}")
|
||||
openControlSocket()
|
||||
}
|
||||
|
||||
private fun openControlSocket(): Boolean {
|
||||
val localClient = client ?: return false
|
||||
if (!running.get()) return false
|
||||
val url = controlUrl ?: return false
|
||||
if (!controlSocketState.compareAndSet(
|
||||
CONTROL_SOCKET_IDLE,
|
||||
CONTROL_SOCKET_OPENING
|
||||
)
|
||||
) return false
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
return runCatching {
|
||||
val socket = localClient.newWebSocket(request, ControlSocketListener(url))
|
||||
controlSockets.add(socket)
|
||||
debug("BrowserDialer: opening control socket url=$url ${poolState()}")
|
||||
true
|
||||
}.getOrElse {
|
||||
controlSocketState.set(CONTROL_SOCKET_IDLE)
|
||||
debug("BrowserDialer: failed to open control socket", it)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun poolState(): String {
|
||||
return "idleGate=${controlSocketState.get()} liveSockets=${controlSockets.size}"
|
||||
}
|
||||
|
||||
private fun debug(message: String, throwable: Throwable? = null) {
|
||||
@Suppress("KotlinConstantConditions")
|
||||
if (!DEBUG_LOG) return
|
||||
if (throwable == null) {
|
||||
LogUtil.d(AppConfig.TAG, message)
|
||||
} else {
|
||||
LogUtil.d(AppConfig.TAG, message, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveControlWsUrl(rawAddr: String, probeClient: OkHttpClient): String? {
|
||||
val uri = parseDialerUri(rawAddr) ?: return null
|
||||
val probeUrl = buildDialerProbeUrl(rawAddr) ?: return null
|
||||
val request = Request.Builder().url(probeUrl).get().build()
|
||||
val token = runCatching {
|
||||
probeClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
extractControlToken(response.body.string())
|
||||
}
|
||||
}.getOrNull() ?: return null
|
||||
val host = uri.host ?: return null
|
||||
return URI(
|
||||
"ws",
|
||||
uri.userInfo,
|
||||
host,
|
||||
uri.port,
|
||||
"/websocket",
|
||||
"token=$token",
|
||||
null
|
||||
).toString()
|
||||
}
|
||||
|
||||
private fun buildDialerProbeUrl(rawAddr: String): String? {
|
||||
val normalized = rawAddr.trim()
|
||||
if (normalized.isEmpty()) return null
|
||||
|
||||
val uri = parseDialerUri(normalized) ?: return null
|
||||
val host = uri.host ?: return null
|
||||
val probeScheme = when (uri.scheme?.lowercase()) {
|
||||
"https", "wss" -> "https"
|
||||
else -> "http"
|
||||
}
|
||||
return URI(probeScheme, uri.userInfo, host, uri.port, "/", null, null).toString()
|
||||
}
|
||||
|
||||
private fun parseDialerUri(rawAddr: String): URI? {
|
||||
val normalized = rawAddr.trim()
|
||||
if (normalized.isEmpty()) return null
|
||||
return runCatching {
|
||||
if (normalized.contains("://")) URI(normalized) else URI("http://$normalized")
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun extractControlToken(html: String): String? {
|
||||
val match = TOKEN_REGEX.find(html) ?: return null
|
||||
return match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private inner class ControlSocketListener(
|
||||
private val controlUrl: String
|
||||
) : WebSocketListener() {
|
||||
private val socketId = if (DEBUG_LOG) NEXT_SOCKET_ID.incrementAndGet() else 0L
|
||||
private val taskAccepted = AtomicBoolean(false)
|
||||
private val closed = AtomicBoolean(false)
|
||||
private val taskStartedAtMs = AtomicLong(0L)
|
||||
|
||||
@Volatile
|
||||
private var taskKind = "none"
|
||||
private var upstreamSocket: WebSocket? = null
|
||||
private var upstreamCall: Call? = null
|
||||
private var timeoutJob: Job? = null
|
||||
private var binaryHandler: ((ByteArray) -> Unit)? = null
|
||||
private var textHandler: ((String) -> Unit)? = null
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
debug("BrowserDialer: control socket opened socketId=$socketId url=$controlUrl ${poolState()}")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (taskAccepted.compareAndSet(false, true)) {
|
||||
controlSocketState.set(CONTROL_SOCKET_IDLE)
|
||||
debug(
|
||||
"BrowserDialer: control socket accepted task socketId=$socketId url=$controlUrl textSize=${text.length} ${poolState()}"
|
||||
)
|
||||
tryOpenNextControlSocket()
|
||||
handleTask(webSocket, BrowserDialerTask.parse(text))
|
||||
return
|
||||
}
|
||||
textHandler?.invoke(text)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
if (!taskAccepted.get()) {
|
||||
failAndClose(webSocket, 1002, "task must be text json")
|
||||
return
|
||||
}
|
||||
binaryHandler?.invoke(bytes.toByteArray())
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
cleanup(webSocket)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
val status = response?.code?.toString() ?: "no-http-response"
|
||||
val stateText = "${poolState()} accepted=${taskAccepted.get()} closed=${closed.get()}"
|
||||
if (isExpectedControlFailure(t, status)) {
|
||||
debug("BrowserDialer: control socket closed socketId=$socketId url=$controlUrl status=$status cause=${t.javaClass.simpleName} $stateText"
|
||||
)
|
||||
debug(
|
||||
"BrowserDialer: control socket failure detail socketId=$socketId url=$controlUrl status=$status $stateText",
|
||||
t
|
||||
)
|
||||
} else {
|
||||
debug("BrowserDialer: control socket failure socketId=$socketId url=$controlUrl status=$status $stateText",
|
||||
t
|
||||
)
|
||||
}
|
||||
cleanup(webSocket)
|
||||
}
|
||||
|
||||
private fun isExpectedControlFailure(t: Throwable, status: String): Boolean {
|
||||
if (!running.get() || closed.get()) return true
|
||||
if (status != "no-http-response") return false
|
||||
if (t is EOFException) return true
|
||||
if (t is SocketException) {
|
||||
val message = t.message.orEmpty().lowercase()
|
||||
if ("socket closed" in message || "software caused connection abort" in message) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
val message = t.message.orEmpty().lowercase()
|
||||
return "canceled" in message || "cancelled" in message
|
||||
}
|
||||
|
||||
|
||||
private fun tryOpenNextControlSocket() {
|
||||
scope.launch {
|
||||
if (running.get()) maintainControlSocketPool()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup(webSocket: WebSocket) {
|
||||
if (!closed.compareAndSet(false, true)) return
|
||||
val removed = controlSockets.remove(webSocket)
|
||||
if (!taskAccepted.get()) {
|
||||
controlSocketState.set(CONTROL_SOCKET_IDLE)
|
||||
tryOpenNextControlSocket()
|
||||
}
|
||||
val started = taskStartedAtMs.get()
|
||||
val duration =
|
||||
if (started > 0L) (System.currentTimeMillis() - started).coerceAtLeast(0L) else -1L
|
||||
debug(
|
||||
"BrowserDialer: cleanup socketId=$socketId url=$controlUrl task=$taskKind taskAccepted=${taskAccepted.get()} removed=$removed durationMs=$duration ${poolState()}"
|
||||
)
|
||||
binaryHandler = null
|
||||
textHandler = null
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
upstreamCall?.cancel()
|
||||
upstreamCall = null
|
||||
upstreamSocket?.close(1000, "control closed")
|
||||
upstreamSocket = null
|
||||
taskKind = "closed"
|
||||
taskStartedAtMs.set(0L)
|
||||
}
|
||||
|
||||
private fun handleTask(webSocket: WebSocket, task: BrowserDialerTask?) {
|
||||
if (task == null) {
|
||||
failAndClose(webSocket, 1007, "invalid task")
|
||||
return
|
||||
}
|
||||
|
||||
taskStartedAtMs.set(System.currentTimeMillis())
|
||||
taskKind = when {
|
||||
task.method == "WS" -> "ws"
|
||||
task.method == "GET" && task.streamResponse -> "streaming_get"
|
||||
!task.streamResponse -> "unary_${task.method.lowercase()}"
|
||||
else -> "unsupported"
|
||||
}
|
||||
|
||||
when {
|
||||
task.method == "WS" -> handleWsTask(webSocket, task)
|
||||
task.method == "GET" && task.streamResponse -> handleStreamingGetTask(
|
||||
webSocket,
|
||||
task
|
||||
)
|
||||
|
||||
!task.streamResponse -> handleUnaryTask(webSocket, task)
|
||||
else -> {
|
||||
failAndClose(webSocket, 1003, "unsupported task")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWsTask(controlSocket: WebSocket, task: BrowserDialerTask) {
|
||||
val localClient = client ?: run {
|
||||
failAndClose(controlSocket, 1011, "client unavailable")
|
||||
return
|
||||
}
|
||||
debug("BrowserDialer: handling WS task socketId=$socketId url=${task.url} protocols=${task.extra.protocols.size}")
|
||||
val requestBuilder = Request.Builder().url(task.url)
|
||||
if (task.extra.protocols.isNotEmpty()) {
|
||||
requestBuilder.header(
|
||||
"Sec-WebSocket-Protocol",
|
||||
task.extra.protocols.joinToString(",")
|
||||
)
|
||||
}
|
||||
|
||||
val opened = AtomicBoolean(false)
|
||||
upstreamSocket =
|
||||
localClient.newWebSocket(requestBuilder.build(), object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
opened.set(true)
|
||||
try {
|
||||
controlSocket.send("ok")
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: failed to send ok for WS task",
|
||||
e
|
||||
)
|
||||
webSocket.close(1000, "control failed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
controlSocket.send(text)
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: control socket closed during WS message transfer socketId=$socketId",
|
||||
e
|
||||
)
|
||||
webSocket.close(1000, "control closed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
try {
|
||||
controlSocket.send(bytes)
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: control socket closed during WS binary transfer socketId=$socketId",
|
||||
e
|
||||
)
|
||||
webSocket.close(1000, "control closed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
try {
|
||||
controlSocket.close(1000, "upstream closed")
|
||||
} catch (_: Exception) {
|
||||
debug("BrowserDialer: control socket already closed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?
|
||||
) {
|
||||
if (!opened.get()) {
|
||||
try {
|
||||
controlSocket.send("fail")
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: control socket send failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
controlSocket.close(1011, "upstream failure")
|
||||
} catch (_: Exception) {
|
||||
debug("BrowserDialer: control socket already closed socketId=$socketId"
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
textHandler = { message ->
|
||||
try {
|
||||
upstreamSocket?.send(message)
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: upstream socket send failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
binaryHandler = { data ->
|
||||
try {
|
||||
upstreamSocket?.send(data.toByteString())
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: upstream socket binary send failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStreamingGetTask(controlSocket: WebSocket, task: BrowserDialerTask) {
|
||||
val localClient = client ?: run {
|
||||
failAndClose(controlSocket, 1011, "client unavailable")
|
||||
return
|
||||
}
|
||||
debug("BrowserDialer: handling streaming GET task socketId=$socketId url=${task.url}")
|
||||
val request = buildRequest(task, null)
|
||||
|
||||
try {
|
||||
controlSocket.send("ok")
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: failed to send ok for streaming GET socketId=$socketId",
|
||||
e
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val call = localClient.newCall(request)
|
||||
upstreamCall = call
|
||||
try {
|
||||
call.execute().use { response ->
|
||||
val source = response.body.source()
|
||||
val buffer = Buffer()
|
||||
while (running.get() && !closed.get()) {
|
||||
try {
|
||||
val read = source.read(buffer, DEFAULT_BUFFER_SIZE.toLong())
|
||||
if (read < 0) break
|
||||
|
||||
// Send data, catch exception if WebSocket is closed
|
||||
try {
|
||||
controlSocket.send(buffer.readByteString())
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: WebSocket send failed during streaming, stopping stream socketId=$socketId",
|
||||
e
|
||||
)
|
||||
break
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Error reading from source, stop streaming
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: streaming GET failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
try {
|
||||
controlSocket.send("fail")
|
||||
} catch (_: Exception) {
|
||||
// WebSocket may already be closed
|
||||
}
|
||||
} finally {
|
||||
upstreamCall = null
|
||||
try {
|
||||
controlSocket.close(1000, "streaming done")
|
||||
} catch (_: Exception) {
|
||||
debug("BrowserDialer: WebSocket already closed socketId=$socketId"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUnaryTask(controlSocket: WebSocket, task: BrowserDialerTask) {
|
||||
val localClient = client ?: run {
|
||||
failAndClose(controlSocket, 1011, "client unavailable")
|
||||
return
|
||||
}
|
||||
debug("BrowserDialer: handling unary task socketId=$socketId method=${task.method} url=${task.url}")
|
||||
|
||||
try {
|
||||
controlSocket.send("ok")
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: failed to send ok for unary task socketId=$socketId",
|
||||
e
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val done = AtomicBoolean(false)
|
||||
|
||||
timeoutJob = scope.launch {
|
||||
delay(UNARY_BODY_WAIT_TIMEOUT_MS)
|
||||
if (done.compareAndSet(false, true)) {
|
||||
binaryHandler = null
|
||||
textHandler = null
|
||||
debug("BrowserDialer: unary task timed out waiting for payload socketId=$socketId method=${task.method} url=${task.url}")
|
||||
failAndClose(controlSocket, 1000, "unary payload timeout")
|
||||
}
|
||||
}
|
||||
|
||||
val executeRequest: (ByteArray?) -> Unit = { payload ->
|
||||
if (done.compareAndSet(false, true)) {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
binaryHandler = null
|
||||
textHandler = null
|
||||
scope.launch {
|
||||
val request = buildRequest(task, payload)
|
||||
val call = localClient.newCall(request)
|
||||
upstreamCall = call
|
||||
try {
|
||||
call.execute().use { response ->
|
||||
try {
|
||||
controlSocket.send(if (response.isSuccessful) "ok" else "fail")
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: WebSocket send failed for unary response socketId=$socketId",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: unary request failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
try {
|
||||
controlSocket.send("fail")
|
||||
} catch (_: Exception) {
|
||||
// WebSocket may already be closed
|
||||
}
|
||||
} finally {
|
||||
upstreamCall = null
|
||||
try {
|
||||
controlSocket.close(1000, "request done")
|
||||
} catch (_: Exception) {
|
||||
debug("BrowserDialer: WebSocket already closed socketId=$socketId"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binaryHandler = { body -> executeRequest(body.takeIf { it.isNotEmpty() }) }
|
||||
textHandler = { body -> executeRequest(body.toByteArray().takeIf { it.isNotEmpty() }) }
|
||||
}
|
||||
|
||||
private fun buildRequest(task: BrowserDialerTask, payload: ByteArray?): Request {
|
||||
val requestBuilder = Request.Builder().url(task.url)
|
||||
// task.extra.headers.forEach { (key, value) -> requestBuilder.header(key, value) }
|
||||
// Just set no cache headers
|
||||
requestBuilder.header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
task.extra.referrer?.takeIf { it.isNotBlank() }
|
||||
?.let { requestBuilder.header("Referer", it) }
|
||||
|
||||
val method = task.method.uppercase()
|
||||
val methodAllowsBody = method !in METHODS_WITHOUT_BODY
|
||||
val body = when {
|
||||
methodAllowsBody && payload != null && payload.isNotEmpty() -> payload.toRequestBody(null)
|
||||
methodAllowsBody -> ByteArray(0).toRequestBody(null)
|
||||
else -> null
|
||||
}
|
||||
requestBuilder.method(method, body)
|
||||
return requestBuilder.build()
|
||||
}
|
||||
|
||||
private fun failAndClose(socket: WebSocket, code: Int, reason: String) {
|
||||
try {
|
||||
socket.send("fail")
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: failed to send fail message", e)
|
||||
}
|
||||
try {
|
||||
socket.close(code, reason)
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: failed to close socket", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class BrowserDialerTask(
|
||||
val method: String,
|
||||
val url: String,
|
||||
val streamResponse: Boolean,
|
||||
val extra: Extra
|
||||
) {
|
||||
data class Extra(
|
||||
// val headers: Map<String, String> = emptyMap(),
|
||||
// val cookies: Map<String, String> = emptyMap(),
|
||||
val protocols: List<String> = emptyList(),
|
||||
val referrer: String? = null
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun parse(payload: String): BrowserDialerTask? {
|
||||
return runCatching {
|
||||
val root = JSONObject(payload)
|
||||
val method = root.optString("method")
|
||||
val url = root.optString("url")
|
||||
if (method.isBlank() || url.isBlank()) return null
|
||||
|
||||
val streamResponse = root.optBoolean("streamResponse", false)
|
||||
val extraObject = root.optJSONObject("extra")
|
||||
// val headers = extraObject.optStringMap("headers")
|
||||
// val cookies = extraObject.optStringMap("cookies")
|
||||
val referrer = extraObject?.optString("referrer")?.takeIf { it.isNotBlank() }
|
||||
val protocols = extraObject.optProtocols()
|
||||
|
||||
BrowserDialerTask(
|
||||
method = method,
|
||||
url = url,
|
||||
streamResponse = streamResponse,
|
||||
extra = Extra(
|
||||
// headers = headers,
|
||||
// cookies = cookies,
|
||||
protocols = protocols,
|
||||
referrer = referrer
|
||||
)
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
// private fun JSONObject?.optStringMap(name: String): Map<String, String> {
|
||||
// val child = this?.optJSONObject(name) ?: return emptyMap()
|
||||
// val map = LinkedHashMap<String, String>()
|
||||
// val iter = child.keys()
|
||||
// while (iter.hasNext()) {
|
||||
// val key = iter.next()
|
||||
// map[key] = child.optString(key)
|
||||
// }
|
||||
// return map
|
||||
// }
|
||||
|
||||
private fun JSONObject?.optProtocols(): List<String> {
|
||||
val raw = this?.opt("protocol") ?: return emptyList()
|
||||
return when (raw) {
|
||||
is String -> raw.takeIf { it.isNotBlank() }?.let { listOf(it) } ?: emptyList()
|
||||
is JSONArray -> buildList {
|
||||
for (i in 0 until raw.length()) {
|
||||
val item = raw.optString(i)
|
||||
if (item.isNotBlank()) {
|
||||
add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
class DialerWebviewService : IDialerService {
|
||||
private var webView: WebView? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val keepAliveInterval = 30_000L // 30 seconds
|
||||
|
||||
private val keepAliveRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
webView?.let {
|
||||
it.resumeTimers()
|
||||
it.onResume()
|
||||
}
|
||||
handler.postDelayed(this, keepAliveInterval)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Starts the WebView.
|
||||
* @param context Service context
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun start(context: Context, dialerAddr: String) {
|
||||
if (webView != null) stop()
|
||||
if (dialerAddr.isEmpty()) return
|
||||
val dialerUrl = "http://$dialerAddr/"
|
||||
|
||||
webView = WebView(context.applicationContext).apply {
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
// Allow JS to run even if not triggered by user
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
// Prevent aggressive caching issues
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
view?.onResume()
|
||||
view?.resumeTimers()
|
||||
}
|
||||
}
|
||||
|
||||
loadUrl(dialerUrl)
|
||||
}
|
||||
|
||||
handler.post(keepAliveRunnable)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
handler.removeCallbacks(keepAliveRunnable)
|
||||
webView?.apply {
|
||||
stopLoading()
|
||||
pauseTimers()
|
||||
// Important to call onPause to stop internal Chromium threads properly
|
||||
onPause()
|
||||
destroy()
|
||||
}
|
||||
webView = null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface IDialerService {
|
||||
fun start(context: Context, dialerAddr: String)
|
||||
fun stop()
|
||||
}
|
||||
@@ -85,6 +85,9 @@ class ServerActivity : BaseActivity() {
|
||||
private val xhttpMode: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.xhttp_mode)
|
||||
}
|
||||
private val browserDialerModes: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.browser_dialer_mode)
|
||||
}
|
||||
|
||||
|
||||
// Kotlin synthetics was used, but since it is removed in 1.8. We switch to old manual approach.
|
||||
@@ -140,6 +143,8 @@ class ServerActivity : BaseActivity() {
|
||||
private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) }
|
||||
private val et_pinned_ca256: EditText? by lazy { findViewById(R.id.et_pinned_ca256) }
|
||||
private val container_pinned_ca256: LinearLayout? by lazy { findViewById(R.id.lay_pinned_ca256) }
|
||||
private val layout_browser_dialer: LinearLayout? by lazy { findViewById(R.id.layout_browser_dialer) }
|
||||
private val sp_browser_dialer_mode: Spinner? by lazy { findViewById(R.id.sp_browser_dialer_mode) }
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -255,6 +260,13 @@ class ServerActivity : BaseActivity() {
|
||||
NetworkType.XHTTP.type -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
|
||||
layout_browser_dialer?.visibility =
|
||||
when (networks[position]) {
|
||||
NetworkType.WS.type -> View.VISIBLE
|
||||
NetworkType.XHTTP.type -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
@@ -412,6 +424,12 @@ class ServerActivity : BaseActivity() {
|
||||
if (network >= 0) {
|
||||
sp_network?.setSelection(network)
|
||||
}
|
||||
|
||||
val browserDialerMode = Utils.arrayFind(browserDialerModes, config.browserDialerMode.orEmpty())
|
||||
if (browserDialerMode >= 0) {
|
||||
sp_browser_dialer_mode?.setSelection(browserDialerMode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -440,6 +458,7 @@ class ServerActivity : BaseActivity() {
|
||||
et_local_address?.text =
|
||||
Utils.getEditable(WIREGUARD_LOCAL_ADDRESS_V4)
|
||||
et_local_mtu?.text = Utils.getEditable(WIREGUARD_LOCAL_MTU)
|
||||
sp_browser_dialer_mode?.setSelection(0)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -572,6 +591,17 @@ class ServerActivity : BaseActivity() {
|
||||
profileItem.finalMask = et_fm?.text?.toString()?.trim()?.nullIfBlank()
|
||||
profileItem.kcpMtu = et_kcp_mtu?.text?.toString()?.toIntOrNull()
|
||||
profileItem.kcpTti = et_kcp_tti?.text?.toString()?.toIntOrNull()
|
||||
if (networks[network] == NetworkType.WS.type || networks[network] == NetworkType.XHTTP.type) {
|
||||
val browserDialerMode = browserDialerModes[sp_browser_dialer_mode?.selectedItemPosition ?: 0]
|
||||
if (browserDialerMode != browserDialerModes[0]) {
|
||||
profileItem.browserDialerMode = browserDialerMode
|
||||
} else {
|
||||
profileItem.browserDialerMode = null
|
||||
}
|
||||
}
|
||||
else {
|
||||
profileItem.browserDialerMode = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveTls(config: ProfileItem) {
|
||||
|
||||
@@ -488,6 +488,16 @@ object Utils {
|
||||
throw IOException("no free port found")
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a random free port.
|
||||
*
|
||||
* @return A random free port.
|
||||
* @throws IOException If no free port is found.
|
||||
*/
|
||||
fun findRandomFreePort(): Int {
|
||||
return ServerSocket(0).use { it.localPort }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid subscription URL.
|
||||
*
|
||||
|
||||
@@ -161,25 +161,54 @@
|
||||
android:minLines="4" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_fm"
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_fm"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_final_mask" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_fm"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
android:gravity="top"
|
||||
android:inputType="textMultiLine"
|
||||
android:maxLines="20"
|
||||
android:minLines="4" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_final_mask" />
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_browser_dialer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_fm"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top"
|
||||
android:inputType="textMultiLine"
|
||||
android:maxLines="20"
|
||||
android:minLines="4" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_browser_dialer" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/sp_browser_dialer_mode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:layout_marginBottom="@dimen/padding_spacing_dp16"
|
||||
android:entries="@array/browser_dialer_mode" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp4"
|
||||
android:text="@string/server_lab_browser_dialer_tip"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -118,6 +118,8 @@
|
||||
<string name="server_lab_final_mask">FinalMask 原始 JSON 格式: { FinalMaskObject }</string>
|
||||
<string name="server_lab_ech_config_list">EchConfigList</string>
|
||||
<string name="server_lab_pinned_ca256">证书指纹 (SHA-256)</string>
|
||||
<string name="server_lab_browser_dialer">启用浏览器转发</string>
|
||||
<string name="server_lab_browser_dialer_tip">仅支持 xhttp (packet-up) 和 ws。与优选域名冲突,utls, alpn, ech 等 TLS 设置将被忽略</string>
|
||||
|
||||
<!-- UserAssetActivity -->
|
||||
<string name="toast_asset_copy_failed">失败, 请使用文件管理器</string>
|
||||
|
||||
@@ -117,6 +117,12 @@
|
||||
<item>VPN</item>
|
||||
<item>Proxy only</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="browser_dialer_mode_value" translatable="false">
|
||||
<item>Disable</item>
|
||||
<item>OkHttp</item>
|
||||
<item>WebView</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="hev_tunnel_loglevel" translatable="false">
|
||||
<item>error</item>
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
|
||||
<string name="server_lab_ech_config_list">EchConfigList</string>
|
||||
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
|
||||
<string name="server_lab_browser_dialer">Enable Browser Dialer</string>
|
||||
<string name="server_lab_browser_dialer_tip">Only supports xhttp (packet-up) and ws outbound; TLS-related settings may be ignored or conflict</string>
|
||||
|
||||
<!-- UserAssetActivity -->
|
||||
<string name="toast_asset_copy_failed">File copy failed, please use File Manager</string>
|
||||
@@ -427,6 +429,12 @@
|
||||
<item>Proxy only</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="browser_dialer_mode">
|
||||
<item>Disable</item>
|
||||
<item>OkHttp</item>
|
||||
<item>WebView</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="ui_mode_night">
|
||||
<item>Follow system</item>
|
||||
<item>Light</item>
|
||||
|
||||
Reference in new issue
Block a user