From d3ea2e35d7dbf048a4a4f8eede597967eeae072c Mon Sep 17 00:00:00 2001 From: haomingming Date: Tue, 10 Nov 2026 16:40:07 +0800 Subject: [PATCH] 4kvm --- app/src/main/assets/remote.html | 64 +++++ .../java/com/tv/browser/BrowserActivity.kt | 41 ++- .../com/tv/browser/DiagnosticsExporter.kt | 240 ++++++++++++++++++ app/src/main/java/com/tv/browser/Logx.kt | 127 +++++++++ .../java/com/tv/browser/PlayerActivity.kt | 71 +++++- .../com/tv/browser/RemoteControlServer.kt | 33 +++ .../java/com/tv/browser/SettingsDialog.kt | 58 +++++ .../com/tv/browser/TVBrowserApplication.kt | 17 +- .../main/java/com/tv/browser/VideoResolver.kt | 11 +- .../com/tv/browser/VideoSnifferController.kt | 34 ++- .../java/com/tv/browser/VideoSnifferHelper.kt | 9 +- app/src/main/res/layout/dialog_settings.xml | 42 +++ app/src/main/res/xml/file_paths.xml | 6 + .../test/java/com/tv/browser/CoreLogicTest.kt | 16 ++ 14 files changed, 734 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/tv/browser/DiagnosticsExporter.kt create mode 100644 app/src/main/java/com/tv/browser/Logx.kt diff --git a/app/src/main/assets/remote.html b/app/src/main/assets/remote.html index 0afec36..f622852 100644 --- a/app/src/main/assets/remote.html +++ b/app/src/main/assets/remote.html @@ -266,6 +266,20 @@ + +
+
+ + 导出电视诊断日志 +
+
+ +
+
+ 包含系统与 WebView 内核版本、解码能力、当前设置、崩溃日志与运行日志,视频播放异常时先导这个。 +
+
+
@@ -461,6 +475,56 @@ xhr.send(formData); }); + // --- 诊断日志下载 --- + var btnDownloadLog = document.getElementById('btnDownloadLog'); + var logHint = document.getElementById('logHint'); + btnDownloadLog.addEventListener('click', function() { + var original = btnDownloadLog.textContent; + btnDownloadLog.disabled = true; + btnDownloadLog.textContent = '⏳ 正在生成…'; + var xhr = new XMLHttpRequest(); + xhr.open('GET', base + '/logs', true); + xhr.setRequestHeader('X-TV-Token', tvToken); + xhr.onload = function() { + btnDownloadLog.disabled = false; + if (xhr.status !== 200) { + btnDownloadLog.textContent = original; + logHint.textContent = '❌ 下载失败(' + xhr.status + '),请刷新页面重试'; + logHint.style.color = '#ef4444'; + return; + } + var text = xhr.responseText || ''; + var now = new Date(); + function p(n) { return (n < 10 ? '0' : '') + n; } + var name = 'tv-browser-log-' + now.getFullYear() + p(now.getMonth() + 1) + p(now.getDate()) + + '-' + p(now.getHours()) + p(now.getMinutes()) + p(now.getSeconds()) + '.txt'; + var blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + if (window.navigator && window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveOrOpenBlob(blob, name); + } else { + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name; + document.body.appendChild(a); + a.click(); + setTimeout(function() { + URL.revokeObjectURL(a.href); + a.remove(); + }, 2000); + } + btnDownloadLog.textContent = original; + logHint.textContent = '✓ 已下载 ' + name + '(' + Math.round(text.length / 1024) + ' KB)'; + logHint.style.color = '#34d399'; + }; + xhr.onerror = function() { + btnDownloadLog.disabled = false; + btnDownloadLog.textContent = original; + logHint.textContent = '❌ 无法连接电视,请确认手机与电视在同一 WiFi'; + logHint.style.color = '#ef4444'; + }; + xhr.send(); + }); + // --- 触控板 --- var touchpad = document.getElementById('touchpad'); var lastX = 0, lastY = 0; diff --git a/app/src/main/java/com/tv/browser/BrowserActivity.kt b/app/src/main/java/com/tv/browser/BrowserActivity.kt index 39c35d6..4cab0ea 100644 --- a/app/src/main/java/com/tv/browser/BrowserActivity.kt +++ b/app/src/main/java/com/tv/browser/BrowserActivity.kt @@ -190,7 +190,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { webView.addJavascriptInterface(NavGuardBridge(), "AndroidNavGuard") webView.addJavascriptInterface(PurifyBridge(), PurifyHelper.BRIDGE_NAME) hasDocStartHook = VideoSnifferHelper.installDocumentStartHook(webView) - if (BuildConfig.DEBUG) Log.d(TAG_VIDEO, "document-start hook installed=$hasDocStartHook") + Logx.i(TAG_VIDEO, "document-start hook installed=$hasDocStartHook (docStartScript=${VideoSnifferHelper.supportsDocumentStartScript()})") val prefs = getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE) currentZoomLevel = prefs.getInt(KEY_ZOOM_LEVEL, 100) @@ -258,16 +258,33 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { } snifferController.requestHeaderCache.put(reqUrl, request.requestHeaders) if (request.isForMainFrame) updateContentFilterState(reqUrl) - if (AdBlockHelper.isEnabled(this@BrowserActivity) && AdBlockHelper.shouldBlockUrl(reqUrl)) return AdBlockHelper.createEmptyResponse() - if (contentFilterActive && ContentFilterHelper.shouldBlockImage(request, currentPageHost)) return ContentFilterHelper.createEmptyImageResponse() - if (contentFilterActive && ContentFilterHelper.shouldBlockScript(request, currentPageHost)) return ContentFilterHelper.createEmptyScriptResponse() - VideoSnifferHelper.createCompatScriptResponse(reqUrl)?.let { return it } - if (VideoSnifferHelper.isVideoStreamUrl(reqUrl)) onVideoSniffed(reqUrl, currentPageTitle, "net") + if (AdBlockHelper.isEnabled(this@BrowserActivity) && AdBlockHelper.shouldBlockUrl(reqUrl)) { + Logx.d("Block", "adblock ${Logx.redactUrl(reqUrl)}") + return AdBlockHelper.createEmptyResponse() + } + if (contentFilterActive && ContentFilterHelper.shouldBlockImage(request, currentPageHost)) { + Logx.d("Block", "image ${Logx.redactUrl(reqUrl)}") + return ContentFilterHelper.createEmptyImageResponse() + } + if (contentFilterActive && ContentFilterHelper.shouldBlockScript(request, currentPageHost)) { + // 强过滤误伤播放器脚本时,只有这条日志能看出来 + Logx.w("Block", "script ${Logx.redactUrl(reqUrl)}") + return ContentFilterHelper.createEmptyScriptResponse() + } + VideoSnifferHelper.createCompatScriptResponse(reqUrl)?.let { + Logx.i("Compat", "stub ${Logx.redactUrl(reqUrl)}") + return it + } + if (VideoSnifferHelper.isVideoStreamUrl(reqUrl)) { + Logx.i("Sniff", "net hit ${Logx.redactUrl(reqUrl)}") + onVideoSniffed(reqUrl, currentPageTitle, "net") + } return super.shouldInterceptRequest(view, request) } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) + Logx.i("Page", "start ${Logx.redactUrl(url)} ua=${if (isDesktopUa) "desktop" else "mobile"}") progressBar.visibility = View.VISIBLE if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "") currentPageTitle = "" @@ -302,6 +319,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) + Logx.i("Page", "finished ${Logx.redactUrl(url)} title=${currentPageTitle.take(40)}") progressBar.visibility = View.GONE if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "") if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) @@ -361,8 +379,15 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { } override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - if (BuildConfig.DEBUG) { - consoleMessage?.let { Log.d(TAG_VIDEO, "JS console [${it.messageLevel()}] ${it.sourceId()}:${it.lineNumber()} ${it.message()}") } + // 站点自身的 JS 报错是电视端最有力的证据(例如旧内核缺 CSS/JS 能力导致的异常), + // 因此不论 DEBUG / release 都写进可导出的运行日志缓冲 + consoleMessage?.let { + Logx.console( + it.messageLevel()?.name ?: "LOG", + it.sourceId().orEmpty(), + it.lineNumber(), + it.message().orEmpty() + ) } return false } diff --git a/app/src/main/java/com/tv/browser/DiagnosticsExporter.kt b/app/src/main/java/com/tv/browser/DiagnosticsExporter.kt new file mode 100644 index 0000000..864ae50 --- /dev/null +++ b/app/src/main/java/com/tv/browser/DiagnosticsExporter.kt @@ -0,0 +1,240 @@ +package com.tv.browser + +import android.content.Context +import android.content.Intent +import android.media.MediaCodecList +import android.os.Build +import androidx.core.content.FileProvider +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 诊断日志导出:把「设备 / WebView 内核能力 / 设置 / 解码器 / 崩溃日志 / 运行日志」拼成一份文本, + * 落到外置应用目录,供电视文件管理器、U 盘或手机遥控页取走。 + * + * 动机:崩溃日志原本只写内部存储(非 root 读不到),运行日志只在 Logcat,电视没 adb 就没法排查。 + * + * 为什么先看「WebView 内核版本 + 特性支持」:影视站的播放器普遍用较新的 CSS/JS 能力 + * (如 `aspect-ratio` 需要 Chromium 88+、`:has()` 需要 105+),电视内核通常停在 7x~8x, + * 这类问题在手机上永远复现不出来,必须靠这份报告一眼定位。 + */ +object DiagnosticsExporter { + + private const val TAG = "TVB.Export" + private const val DIR_NAME = "diagnostics" + private const val MAX_CRASH_FILES = 3 + private const val MAX_CRASH_LINES = 60 + private const val MAX_LOG_LINES = 1200 + + data class Result(val file: File?, val summary: String, val error: String?) + + /** 诊断日志目录(优先外置应用目录:电视上的文件管理器 / U 盘可直接访问) */ + fun dir(context: Context): File { + val dir = context.getExternalFilesDir(DIR_NAME) ?: File(context.filesDir, DIR_NAME) + if (!dir.exists()) dir.mkdirs() + return dir + } + + /** 目录里最近一份导出文件(手机遥控页 /logs 无参时优先返回它,避免重复生成) */ + fun latestFile(context: Context): File? = dir(context) + .listFiles { f -> f.isFile && f.name.startsWith("diagnostics_") } + ?.maxByOrNull { it.lastModified() } + + /** 生成并落盘;失败时返回 [Result.error] 表示原因,不抛异常(调用方多在 UI 线程) */ + fun export(context: Context): Result { + val report = try { + buildReport(context) + } catch (e: Throwable) { + Logx.e(TAG, "buildReport failed", e) + return Result(null, "生成诊断日志失败", e.message ?: e.javaClass.simpleName) + } + return try { + val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val file = File(dir(context), "diagnostics_$stamp.txt") + file.writeText(report, Charsets.UTF_8) + Logx.i(TAG, "diagnostics saved: ${file.absolutePath} (${file.length()} bytes)") + val preview = report.lineSequence().take(12).joinToString("\n") + Result(file, preview, null) + } catch (e: Throwable) { + Logx.e(TAG, "write diagnostics failed", e) + Result(null, "写入诊断日志失败", e.message ?: e.javaClass.simpleName) + } + } + + /** 通过系统分享面板把日志发出去(电视上部分系统没有可用目标,失败时由调用方提示路径) */ + fun share(context: Context, file: File): Boolean { + return try { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "TV浏览器诊断日志") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + val chooser = Intent.createChooser(intent, "分享诊断日志").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(chooser) + true + } catch (e: Throwable) { + Logx.w(TAG, "share failed: ${e.message}") + false + } + } + + // --------------------------------------------------------------------------------------- + // 报告内容 + // --------------------------------------------------------------------------------------- + + fun buildReport(context: Context): String { + val sb = StringBuilder(16 * 1024) + val now = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()) + + sb.appendLine("===== TV 浏览器诊断日志 =====") + sb.appendLine("生成时间: $now") + sb.appendLine("应用版本: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) · ${if (BuildConfig.DEBUG) "Debug" else "Release"}") + sb.appendLine() + + sb.appendLine("--- 设备 ---") + sb.appendLine("厂商/型号: ${Build.MANUFACTURER} ${Build.MODEL}") + sb.appendLine("设备代号: ${Build.DEVICE} / ${Build.PRODUCT}") + sb.appendLine("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") + sb.appendLine("CPU 架构: ${Build.SUPPORTED_ABIS.joinToString(", ")}") + sb.appendLine("应用内存上限: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)} MB") + sb.appendLine() + + appendWebViewInfo(context, sb) + appendNetworkInfo(context, sb) + appendSettingsInfo(context, sb) + appendDecoderInfo(sb) + appendPlaybackHistory(context, sb) + appendCrashLogs(context, sb) + + sb.appendLine("--- 运行日志(最近 ${MAX_LOG_LINES} 行,缓冲共 ${Logx.lineCount()} 行)---") + sb.appendLine(Logx.snapshot(MAX_LOG_LINES)) + sb.appendLine() + sb.appendLine("===== 日志结束 =====") + return sb.toString() + } + + /** WebView 内核版本与关键特性支持情况:电视端播放与页面异常的第一嫌疑点 */ + private fun appendWebViewInfo(context: Context, sb: StringBuilder) { + sb.appendLine("--- WebView 内核 ---") + val pkg = runCatching { WebViewCompat.getCurrentWebViewPackage(context) }.getOrNull() + sb.appendLine("包名: ${pkg?.packageName ?: "未知"}") + sb.appendLine("版本: ${pkg?.versionName ?: "未知"}") + sb.appendLine("特性支持:") + FEATURES.forEach { (name, feature) -> + val supported = runCatching { WebViewFeature.isFeatureSupported(feature) }.getOrDefault(false) + sb.appendLine(" $name = ${if (supported) "YES" else "NO"}") + } + sb.appendLine("说明: DOCUMENT_START_SCRIPT 需要 WebView 83+(为 NO 时嗅探钩子只能滞后注入)") + sb.appendLine() + } + + private fun appendNetworkInfo(context: Context, sb: StringBuilder) { + sb.appendLine("--- 网络与系统状态 ---") + val status = runCatching { NetworkChecker.getStatusMessage(context) }.getOrDefault("未知") + sb.appendLine("网络: $status") + val clockAbnormal = runCatching { NetworkChecker.isSystemClockAbnormal() }.getOrDefault(false) + sb.appendLine("系统时钟异常: ${if (clockAbnormal) "是(已按日期兼容放行 SSL)" else "否"}") + sb.appendLine() + } + + private fun appendSettingsInfo(context: Context, sb: StringBuilder) { + sb.appendLine("--- 当前设置 ---") + val prefs = context.getSharedPreferences(BrowserActivity.PREFS_SETTINGS, Context.MODE_PRIVATE) + val desktopUa = prefs.getBoolean(BrowserActivity.KEY_IS_DESKTOP_UA, false) + sb.appendLine("浏览器标识: ${if (desktopUa) "电脑桌面版" else "移动手机版"}") + sb.appendLine("当前 UA: ${BrowserActivity.currentUserAgent(context)}") + sb.appendLine("自动全屏接管: ${onOff(AppSettings.isAutoTakeover(context))}") + sb.appendLine("提前解析下一集: ${onOff(AppSettings.isPreloadNext(context))}") + sb.appendLine("净化播放页: ${onOff(AppSettings.isPurifyPage(context))}") + sb.appendLine("解码方式: ${AppSettings.decoderModeName(AppSettings.getDecoderMode(context))}") + sb.appendLine("宽松 SSL: ${onOff(AppSettings.isLenientSsl(context))}") + sb.appendLine("广告拦截: ${onOff(AdBlockHelper.isEnabled(context))}") + sb.appendLine("强力过滤: ${onOff(ContentFilterHelper.isStrongFilterEnabled(context))}") + sb.appendLine("无图模式: ${onOff(ContentFilterHelper.isNoImageMode(context))}") + sb.appendLine("网页缩放: ${prefs.getInt(BrowserActivity.KEY_ZOOM_LEVEL, 100)}%") + sb.appendLine("播放倍速: ${AppSettings.getPlaybackSpeed(context)}x") + sb.appendLine() + } + + /** 系统支持的视频解码器(判断 4K / HEVC / AV1 能不能硬解的关键) */ + private fun appendDecoderInfo(sb: StringBuilder) { + sb.appendLine("--- 视频解码器(系统媒体能力)---") + val decoders = runCatching { + MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos + .filter { !it.isEncoder } + .mapNotNull { info -> + val videoTypes = info.supportedTypes + .filter { it.startsWith("video/") } + .map { it.removePrefix("video/") } + if (videoTypes.isEmpty()) null else "${info.name} [${videoTypes.joinToString(",")}]" + } + }.getOrElse { + sb.appendLine("读取失败: ${it.javaClass.simpleName}") + emptyList() + } + if (decoders.isEmpty()) sb.appendLine("(无)") + decoders.forEach { sb.appendLine(it) } + sb.appendLine() + } + + private fun appendPlaybackHistory(context: Context, sb: StringBuilder) { + sb.appendLine("--- 最近播放记录(地址已脱敏)---") + val records = runCatching { PlaybackHistoryRepository(context).getAll().take(8) }.getOrDefault(emptyList()) + if (records.isEmpty()) sb.appendLine("(无)") + records.forEach { r -> + val ep = r.episodeName.ifBlank { r.displaySubtitle() } + sb.appendLine("${r.displayTitle()} · $ep | ${Logx.redactUrl(r.url)}") + } + sb.appendLine() + } + + private fun appendCrashLogs(context: Context, sb: StringBuilder) { + sb.appendLine("--- 崩溃日志(最近 $MAX_CRASH_FILES 条)---") + val files = crashDirs(context) + .flatMap { d -> d.listFiles { f -> f.isFile && f.name.startsWith("crash_") }?.toList().orEmpty() } + .sortedByDescending { it.lastModified() } + .take(MAX_CRASH_FILES) + if (files.isEmpty()) { + sb.appendLine("(无)") + } else { + files.forEach { f -> + sb.appendLine(">>> ${f.name} (${f.length()} bytes)") + val content = runCatching { f.readLines() }.getOrDefault(emptyList()) + content.take(MAX_CRASH_LINES).forEach { sb.appendLine(it) } + if (content.size > MAX_CRASH_LINES) sb.appendLine("... (已截断,共 ${content.size} 行)") + } + } + sb.appendLine() + } + + /** 崩溃日志可能在外置目录(新)或内部目录(旧版本遗留),两处都读 */ + private fun crashDirs(context: Context): List = listOfNotNull( + context.getExternalFilesDir(TVBrowserApplication.CRASH_LOG_DIR), + File(context.filesDir, TVBrowserApplication.CRASH_LOG_DIR) + ).distinctBy { it.absolutePath }.filter { it.isDirectory } + + private fun onOff(on: Boolean) = if (on) "开启" else "关闭" + + /** + * 关键 WebView 特性。启用状态直接影响嗅探链路: + * - DOCUMENT_START_SCRIPT:能否在页面执行前注入 Hook(否则只能滞后注入,可能漏掉首屏视频请求) + * - USER_AGENT_METADATA:能否同步 UA Client Hints(否则电脑版 UA 只是半套) + */ + private val FEATURES: List> = listOf( + "DOCUMENT_START_SCRIPT" to WebViewFeature.DOCUMENT_START_SCRIPT, + "USER_AGENT_METADATA" to WebViewFeature.USER_AGENT_METADATA, + "WEB_MESSAGE_LISTENER" to WebViewFeature.WEB_MESSAGE_LISTENER, + "POST_WEB_MESSAGE" to WebViewFeature.POST_WEB_MESSAGE, + "MULTI_PROCESS" to WebViewFeature.MULTI_PROCESS, + "START_SAFE_BROWSING" to WebViewFeature.START_SAFE_BROWSING, + "ALGORITHMIC_DARKENING" to WebViewFeature.ALGORITHMIC_DARKENING + ) +} diff --git a/app/src/main/java/com/tv/browser/Logx.kt b/app/src/main/java/com/tv/browser/Logx.kt new file mode 100644 index 0000000..d611320 --- /dev/null +++ b/app/src/main/java/com/tv/browser/Logx.kt @@ -0,0 +1,127 @@ +package com.tv.browser + +import android.util.Log +import java.text.SimpleDateFormat +import java.util.ArrayDeque +import java.util.Date +import java.util.Locale + +/** + * 统一日志门面 + 内存环形缓冲,用于电视端「取不出日志」的现场排查。 + * + * 解决的三个现实问题: + * 1. **release 包可能被 R8 剥掉日志**:`proguard-android-optimize.txt` 历来包含 + * `-assumenosideeffects android.util.Log.d/v`,导致线上一条都留不下。这里先写内存缓冲再转 + * Logcat,缓冲不依赖 Log 调用是否被保留。 + * 2. **电视没有 adb**:缓冲可持续导出(见 [DiagnosticsExporter]),通过文件或手机遥控页取走。 + * 3. **WebView 内的 JS 报错查不到**:`onConsoleMessage` 原本只在 DEBUG 转发 Logcat,现在统一入缓冲。 + * + * 全部方法线程安全:`shouldInterceptRequest` 在 WebView 后台线程、播放器回调在各自线程写日志。 + */ +object Logx { + + /** 缓冲上限(行)。4000 行量级约几百 KB,低内存电视也能承受 */ + private const val MAX_LINES = 4000 + + /** 单行上限,防止超长 URL / 堆栈把缓冲撑爆 */ + private const val MAX_LINE_LENGTH = 3000 + + /** 相同内容在该时间窗内折叠为一条(站点轮询 / 高频请求会刷屏) */ + private const val DEDUPE_WINDOW_MS = 1000L + + private val lock = Any() + private val lines = ArrayDeque(256) + private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + + /** 去重状态:上一行内容、写入时间、被折叠的重复次数 */ + private var lastEntry: String? = null + private var lastAt: Long = 0L + private var dupCount = 0 + + val isEmpty: Boolean get() = synchronized(lock) { lines.isEmpty() } + + fun d(tag: String, msg: String) = append("D", tag, msg, null) + + fun i(tag: String, msg: String) = append("I", tag, msg, null) + + fun w(tag: String, msg: String, tr: Throwable? = null) = append("W", tag, msg, tr) + + fun e(tag: String, msg: String, tr: Throwable? = null) = append("E", tag, msg, tr) + + /** 写入一条会话分隔,方便导出后按时间切分现场 */ + fun mark(title: String) = append("I", "SESSION", "===== $title =====", null) + + /** + * WebView 控制台消息(含 JS 异常、资源加载失败、站点自身报错)。 + * 站点出错时这里往往是最直接的证据,因此不论 DEBUG / release 都记录。 + */ + fun console(level: String, source: String, lineNumber: Int, message: String) { + val src = source.substringAfterLast('/').take(48) + append("W", "JS", "[$level] $message @$src:$lineNumber", null) + } + + private fun append(level: String, tag: String, msg: String, tr: Throwable?) { + val text = if (tr == null) msg else "$msg | ${tr.javaClass.simpleName}: ${tr.message}" + // 先转 Logcat(release 下可能被剥离,不影响内存缓冲) + when (level) { + "E" -> Log.e(tag, text, tr) + "W" -> Log.w(tag, text, tr) + "I" -> Log.i(tag, text) + else -> Log.d(tag, text) + } + val body = text.replace('\n', '⏎').take(MAX_LINE_LENGTH) + val now = System.currentTimeMillis() + synchronized(lock) { + val entry = "${timeFormat.format(Date(now))} $level/$tag: $body" + val withinWindow = now - lastAt <= DEDUPE_WINDOW_MS + if (entry == lastEntry && withinWindow) { + dupCount++ + lastAt = now + return + } + flushDupes() + lastEntry = entry + lastAt = now + lines.addLast(entry) + while (lines.size > MAX_LINES) lines.pollFirst() + } + } + + /** 把累计的重复次数补记到最后一行上(必须在持有 [lock] 时调用) */ + private fun flushDupes() { + if (dupCount <= 0) return + val prev = lines.pollLast() + if (prev != null) lines.addLast("$prev (×${dupCount + 1})") + dupCount = 0 + } + + /** 导出用快照:默认最近 [maxLines] 行 */ + fun snapshot(maxLines: Int = 2000): String = synchronized(lock) { + if (lines.isEmpty()) return "(本次会话没有记录到日志)" + val all = lines.toList() + val from = (all.size - maxLines).coerceAtLeast(0) + all.subList(from, all.size).joinToString("\n") + } + + /** 当前缓冲行数(导出报告里展示,便于判断日志是否被裁剪) */ + fun lineCount(): Int = synchronized(lock) { lines.size } + + fun clear() = synchronized(lock) { + lines.clear() + lastEntry = null + lastAt = 0L + dupCount = 0 + } + + /** + * URL 脱敏:只保留 `scheme://host/path`,去掉 query(视频直链的签名 / token 都在 query 里) + * 与 fragment。导出日志可能被发到群里求助,不能带签名。 + */ + fun redactUrl(url: String?): String { + val raw = url?.trim().orEmpty() + if (raw.isEmpty()) return "" + val cut = raw.substringBefore('?').substringBefore('#') + val hadQuery = raw.contains('?') + return if (hadQuery) "$cut?" else cut + } +} diff --git a/app/src/main/java/com/tv/browser/PlayerActivity.kt b/app/src/main/java/com/tv/browser/PlayerActivity.kt index 1c8095f..796a244 100644 --- a/app/src/main/java/com/tv/browser/PlayerActivity.kt +++ b/app/src/main/java/com/tv/browser/PlayerActivity.kt @@ -276,6 +276,7 @@ class PlayerActivity : AppCompatActivity() { binding.tvStatus.visibility = View.GONE stallHintShown = false readySinceReResolve = true + logPlaybackFormat() updateProgress(forceUi = true) maybeSkipIntro() failedSources.clear() @@ -424,7 +425,11 @@ class PlayerActivity : AppCompatActivity() { if (!canPlayNow) p.pause() if (carried <= 0 && record != null && startPos > 0) showFeedback("已自动续播至 ${record.formatPosition()}") - if (BuildConfig.DEBUG) Log.d(TAG, "play: ${source.url} hls=${source.isHls} canPlayNow=$canPlayNow headers=${source.headers.keys}") + Logx.i( + TAG, + "play hls=${source.isHls} canPlayNow=$canPlayNow startPos=$startPos " + + "headers=${source.headers.keys} url=${Logx.redactUrl(source.url)}" + ) lastSavedSec = 0 suppressSave = false introChecked = false @@ -469,10 +474,36 @@ class PlayerActivity : AppCompatActivity() { }) } + /** + * 首帧就绪时记录实际生效的编码与声道: + * 电视端「黑屏 / 花屏 / 有声无画」几乎都是解码器能力问题,这是第一手证据 + */ + private fun logPlaybackFormat() { + val p = player ?: return + val vf = p.videoFormat + val af = p.audioFormat + Logx.i( + TAG, + "ready video=${vf?.sampleMimeType ?: "?"} codec=${vf?.codecs ?: "?"} " + + "${vf?.width ?: 0}x${vf?.height ?: 0}@${vf?.frameRate?.toInt() ?: 0}fps " + + "audio=${af?.sampleMimeType ?: "?"}/${af?.channelCount ?: 0}ch " + + "decoder=${AppSettings.decoderModeName(AppSettings.getDecoderMode(this))} " + + "hls=${currentSource?.isHls ?: false}" + ) + } + private fun handlePlayerError(error: PlaybackException) { binding.playerLoading.visibility = View.GONE val cause = PlayerSsl.rootCauseText(error) - Log.e(TAG, "playback error: ${error.errorCodeName} cause=$cause inBg=$isInBackground", error) + val videoFormat = player?.videoFormat + Logx.e( + TAG, + "playback error: ${error.errorCodeName} cause=$cause inBg=$isInBackground " + + "pos=$lastKnownPos decoder=${AppSettings.decoderModeName(AppSettings.getDecoderMode(this))} " + + "codec=${videoFormat?.codecs ?: videoFormat?.sampleMimeType ?: "?"} " + + "url=${Logx.redactUrl(currentSource?.url)}", + error + ) if (isInBackground) { pauseForBackground() return @@ -703,7 +734,7 @@ class PlayerActivity : AppCompatActivity() { } override fun onFailed(reason: String) { - Log.w(TAG, "preload #$nextIndex failed: $reason") + Logx.w(TAG, "preload #$nextIndex failed: $reason") // 失败不重试,切集时再正常解析;保持 preloadingIndex 防止反复触发 } }) @@ -896,10 +927,38 @@ class PlayerActivity : AppCompatActivity() { .setTitle("📊 播放详细参数") .setView(scroll) .setPositiveButton("确定", null) + // 播放问题现场直接导出诊断日志(含解码器、内核能力、运行日志),不用连 adb + .setNeutralButton("导出日志") { _, _ -> exportDiagnostics() } .setOnDismissListener { showOsdTemporarily() } .show() } + private fun exportDiagnostics() { + Toast.makeText(this, "正在生成诊断日志…", Toast.LENGTH_SHORT).show() + Thread { + val result = DiagnosticsExporter.export(this) + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + val file = result.file + if (file == null) { + Toast.makeText(this, "导出失败:${result.error}", Toast.LENGTH_LONG).show() + return@runOnUiThread + } + val path = file.absolutePath + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("✅ 诊断日志已导出") + .setMessage("${file.length() / 1024} KB\n$path\n\n可通过「手机遥控」页面下载,或复制到 U 盘") + .setPositiveButton("分享") { _, _ -> + if (!DiagnosticsExporter.share(this, file)) { + Toast.makeText(this, "本机没有可用分享目标,请从上面的路径取文件", Toast.LENGTH_LONG).show() + } + } + .setNegativeButton("确定", null) + .show() + } + }.start() + } + private fun updateEpisodeUi() { binding.btnEpisodes.visibility = if (episodes.size > 1) View.VISIBLE else View.GONE binding.btnNext.visibility = if (hasNextEpisode()) View.VISIBLE else View.GONE @@ -1226,7 +1285,7 @@ class PlayerActivity : AppCompatActivity() { Toast.makeText(this, "当前电视设备不支持画中画功能", Toast.LENGTH_SHORT).show() } } catch (e: Exception) { - Log.e(TAG, "enterPictureInPictureMode failed", e) + Logx.e(TAG, "enterPictureInPictureMode failed", e) Toast.makeText(this, "开启画中画失败", Toast.LENGTH_SHORT).show() } } else { @@ -1358,7 +1417,7 @@ class PlayerActivity : AppCompatActivity() { } startActivity(Intent.createChooser(intent, "选择外部播放器")) } catch (e: Exception) { - Log.e(TAG, "Failed to launch external player", e) + Logx.e(TAG, "Failed to launch external player", e) Toast.makeText(this, "未找到已安装的外部视频播放器", Toast.LENGTH_SHORT).show() } } @@ -1747,7 +1806,7 @@ class PlayerActivity : AppCompatActivity() { context.startActivity(inst.buildRestoreIntent()) true } catch (e: Exception) { - Log.e(TAG, "restore from PiP failed", e) + Logx.e(TAG, "restore from PiP failed", e) false } } diff --git a/app/src/main/java/com/tv/browser/RemoteControlServer.kt b/app/src/main/java/com/tv/browser/RemoteControlServer.kt index 8eb3b2f..e934fe0 100644 --- a/app/src/main/java/com/tv/browser/RemoteControlServer.kt +++ b/app/src/main/java/com/tv/browser/RemoteControlServer.kt @@ -79,6 +79,13 @@ class RemoteControlServer( return !headerToken.isNullOrEmpty() && headerToken == sessionToken } + /** GET 请求也可携带令牌(遥控页 fetch 用请求头;直接在电脑/手机浏览器打开时用 ?token=) */ + private fun isAuthorizedGet(session: IHTTPSession): Boolean { + if (isAuthorized(session)) return true + val queryToken = session.parameters["token"]?.firstOrNull()?.trim() + return !queryToken.isNullOrEmpty() && queryToken == sessionToken + } + override fun serve(session: IHTTPSession): NanoHTTPD.Response { val uri = session.uri if (session.method == NanoHTTPD.Method.GET && (uri == "/" || uri == "/remote" || uri.isEmpty())) { @@ -89,6 +96,32 @@ class RemoteControlServer( htmlBytes ) } + // 诊断日志下载:手机浏览器点一下就能把电视端日志存成 txt + if (session.method == NanoHTTPD.Method.GET && uri == "/logs") { + if (!isAuthorizedGet(session)) { + Logx.w(TAG, "Rejected unauthorized /logs from ${session.remoteIpAddress}") + return newSameOriginResponse( + NanoHTTPD.Response.Status.FORBIDDEN, + "text/plain; charset=utf-8", + "Forbidden: invalid session token".toByteArray(Charsets.UTF_8) + ) + } + val report = try { + DiagnosticsExporter.buildReport(context) + } catch (e: Throwable) { + Logx.e(TAG, "build diagnostics for /logs failed", e) + "生成诊断日志失败: ${e.message}" + } + val bytes = report.toByteArray(Charsets.UTF_8) + Logx.i(TAG, "/logs served (${bytes.size} bytes) to ${session.remoteIpAddress}") + return newSameOriginResponse( + NanoHTTPD.Response.Status.OK, + "text/plain; charset=utf-8", + bytes + ).apply { + addHeader("Content-Disposition", "attachment; filename=\"tv-browser-log.txt\"") + } + } if (session.method == NanoHTTPD.Method.POST && (uri == "/cmd" || uri == "/upload")) { if (!isAuthorized(session)) { Log.w(TAG, "Rejected unauthorized request to $uri from ${session.remoteIpAddress}") diff --git a/app/src/main/java/com/tv/browser/SettingsDialog.kt b/app/src/main/java/com/tv/browser/SettingsDialog.kt index e153847..503e5dd 100644 --- a/app/src/main/java/com/tv/browser/SettingsDialog.kt +++ b/app/src/main/java/com/tv/browser/SettingsDialog.kt @@ -344,6 +344,11 @@ class SettingsDialog : DialogFragment() { bindCardLeftKeyToTab(b.cardClearCache, 3) bindCardLeftKeyToTab(b.cardClearHistory, 3) bindCardLeftKeyToTab(b.cardNetworkDiag, 3) + bindCardLeftKeyToTab(b.cardExportLog, 3) + + // 0) 导出诊断日志(电视端排查播放问题的第一入口) + refreshLogStatus() + b.cardExportLog.setOnClickListener { exportDiagnostics() } // 1) 清除缓存 b.cardClearCache.setOnClickListener { @@ -371,6 +376,59 @@ class SettingsDialog : DialogFragment() { } } + /** 刷新「导出诊断日志」卡片的副标题:显示缓冲行数与最近一次导出时间 */ + private fun refreshLogStatus() { + val b = binding ?: return + val ctx = context ?: return + val lines = Logx.lineCount() + val latest = DiagnosticsExporter.latestFile(ctx) + b.tvLogStatus.text = if (latest != null) { + val time = android.text.format.DateFormat.format("MM-dd HH:mm", latest.lastModified()).toString() + "运行日志 $lines 行 · 上次导出 $time · 点此重新生成" + } else { + "包含系统/WebView 内核版本、解码能力、当前设置、崩溃与运行日志(当前 $lines 行)" + } + } + + /** 生成诊断日志:后台拼装 + 落盘,完成后给出路径、分享与预览 */ + private fun exportDiagnostics() { + val ctx = context ?: return + Toast.makeText(ctx, "正在生成诊断日志…", Toast.LENGTH_SHORT).show() + Thread { + val result = DiagnosticsExporter.export(ctx) + activity?.runOnUiThread { + if (!isAdded || binding == null) return@runOnUiThread + val file = result.file + if (file == null) { + AlertDialog.Builder(ctx) + .setTitle("导出失败") + .setMessage(result.error ?: "未知错误") + .setPositiveButton("确定", null) + .show() + return@runOnUiThread + } + refreshLogStatus() + AlertDialog.Builder(ctx) + .setTitle("✅ 诊断日志已导出") + .setMessage( + "${file.length() / 1024} KB · ${file.absolutePath}\n\n" + + "取走方式:\n" + + "1. 手机遥控页(手机与电视同一 WiFi)点「下载电视日志」\n" + + "2. 电视文件管理器进入 Android/data/${ctx.packageName}/files/${file.parentFile?.name}/\n" + + "3. 点下方「分享」发到可用目标\n\n" + + "--- 预览 ---\n${result.summary}" + ) + .setPositiveButton("分享") { _, _ -> + if (!DiagnosticsExporter.share(ctx, file)) { + Toast.makeText(ctx, "本机没有可用分享目标,请用上面的方式取文件", Toast.LENGTH_LONG).show() + } + } + .setNegativeButton("确定", null) + .show() + } + }.start() + } + // ========================================================================= // 6. 关于与按键面板 // ========================================================================= diff --git a/app/src/main/java/com/tv/browser/TVBrowserApplication.kt b/app/src/main/java/com/tv/browser/TVBrowserApplication.kt index 61b308d..9d749cd 100644 --- a/app/src/main/java/com/tv/browser/TVBrowserApplication.kt +++ b/app/src/main/java/com/tv/browser/TVBrowserApplication.kt @@ -33,14 +33,23 @@ class TVBrowserApplication : Application() { } private fun pruneCrashLogsAsync() { - val dir = File(filesDir, CRASH_LOG_DIR) + val dirs = crashDirs() Thread { try { - pruneCrashLogs(dir) + dirs.forEach { pruneCrashLogs(it) } } catch (_: Throwable) { } }.start() } + /** + * 崩溃日志目录:优先外置应用目录(电视上文件管理器 / U 盘可直接拷走), + * 无外置存储时回退到内部目录。两处都可能存在旧文件,读取与裁剪都要覆盖。 + */ + private fun crashDirs(): List = listOfNotNull( + getExternalFilesDir(CRASH_LOG_DIR), + File(filesDir, CRASH_LOG_DIR) + ).distinctBy { it.absolutePath } + private fun pruneCrashLogs(dir: File) { if (!dir.exists()) return val files = dir.listFiles { f -> f.isFile && f.name.startsWith("crash_") } ?: return @@ -51,7 +60,9 @@ class TVBrowserApplication : Application() { } private fun saveCrashLog(thread: Thread, throwable: Throwable) { - val dir = File(filesDir, CRASH_LOG_DIR) + // 同时写入运行日志缓冲:即使崩溃文件写失败(存储不可写等),导出的诊断日志里也有堆栈 + Logx.e(TAG, "crash in thread ${thread.name}", throwable) + val dir = getExternalFilesDir(CRASH_LOG_DIR) ?: File(filesDir, CRASH_LOG_DIR) if (!dir.exists()) dir.mkdirs() val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US) val fileName = "crash_${dateFormat.format(Date())}.txt" diff --git a/app/src/main/java/com/tv/browser/VideoResolver.kt b/app/src/main/java/com/tv/browser/VideoResolver.kt index f71e777..cc458fc 100644 --- a/app/src/main/java/com/tv/browser/VideoResolver.kt +++ b/app/src/main/java/com/tv/browser/VideoResolver.kt @@ -6,7 +6,6 @@ import android.graphics.Bitmap import android.net.http.SslError import android.os.Handler import android.os.Looper -import android.util.Log import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.SslErrorHandler @@ -75,7 +74,7 @@ class VideoResolver( generation++ val wv = createWebView(generation) webView = wv - Log.d(TAG, "resolve: $pageUrl (docStartHook=$hasDocStartHook)") + Logx.i(TAG, "resolve: ${Logx.redactUrl(pageUrl)} (docStartHook=$hasDocStartHook)") wv.loadUrl(pageUrl) handler.postDelayed(timeoutRunnable, timeoutMs) } @@ -184,7 +183,7 @@ class VideoResolver( @JavascriptInterface fun onPlaylist(url: String, finalUrl: String, text: String) { PlaylistCache.put(url, finalUrl, text) - Log.d(TAG, "playlist captured: $url (${text.length} chars)") + Logx.d(TAG, "playlist captured: ${Logx.redactUrl(url)} (${text.length} chars)") } @JavascriptInterface @@ -204,14 +203,14 @@ class VideoResolver( if (gen != generation || finished) return if (!url.startsWith("http")) return if (VideoSnifferHelper.isWebOnlyStream(url)) { - Log.d(TAG, "web-only stream: $url") + Logx.w(TAG, "web-only stream: ${Logx.redactUrl(url)}") fail(VideoSnifferHelper.WEB_ONLY_MESSAGE) return } if (AdBlockHelper.isEnabled(context) && AdBlockHelper.shouldBlockUrl(url)) return if (candidates.any { it.url == url || VideoSnifferHelper.isLikelyVariantOf(url, it.url) }) return candidates.add(Candidate(url, kind, frameUrl, title)) - Log.d(TAG, "candidate[$kind]: $url") + Logx.i(TAG, "candidate[$kind]: ${Logx.redactUrl(url)}") handler.removeCallbacks(finalizeRunnable) if (candidates.any { VideoSnifferHelper.isHls(it.url, it.kind) }) { // 拿到 HLS:稍等片刻让对应的网络请求头进入缓存 @@ -257,7 +256,7 @@ class VideoResolver( headers = headers, isHls = VideoSnifferHelper.isHls(best.url, best.kind) ) - Log.d(TAG, "resolved: ${source.url} headers=${headers.keys}") + Logx.i(TAG, "resolved: ${Logx.redactUrl(source.url)} headers=${headers.keys} hls=${source.isHls}") val cb = callback finished = true handler.removeCallbacks(timeoutRunnable) diff --git a/app/src/main/java/com/tv/browser/VideoSnifferController.kt b/app/src/main/java/com/tv/browser/VideoSnifferController.kt index 29eee0c..8f5cd1e 100644 --- a/app/src/main/java/com/tv/browser/VideoSnifferController.kt +++ b/app/src/main/java/com/tv/browser/VideoSnifferController.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.net.Uri import android.os.Handler import android.os.Looper -import android.util.Log import android.view.View import android.webkit.WebView import android.widget.Button @@ -41,6 +40,9 @@ class VideoSnifferController( private var autoPlayHandledPage: String? = null private var autoPlayPage: String? = null private var autoPlayRemaining = 0 + + /** 已记录过「候选被丢弃」的地址,避免同一地址在日志里刷屏 */ + private val dropLogged = HashSet() private val autoPlayHandler = Handler(Looper.getMainLooper()) private val autoPlayTick = Runnable { onAutoPlayTick() } @@ -66,6 +68,7 @@ class VideoSnifferController( webOnlyNoticeShown = false currentSiteInfo = null currentSiteInfoJson = null + dropLogged.clear() btnNativePlay.visibility = View.GONE btnFloatingPlay.visibility = View.GONE } @@ -78,7 +81,7 @@ class VideoSnifferController( // 央视网等加密流:原生播放会花屏,留在网页里播放 if (VideoSnifferHelper.isWebOnlyStream(trimmed)) { - Log.d(TAG, "Web-only stream, skip native: $trimmed") + Logx.w(TAG, "web-only stream, keep in page: ${Logx.redactUrl(trimmed)}") if (!webOnlyNoticeShown) { webOnlyNoticeShown = true Toast.makeText(activity, VideoSnifferHelper.WEB_ONLY_MESSAGE, Toast.LENGTH_LONG).show() @@ -86,8 +89,19 @@ class VideoSnifferController( return } val isHls = VideoSnifferHelper.isHls(trimmed, kind) - if (!isHls && !VideoSnifferHelper.isVideoStreamUrl(trimmed)) return - if (AdBlockHelper.isEnabled(activity) && AdBlockHelper.shouldBlockUrl(trimmed)) return + if (!isHls && !VideoSnifferHelper.isVideoStreamUrl(trimmed)) { + // 关键排查点:候选被丢弃必须留痕。电视端「嗅探不到视频源」大多就是这里静默 return, + // 典型如站点给的是无扩展名直链(.../video/tos/cn/?mime_type=video_mp4) + if (dropLogged.add(trimmed)) { + if (dropLogged.size > MAX_DROP_LOG) dropLogged.clear() + Logx.w(TAG, "drop candidate kind=$kind title=${title.take(24)} url=${Logx.redactUrl(trimmed)}") + } + return + } + if (AdBlockHelper.isEnabled(activity) && AdBlockHelper.shouldBlockUrl(trimmed)) { + Logx.w(TAG, "drop candidate blocked by adblock: ${Logx.redactUrl(trimmed)}") + return + } if (sniffedVideoList.any { it.url == trimmed || VideoSnifferHelper.isLikelyVariantOf(trimmed, it.url) }) return val effectiveTitle = title.ifBlank { currentPageTitleProvider().ifBlank { "在线影视" } } @@ -99,7 +113,7 @@ class VideoSnifferController( sniffedVideoList.add(SniffedVideo(trimmed, effectiveTitle, streamType, kind, frameUrl)) currentSniffedVideoUrl = trimmed currentSniffedVideoTitle = effectiveTitle - Log.d(TAG, "Video sniffed[$kind]: $trimmed (total streams=${sniffedVideoList.size})") + Logx.i(TAG, "accept kind=$kind hls=$isHls total=${sniffedVideoList.size} url=${Logx.redactUrl(trimmed)}") btnNativePlay.visibility = View.VISIBLE btnFloatingPlay.visibility = View.VISIBLE @@ -131,7 +145,7 @@ class VideoSnifferController( if (page == autoPlayHandledPage) return autoPlayPage = page autoPlayRemaining = AUTO_PLAY_SECONDS - Log.d(TAG, "auto play countdown start: $page") + Logx.i(TAG, "auto play countdown start: $page") updatePlayButtonsText() autoPlayHandler.removeCallbacks(autoPlayTick) autoPlayHandler.postDelayed(autoPlayTick, 1000) @@ -159,7 +173,7 @@ class VideoSnifferController( val video = sniffedVideoList.firstOrNull { VideoSnifferHelper.isHls(it.url, it.kind) } ?: sniffedVideoList.firstOrNull() if (video != null) { - Log.d(TAG, "auto play: ${video.url}") + Logx.i(TAG, "auto play: ${Logx.redactUrl(video.url)}") startNativePlayer(video) } } @@ -205,7 +219,7 @@ class VideoSnifferController( currentPageKey()?.let { autoPlayHandledPage = it } onSuspendWebMedia() val source = buildVideoSource(video) - Log.d(TAG, "launch native: ${source.url} page=${source.pageUrl} headers=${source.headers.keys}") + Logx.i(TAG, "launch native hls=${source.isHls} url=${Logx.redactUrl(source.url)} headers=${source.headers.keys}") PlayerActivity.start(activity, source, siteInfoForCurrentPage()) } @@ -273,7 +287,7 @@ class VideoSnifferController( } activity.startActivity(Intent.createChooser(intent, "选择外部播放器")) } catch (e: Exception) { - Log.e(TAG, "External player launch failed", e) + Logx.e(TAG, "External player launch failed", e) Toast.makeText(activity, "未找到外部播放器,切换内置原生播放", Toast.LENGTH_SHORT).show() PlayerActivity.start(activity, source, siteInfoForCurrentPage()) } @@ -282,5 +296,7 @@ class VideoSnifferController( companion object { private const val TAG = "TVBrowser.Video" private const val AUTO_PLAY_SECONDS = 3 + /** 丢弃日志的地址记忆上限,超出直接清空(宁可能重复记录,也不无限增长) */ + private const val MAX_DROP_LOG = 200 } } diff --git a/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt b/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt index c909c5d..6160735 100644 --- a/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt +++ b/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt @@ -1,7 +1,6 @@ package com.tv.browser import android.net.Uri -import android.util.Log import android.webkit.CookieManager import android.webkit.WebResourceResponse import android.webkit.WebView @@ -212,12 +211,16 @@ object VideoSnifferHelper { * @return true 表示已安装;false 表示系统 WebView 太旧,调用方需在 onPageStarted 用 evaluateJavascript 兜底 */ fun installDocumentStartHook(webView: WebView): Boolean { - if (!supportsDocumentStartScript()) return false + if (!supportsDocumentStartScript()) { + Logx.w(TAG, "DOCUMENT_START_SCRIPT unavailable: hook will be injected late (may miss first video request)") + return false + } return try { WebViewCompat.addDocumentStartJavaScript(webView, JS_DOCUMENT_START_HOOK, setOf("*")) + Logx.i(TAG, "document-start hook installed for all frames") true } catch (e: Throwable) { - Log.w(TAG, "addDocumentStartJavaScript failed", e) + Logx.w(TAG, "addDocumentStartJavaScript failed: ${e.message}", e) false } } diff --git a/app/src/main/res/layout/dialog_settings.xml b/app/src/main/res/layout/dialog_settings.xml index c62ebcf..4aee1b0 100644 --- a/app/src/main/res/layout/dialog_settings.xml +++ b/app/src/main/res/layout/dialog_settings.xml @@ -820,6 +820,48 @@ android:textSize="14sp" android:textStyle="bold" /> + + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index da170b0..7a669b3 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,4 +3,10 @@ + + + + + + diff --git a/app/src/test/java/com/tv/browser/CoreLogicTest.kt b/app/src/test/java/com/tv/browser/CoreLogicTest.kt index 9954e42..a28f119 100644 --- a/app/src/test/java/com/tv/browser/CoreLogicTest.kt +++ b/app/src/test/java/com/tv/browser/CoreLogicTest.kt @@ -191,4 +191,20 @@ class CoreLogicTest { assertFalse(RemoteControlServer.isAllowedUploadFilename("page.html")) assertFalse(RemoteControlServer.isAllowedUploadFilename("")) } + + @Test + fun testLogxRedactUrl() { + // 签名 / token 都在 query 里,导出日志时必须抹掉 + assertEquals("https://v3-default.douyinvod.com/a/b/video/", Logx.redactUrl("https://v3-default.douyinvod.com/a/b/video/")) + assertEquals( + "https://cdn.example.com/hls/index.m3u8?", + Logx.redactUrl("https://cdn.example.com/hls/index.m3u8?sign=abc&expires=123") + ) + assertEquals( + "https://cdn.example.com/a.mp4?", + Logx.redactUrl("https://cdn.example.com/a.mp4#t=10&x=1?y=2") + ) + assertEquals("", Logx.redactUrl(null)) + assertEquals("", Logx.redactUrl(" ")) + } }