@@ -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(" "))
+ }
}