This commit is contained in:
haomingming
2026-11-10 16:40:07 +08:00
parent 4c60d742ee
commit d3ea2e35d7
14 changed files with 734 additions and 35 deletions
+64
View File
@@ -266,6 +266,20 @@
</div>
</div>
<!-- 诊断日志下载卡片 -->
<div class="push-url-card log-card">
<div class="push-url-title" style="color: #34d399;">
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-2 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
<span>导出电视诊断日志</span>
</div>
<div class="push-url-row">
<button id="btnDownloadLog" style="flex: 1; background: linear-gradient(135deg, #059669 0%, #0d9488 100%);">📄 下载电视日志(txt)</button>
</div>
<div id="logHint" style="font-size:12px; color:#94a3b8; margin-top:6px; line-height:1.4;">
包含系统与 WebView 内核版本、解码能力、当前设置、崩溃日志与运行日志,视频播放异常时先导这个。
</div>
</div>
<!-- 触控板 -->
<div class="touchpad" id="touchpad">
<div class="hint-icon">
@@ -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;
@@ -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
}
@@ -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<File> = 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<Pair<String, String>> = 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
)
}
+127
View File
@@ -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<String>(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?<redacted>" else cut
}
}
@@ -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
}
}
@@ -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}")
@@ -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. 关于与按键面板
// =========================================================================
@@ -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<File> = 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"
@@ -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)
@@ -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<String>()
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
}
}
@@ -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
}
}
@@ -820,6 +820,48 @@
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
<!-- 导出诊断日志 -->
<LinearLayout
android:id="@+id/cardExportLog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/setting_card_bg"
android:orientation="horizontal"
android:padding="14dp"
android:focusable="true"
android:gravity="center_vertical"
android:layout_marginBottom="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="导出诊断日志(排查播放问题)"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvLogStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="包含系统/WebView 内核版本、解码能力、当前设置、崩溃与运行日志"
android:textColor="#94A3B8"
android:textSize="12sp"
android:layout_marginTop="3dp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="导出"
android:textColor="#38BDF8"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</ScrollView>
+6
View File
@@ -3,4 +3,10 @@
<external-files-path name="uploads" path="uploads" />
<files-path name="internal_uploads" path="uploads" />
<cache-path name="cache" path="." />
<!-- 诊断日志(可分享 / 手机遥控页下载) -->
<external-files-path name="diagnostics" path="diagnostics" />
<files-path name="internal_diagnostics" path="diagnostics" />
<!-- 崩溃日志现在优先写外置目录,旧版本可能仍留在内部目录 -->
<external-files-path name="crash_logs" path="crash_logs" />
<files-path name="internal_crash_logs" path="crash_logs" />
</paths>
@@ -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?<redacted>",
Logx.redactUrl("https://cdn.example.com/hls/index.m3u8?sign=abc&expires=123")
)
assertEquals(
"https://cdn.example.com/a.mp4?<redacted>",
Logx.redactUrl("https://cdn.example.com/a.mp4#t=10&x=1?y=2")
)
assertEquals("", Logx.redactUrl(null))
assertEquals("", Logx.redactUrl(" "))
}
}