V1.3.1: 设备MAC改取当前网卡 + 播放界面当前/下一首实时刷新 + 日志IP与启动信息调整

1) 设备 MAC: 新增 DeviceMac 解析「当前在用网卡」硬件地址(优先 wlan/eth/en,
   忽略虚拟/蜂窝/VPN; 直接读 NetworkInterface.getHardwareAddress, 规避 Android 6+
   WifiInfo 恒返回 02:00:00:00:00:00 的限制), 读出无效值或失败时回退历史抓包值。
   结果按进程缓存, 网络切换时由 remoteNetworkMonitor 重解析。
   消费方: KtvJsBridge.DEVICE_MAC(JS 端 mac)、whoami 心跳 _mac/androidid/_cid。
2) 播放界面: 抽出 refreshNowPlayingLabels() 作为「当前播放/下一首」的唯一刷新入口,
   只在文本变化时写 View, 并挂到 lyricTicker(500ms), 使播放界面左上角、首页播放条、
   旧版播放区与顶部状态栏在本地点歌/扫码点歌/自动切歌后都能实时同步;
   顶部状态栏的「下首」改为按当前曲目位置取其后一首(原先固定取队列第 2 首)。
3) 远程日志: serverip 改为主机 IP(公网出口 IP, 查询后缓存 5 分钟、失败 1 分钟后重试,
   后台线程执行), 新增 clientip 字段承载设备网卡获取到的局域网 IP; 启动上报改为
   采集 10 项: 包名、版本号、安装时间、品牌、型号、设备名、Android 版本、CPU 架构、
   内核版本、屏幕分辨率。
