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
@@ -0,0 +1,108 @@
package com.local.ktv
import android.util.Log
import java.net.NetworkInterface
import java.util.Locale
/**
* 设备 MAC 解析 —— 取「当前实际在用的网卡」的硬件地址。
*
* 背景:歌曲源(w.w345.my / e.ac19.cn 等)与设备心跳(whoami)按 `device`(mac + sn)
* 识别设备。早期实现把 mac 写死为抓包设备的固定值,导致所有设备共用同一身份。
* 这里改为读取本机当前启用网卡的 MAC(12 位小写十六进制、无分隔符)。
*
* 取值优先级:
* 1) 启用中、非虚拟、名称形如 `wlan*` / `eth*` / `en*` 的网卡(有线优先于无线);
* 2) 其它启用中的非虚拟网卡;
* 3) [FALLBACK_MAC](历史抓包值,保证读不到时仍沿用旧身份,不会变成空值)。
*
* 注意:
* - Android 6 起 `WifiInfo.getMacAddress()` 恒返回 `02:00:00:00:00:00`,
* 因此这里不使用 WifiManager,直接读 [NetworkInterface.getHardwareAddress];
* - `NetworkInterface.isUp/isVirtual` 是 API 24 才有的方法,低版本上会抛错,
* 统一用 [safeUp]/[safeVirtual] 包一层,失败时按「可用/非虚拟」处理;
* - 结果按进程缓存,避免每次点歌/心跳都枚举网卡;网络切换时调用 [refresh] 重新解析。
*/
object DeviceMac {
private const val TAG = "DeviceMac"
/** 历史抓包设备 MAC:读不到真实网卡时兜底,保证设备身份始终非空。 */
const val FALLBACK_MAC = "ae60e9547958"
private const val HEX = "0123456789abcdef"
@Volatile private var cached: String? = null
/** 当前网卡 MAC(小写、无分隔符);读不到返回空串。 */
fun current(): String {
cached?.let { return it }
synchronized(this) {
cached?.let { return it }
val value = read()
cached = value
if (value.isEmpty()) Log.w(TAG, "no usable network interface mac, fallback to legacy id")
return value
}
}
/** 当前网卡 MAC;读不到时回退到 [FALLBACK_MAC]。 */
fun effective(): String = current().ifEmpty { FALLBACK_MAC }
/** 清空缓存并重新解析(网络切换 / 网卡热插拔后调用)。 */
fun refresh(): String {
synchronized(this) { cached = null }
return current()
}
private fun read(): String {
var candidate = ""
try {
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return ""
while (interfaces.hasMoreElements()) {
val nif = interfaces.nextElement() ?: continue
if (!safeUp(nif) || safeLoopback(nif) || safeVirtual(nif)) continue
val name = nif.name.orEmpty().lowercase(Locale.ROOT)
if (isIgnoredName(name)) continue
val hex = nif.hardwareAddress.toMacHex()
if (hex.isEmpty()) continue
// 有线/无线网卡直接采用;否则先记下,继续找更合适的。
if (isPreferredName(name)) return hex
if (candidate.isEmpty()) candidate = hex
}
} catch (e: Throwable) {
Log.w(TAG, "read network mac failed: ${e.message}")
}
return candidate
}
/** 虚拟/点对点/蜂窝接口不算「当前使用的网卡」。 */
private fun isIgnoredName(name: String): Boolean =
name.startsWith("tun") || name.startsWith("ppp") || name.startsWith("wg") ||
name.startsWith("rmnet") || name.startsWith("radio") || name.startsWith("ccmni") ||
name.startsWith("pdp") || name.startsWith("dummy") || name.startsWith("sit") ||
name.startsWith("ip6tnl") || name.startsWith("gre") || name.contains("vpn")
private fun isPreferredName(name: String): Boolean =
name.startsWith("wlan") || name.startsWith("eth") || name.startsWith("en")
private fun ByteArray?.toMacHex(): String {
if (this == null || size < 6) return ""
val sb = StringBuilder(size * 2)
for (b in this) {
val v = b.toInt() and 0xFF
sb.append(HEX[v shr 4]).append(HEX[v and 0x0F])
}
val hex = sb.toString()
// 全 0 与 Android 6+ 的占位 MAC 视为无效。
return if (hex == "000000000000" || hex == "020000000000") "" else hex
}
private fun safeUp(nif: NetworkInterface): Boolean =
try { nif.isUp } catch (e: Throwable) { true }
private fun safeLoopback(nif: NetworkInterface): Boolean =
try { nif.isLoopback } catch (e: Throwable) { false }
private fun safeVirtual(nif: NetworkInterface): Boolean =
try { nif.isVirtual } catch (e: Throwable) { false }
}