117 lines
4.5 KiB
Kotlin
117 lines
4.5 KiB
Kotlin
package com.opendance.studio.net
|
||
|
||
import android.util.Log
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.async
|
||
import kotlinx.coroutines.awaitAll
|
||
import kotlinx.coroutines.coroutineScope
|
||
import kotlinx.coroutines.flow.Flow
|
||
import kotlinx.coroutines.flow.flow
|
||
import kotlinx.coroutines.flow.flowOn
|
||
import kotlinx.coroutines.sync.Semaphore
|
||
import kotlinx.coroutines.sync.withPermit
|
||
import kotlinx.coroutines.withContext
|
||
import java.net.Inet4Address
|
||
import java.net.InetSocketAddress
|
||
import java.net.NetworkInterface
|
||
import java.net.Socket
|
||
|
||
/**
|
||
* T-AND-006:主机发现
|
||
*
|
||
* Windows 客户端是**裸 WebSocket 服务端**(不是 HTTP 服务),因此不能靠扫描
|
||
* HTTP 端点发现;这里用「TCP 能否连上 8765 端口」作为探测手段。
|
||
*
|
||
* 策略:取手机在 WiFi 网的 IPv4 地址,推导 /24 网段,并发探测该网段内所有
|
||
* 主机的目标端口(默认并发 32、单机超时 300ms,整段约 2~3 秒)。
|
||
*/
|
||
object HostDiscovery {
|
||
|
||
private const val TAG = "HostDiscovery"
|
||
const val DEFAULT_PORT = 8765
|
||
private const val CONNECT_TIMEOUT_MS = 300
|
||
private const val MAX_CONCURRENCY = 32
|
||
|
||
/** 发现结果 */
|
||
data class Candidate(val ip: String, val port: Int, val latencyMs: Long)
|
||
|
||
/** 手机自身的 WiFi IPv4 地址(形如 192.168.1.23),无网络时返回 null */
|
||
suspend fun localIpv4Address(): String? = withContext(Dispatchers.IO) {
|
||
try {
|
||
val candidates = mutableListOf<String>()
|
||
for (nif in NetworkInterface.getNetworkInterfaces()) {
|
||
if (!nif.isUp || nif.isLoopback) continue
|
||
val name = nif.name.lowercase()
|
||
// 排除蜂窝与隧道网卡,优先 WiFi / 以太网
|
||
if (name.startsWith("rmnet") || name.startsWith("dummy") || name.startsWith("tun")) continue
|
||
|
||
for (address in nif.inetAddresses) {
|
||
if (address !is Inet4Address || address.isLoopbackAddress) continue
|
||
val ip = address.hostAddress ?: continue
|
||
// 排除链路本地地址(169.254.x.x)
|
||
if (ip.startsWith("169.254.")) continue
|
||
candidates.add(ip)
|
||
}
|
||
}
|
||
// 优先返回私有网段地址;否则退回第一个可用地址
|
||
candidates.firstOrNull { isPrivateIpv4(it) } ?: candidates.firstOrNull()
|
||
} catch (e: Exception) {
|
||
Log.w(TAG, "获取本机地址失败:${e.message}")
|
||
null
|
||
}
|
||
}
|
||
|
||
/** 判断是否为 RFC1918 私有地址 */
|
||
fun isPrivateIpv4(ip: String): Boolean {
|
||
val parts = ip.split(".").mapNotNull { it.toIntOrNull() }
|
||
if (parts.size != 4) return false
|
||
val (a, b) = parts[0] to parts[1]
|
||
return a == 10 ||
|
||
(a == 172 && b in 16..31) ||
|
||
(a == 192 && b == 168)
|
||
}
|
||
|
||
/** 由本机地址推导 /24 前缀,如 192.168.1.23 → "192.168.1" */
|
||
fun subnetPrefix(ip: String): String? {
|
||
val parts = ip.split(".")
|
||
if (parts.size != 4) return null
|
||
return parts.take(3).joinToString(".")
|
||
}
|
||
|
||
/**
|
||
* 扫描网段。以 Flow 形式逐步产出候选,便于 UI 边扫边显示。
|
||
*/
|
||
fun scan(prefix: String, port: Int = DEFAULT_PORT): Flow<Candidate> = flow {
|
||
val semaphore = Semaphore(MAX_CONCURRENCY)
|
||
val candidates = coroutineScope {
|
||
(1..254).map { last ->
|
||
async(Dispatchers.IO) {
|
||
semaphore.withPermit {
|
||
probe("$prefix.$last", port)
|
||
}
|
||
}
|
||
}.awaitAll()
|
||
}
|
||
// 按延迟排序,最快响应的最可能是目标机
|
||
candidates.filterNotNull().sortedBy { it.latencyMs }.forEach { emit(it) }
|
||
}.flowOn(Dispatchers.IO)
|
||
|
||
/** 探测单个主机:能建立 TCP 连接即认为端口开放 */
|
||
private fun probe(ip: String, port: Int): Candidate? {
|
||
val started = System.currentTimeMillis()
|
||
return try {
|
||
Socket().use { socket ->
|
||
socket.connect(InetSocketAddress(ip, port), CONNECT_TIMEOUT_MS)
|
||
Candidate(ip, port, System.currentTimeMillis() - started)
|
||
}
|
||
} catch (e: Exception) {
|
||
null
|
||
}
|
||
}
|
||
|
||
/** 单个主机连通性检查(连接页「测试连接」用) */
|
||
suspend fun checkHost(ip: String, port: Int = DEFAULT_PORT): Candidate? = withContext(Dispatchers.IO) {
|
||
probe(ip, port)
|
||
}
|
||
}
|