4kvm
This commit is contained in:
@@ -266,6 +266,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="touchpad" id="touchpad">
|
||||||
<div class="hint-icon">
|
<div class="hint-icon">
|
||||||
@@ -461,6 +475,56 @@
|
|||||||
xhr.send(formData);
|
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 touchpad = document.getElementById('touchpad');
|
||||||
var lastX = 0, lastY = 0;
|
var lastX = 0, lastY = 0;
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
|||||||
webView.addJavascriptInterface(NavGuardBridge(), "AndroidNavGuard")
|
webView.addJavascriptInterface(NavGuardBridge(), "AndroidNavGuard")
|
||||||
webView.addJavascriptInterface(PurifyBridge(), PurifyHelper.BRIDGE_NAME)
|
webView.addJavascriptInterface(PurifyBridge(), PurifyHelper.BRIDGE_NAME)
|
||||||
hasDocStartHook = VideoSnifferHelper.installDocumentStartHook(webView)
|
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)
|
val prefs = getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE)
|
||||||
currentZoomLevel = prefs.getInt(KEY_ZOOM_LEVEL, 100)
|
currentZoomLevel = prefs.getInt(KEY_ZOOM_LEVEL, 100)
|
||||||
@@ -258,16 +258,33 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
|||||||
}
|
}
|
||||||
snifferController.requestHeaderCache.put(reqUrl, request.requestHeaders)
|
snifferController.requestHeaderCache.put(reqUrl, request.requestHeaders)
|
||||||
if (request.isForMainFrame) updateContentFilterState(reqUrl)
|
if (request.isForMainFrame) updateContentFilterState(reqUrl)
|
||||||
if (AdBlockHelper.isEnabled(this@BrowserActivity) && AdBlockHelper.shouldBlockUrl(reqUrl)) return AdBlockHelper.createEmptyResponse()
|
if (AdBlockHelper.isEnabled(this@BrowserActivity) && AdBlockHelper.shouldBlockUrl(reqUrl)) {
|
||||||
if (contentFilterActive && ContentFilterHelper.shouldBlockImage(request, currentPageHost)) return ContentFilterHelper.createEmptyImageResponse()
|
Logx.d("Block", "adblock ${Logx.redactUrl(reqUrl)}")
|
||||||
if (contentFilterActive && ContentFilterHelper.shouldBlockScript(request, currentPageHost)) return ContentFilterHelper.createEmptyScriptResponse()
|
return AdBlockHelper.createEmptyResponse()
|
||||||
VideoSnifferHelper.createCompatScriptResponse(reqUrl)?.let { return it }
|
}
|
||||||
if (VideoSnifferHelper.isVideoStreamUrl(reqUrl)) onVideoSniffed(reqUrl, currentPageTitle, "net")
|
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)
|
return super.shouldInterceptRequest(view, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
super.onPageStarted(view, url, favicon)
|
super.onPageStarted(view, url, favicon)
|
||||||
|
Logx.i("Page", "start ${Logx.redactUrl(url)} ua=${if (isDesktopUa) "desktop" else "mobile"}")
|
||||||
progressBar.visibility = View.VISIBLE
|
progressBar.visibility = View.VISIBLE
|
||||||
if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "")
|
if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "")
|
||||||
currentPageTitle = ""
|
currentPageTitle = ""
|
||||||
@@ -302,6 +319,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
|||||||
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
|
Logx.i("Page", "finished ${Logx.redactUrl(url)} title=${currentPageTitle.take(40)}")
|
||||||
progressBar.visibility = View.GONE
|
progressBar.visibility = View.GONE
|
||||||
if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "")
|
if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "")
|
||||||
if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null)
|
if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null)
|
||||||
@@ -361,8 +379,15 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||||
if (BuildConfig.DEBUG) {
|
// 站点自身的 JS 报错是电视端最有力的证据(例如旧内核缺 CSS/JS 能力导致的异常),
|
||||||
consoleMessage?.let { Log.d(TAG_VIDEO, "JS console [${it.messageLevel()}] ${it.sourceId()}:${it.lineNumber()} ${it.message()}") }
|
// 因此不论 DEBUG / release 都写进可导出的运行日志缓冲
|
||||||
|
consoleMessage?.let {
|
||||||
|
Logx.console(
|
||||||
|
it.messageLevel()?.name ?: "LOG",
|
||||||
|
it.sourceId().orEmpty(),
|
||||||
|
it.lineNumber(),
|
||||||
|
it.message().orEmpty()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
binding.tvStatus.visibility = View.GONE
|
||||||
stallHintShown = false
|
stallHintShown = false
|
||||||
readySinceReResolve = true
|
readySinceReResolve = true
|
||||||
|
logPlaybackFormat()
|
||||||
updateProgress(forceUi = true)
|
updateProgress(forceUi = true)
|
||||||
maybeSkipIntro()
|
maybeSkipIntro()
|
||||||
failedSources.clear()
|
failedSources.clear()
|
||||||
@@ -424,7 +425,11 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
if (!canPlayNow) p.pause()
|
if (!canPlayNow) p.pause()
|
||||||
if (carried <= 0 && record != null && startPos > 0) showFeedback("已自动续播至 ${record.formatPosition()}")
|
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
|
lastSavedSec = 0
|
||||||
suppressSave = false
|
suppressSave = false
|
||||||
introChecked = 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) {
|
private fun handlePlayerError(error: PlaybackException) {
|
||||||
binding.playerLoading.visibility = View.GONE
|
binding.playerLoading.visibility = View.GONE
|
||||||
val cause = PlayerSsl.rootCauseText(error)
|
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) {
|
if (isInBackground) {
|
||||||
pauseForBackground()
|
pauseForBackground()
|
||||||
return
|
return
|
||||||
@@ -703,7 +734,7 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailed(reason: String) {
|
override fun onFailed(reason: String) {
|
||||||
Log.w(TAG, "preload #$nextIndex failed: $reason")
|
Logx.w(TAG, "preload #$nextIndex failed: $reason")
|
||||||
// 失败不重试,切集时再正常解析;保持 preloadingIndex 防止反复触发
|
// 失败不重试,切集时再正常解析;保持 preloadingIndex 防止反复触发
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -896,10 +927,38 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
.setTitle("📊 播放详细参数")
|
.setTitle("📊 播放详细参数")
|
||||||
.setView(scroll)
|
.setView(scroll)
|
||||||
.setPositiveButton("确定", null)
|
.setPositiveButton("确定", null)
|
||||||
|
// 播放问题现场直接导出诊断日志(含解码器、内核能力、运行日志),不用连 adb
|
||||||
|
.setNeutralButton("导出日志") { _, _ -> exportDiagnostics() }
|
||||||
.setOnDismissListener { showOsdTemporarily() }
|
.setOnDismissListener { showOsdTemporarily() }
|
||||||
.show()
|
.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() {
|
private fun updateEpisodeUi() {
|
||||||
binding.btnEpisodes.visibility = if (episodes.size > 1) View.VISIBLE else View.GONE
|
binding.btnEpisodes.visibility = if (episodes.size > 1) View.VISIBLE else View.GONE
|
||||||
binding.btnNext.visibility = if (hasNextEpisode()) 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()
|
Toast.makeText(this, "当前电视设备不支持画中画功能", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "enterPictureInPictureMode failed", e)
|
Logx.e(TAG, "enterPictureInPictureMode failed", e)
|
||||||
Toast.makeText(this, "开启画中画失败", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "开启画中画失败", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1358,7 +1417,7 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
startActivity(Intent.createChooser(intent, "选择外部播放器"))
|
startActivity(Intent.createChooser(intent, "选择外部播放器"))
|
||||||
} catch (e: Exception) {
|
} 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()
|
Toast.makeText(this, "未找到已安装的外部视频播放器", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1747,7 +1806,7 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
context.startActivity(inst.buildRestoreIntent())
|
context.startActivity(inst.buildRestoreIntent())
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "restore from PiP failed", e)
|
Logx.e(TAG, "restore from PiP failed", e)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ class RemoteControlServer(
|
|||||||
return !headerToken.isNullOrEmpty() && headerToken == sessionToken
|
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 {
|
override fun serve(session: IHTTPSession): NanoHTTPD.Response {
|
||||||
val uri = session.uri
|
val uri = session.uri
|
||||||
if (session.method == NanoHTTPD.Method.GET && (uri == "/" || uri == "/remote" || uri.isEmpty())) {
|
if (session.method == NanoHTTPD.Method.GET && (uri == "/" || uri == "/remote" || uri.isEmpty())) {
|
||||||
@@ -89,6 +96,32 @@ class RemoteControlServer(
|
|||||||
htmlBytes
|
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 (session.method == NanoHTTPD.Method.POST && (uri == "/cmd" || uri == "/upload")) {
|
||||||
if (!isAuthorized(session)) {
|
if (!isAuthorized(session)) {
|
||||||
Log.w(TAG, "Rejected unauthorized request to $uri from ${session.remoteIpAddress}")
|
Log.w(TAG, "Rejected unauthorized request to $uri from ${session.remoteIpAddress}")
|
||||||
|
|||||||
@@ -344,6 +344,11 @@ class SettingsDialog : DialogFragment() {
|
|||||||
bindCardLeftKeyToTab(b.cardClearCache, 3)
|
bindCardLeftKeyToTab(b.cardClearCache, 3)
|
||||||
bindCardLeftKeyToTab(b.cardClearHistory, 3)
|
bindCardLeftKeyToTab(b.cardClearHistory, 3)
|
||||||
bindCardLeftKeyToTab(b.cardNetworkDiag, 3)
|
bindCardLeftKeyToTab(b.cardNetworkDiag, 3)
|
||||||
|
bindCardLeftKeyToTab(b.cardExportLog, 3)
|
||||||
|
|
||||||
|
// 0) 导出诊断日志(电视端排查播放问题的第一入口)
|
||||||
|
refreshLogStatus()
|
||||||
|
b.cardExportLog.setOnClickListener { exportDiagnostics() }
|
||||||
|
|
||||||
// 1) 清除缓存
|
// 1) 清除缓存
|
||||||
b.cardClearCache.setOnClickListener {
|
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. 关于与按键面板
|
// 6. 关于与按键面板
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -33,14 +33,23 @@ class TVBrowserApplication : Application() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun pruneCrashLogsAsync() {
|
private fun pruneCrashLogsAsync() {
|
||||||
val dir = File(filesDir, CRASH_LOG_DIR)
|
val dirs = crashDirs()
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
try {
|
||||||
pruneCrashLogs(dir)
|
dirs.forEach { pruneCrashLogs(it) }
|
||||||
} catch (_: Throwable) { }
|
} catch (_: Throwable) { }
|
||||||
}.start()
|
}.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) {
|
private fun pruneCrashLogs(dir: File) {
|
||||||
if (!dir.exists()) return
|
if (!dir.exists()) return
|
||||||
val files = dir.listFiles { f -> f.isFile && f.name.startsWith("crash_") } ?: 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) {
|
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()
|
if (!dir.exists()) dir.mkdirs()
|
||||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
|
||||||
val fileName = "crash_${dateFormat.format(Date())}.txt"
|
val fileName = "crash_${dateFormat.format(Date())}.txt"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import android.graphics.Bitmap
|
|||||||
import android.net.http.SslError
|
import android.net.http.SslError
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.SslErrorHandler
|
import android.webkit.SslErrorHandler
|
||||||
@@ -75,7 +74,7 @@ class VideoResolver(
|
|||||||
generation++
|
generation++
|
||||||
val wv = createWebView(generation)
|
val wv = createWebView(generation)
|
||||||
webView = wv
|
webView = wv
|
||||||
Log.d(TAG, "resolve: $pageUrl (docStartHook=$hasDocStartHook)")
|
Logx.i(TAG, "resolve: ${Logx.redactUrl(pageUrl)} (docStartHook=$hasDocStartHook)")
|
||||||
wv.loadUrl(pageUrl)
|
wv.loadUrl(pageUrl)
|
||||||
handler.postDelayed(timeoutRunnable, timeoutMs)
|
handler.postDelayed(timeoutRunnable, timeoutMs)
|
||||||
}
|
}
|
||||||
@@ -184,7 +183,7 @@ class VideoResolver(
|
|||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun onPlaylist(url: String, finalUrl: String, text: String) {
|
fun onPlaylist(url: String, finalUrl: String, text: String) {
|
||||||
PlaylistCache.put(url, finalUrl, text)
|
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
|
@JavascriptInterface
|
||||||
@@ -204,14 +203,14 @@ class VideoResolver(
|
|||||||
if (gen != generation || finished) return
|
if (gen != generation || finished) return
|
||||||
if (!url.startsWith("http")) return
|
if (!url.startsWith("http")) return
|
||||||
if (VideoSnifferHelper.isWebOnlyStream(url)) {
|
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)
|
fail(VideoSnifferHelper.WEB_ONLY_MESSAGE)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (AdBlockHelper.isEnabled(context) && AdBlockHelper.shouldBlockUrl(url)) return
|
if (AdBlockHelper.isEnabled(context) && AdBlockHelper.shouldBlockUrl(url)) return
|
||||||
if (candidates.any { it.url == url || VideoSnifferHelper.isLikelyVariantOf(url, it.url) }) return
|
if (candidates.any { it.url == url || VideoSnifferHelper.isLikelyVariantOf(url, it.url) }) return
|
||||||
candidates.add(Candidate(url, kind, frameUrl, title))
|
candidates.add(Candidate(url, kind, frameUrl, title))
|
||||||
Log.d(TAG, "candidate[$kind]: $url")
|
Logx.i(TAG, "candidate[$kind]: ${Logx.redactUrl(url)}")
|
||||||
handler.removeCallbacks(finalizeRunnable)
|
handler.removeCallbacks(finalizeRunnable)
|
||||||
if (candidates.any { VideoSnifferHelper.isHls(it.url, it.kind) }) {
|
if (candidates.any { VideoSnifferHelper.isHls(it.url, it.kind) }) {
|
||||||
// 拿到 HLS:稍等片刻让对应的网络请求头进入缓存
|
// 拿到 HLS:稍等片刻让对应的网络请求头进入缓存
|
||||||
@@ -257,7 +256,7 @@ class VideoResolver(
|
|||||||
headers = headers,
|
headers = headers,
|
||||||
isHls = VideoSnifferHelper.isHls(best.url, best.kind)
|
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
|
val cb = callback
|
||||||
finished = true
|
finished = true
|
||||||
handler.removeCallbacks(timeoutRunnable)
|
handler.removeCallbacks(timeoutRunnable)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
@@ -41,6 +40,9 @@ class VideoSnifferController(
|
|||||||
private var autoPlayHandledPage: String? = null
|
private var autoPlayHandledPage: String? = null
|
||||||
private var autoPlayPage: String? = null
|
private var autoPlayPage: String? = null
|
||||||
private var autoPlayRemaining = 0
|
private var autoPlayRemaining = 0
|
||||||
|
|
||||||
|
/** 已记录过「候选被丢弃」的地址,避免同一地址在日志里刷屏 */
|
||||||
|
private val dropLogged = HashSet<String>()
|
||||||
private val autoPlayHandler = Handler(Looper.getMainLooper())
|
private val autoPlayHandler = Handler(Looper.getMainLooper())
|
||||||
private val autoPlayTick = Runnable { onAutoPlayTick() }
|
private val autoPlayTick = Runnable { onAutoPlayTick() }
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ class VideoSnifferController(
|
|||||||
webOnlyNoticeShown = false
|
webOnlyNoticeShown = false
|
||||||
currentSiteInfo = null
|
currentSiteInfo = null
|
||||||
currentSiteInfoJson = null
|
currentSiteInfoJson = null
|
||||||
|
dropLogged.clear()
|
||||||
btnNativePlay.visibility = View.GONE
|
btnNativePlay.visibility = View.GONE
|
||||||
btnFloatingPlay.visibility = View.GONE
|
btnFloatingPlay.visibility = View.GONE
|
||||||
}
|
}
|
||||||
@@ -78,7 +81,7 @@ class VideoSnifferController(
|
|||||||
|
|
||||||
// 央视网等加密流:原生播放会花屏,留在网页里播放
|
// 央视网等加密流:原生播放会花屏,留在网页里播放
|
||||||
if (VideoSnifferHelper.isWebOnlyStream(trimmed)) {
|
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) {
|
if (!webOnlyNoticeShown) {
|
||||||
webOnlyNoticeShown = true
|
webOnlyNoticeShown = true
|
||||||
Toast.makeText(activity, VideoSnifferHelper.WEB_ONLY_MESSAGE, Toast.LENGTH_LONG).show()
|
Toast.makeText(activity, VideoSnifferHelper.WEB_ONLY_MESSAGE, Toast.LENGTH_LONG).show()
|
||||||
@@ -86,8 +89,19 @@ class VideoSnifferController(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val isHls = VideoSnifferHelper.isHls(trimmed, kind)
|
val isHls = VideoSnifferHelper.isHls(trimmed, kind)
|
||||||
if (!isHls && !VideoSnifferHelper.isVideoStreamUrl(trimmed)) return
|
if (!isHls && !VideoSnifferHelper.isVideoStreamUrl(trimmed)) {
|
||||||
if (AdBlockHelper.isEnabled(activity) && AdBlockHelper.shouldBlockUrl(trimmed)) return
|
// 关键排查点:候选被丢弃必须留痕。电视端「嗅探不到视频源」大多就是这里静默 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
|
if (sniffedVideoList.any { it.url == trimmed || VideoSnifferHelper.isLikelyVariantOf(trimmed, it.url) }) return
|
||||||
|
|
||||||
val effectiveTitle = title.ifBlank { currentPageTitleProvider().ifBlank { "在线影视" } }
|
val effectiveTitle = title.ifBlank { currentPageTitleProvider().ifBlank { "在线影视" } }
|
||||||
@@ -99,7 +113,7 @@ class VideoSnifferController(
|
|||||||
sniffedVideoList.add(SniffedVideo(trimmed, effectiveTitle, streamType, kind, frameUrl))
|
sniffedVideoList.add(SniffedVideo(trimmed, effectiveTitle, streamType, kind, frameUrl))
|
||||||
currentSniffedVideoUrl = trimmed
|
currentSniffedVideoUrl = trimmed
|
||||||
currentSniffedVideoTitle = effectiveTitle
|
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
|
btnNativePlay.visibility = View.VISIBLE
|
||||||
btnFloatingPlay.visibility = View.VISIBLE
|
btnFloatingPlay.visibility = View.VISIBLE
|
||||||
@@ -131,7 +145,7 @@ class VideoSnifferController(
|
|||||||
if (page == autoPlayHandledPage) return
|
if (page == autoPlayHandledPage) return
|
||||||
autoPlayPage = page
|
autoPlayPage = page
|
||||||
autoPlayRemaining = AUTO_PLAY_SECONDS
|
autoPlayRemaining = AUTO_PLAY_SECONDS
|
||||||
Log.d(TAG, "auto play countdown start: $page")
|
Logx.i(TAG, "auto play countdown start: $page")
|
||||||
updatePlayButtonsText()
|
updatePlayButtonsText()
|
||||||
autoPlayHandler.removeCallbacks(autoPlayTick)
|
autoPlayHandler.removeCallbacks(autoPlayTick)
|
||||||
autoPlayHandler.postDelayed(autoPlayTick, 1000)
|
autoPlayHandler.postDelayed(autoPlayTick, 1000)
|
||||||
@@ -159,7 +173,7 @@ class VideoSnifferController(
|
|||||||
val video = sniffedVideoList.firstOrNull { VideoSnifferHelper.isHls(it.url, it.kind) }
|
val video = sniffedVideoList.firstOrNull { VideoSnifferHelper.isHls(it.url, it.kind) }
|
||||||
?: sniffedVideoList.firstOrNull()
|
?: sniffedVideoList.firstOrNull()
|
||||||
if (video != null) {
|
if (video != null) {
|
||||||
Log.d(TAG, "auto play: ${video.url}")
|
Logx.i(TAG, "auto play: ${Logx.redactUrl(video.url)}")
|
||||||
startNativePlayer(video)
|
startNativePlayer(video)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +219,7 @@ class VideoSnifferController(
|
|||||||
currentPageKey()?.let { autoPlayHandledPage = it }
|
currentPageKey()?.let { autoPlayHandledPage = it }
|
||||||
onSuspendWebMedia()
|
onSuspendWebMedia()
|
||||||
val source = buildVideoSource(video)
|
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())
|
PlayerActivity.start(activity, source, siteInfoForCurrentPage())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +287,7 @@ class VideoSnifferController(
|
|||||||
}
|
}
|
||||||
activity.startActivity(Intent.createChooser(intent, "选择外部播放器"))
|
activity.startActivity(Intent.createChooser(intent, "选择外部播放器"))
|
||||||
} catch (e: Exception) {
|
} 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()
|
Toast.makeText(activity, "未找到外部播放器,切换内置原生播放", Toast.LENGTH_SHORT).show()
|
||||||
PlayerActivity.start(activity, source, siteInfoForCurrentPage())
|
PlayerActivity.start(activity, source, siteInfoForCurrentPage())
|
||||||
}
|
}
|
||||||
@@ -282,5 +296,7 @@ class VideoSnifferController(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "TVBrowser.Video"
|
private const val TAG = "TVBrowser.Video"
|
||||||
private const val AUTO_PLAY_SECONDS = 3
|
private const val AUTO_PLAY_SECONDS = 3
|
||||||
|
/** 丢弃日志的地址记忆上限,超出直接清空(宁可能重复记录,也不无限增长) */
|
||||||
|
private const val MAX_DROP_LOG = 200
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.tv.browser
|
package com.tv.browser
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.WebResourceResponse
|
import android.webkit.WebResourceResponse
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
@@ -212,12 +211,16 @@ object VideoSnifferHelper {
|
|||||||
* @return true 表示已安装;false 表示系统 WebView 太旧,调用方需在 onPageStarted 用 evaluateJavascript 兜底
|
* @return true 表示已安装;false 表示系统 WebView 太旧,调用方需在 onPageStarted 用 evaluateJavascript 兜底
|
||||||
*/
|
*/
|
||||||
fun installDocumentStartHook(webView: WebView): Boolean {
|
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 {
|
return try {
|
||||||
WebViewCompat.addDocumentStartJavaScript(webView, JS_DOCUMENT_START_HOOK, setOf("*"))
|
WebViewCompat.addDocumentStartJavaScript(webView, JS_DOCUMENT_START_HOOK, setOf("*"))
|
||||||
|
Logx.i(TAG, "document-start hook installed for all frames")
|
||||||
true
|
true
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.w(TAG, "addDocumentStartJavaScript failed", e)
|
Logx.w(TAG, "addDocumentStartJavaScript failed: ${e.message}", e)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -820,6 +820,48 @@
|
|||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
</LinearLayout>
|
</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>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,10 @@
|
|||||||
<external-files-path name="uploads" path="uploads" />
|
<external-files-path name="uploads" path="uploads" />
|
||||||
<files-path name="internal_uploads" path="uploads" />
|
<files-path name="internal_uploads" path="uploads" />
|
||||||
<cache-path name="cache" path="." />
|
<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>
|
</paths>
|
||||||
|
|||||||
@@ -191,4 +191,20 @@ class CoreLogicTest {
|
|||||||
assertFalse(RemoteControlServer.isAllowedUploadFilename("page.html"))
|
assertFalse(RemoteControlServer.isAllowedUploadFilename("page.html"))
|
||||||
assertFalse(RemoteControlServer.isAllowedUploadFilename(""))
|
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(" "))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user