Files
opendance-android/app/src/main/java/com/opendance/studio/net/HostDiscovery.kt
T

117 lines
4.5 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}