diff --git a/AGENTS.md b/AGENTS.md index a5fed1b..11daaf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.4.7"` / `versionCode 27`(18~21 被本机临时诊断包占用过) +- `app/build.gradle`:`versionName "1.4.8"` / `versionCode 28`(18~21 被本机临时诊断包占用过) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 @@ -297,6 +297,23 @@ $BT/apksigner verify 目标名.apk 客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error` 保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、 设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率 + - **写日志的五条约定**(2026-09-26 依据现场日志复盘补充): + 1. **持久状态只记一次**,不要每次事件都记。反例:桥不可用曾按「每次点歌一条 WARN」输出, + 一台机器一天几百条、把日志刷满且毫无新增信息。现在由 `KtvJsBridge.markUnavailable()` + 在**状态首次变化**时记一条。 + 2. **关键行必须带全上下文**:`STORAGE`/`BOOT` 这类启动行要放在 `reportDeviceBootInfo()` 的 + 后台线程里、`refreshLogDeviceContext()` 与 `MiliLog.awaitServerIp()` **之后**, + 否则 `serverip`/`clientip`/`user`/`traceid` 全是 `undefined`(旧日志按设备检索不到)。 + 3. **不要在日志行里做重活**:`AppPaths.storageFacts()`/`diagnose()` 会真实建目录、写探针、 + 读 `/proc/mounts`,**绝不能放在主线程**(启动时曾如此)。 + 4. **日志要能自我定位**:失败行写清"哪一步/哪个源/耗时/原因",例如 + `NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch 用时=...ms`; + 桥侧由 `JS-BRIDGE ...` 说明 WebView 到底卡在哪一步(初始化失败/KtvApi 未导出/20s 超时)。 + 5. **状态样本 + 事件**缺一不可:事件(点歌/失败)说明"发生了什么",但排查"随时间变化"的 + 问题(空间变少、曲库丢失、长期离线)需要状态样本 —— 现由 6 小时一条 `HEALTH ...` + (版本/在线/可用空间/曲库大小/根目录)提供。 + - 一段典型现场日志应为:`BOOT` → `STORAGE` → (点歌)`点歌编号=... 请求源=... 状态=成功 urlHost=...` + → `PLAY ... src=local` → 每 6h `HEALTH ...`;异常时才有 `RAW-ERR`/`NET-ERR`/`NATIVE-SONG ...失败`。 - **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询) 维护全局 `online`;`MainActivity.onNetworkStateChanged()` 负责: - 断网 → 播放页右上角 `fullScreenNetworkBadge` 显示「网络未连接」(在全屏控制层 header 内、 diff --git a/app/build.gradle b/app/build.gradle index 5b1d094..2598518 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 27 - versionName "1.4.7" + versionCode 28 + versionName "1.4.8" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index 8c5a0a7..2326cf8 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -45,6 +45,8 @@ class KtvJsBridge(context: Context) { "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 JS_REQUEST_TIMEOUT_MS = 180_000 + /** WebView 初始化看门狗:超过这个时间还没就绪就判定本机 WebView 不可用。 */ + private const val BRIDGE_INIT_TIMEOUT_MS = 20_000L // 歌曲源(w.w345.my 与 e.ac19.cn 同后台)按 device 识别; 随机/变化的 device 会被风控。 // mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac); // sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。 @@ -75,6 +77,17 @@ class KtvJsBridge(context: Context) { @Volatile private var webView: WebView? = null @Volatile private var ready = false @Volatile private var destroyed = false + + /** + * 已判定「本机 WebView 不可用」。 + * + * 一旦置位,`SongApiClient` 就不再为取址白等 15 秒,直接走原生通道 + * (极米 H1S 实测每次点歌都要等满 15 秒,日志里表现为每首歌前一条 + * 「JS-BRIDGE 15s 未就绪」)。桥万一后来就绪(热更新重载等)会自动清除。 + */ + @Volatile private var unavailable = false + private var unavailableReason: String = "" + private var loadedFromCache = false private var assetRetryAttempted = false @@ -96,9 +109,17 @@ class KtvJsBridge(context: Context) { loadedFromCache = cachedScript != null createWebView(cachedScript ?: readAssetScript(), callback) updateCache(cached) + // 看门狗:onPageFinished 一直不回调(WebView 静默失效)时给出**明确原因**。 + // 否则远程日志里只会看到反复的「15s 未就绪」,不知道卡在哪一步。 + mainHandler.postDelayed({ + if (!ready && !destroyed && !unavailable) { + markUnavailable("初始化超时:onPageFinished 未回调或 KtvApi 未导出") + callback(false) + } + }, BRIDGE_INIT_TIMEOUT_MS) } catch (error: Throwable) { Log.e(TAG, "WebView init failed", error) - MiliLog.error("JS-BRIDGE WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}") + markUnavailable("WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}") callback(false) } } @@ -138,6 +159,12 @@ class KtvJsBridge(context: Context) { view.evaluateJavascript("typeof window.KtvApi === 'function'") { result -> if (result == "true") { ready = true + // 桥后来就绪(例如热更新重载成功)→ 清除"不可用"标记,恢复 JS 主路径。 + if (unavailable) { + unavailable = false + MiliLog.info("JS-BRIDGE 已恢复可用(${unavailableReason.ifEmpty { "?" }} 已解除)") + unavailableReason = "" + } Log.i(TAG, "JS runtime ready (${if (loadedFromCache) "cache" else "asset"})") MiliLog.info("JS-BRIDGE ready (${if (loadedFromCache) "cache" else "asset"})") callback(true) @@ -150,7 +177,7 @@ class KtvJsBridge(context: Context) { Log.e(TAG, "KtvApi was not exported by JS") // 老电视盒子上 WebView 不可用/被裁剪时常走这里:桥永远不就绪, // 取址只能回退 DB → 「无法生成下载地址」。必须能远程看到。 - MiliLog.error("JS-BRIDGE KtvApi 未导出,WebView 不可用") + markUnavailable("KtvApi 未导出") callback(false) } } @@ -163,6 +190,20 @@ class KtvJsBridge(context: Context) { /** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */ fun isReady(): Boolean = ready && !destroyed + /** 是否已判定本机 WebView 不可用(用于跳过必然失败的等待)。 */ + fun isUnavailable(): Boolean = unavailable && !destroyed && !ready + + /** + * 标记桥在本机不可用。**只在状态首次变化时上报一次**: + * 这是持久状态,若每次点歌都记一条会把远程日志刷满(实测每首歌一条)。 + */ + fun markUnavailable(reason: String) { + if (unavailable) return + unavailable = true + unavailableReason = reason + MiliLog.error("JS-BRIDGE 判定本机 WebView 不可用($reason),后续点歌直接走原生取址通道") + } + /** * 供**原生兜底取址解析器**([SongUrlResolver])复用的「干净请求」通道。 * diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index b350e89..05c3cfa 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -187,12 +187,32 @@ class MainActivity : AppCompatActivity() { } /** 周期向 hbeat.ktvsky.com 上报设备身份,维持歌曲源对"合法设备"的判定。 */ + private var heartbeatTicks = 0 + private val ktvHeartbeatRunnable = object : Runnable { override fun run() { sendDeviceHeartbeat(boot = false) + // 每 6 小时留一条健康快照:日志此前只有"事件"(点歌/失败/升级), + // 没有"状态样本",现场排查空间/曲库/网络随时间的变化时缺参照。 + heartbeatTicks++ + if (heartbeatTicks % 6 == 0) logHealthSnapshot() main.postDelayed(this, HEARTBEAT_INTERVAL_MS) } } + + /** 健康快照:版本 / 在线 / 可用空间 / 曲库文件 —— 单行,便于按 HEALTH 检索。 */ + private fun logHealthSnapshot() { + runCatching { + val db = AppPaths.databaseFile + val freeGb = AppPaths.databaseDir.usableSpace / 1024.0 / 1024.0 / 1024.0 + MiliLog.notice( + "HEALTH ver=${BuildConfig.VERSION_NAME} online=${KtvNetworkMonitor.online}" + + " free=${String.format(Locale.ROOT, "%.2f", freeGb)}GB" + + " dbExist=${db.exists()} dbMB=${db.length() / 1024 / 1024}" + + " root=${AppPaths.root.absolutePath}", + ) + } + } private val visibleSongs: MutableList = ArrayList() private val visibleSingers: MutableList?> = ArrayList?>() private val visiblePlaylists: MutableList> = ArrayList() @@ -470,8 +490,10 @@ class MainActivity : AppCompatActivity() { playbackEngine = KtvPlaybackEngine(applicationContext) // 远程日志系统(GlogCenter): 启动即上报设备/网络信息。 MiliLog.init(applicationContext) - // 存储根目录探测结果(排查不同盒子存储差异用;单行便于按 STORAGE 检索)。 - MiliLog.info("STORAGE ${AppPaths.storageFacts(this)}") + // ⚠️ STORAGE 探测含真实建目录/写盘与 /proc/mounts 读取,**不能放在主线程**, + // 而且必须在 refreshLogDeviceContext() 之后,否则这一行的 + // serverip/clientip/user/traceid 全是 undefined(旧日志就是这样,无法按设备检索)。 + // 现在统一在 reportDeviceBootInfo() 的后台线程里、awaitServerIp() 之后上报。 refreshLogDeviceContext() reportDeviceBootInfo() // 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403, @@ -3725,6 +3747,9 @@ class MainActivity : AppCompatActivity() { // 等主机 IP(公网出口 IP)就绪,避免启动这条日志的 serverip 为空。 MiliLog.awaitServerIp() MiliLog.notice(sb.toString()) + // 存储根目录探测结果(排查不同盒子存储差异用;单行便于按 STORAGE 检索)。 + // 放在这里而不是启动主线程:既有完整上下文,又避免了主线程磁盘探测。 + runCatching { MiliLog.notice("STORAGE ${AppPaths.storageFacts(this@MainActivity)}") } } catch (ignored: Exception) { } }, "ktv-boot-log").start() diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt index 99ef3bd..26f8c5f 100644 --- a/app/src/main/java/com/local/ktv/SongApiClient.kt +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -55,6 +55,11 @@ object SongApiClient { return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) } val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) + // 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。 + // (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。) + if (bridge.isUnavailable()) { + return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) + } // 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备 // (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。 // 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。 @@ -67,9 +72,10 @@ object SongApiClient { } if (!bridge.isReady()) { // ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是 - // 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器。 + // 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器, + // 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。 Log.w(TAG, "Bridge not ready after wait, native resolver") - MiliLog.warn("JS-BRIDGE 15s 未就绪,改用原生取址通道") + bridge.markUnavailable("15s 未就绪") return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) } val result = AtomicReference() @@ -140,7 +146,8 @@ object SongApiClient { bridge?.resetApiSession() } - /** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */ + /** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */ @JvmStatic - fun lastSongSourceHost(): String = bridge?.lastSongSourceHost.orEmpty() + fun lastSongSourceHost(): String = + bridge?.lastSongSourceHost?.takeIf { it.isNotEmpty() } ?: SongUrlResolver.lastHost } diff --git a/app/src/main/java/com/local/ktv/SongUrlResolver.kt b/app/src/main/java/com/local/ktv/SongUrlResolver.kt index 165eed2..b6fb8d8 100644 --- a/app/src/main/java/com/local/ktv/SongUrlResolver.kt +++ b/app/src/main/java/com/local/ktv/SongUrlResolver.kt @@ -56,6 +56,15 @@ object SongUrlResolver { @Volatile private var sn: String = KtvJsBridge.DEVICE_SN + /** + * 最近一次取址命中的源 host。 + * 供 `SongApiClient.lastSongSourceHost()` 使用 —— 走原生兜底时 JS 的 + * `reportSongResult` 不会触发,否则日志里「请求源=」永远是空的。 + */ + @Volatile + var lastHost: String = "" + private set + /** * 安装取址通道。 * @param fetch 干净的 HTTP GET(应传 [KtvJsBridge.rawCleanGet]); @@ -115,6 +124,7 @@ object SongUrlResolver { } if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) { promote(host) + lastHost = host val elapsed = System.currentTimeMillis() - startedAt val urlHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "?" MiliLog.info(