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
+19 -4
View File
@@ -98,10 +98,25 @@ $BT/apksigner verify 目标名.apk
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
- **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载) - **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
- **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建 - **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
- **设备 MAC**:统一由 `DeviceMac` 解析**当前在用网卡**的硬件地址(12 位小写十六进制)。优先
`wlan*`/`eth*`/`en*`,忽略虚拟/蜂窝/VPN 接口;Android 6 起 `WifiInfo.getMacAddress()` 恒为
`02:00:00:00:00:00`,因此直接读 `NetworkInterface.getHardwareAddress()`;读出 `02:00:00:00:00:00`
或全 0 视为无效。读取失败回退 `DeviceMac.FALLBACK_MAC`(历史抓包值),保证身份永不为空。
结果按进程缓存,网络切换时由 `remoteNetworkMonitor` 调用 `DeviceMac.refresh()` 重解析。
消费方:`KtvJsBridge.DEVICE_MAC`(JS 端 `mac`)、whoami 心跳的 `_mac`/`androidid`/`_cid`
(抓包时三者同值,保持一致)
- **播放信息实时刷新**:`refreshNowPlayingLabels()` 是播放界面左上角「当前播放 / 下一首」、首页播放条、
旧版播放区与顶部状态栏的**唯一**刷新入口,只在文本变化时写 View;已挂到 `lyricTicker`(500ms)上,
因此队列变化(本地点歌 / 扫码点歌 / 自动切歌)最迟 500ms 内必然同步。新增播放信息展示时请复用它,
不要另起刷新路径
- **远程日志**:接入 GlogCenter 集中式日志(HTTP 接口,单条 JSON)。**域名属敏感信息,不得写入任何文档** - **远程日志**:接入 GlogCenter 集中式日志(HTTP 接口,单条 JSON)。**域名属敏感信息,不得写入任何文档**
(端点常量见代码 `MiliLog.ENDPOINT`,需要时查代码,勿在 README/AGENTS 中记录域名)。字段: (端点常量见代码 `MiliLog.ENDPOINT`,需要时查代码,勿在 README/AGENTS 中记录域名)。字段:
`system`/`date`/`text`/`servername`/`serverip`/`loglevel`/`traceid`/`user`;`date` 必须为 `system`/`date`/`text`/`servername`/`serverip`/`clientip`/`loglevel`/`traceid`/`user`;`date` 必须为
`yyyy-MM-dd HH:mm:ss.SSS`,否则按时间检索异常。客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试), `yyyy-MM-dd HH:mm:ss.SSS`,否则按时间检索异常。其中 `serverip` = **主机 IP**(公网出口 IP,
调用点 `MiliLog.info/notice/warn/error` 保持不变;设备 IP/标识通过 `MiliLog.updateDeviceContext()` 注入, 由 `MiliLog.PUBLIC_IP_URL` 查询,结果缓存 5 分钟、失败 1 分钟后重试,查询在独立后台线程,
网络变化时自动刷新 绝不在主线程发起),`clientip` = **客户端 IP**(设备当前网卡获取到的局域网 IP,由
`MiliLog.updateDeviceContext(ip = ...)` 注入,网络变化时自动刷新并顺带复核公网 IP)。
客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error`
保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、
设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403) - **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403)
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv" applicationId "com.local.ktv"
minSdk 21 minSdk 21
targetSdk 25 targetSdk 25
versionCode 6 versionCode 7
versionName "1.3.0" versionName "1.3.1"
ndk { ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
@@ -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 }
}
@@ -42,9 +42,10 @@ class KtvJsBridge(context: Context) {
"https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js" "https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js"
private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024 private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
private const val JS_REQUEST_TIMEOUT_MS = 180_000 private const val JS_REQUEST_TIMEOUT_MS = 180_000
// 当前歌曲源 w.w345.my 使用的设备标识(mac + sn)。与用户抓包示例一致, // 歌曲源(w.w345.my 与 e.ac19.cn 同后台)按 device 识别; 随机/变化的 device 会被风控。
// w.w345.my 与 e.ac19.cn 同后台,按 device 识别; 随机/变化的 device 会被风控。 // mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
private const val DEVICE_MAC = "ae60e9547958" // sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
private val DEVICE_MAC: String get() = DeviceMac.effective()
private const val DEVICE_SN = "2d7bb4a7d7f7" private const val DEVICE_SN = "2d7bb4a7d7f7"
} }
@@ -144,7 +145,7 @@ class KtvJsBridge(context: Context) {
Promise.resolve().then(async function() { Promise.resolve().then(async function() {
if (!window.api) { if (!window.api) {
// 歌曲源按 device 识别:随机/每次更换的 device 会被判"异常行为"。 // 歌曲源按 device 识别:随机/每次更换的 device 会被判"异常行为"。
// 固定使用抓包注册设备的 mac+sn(与抓包请求一致),并保持版本号一致。 // 使用本机当前网卡 mac + 抓包注册的 sn(会话内保持稳定),并保持版本号一致。
if (window.KtvApiConfig) { if (window.KtvApiConfig) {
window.KtvApiConfig.VER = '2.0'; window.KtvApiConfig.VER = '2.0';
window.KtvApiConfig.VN = '4.1.3.03281430'; window.KtvApiConfig.VN = '4.1.3.03281430';
+78 -58
View File
@@ -162,7 +162,8 @@ class MainActivity : AppCompatActivity() {
startRemoteServer() startRemoteServer()
updateMobileQrOverlay() updateMobileQrOverlay()
updateFullScreenQrOverlay() updateFullScreenQrOverlay()
// 网络/IP 变化后同步日志上报的 serverip。 // 网络切换后重新解析当前网卡 MAC,并同步日志上报的 IP/设备标识。
DeviceMac.refresh()
refreshLogDeviceContext() refreshLogDeviceContext()
} }
main.postDelayed(this, 5000) main.postDelayed(this, 5000)
@@ -245,6 +246,9 @@ class MainActivity : AppCompatActivity() {
override fun run() { override fun run() {
updateFullScreenProgress() updateFullScreenProgress()
updateLyricView() updateLyricView()
// 播放界面左上角的「当前播放 / 下一首」实时刷新: 无论队列是本地点歌、
// 手机扫码点歌还是自动切歌而变化, 500ms 内必然同步到界面。
refreshNowPlayingLabels()
main.postDelayed(this, 500) main.postDelayed(this, 500)
} }
} }
@@ -3471,16 +3475,16 @@ class MainActivity : AppCompatActivity() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val sb = StringBuilder("http://hbeat.ktvsky.com/whoami?_src=600007&_src_at=600001") val sb = StringBuilder("http://hbeat.ktvsky.com/whoami?_src=600007&_src_at=600001")
sb.append("&_m=").append(java.net.URLEncoder.encode(Build.MODEL, "UTF-8")) sb.append("&_m=").append(java.net.URLEncoder.encode(Build.MODEL, "UTF-8"))
sb.append("&_mac=").append(LEGACY_DEVICE_ID) sb.append("&_mac=").append(deviceMac())
sb.append("&_vc=4&_vn=4.1.3.03281430") sb.append("&_vc=4&_vn=4.1.3.03281430")
sb.append("&_pkn=").append(LEGACY_PACKAGE) sb.append("&_pkn=").append(LEGACY_PACKAGE)
sb.append("&_player=0&_boot=").append(if (boot) 1 else 0) sb.append("&_player=0&_boot=").append(if (boot) 1 else 0)
sb.append("&_ts=").append(now) sb.append("&_ts=").append(now)
sb.append("&_api=").append(Build.VERSION.SDK_INT) sb.append("&_api=").append(Build.VERSION.SDK_INT)
sb.append("&ktv_id=").append(LEGACY_KTV_ID) sb.append("&ktv_id=").append(LEGACY_KTV_ID)
sb.append("&androidid=").append(LEGACY_DEVICE_ID) sb.append("&androidid=").append(deviceMac())
sb.append("&_manuf=").append(java.net.URLEncoder.encode(Build.MANUFACTURER, "UTF-8")) sb.append("&_manuf=").append(java.net.URLEncoder.encode(Build.MANUFACTURER, "UTF-8"))
sb.append("&_cid=A_").append(LEGACY_DEVICE_ID) sb.append("&_cid=A_").append(deviceMac())
sb.append("&d_name=").append(java.net.URLEncoder.encode(Build.PRODUCT, "UTF-8")) sb.append("&d_name=").append(java.net.URLEncoder.encode(Build.PRODUCT, "UTF-8"))
sb.append("&d_device=").append(java.net.URLEncoder.encode(Build.DEVICE, "UTF-8")) sb.append("&d_device=").append(java.net.URLEncoder.encode(Build.DEVICE, "UTF-8"))
sb.append("&d_board=").append(java.net.URLEncoder.encode(Build.BOARD, "UTF-8")) sb.append("&d_board=").append(java.net.URLEncoder.encode(Build.BOARD, "UTF-8"))
@@ -3507,50 +3511,57 @@ class MainActivity : AppCompatActivity() {
}, "ktv-heartbeat").start() }, "ktv-heartbeat").start()
} }
/** 远程日志: 把设备 IP 与设备标识同步给日志客户端(GlogCenter 的 serverip/user/traceid)。 */ /** 当前设备 MAC(12 位小写十六进制): 本机当前在用网卡, 读不到时回退历史抓包值。 */
private fun deviceMac(): String = DeviceMac.effective()
/** 远程日志: 把设备网卡 IP 与设备标识同步给日志客户端(GlogCenter 的 clientip/user/traceid)。 */
private fun refreshLogDeviceContext() { private fun refreshLogDeviceContext() {
MiliLog.updateDeviceContext(ip = localIp(), deviceId = LEGACY_DEVICE_ID) MiliLog.updateDeviceContext(ip = localIp(), deviceId = deviceMac())
} }
/** 远程日志: App 启动时上报设备信息与网络信息(异步, 尽力而为)。 */ /** 远程日志: App 启动时上报设备信息(异步, 尽力而为)。 */
private fun reportDeviceBootInfo() { private fun reportDeviceBootInfo() {
Thread({ Thread({
try { try {
val sb = StringBuilder() val sb = StringBuilder()
sb.append("BOOT pkg=").append(packageName) sb.append("BOOT")
sb.append(" pkg=").append(packageName)
sb.append(" ver=").append(BuildConfig.VERSION_NAME) sb.append(" ver=").append(BuildConfig.VERSION_NAME)
sb.append(" device=").append(Build.BRAND).append(' ').append(Build.MODEL) sb.append(" install=").append(installTimeText())
sb.append(" android=").append(Build.VERSION.RELEASE).append("(api").append(Build.VERSION.SDK_INT).append(')') sb.append(" brand=").append(Build.BRAND)
sb.append(" abi=").append(Build.SUPPORTED_ABIS?.joinToString(",")) sb.append(" model=").append(Build.MODEL)
// 歌曲源设备身份(与抓包请求一致) sb.append(" device=").append(Build.DEVICE)
sb.append(" mac=").append(LEGACY_DEVICE_ID).append(" ktv=").append(LEGACY_KTV_ID) sb.append(" android=").append(Build.VERSION.RELEASE)
sb.append(" ip=").append(localIp()) .append("(api").append(Build.VERSION.SDK_INT).append(')')
// 网关 / DNS / WiFi 基础信息 sb.append(" abi=").append(Build.SUPPORTED_ABIS?.joinToString(",").orEmpty())
try { sb.append(" kernel=").append(System.getProperty("os.version").orEmpty())
val wm = getSystemService(WIFI_SERVICE) as android.net.wifi.WifiManager sb.append(" screen=").append(screenResolutionText())
val info = wm.connectionInfo // 等主机 IP(公网出口 IP)就绪,避免启动这条日志的 serverip 为空。
if (info != null) { MiliLog.awaitServerIp()
val ssid = info.ssid?.replace("\"", "")
sb.append(" wifi=").append(ssid)
sb.append(" rssi=").append(info.rssi)
}
} catch (ignored: Exception) {
}
try {
val dm = getSystemService(CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
val network = dm.activeNetwork
if (network != null) {
sb.append(" net=").append(dm.getNetworkCapabilities(network)?.toString().orEmpty())
}
} catch (ignored: Exception) {
}
sb.append(" bootTs=").append(System.currentTimeMillis())
MiliLog.notice(sb.toString()) MiliLog.notice(sb.toString())
} catch (ignored: Exception) { } catch (ignored: Exception) {
} }
}, "ktv-boot-log").start() }, "ktv-boot-log").start()
} }
/** 应用安装时间(yyyy-MM-dd HH:mm:ss); 取不到返回 unknown。 */
private fun installTimeText(): String = try {
val info = packageManager.getPackageInfo(packageName, 0)
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT).format(Date(info.firstInstallTime))
} catch (e: Exception) {
"unknown"
}
/** 屏幕物理分辨率(宽x高); 取不到返回 unknown。 */
private fun screenResolutionText(): String = try {
val metrics = android.util.DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
metrics.widthPixels.toString() + "x" + metrics.heightPixels
} catch (e: Exception) {
"unknown"
}
private fun remoteApi( private fun remoteApi(
method: String, method: String,
path: String, path: String,
@@ -7084,33 +7095,39 @@ class MainActivity : AppCompatActivity() {
* 同步当前播放信息到首页和子页面的播放器覆盖层 * 同步当前播放信息到首页和子页面的播放器覆盖层
*/ */
private fun syncPlayerInfo() { private fun syncPlayerInfo() {
val title = if (currentSong == null) "未播放" else currentSong!!.title refreshNowPlayingLabels()
val singer = if (currentSong == null) "" else currentSong!!.singer
if (homePlayerTitle != null) homePlayerTitle!!.text = currentAndNextText()
if (homePlayerSinger != null) homePlayerSinger!!.setText(singer)
if (playerSongTitle != null) playerSongTitle!!.setText(title)
if (playerSongSinger != null) playerSongSinger!!.setText(singer)
// 顶部状态栏 - 当前/下首
if (textCurrentSongTip != null) {
val cur =
"当前: " + title + (if (singer == null || singer.isEmpty()) "" else " - " + singer)
val nxt = if (orderQueue!!.size >= 2)
orderQueue.get(1).title + (if (orderQueue.get(1).singer == null || orderQueue.get(
1
).singer!!.isEmpty()
) "" else " - " + orderQueue.get(1).singer)
else
"无"
textCurrentSongTip!!.setText(cur + " 下首: " + nxt)
}
fullScreenSongInfo?.text = currentAndNextText()
updateOrderBadge() updateOrderBadge()
if (browseMode == "ordered" || browseMode == "sang" || browseMode == "downloads") { if (browseMode == "ordered" || browseMode == "sang" || browseMode == "downloads") {
renderSongList() renderSongList()
} }
} }
private fun currentAndNextText(): String { /**
* 实时刷新「当前播放 / 下一首」文本。
*
* 覆盖播放界面左上角、首页播放条、旧版播放区与顶部状态栏四处; 每次只在文本
* 真正变化时才写 View, 因此可以安全地由 `lyricTicker` 每 500ms 调用一次。
*/
private fun refreshNowPlayingLabels() {
val nowNext = nowNextText()
val combined = nowNext.combined()
if (fullScreenSongInfo?.text?.toString() != combined) fullScreenSongInfo?.text = combined
if (homePlayerTitle?.text?.toString() != combined) homePlayerTitle?.text = combined
val singer = currentSong?.singer.orEmpty()
if (homePlayerSinger?.text?.toString() != singer) homePlayerSinger?.setText(singer)
val title = currentSong?.title ?: "未播放"
if (playerSongTitle?.text?.toString() != title) playerSongTitle?.setText(title)
if (playerSongSinger?.text?.toString() != singer) playerSongSinger?.setText(singer)
val tip = "当前: " + nowNext.current + " 下首: " + nowNext.next
if (textCurrentSongTip?.text?.toString() != tip) textCurrentSongTip?.setText(tip)
}
/** 「当前播放 / 下一首」文本对(下一首按当前曲目在队列中的位置取其后一首)。 */
private data class NowNextText(val current: String, val next: String) {
fun combined(): String = "当前:$current 下一首:$next"
}
private fun nowNextText(): NowNextText {
val current = currentSong?.let { song -> val current = currentSong?.let { song ->
song.title.orEmpty() + song.singer?.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty() song.title.orEmpty() + song.singer?.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty()
} ?: "未播放" } ?: "未播放"
@@ -7121,9 +7138,11 @@ class MainActivity : AppCompatActivity() {
else -> orderQueue?.firstOrNull { currentSong?.equals(it) != true } else -> orderQueue?.firstOrNull { currentSong?.equals(it) != true }
}?.let { song -> song.title.orEmpty() + song.singer?.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty() } }?.let { song -> song.title.orEmpty() + song.singer?.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty() }
?: "无" ?: "无"
return "当前:$current 下一首:$next" return NowNextText(current, next)
} }
private fun currentAndNextText(): String = nowNextText().combined()
/** 顶栏搜索按钮 - 跳到搜索页 */ /** 顶栏搜索按钮 - 跳到搜索页 */
private fun showSearchPage() { private fun showSearchPage() {
searchScope = "song" searchScope = "song"
@@ -10229,9 +10248,10 @@ class MainActivity : AppCompatActivity() {
// ===== 歌曲源设备身份(用于 w.w345.my 等歌曲源的保活/认证伪装) ===== // ===== 歌曲源设备身份(用于 w.w345.my 等歌曲源的保活/认证伪装) =====
// 歌曲源通过共享后台按 device 判定合法性, App 会向 hbeat.ktvsky.com // 歌曲源通过共享后台按 device 判定合法性, App 会向 hbeat.ktvsky.com
// 周期心跳(whoami)维持设备活跃;本 App 若不做心跳,设备可能被判为不合法而限流。 // 周期心跳(whoami)维持设备活跃;本 App 若不做心跳,设备可能被判为不合法而限流。
// 设备标识与抓包请求一致(mac + sn 见 KtvJsBridge.DEVICE_MAC/DEVICE_SN)。 // 设备 MAC 取本机「当前在用网卡」的硬件地址(见 DeviceMac, 读不到时回退历史抓包值);
// 抓包时原版 App 的 _mac / androidid / _cid 三者同值, 这里保持该一致性。
// sn 见 KtvJsBridge.DEVICE_SN。
private const val LEGACY_PACKAGE = "com.origjoy.local.ktv" private const val LEGACY_PACKAGE = "com.origjoy.local.ktv"
private const val LEGACY_DEVICE_ID = "ae60e9547958" // androidid / _mac
private const val LEGACY_KTV_ID = "10006789" // 门店/包厢 id private const val LEGACY_KTV_ID = "10006789" // 门店/包厢 id
private const val HEARTBEAT_INTERVAL_MS = 3600_000L private const val HEARTBEAT_INTERVAL_MS = 3600_000L
+114 -3
View File
@@ -23,7 +23,8 @@ import java.util.concurrent.atomic.AtomicBoolean
* date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致) * date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致)
* text 日志内容 * text 日志内容
* servername 主机名(设备型号) * servername 主机名(设备型号)
* serverip 主机 IP(设备局域网 IP) * serverip 主机 IP —— 公网出口 IP,由 [PUBLIC_IP_URL] 查询,结果缓存 [PUBLIC_IP_TTL_MS]
* clientip 客户端 IP —— 设备当前网卡获取到的局域网 IP(由调用方注入)
* loglevel 日志级别(INFO / WARN / ERROR) * loglevel 日志级别(INFO / WARN / ERROR)
* traceid 追踪码(此处用设备标识) * traceid 追踪码(此处用设备标识)
* user 用户(此处用设备标识) * user 用户(此处用设备标识)
@@ -35,6 +36,16 @@ object MiliLog {
private const val TAG = "MiliLog" private const val TAG = "MiliLog"
private const val ENDPOINT = "http://log.kasugano.cn/glc/v1/log/add" 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 页面上的「系统名」,用于区分应用。 */ /** GlogCenter 页面上的「系统名」,用于区分应用。 */
private const val SYSTEM = "maidongktv" private const val SYSTEM = "maidongktv"
private const val MAX_TEXT_LEN = 900 private const val MAX_TEXT_LEN = 900
@@ -46,11 +57,18 @@ object MiliLog {
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
private val publicIpFetching = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE) private val queue = LinkedBlockingQueue<String>(MAX_QUEUE)
private var senderThread: Thread? = null private var senderThread: Thread? = null
@Volatile private var serverName: String = "android" @Volatile private var serverName: String = "android"
/** 主机 IP:公网出口 IP([PUBLIC_IP_URL] 查询,5 分钟缓存)。 */
@Volatile private var serverIp: String = "" @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 traceId: String = ""
@Volatile private var user: String = "" @Volatile private var user: String = ""
@@ -60,11 +78,21 @@ object MiliLog {
serverName = sanitize(Build.MODEL).ifEmpty { "android" } serverName = sanitize(Build.MODEL).ifEmpty { "android" }
running.set(true) running.set(true)
senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() } 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) { 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()) { if (!deviceId.isNullOrBlank()) {
user = deviceId user = deviceId
traceId = deviceId traceId = deviceId
@@ -78,6 +106,8 @@ object MiliLog {
private fun enqueue(level: String, msg: String) { private fun enqueue(level: String, msg: String) {
if (!started.get()) return if (!started.get()) return
// 公网 IP 过期时顺带后台刷新(内部有 5 分钟节流,不会频繁调用外部接口)。
ensureServerIp()
val body = buildBody(level, msg) ?: return val body = buildBody(level, msg) ?: return
// 队列满时丢最旧,保证新日志能进队。 // 队列满时丢最旧,保证新日志能进队。
if (!queue.offer(body)) { if (!queue.offer(body)) {
@@ -101,6 +131,7 @@ object MiliLog {
put("text", truncated) put("text", truncated)
put("servername", serverName) put("servername", serverName)
put("serverip", serverIp) put("serverip", serverIp)
put("clientip", clientIp)
put("loglevel", level) put("loglevel", level)
put("traceid", traceId) put("traceid", traceId)
put("user", user) put("user", user)
@@ -116,6 +147,86 @@ object MiliLog {
value.map { if (it.isLetterOrDigit() || it == '-' || it == '_' || it == ' ') it else ' ' } value.map { if (it.isLetterOrDigit() || it == '-' || it == '_' || it == ' ') it else ' ' }
.joinToString("").trim() .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() { private fun sendLoop() {