4) 版本号 1.3.0 -> 1.3.1(versionCode 6 -> 7)。本次不发布 Release, 仅本地打包测试。
This commit is contained in:
zijunchiang
2026-09-14 02:59:21 +08:00
parent 2a3c4e6003
commit 485235e96e
6 changed files with 326 additions and 71 deletions
+114 -3
View File
@@ -23,7 +23,8 @@ import java.util.concurrent.atomic.AtomicBoolean
* date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致)
* text 日志内容
* servername 主机名(设备型号)
* serverip 主机 IP(设备局域网 IP)
* serverip 主机 IP —— 公网出口 IP,由 [PUBLIC_IP_URL] 查询,结果缓存 [PUBLIC_IP_TTL_MS]
* clientip 客户端 IP —— 设备当前网卡获取到的局域网 IP(由调用方注入)
* loglevel 日志级别(INFO / WARN / ERROR)
* traceid 追踪码(此处用设备标识)
* user 用户(此处用设备标识)
@@ -35,6 +36,16 @@ object MiliLog {
private const val TAG = "MiliLog"
private const val ENDPOINT = "http://log.kasugano.cn/glc/v1/log/add"
/** 公网 IP 查询接口(主机 IP)。 */
private const val PUBLIC_IP_URL = "https://ipinfo.io/ip"
/** 公网 IP 缓存时长:5 分钟内复用,避免频繁调用外部接口。 */
private const val PUBLIC_IP_TTL_MS = 5 * 60 * 1000L
/** 公网 IP 查询失败后的重试间隔。 */
private const val PUBLIC_IP_RETRY_MS = 60 * 1000L
private const val PUBLIC_IP_TIMEOUT_MS = 6000
/** GlogCenter 页面上的「系统名」,用于区分应用。 */
private const val SYSTEM = "maidongktv"
private const val MAX_TEXT_LEN = 900
@@ -46,11 +57,18 @@ object MiliLog {
private val started = AtomicBoolean(false)
private val running = AtomicBoolean(false)
private val publicIpFetching = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE)
private var senderThread: Thread? = null
@Volatile private var serverName: String = "android"
/** 主机 IP:公网出口 IP([PUBLIC_IP_URL] 查询,5 分钟缓存)。 */
@Volatile private var serverIp: String = ""
@Volatile private var serverIpAt: Long = 0L
/** 客户端 IP:设备当前网卡获取到的局域网 IP。 */
@Volatile private var clientIp: String = ""
@Volatile private var traceId: String = ""
@Volatile private var user: String = ""
@@ -60,11 +78,21 @@ object MiliLog {
serverName = sanitize(Build.MODEL).ifEmpty { "android" }
running.set(true)
senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() }
ensureServerIp()
}
/** 更新上报用的设备网络/身份字段(IP 变化或重新登录时调用)。 */
/**
* 更新上报用的客户端网络/身份字段。
*
* @param ip 设备当前网卡获取到的局域网 IP(clientip),变化时会顺带复核公网 IP
* @param deviceId 设备标识(同时写入 user / traceid)
*/
fun updateDeviceContext(ip: String? = null, deviceId: String? = null) {
if (!ip.isNullOrBlank()) serverIp = ip
if (!ip.isNullOrBlank() && ip != clientIp) {
clientIp = ip
// 局域网 IP 变化通常意味着换了网络,公网出口 IP 需要立即复核。
ensureServerIp(force = true)
}
if (!deviceId.isNullOrBlank()) {
user = deviceId
traceId = deviceId
@@ -78,6 +106,8 @@ object MiliLog {
private fun enqueue(level: String, msg: String) {
if (!started.get()) return
// 公网 IP 过期时顺带后台刷新(内部有 5 分钟节流,不会频繁调用外部接口)。
ensureServerIp()
val body = buildBody(level, msg) ?: return
// 队列满时丢最旧,保证新日志能进队。
if (!queue.offer(body)) {
@@ -101,6 +131,7 @@ object MiliLog {
put("text", truncated)
put("servername", serverName)
put("serverip", serverIp)
put("clientip", clientIp)
put("loglevel", level)
put("traceid", traceId)
put("user", user)
@@ -116,6 +147,86 @@ object MiliLog {
value.map { if (it.isLetterOrDigit() || it == '-' || it == '_' || it == ' ') it else ' ' }
.joinToString("").trim()
// ─── 公网 IP(主机 IP)────────────────────────────
/**
* 确保主机 IP(公网出口 IP)可用。
*
* 仅在「从未获取过 / 已超过 [PUBLIC_IP_TTL_MS] / 显式要求复核」时发起查询,
* 且同一时刻只允许一个查询在飞。查询在独立后台线程完成,绝不阻塞调用方
* (`enqueue` 可能来自主线程)。
*/
private fun ensureServerIp(force: Boolean = false) {
val age = System.currentTimeMillis() - serverIpAt
val fresh = serverIp.isNotEmpty() && !force && age < PUBLIC_IP_TTL_MS
if (fresh) return
if (!publicIpFetching.compareAndSet(false, true)) return
Thread({
try {
val ip = queryPublicIp()
if (ip.isNotEmpty()) {
serverIp = ip
serverIpAt = System.currentTimeMillis()
} else {
// 查询失败:保留旧值,但延后重试,避免每条日志都触发一次请求。
serverIpAt = System.currentTimeMillis() - PUBLIC_IP_TTL_MS + PUBLIC_IP_RETRY_MS
}
} finally {
publicIpFetching.set(false)
}
}, "mili-pubip").also { it.isDaemon = true; it.start() }
}
/**
* 等待主机 IP 就绪(最多 [timeoutMs]),供启动上报使用。
*
* 调用方必须在子线程(内部会短暂 sleep);超时未就绪则返回空串,不影响上报。
*/
fun awaitServerIp(timeoutMs: Long = 3000L): String {
ensureServerIp()
val deadline = System.currentTimeMillis() + timeoutMs
while (serverIp.isEmpty() && System.currentTimeMillis() < deadline) {
try {
Thread.sleep(100L)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
break
}
}
return serverIp
}
/** 查询公网出口 IP([PUBLIC_IP_URL] 返回纯文本 IP);失败返回空串。 */
private fun queryPublicIp(): String = try {
val connection = (URL(PUBLIC_IP_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = PUBLIC_IP_TIMEOUT_MS
readTimeout = PUBLIC_IP_TIMEOUT_MS
setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
}
try {
val code = connection.responseCode
if (code in 200..299) {
val text = connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) }.trim()
// 只接受形如 IPv4/IPv6 的纯文本,长度上限 45(IPv6 最长)。
if (text.isNotEmpty() && text.length <= 45 &&
text.all { it.isLetterOrDigit() || it == '.' || it == ':' }
) {
text
} else {
""
}
} else {
""
}
} finally {
connection.disconnect()
}
} catch (e: Throwable) {
Log.w(TAG, "public ip query failed: ${e.message}")
""
}
// ─── 发送线程 ────────────────────────────────────
private fun sendLoop() {