From 76ec185e7650edac1b3f6889a9f7605ba750a71a Mon Sep 17 00:00:00 2001 From: haomingming Date: Mon, 9 Nov 2026 15:49:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 15 ++ .gitignore | 13 +- app/build.gradle.kts | 6 +- app/src/main/AndroidManifest.xml | 10 +- app/src/main/assets/remote.html | 21 +- .../main/java/com/tv/browser/AdBlockHelper.kt | 9 +- .../main/java/com/tv/browser/AppSettings.kt | 5 +- .../java/com/tv/browser/BookmarkAdapter.kt | 42 --- .../java/com/tv/browser/BookmarkRepository.kt | 98 +++---- .../java/com/tv/browser/BookmarksDialog.kt | 87 ------ .../java/com/tv/browser/BrowserActivity.kt | 80 ++++-- .../java/com/tv/browser/BrowserUiHelper.kt | 27 +- .../java/com/tv/browser/HistoryRepository.kt | 83 ++---- .../main/java/com/tv/browser/JsonListStore.kt | 81 ++++++ .../java/com/tv/browser/NetworkChecker.kt | 48 ++-- .../tv/browser/PlaybackHistoryRepository.kt | 104 +++----- .../java/com/tv/browser/PlayerActivity.kt | 170 ++++++++---- app/src/main/java/com/tv/browser/PlayerSsl.kt | 73 +++-- .../main/java/com/tv/browser/PlaylistCache.kt | 10 +- .../com/tv/browser/RemoteControlCallback.kt | 2 +- .../com/tv/browser/RemoteControlDialog.kt | 24 -- .../com/tv/browser/RemoteControlHelper.kt | 45 ++-- .../com/tv/browser/RemoteControlServer.kt | 96 +++++-- .../java/com/tv/browser/SettingsDialog.kt | 3 +- .../com/tv/browser/TVBrowserApplication.kt | 24 +- .../com/tv/browser/UserScriptRepository.kt | 74 +++-- .../java/com/tv/browser/VideoControlHelper.kt | 34 --- .../java/com/tv/browser/VideoSnifferHelper.kt | 58 ++-- .../main/java/com/tv/browser/VideoSource.kt | 23 -- .../java/com/tv/browser/VideoWebActivity.kt | 112 -------- .../java/com/tv/browser/VodSearchHelper.kt | 28 +- app/src/main/res/drawable/btn_focus_bg.xml | 4 +- .../main/res/layout/activity_video_web.xml | 11 - app/src/main/res/layout/dialog_bookmarks.xml | 57 ---- .../res/layout/dialog_video_play_mode.xml | 53 ---- app/src/main/res/layout/item_bookmark.xml | 55 ---- app/src/main/res/layout/video_control_bar.xml | 82 ------ app/src/main/res/values/colors.xml | 17 +- app/src/main/res/values/strings.xml | 24 -- app/src/main/res/xml/file_paths.xml | 1 + .../test/java/com/tv/browser/CoreLogicTest.kt | 194 ++++++++++++++ classes.jar | Bin 1456906 -> 0 bytes gradle.properties | 2 + gradle/libs.versions.toml | 11 - gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 252 ++++++++++++++++++ gradlew.bat | 94 +++++++ 优化检测.md | 142 ++++++++++ 49 files changed, 1412 insertions(+), 1094 deletions(-) create mode 100644 .gitattributes delete mode 100644 app/src/main/java/com/tv/browser/BookmarkAdapter.kt delete mode 100644 app/src/main/java/com/tv/browser/BookmarksDialog.kt create mode 100644 app/src/main/java/com/tv/browser/JsonListStore.kt delete mode 100644 app/src/main/java/com/tv/browser/VideoWebActivity.kt delete mode 100644 app/src/main/res/layout/activity_video_web.xml delete mode 100644 app/src/main/res/layout/dialog_bookmarks.xml delete mode 100644 app/src/main/res/layout/dialog_video_play_mode.xml delete mode 100644 app/src/main/res/layout/item_bookmark.xml delete mode 100644 app/src/main/res/layout/video_control_bar.xml create mode 100644 app/src/test/java/com/tv/browser/CoreLogicTest.kt delete mode 100644 classes.jar create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 优化检测.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c1481af --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto +*.kt text eol=lf +*.kts text eol=lf +*.xml text eol=lf +*.html text eol=lf +*.js text eol=lf +*.md text eol=lf +*.properties text eol=lf +*.toml text eol=lf +*.pro text eol=lf +*.sh text eol=lf +*.bat text eol=crlf +*.png binary +*.webp binary +*.jar binary diff --git a/.gitignore b/.gitignore index b12708f..f75c835 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,18 @@ /build/ .idea/ .gradle/* +.kotlin/ */release */debug */build/ -local.properties \ No newline at end of file +local.properties +*.apk +*.aab +*.ap_ +*.dex +*.class +/classes.jar +*.jks +*.keystore +.DS_Store +Thumbs.db diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 12b073a..4928743 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,13 +42,9 @@ android { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) - implementation(libs.material) - implementation(libs.constraintlayout) - implementation(libs.leanback) implementation(libs.fragment.ktx) implementation(libs.recyclerview) implementation(libs.nanohttpd) - implementation(libs.nanohttpd.websocket) implementation(libs.zxing.core) // 原生播放器:ExoPlayer(支持 HLS、自定义请求头、倍速) implementation(libs.media3.exoplayer) @@ -56,4 +52,6 @@ dependencies { implementation(libs.media3.ui) // 页面执行前注入脚本(可进入所有 iframe) implementation(libs.androidx.webkit) + + testImplementation(libs.junit) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 979132a..4a690e6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ - - - + s.equals(scheme, ignoreCase = true) } + val scheme = lower.substringBefore(':', "") + return scheme.isNotEmpty() && BLOCKED_SCHEMES.any { s -> s.equals(scheme, ignoreCase = true) } } /** diff --git a/app/src/main/java/com/tv/browser/AppSettings.kt b/app/src/main/java/com/tv/browser/AppSettings.kt index 505271d..07f797e 100644 --- a/app/src/main/java/com/tv/browser/AppSettings.kt +++ b/app/src/main/java/com/tv/browser/AppSettings.kt @@ -107,7 +107,10 @@ object AppSettings { /** 剧集分组 key:站点主域 + 剧名(同一站点同一部剧共用片头片尾设置与追剧记录) */ fun seriesKey(pageUrl: String?, seriesTitle: String): String { if (seriesTitle.isBlank()) return "" - val host = pageUrl?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() } + val host = pageUrl?.let { + runCatching { java.net.URI(it.trim()).host }.getOrNull() + ?: runCatching { android.net.Uri.parse(it).host }.getOrNull() + } val domain = ContentFilterHelper.registrableDomain(host) ?: return "" return "$domain|${seriesTitle.trim()}" } diff --git a/app/src/main/java/com/tv/browser/BookmarkAdapter.kt b/app/src/main/java/com/tv/browser/BookmarkAdapter.kt deleted file mode 100644 index 88cb6e6..0000000 --- a/app/src/main/java/com/tv/browser/BookmarkAdapter.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tv.browser - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import com.tv.browser.databinding.ItemBookmarkBinding - -/** - * 收藏列表适配器,支持点击打开、移除。 - */ -class BookmarkAdapter( - private val context: Context, - private var list: List, - private val onOpen: (Bookmark) -> Unit, - private val onRemove: (Bookmark) -> Unit -) : BaseAdapter() { - - fun updateList(newList: List) { - list = newList - notifyDataSetChanged() - } - - override fun getCount(): Int = list.size - override fun getItem(position: Int): Bookmark = list[position] - override fun getItemId(position: Int): Long = position.toLong() - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val binding = if (convertView == null) { - ItemBookmarkBinding.inflate(LayoutInflater.from(context), parent, false) - } else { - ItemBookmarkBinding.bind(convertView) - } - val item = getItem(position) - binding.bookmarkTitle.text = item.displayTitle() - binding.bookmarkUrl.text = item.url - binding.root.setOnClickListener { onOpen(item) } - binding.btnRemove.setOnClickListener { onRemove(item) } - return binding.root - } -} diff --git a/app/src/main/java/com/tv/browser/BookmarkRepository.kt b/app/src/main/java/com/tv/browser/BookmarkRepository.kt index 2f2bfb3..3cf3e92 100644 --- a/app/src/main/java/com/tv/browser/BookmarkRepository.kt +++ b/app/src/main/java/com/tv/browser/BookmarkRepository.kt @@ -2,67 +2,39 @@ package com.tv.browser import android.content.Context import android.content.SharedPreferences -import org.json.JSONArray import org.json.JSONObject /** - * 使用内存缓存 + SharedPreferences 持久化收藏列表。 + * 使用 [JsonListStore](内存缓存 + SharedPreferences)持久化收藏列表。 */ class BookmarkRepository(context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val keyBookmarks = "bookmarks" - private val keyInitialized = "bookmarks_initialized" + private val appContext = context.applicationContext - fun getAll(): List { - cachedBookmarks?.let { return it } - synchronized(Companion) { - cachedBookmarks?.let { return it } - val isInitialized = prefs.getBoolean(keyInitialized, false) - val json = prefs.getString(keyBookmarks, null) - if (!isInitialized && json == null) { - save(DEFAULT_BOOKMARKS) - prefs.edit() - .putBoolean(keyInitialized, true) - .putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION) - .apply() - cachedBookmarks = DEFAULT_BOOKMARKS - return DEFAULT_BOOKMARKS - } - val list = parse(json) - val migrated = migrateDefaults(list) - cachedBookmarks = migrated - return migrated - } - } - - private fun parse(json: String?): List { - if (json.isNullOrBlank()) { - return emptyList() - } - return try { - val arr = JSONArray(json) - List(arr.length()) { i -> - val obj = arr.getJSONObject(i) - Bookmark( - url = obj.getString(KEY_URL), - title = obj.optString(KEY_TITLE, "") - ) - } - } catch (_: Exception) { - emptyList() + fun getAll(): List = store.getAll(appContext) { prefs, parsed -> + val isInitialized = prefs.getBoolean(KEY_INITIALIZED, false) + val rawJson = prefs.getString(KEY_BOOKMARKS, null) + if (!isInitialized && rawJson == null) { + store.save(appContext, DEFAULT_BOOKMARKS) + prefs.edit() + .putBoolean(KEY_INITIALIZED, true) + .putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION) + .apply() + DEFAULT_BOOKMARKS + } else { + migrateDefaults(prefs, parsed) } } /** 老用户升级:把之后版本新增的默认站点补到最前面(只补一次) */ - private fun migrateDefaults(list: List): List { + private fun migrateDefaults(prefs: SharedPreferences, list: List): List { val version = prefs.getInt(KEY_DEFAULTS_VERSION, 1) if (version >= DEFAULTS_VERSION) return list val added = (version + 1..DEFAULTS_VERSION) .flatMap { DEFAULTS_ADDED_BY_VERSION[it].orEmpty() } .filter { b -> list.none { it.url == b.url } } val result = if (added.isEmpty()) list else added + list - if (added.isNotEmpty()) save(result) + if (added.isNotEmpty()) store.save(appContext, result) prefs.edit().putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION).apply() return result } @@ -71,38 +43,38 @@ class BookmarkRepository(context: Context) { val list = getAll().toMutableList() list.removeAll { it.url == bookmark.url } list.add(0, bookmark) - save(list) + store.save(appContext, list) } fun remove(url: String) { val list = getAll().filter { it.url != url } - save(list) + store.save(appContext, list) } fun contains(url: String): Boolean = getAll().any { it.url == url } - private fun save(list: List) { - val snapshot = list.toList() - synchronized(Companion) { - cachedBookmarks = snapshot - val arr = JSONArray() - snapshot.forEach { b -> - arr.put(JSONObject().apply { - put(KEY_URL, b.url) - put(KEY_TITLE, b.title) - }) - } - prefs.edit().putString(keyBookmarks, arr.toString()).apply() - } - } - companion object { private const val PREFS_NAME = "tv_browser_bookmarks" + private const val KEY_BOOKMARKS = "bookmarks" + private const val KEY_INITIALIZED = "bookmarks_initialized" private const val KEY_URL = "url" private const val KEY_TITLE = "title" private const val KEY_DEFAULTS_VERSION = "bookmarks_defaults_version" - @Volatile - private var cachedBookmarks: List? = null + + private val store = JsonListStore( + prefsName = PREFS_NAME, + key = KEY_BOOKMARKS, + encode = { b -> + JSONObject().apply { + put(KEY_URL, b.url) + put(KEY_TITLE, b.title) + } + }, + decode = { obj -> + val url = obj.optString(KEY_URL, "") + if (url.isBlank()) null else Bookmark(url = url, title = obj.optString(KEY_TITLE, "")) + } + ) /** 默认收藏版本:新增默认站点时 +1,并在 [DEFAULTS_ADDED_BY_VERSION] 登记 */ private const val DEFAULTS_VERSION = 4 diff --git a/app/src/main/java/com/tv/browser/BookmarksDialog.kt b/app/src/main/java/com/tv/browser/BookmarksDialog.kt deleted file mode 100644 index 9d3f2fc..0000000 --- a/app/src/main/java/com/tv/browser/BookmarksDialog.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.tv.browser - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Window -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment -import com.tv.browser.databinding.DialogBookmarksBinding - -/** - * 收藏夹对话框:列表展示、打开、移除,适配 TV 焦点。 - */ -class BookmarksDialog : DialogFragment() { - - var onOpenUrl: ((String) -> Unit)? = null - /** 弹窗关闭时回调(用于恢复光标等) */ - var onDismissCallback: (() -> Unit)? = null - - private val repo: BookmarkRepository by lazy { - BookmarkRepository(requireContext()) - } - - private var binding: DialogBookmarksBinding? = null - private var adapter: BookmarkAdapter? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogBookmarksBinding.inflate(LayoutInflater.from(requireContext())) - val view = binding!!.root - - adapter = BookmarkAdapter( - context = requireContext(), - list = repo.getAll(), - onOpen = { b -> - onOpenUrl?.invoke(b.url) - dismiss() - }, - onRemove = { b -> - repo.remove(b.url) - refreshList() - Toast.makeText(requireContext(), getString(R.string.bookmark_removed), Toast.LENGTH_SHORT).show() - } - ) - - binding!!.bookmarkList.adapter = adapter - binding!!.bookmarkList.setOnItemClickListener { _, _, position, _ -> - adapter?.getItem(position)?.let { onOpenUrl?.invoke(it.url); dismiss() } - } - binding!!.btnClose.setOnClickListener { dismiss() } - - refreshList() - - val dialog = AlertDialog.Builder(requireContext()) - .setView(view) - .create() - dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) - dialog.setOnDismissListener { onDismissCallback?.invoke() } - dialog.setOnShowListener { - binding?.bookmarkList?.let { list -> - list.requestFocus() - if (list.count > 0) list.setSelection(0) - } - } - return dialog - } - - override fun onStart() { - super.onStart() - (activity as? BrowserActivity)?.setCursorOverlayVisible(false, this) - } - - override fun onDestroyView() { - (activity as? BrowserActivity)?.setCursorOverlayVisible(true, this) - super.onDestroyView() - binding = null - } - - private fun refreshList() { - val list = repo.getAll() - adapter?.updateList(list) - binding?.let { b -> - b.bookmarkList.visibility = if (list.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE - b.emptyHint.visibility = if (list.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE - } - } -} diff --git a/app/src/main/java/com/tv/browser/BrowserActivity.kt b/app/src/main/java/com/tv/browser/BrowserActivity.kt index f15e644..39c35d6 100644 --- a/app/src/main/java/com/tv/browser/BrowserActivity.kt +++ b/app/src/main/java/com/tv/browser/BrowserActivity.kt @@ -8,6 +8,7 @@ import android.content.res.Configuration import android.graphics.Bitmap import android.net.Uri import android.net.http.SslError +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -19,7 +20,6 @@ import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface import android.webkit.SslErrorHandler @@ -35,17 +35,13 @@ import android.widget.ImageView import android.widget.ProgressBar import android.widget.Toast import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager import androidx.webkit.ScriptHandler import com.tv.browser.databinding.ActivityBrowserBinding import java.io.File -import java.util.Calendar /** * 视频嗅探条目(支持多线路检测与切换) @@ -86,7 +82,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { internal const val KEY_IS_DESKTOP_UA = "key_is_desktop_ua" internal val ZOOM_OPTIONS = intArrayOf(50, 75, 90, 100, 125, 150, 175, 200) - private val DIALOG_FRAGMENT_TAGS = listOf("bookmarks", "user_scripts", "remote", "history", "settings") + private val DIALOG_FRAGMENT_TAGS = listOf("user_scripts", "remote", "history", "settings") /** 当前浏览器使用的 UA(播放器 / 后台解析需与网页保持一致,部分站点的视频令牌与 UA 绑定) */ fun currentUserAgent(context: Context): String { @@ -104,6 +100,8 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { private var isEditingCards: Boolean = false private var currentPageTitle: String = "" private var hasDocStartHook: Boolean = false + /** 当设备不支持 WebViewCompat DOCUMENT_START 注入时,每个页面仅在加载早期注入一次兜底脚本,避免 onProgressChanged 反复注入 20KB 脚本 */ + private var pageDocStartInjected: Boolean = false private var desktopViewportScript: ScriptHandler? = null internal lateinit var binding: ActivityBrowserBinding @@ -137,8 +135,11 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { private val mainHandler = Handler(Looper.getMainLooper()) private val remoteStatusChecker = object : Runnable { override fun run() { + if (!isActivityResumed) return remoteHelper.updateStatusDot(binding.remoteStatusDot) - binding.homeStatusBar.visibility = View.GONE + if (binding.homeStatusBar.visibility != View.GONE) { + binding.homeStatusBar.visibility = View.GONE + } mainHandler.postDelayed(this, 3000) } } @@ -183,14 +184,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { remoteHelper.ensureStarted() remoteHelper.initStatusDot(binding.remoteStatusDot) - mainHandler.post(remoteStatusChecker) webView.addJavascriptInterface(FullscreenBridge(), "AndroidFullscreenBridge") webView.addJavascriptInterface(VideoBridge(), "AndroidVideoBridge") webView.addJavascriptInterface(NavGuardBridge(), "AndroidNavGuard") webView.addJavascriptInterface(PurifyBridge(), PurifyHelper.BRIDGE_NAME) hasDocStartHook = VideoSnifferHelper.installDocumentStartHook(webView) - Log.d(TAG_VIDEO, "document-start hook installed=$hasDocStartHook") + if (BuildConfig.DEBUG) Log.d(TAG_VIDEO, "document-start hook installed=$hasDocStartHook") val prefs = getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE) currentZoomLevel = prefs.getInt(KEY_ZOOM_LEVEL, 100) @@ -236,7 +236,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { val targetUrl = request?.url?.toString() ?: return false if (AdBlockHelper.isHijackScheme(targetUrl)) { - Log.d("TVBrowser.AdBlock", "Blocked hijack scheme: $targetUrl") + if (BuildConfig.DEBUG) Log.d("TVBrowser.AdBlock", "Blocked hijack scheme: $targetUrl") return true } if (request.isForMainFrame == false) return false @@ -271,9 +271,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { progressBar.visibility = View.VISIBLE if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "") currentPageTitle = "" + pageDocStartInjected = false hideKeyboard() snifferController.resetForNewPage() - if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) + if (!hasDocStartHook) { + view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) + pageDocStartInjected = true + } injectDesktopModeFallback(view) view?.evaluateJavascript(VideoControlHelper.JS_ENABLE_FULLSCREEN_ENHANCEMENT, null) if (AdBlockHelper.isEnabled(this@BrowserActivity)) view?.evaluateJavascript(AdBlockHelper.JS_INJECT_ADBLOCK, null) @@ -317,12 +321,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { @Deprecated("Deprecated in Java") override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) { - progressBar.visibility = View.GONE - Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M && failingUrl == view?.url) { + progressBar.visibility = View.GONE + Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show() + } } override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { - super.onReceivedError(view, request, error) if (request?.isForMainFrame == true) { progressBar.visibility = View.GONE Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show() @@ -332,7 +337,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { val primary = error?.primaryError val isDateError = primary == SslError.SSL_DATE_INVALID || primary == SslError.SSL_EXPIRED || - primary == SslError.SSL_NOTYETVALID || Calendar.getInstance().get(Calendar.YEAR) < 2025 + primary == SslError.SSL_NOTYETVALID || NetworkChecker.isSystemClockAbnormal() val allow = AppSettings.isLenientSsl(this@BrowserActivity) || isDateError Log.w(TAG_VIDEO, "onReceivedSslError: code=$primary allow=$allow url=${error?.url}") if (allow) handler?.proceed() else handler?.cancel() @@ -342,7 +347,10 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { webView.webChromeClient = object : WebChromeClient() { override fun onProgressChanged(view: WebView?, newProgress: Int) { progressBar.progress = newProgress - if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) + if (!hasDocStartHook && !pageDocStartInjected) { + view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) + pageDocStartInjected = true + } if (newProgress >= 100) progressBar.visibility = View.GONE } @@ -353,7 +361,9 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { } override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - consoleMessage?.let { Log.d(TAG_VIDEO, "JS console [${it.messageLevel()}] ${it.sourceId()}:${it.lineNumber()} ${it.message()}") } + if (BuildConfig.DEBUG) { + consoleMessage?.let { Log.d(TAG_VIDEO, "JS console [${it.messageLevel()}] ${it.sourceId()}:${it.lineNumber()} ${it.message()}") } + } return false } @@ -825,7 +835,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { @JavascriptInterface fun onPlaylist(url: String, finalUrl: String, text: String) { PlaylistCache.put(url, finalUrl, text) - Log.d(TAG_VIDEO, "Playlist captured: $url (${text.length} chars)") + if (BuildConfig.DEBUG) Log.d(TAG_VIDEO, "Playlist captured: $url (${text.length} chars)") } @JavascriptInterface @@ -834,7 +844,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { runOnUiThread { snifferController.currentSiteInfo = info snifferController.currentSiteInfoJson = json - Log.d(TAG_VIDEO, "Site info[${info.adapter}]: ${info.displayTitle()} episodes=${info.episodes.size}") + if (BuildConfig.DEBUG) Log.d(TAG_VIDEO, "Site info[${info.adapter}]: ${info.displayTitle()} episodes=${info.episodes.size}") if (AppSettings.isPurifyPage(this@BrowserActivity)) applyPurify(resetDisabled = false) } } @@ -872,21 +882,34 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { runOnUiThread { cursorController.move(dx, dy, isOnHomePage) { setToolbarVisible(true) } } override fun onRemoteClick() = runOnUiThread { + val activePlayer = PlayerActivity.activeInstance?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } + if (activePlayer != null && activePlayer.handleRemoteClick()) return@runOnUiThread if (!fullscreenHelper.handleRemoteClick()) cursorController.dispatchClick() } override fun onRemoteScroll(dx: Int, dy: Int) = runOnUiThread { webView.scrollBy(dx, dy) } override fun onRemoteKey(keyCode: String) = runOnUiThread { + when (keyCode) { + "volume_up" -> { remoteHelper.adjustVolume(raise = true); return@runOnUiThread } + "volume_down" -> { remoteHelper.adjustVolume(raise = false); return@runOnUiThread } + "mute" -> { remoteHelper.toggleMute(); return@runOnUiThread } + } + val activePlayer = PlayerActivity.activeInstance?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } + if (activePlayer != null) { + if (keyCode == "home") { + activePlayer.finish() + showHomePage() + return@runOnUiThread + } + if (activePlayer.handleRemoteKey(keyCode)) return@runOnUiThread + } when (keyCode) { "back" -> onBackPressed() "home" -> showHomePage() "menu" -> if (!isOnHomePage && !isToolbarVisible) setToolbarVisible(true) else showMoreMenu() "refresh" -> webView.reload() "fullscreen" -> fullscreenHelper.triggerVideoFullscreen() - "volume_up" -> remoteHelper.adjustVolume(raise = true) - "volume_down" -> remoteHelper.adjustVolume(raise = false) - "mute" -> remoteHelper.toggleMute() "dpad_left" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT)) "dpad_right" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)) "dpad_up" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP)) @@ -899,6 +922,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun onRemoteOpenUrl(url: String) { val trimmed = url.trim().takeIf { it.isNotEmpty() } ?: return runOnUiThread { + PlayerActivity.activeInstance?.get()?.takeIf { !it.isFinishing && !it.isDestroyed }?.finish() val targetUrl = if (!trimmed.startsWith("http://", true) && !trimmed.startsWith("https://", true)) "https://$trimmed" else trimmed urlBar.setText(targetUrl) loadUrl(targetUrl) @@ -909,8 +933,8 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun onRemoteText(text: String) = runOnUiThread { remoteHelper.handleRemoteText(text, currentFocus, isOnHomePage, webView, urlBar) } - override fun onRemoteFileUploaded(file: File, filename: String) = - runOnUiThread { remoteHelper.handleFileUploaded(file, filename) } + override fun onRemoteFileUploaded(file: File, filename: String, clientIp: String) = + runOnUiThread { remoteHelper.handleFileUploaded(file, filename, clientIp) } internal fun showRemoteControlDialog() = RemoteControlDialog().show(supportFragmentManager, "remote") @@ -946,11 +970,16 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { resumeWebMediaIfSuspended() } if (isOnHomePage && ::binding.isInitialized) updateContinueWatchingBanner() - if (::binding.isInitialized) updateCursorOverlayVisibility() + if (::binding.isInitialized) { + updateCursorOverlayVisibility() + mainHandler.removeCallbacks(remoteStatusChecker) + mainHandler.post(remoteStatusChecker) + } } override fun onPause() { isActivityResumed = false + mainHandler.removeCallbacks(remoteStatusChecker) cancelAutoPlay(markHandled = true) if (::webView.isInitialized) { suspendWebMedia() @@ -961,6 +990,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun onStop() { isActivityResumed = false + mainHandler.removeCallbacks(remoteStatusChecker) cancelAutoPlay(markHandled = true) if (::webView.isInitialized) { suspendWebMedia() diff --git a/app/src/main/java/com/tv/browser/BrowserUiHelper.kt b/app/src/main/java/com/tv/browser/BrowserUiHelper.kt index 1d6c2a5..1c1b7d3 100644 --- a/app/src/main/java/com/tv/browser/BrowserUiHelper.kt +++ b/app/src/main/java/com/tv/browser/BrowserUiHelper.kt @@ -208,7 +208,8 @@ object BrowserUiHelper { try { val expireBefore = System.currentTimeMillis() - 3L * 24 * 3600 * 1000 listOfNotNull(extUploads, intUploads).forEach { dir -> - dir.listFiles()?.forEach { f -> + if (!dir.exists()) return@forEach + dir.walkTopDown().forEach { f -> if (f.isFile && f.lastModified() in 1 until expireBefore) { f.delete() } @@ -355,30 +356,34 @@ object BrowserUiHelper { } fun checkNetworkDiagnostics(activity: AppCompatActivity) { + val appContext = activity.applicationContext + val titleStr = activity.getString(R.string.check_network) + val okStr = activity.getString(R.string.internet_ok) + val unreachableStr = activity.getString(R.string.internet_unreachable) + val closeStr = activity.getString(R.string.close) + val progress = AlertDialog.Builder(activity) - .setTitle(activity.getString(R.string.check_network)) + .setTitle(titleStr) .setMessage(activity.getString(R.string.network_checking)) .setCancelable(false) .create() progress.show() Thread { - val linkStatus = NetworkChecker.getStatusMessage(activity) + val linkStatus = NetworkChecker.getStatusMessage(appContext) val internetReachable = NetworkChecker.isInternetReachable() - val internetStatus = if (internetReachable) { - activity.getString(R.string.internet_ok) - } else { - activity.getString(R.string.internet_unreachable) - } + val internetStatus = if (internetReachable) okStr else unreachableStr val currentYear = Calendar.getInstance().get(Calendar.YEAR) - val timeTip = if (currentYear < 2024) "\n⚠️ 提示:检测到电视系统年份为 ${currentYear} 年,系统时钟可能未同步,浏览器已开启 SSL 兼容放行模式。" else "" + val timeTip = if (NetworkChecker.isSystemClockAbnormal()) { + "\n⚠️ 提示:检测到电视系统时间(当前年份:${currentYear})与标准时间存在明显偏差,浏览器已自动开启 SSL 日期兼容放行模式。" + } else "" activity.runOnUiThread { if (!activity.isFinishing && !activity.isDestroyed) { progress.dismiss() AlertDialog.Builder(activity) - .setTitle(activity.getString(R.string.check_network)) + .setTitle(titleStr) .setMessage("$linkStatus\n$internetStatus$timeTip") - .setPositiveButton(activity.getString(R.string.close), null) + .setPositiveButton(closeStr, null) .show() } } diff --git a/app/src/main/java/com/tv/browser/HistoryRepository.kt b/app/src/main/java/com/tv/browser/HistoryRepository.kt index d676ff0..13e2656 100644 --- a/app/src/main/java/com/tv/browser/HistoryRepository.kt +++ b/app/src/main/java/com/tv/browser/HistoryRepository.kt @@ -1,44 +1,16 @@ package com.tv.browser import android.content.Context -import android.content.SharedPreferences -import org.json.JSONArray import org.json.JSONObject /** - * 浏览历史持久化(内存缓存 + SharedPreferences 异步落盘),最多保留 [MAX_ENTRIES] 条。 + * 浏览历史持久化(基于 [JsonListStore] 内存缓存 + SharedPreferences 异步落盘),最多保留 [MAX_ENTRIES] 条。 */ class HistoryRepository(context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val keyHistory = "history" + private val appContext = context.applicationContext - fun getAll(): List { - cachedHistory?.let { return it } - synchronized(Companion) { - cachedHistory?.let { return it } - val json = prefs.getString(keyHistory, null) - val loaded = if (json.isNullOrBlank()) { - emptyList() - } else { - try { - val arr = JSONArray(json) - List(arr.length()) { i -> - val obj = arr.getJSONObject(i) - HistoryItem( - url = obj.getString(KEY_URL), - title = obj.optString(KEY_TITLE, ""), - visitedAt = obj.optLong(KEY_VISITED_AT, 0L) - ) - } - } catch (_: Exception) { - emptyList() - } - } - cachedHistory = loaded - return loaded - } - } + fun getAll(): List = store.getAll(appContext) /** 添加一条记录:同 URL 只保留最新,总数不超过 [MAX_ENTRIES] */ fun add(url: String, title: String) { @@ -47,39 +19,38 @@ class HistoryRepository(context: Context) { val list = getAll().toMutableList() list.removeAll { it.url == url } list.add(0, HistoryItem(url = url, title = title.trim(), visitedAt = now)) - save(list.take(MAX_ENTRIES)) + store.save(appContext, list) } - fun clear() { - synchronized(Companion) { - cachedHistory = emptyList() - prefs.edit().remove(keyHistory).apply() - } - } - - private fun save(list: List) { - val snapshot = list.toList() - synchronized(Companion) { - cachedHistory = snapshot - val arr = JSONArray() - snapshot.forEach { item -> - arr.put(JSONObject().apply { - put(KEY_URL, item.url) - put(KEY_TITLE, item.title) - put(KEY_VISITED_AT, item.visitedAt) - }) - } - prefs.edit().putString(keyHistory, arr.toString()).apply() - } - } + fun clear() = store.clear(appContext) companion object { private const val PREFS_NAME = "tv_browser_history" + private const val KEY_HISTORY = "history" private const val KEY_URL = "url" private const val KEY_TITLE = "title" private const val KEY_VISITED_AT = "visitedAt" const val MAX_ENTRIES = 50 - @Volatile - private var cachedHistory: List? = null + + private val store = JsonListStore( + prefsName = PREFS_NAME, + key = KEY_HISTORY, + maxEntries = MAX_ENTRIES, + encode = { item -> + JSONObject().apply { + put(KEY_URL, item.url) + put(KEY_TITLE, item.title) + put(KEY_VISITED_AT, item.visitedAt) + } + }, + decode = { obj -> + val url = obj.optString(KEY_URL, "") + if (url.isBlank()) null else HistoryItem( + url = url, + title = obj.optString(KEY_TITLE, ""), + visitedAt = obj.optLong(KEY_VISITED_AT, 0L) + ) + } + ) } } diff --git a/app/src/main/java/com/tv/browser/JsonListStore.kt b/app/src/main/java/com/tv/browser/JsonListStore.kt new file mode 100644 index 0000000..3f84ed8 --- /dev/null +++ b/app/src/main/java/com/tv/browser/JsonListStore.kt @@ -0,0 +1,81 @@ +package com.tv.browser + +import android.content.Context +import android.content.SharedPreferences +import org.json.JSONArray +import org.json.JSONObject + +/** + * 通用 JSON 列表存储引擎(内存缓存 + SharedPreferences 异步落盘 + 容量裁剪): + * 统一 BookmarkRepository、HistoryRepository、PlaybackHistoryRepository 与 UserScriptRepository, + * 消除重复的 synchronized + JSONArray 全量序列化样板代码。 + */ +class JsonListStore( + private val prefsName: String, + private val key: String, + private val maxEntries: Int = 0, + private val encode: (T) -> JSONObject, + private val decode: (JSONObject) -> T? +) { + private val lock = Any() + + @Volatile + private var cached: List? = null + + fun prefs(context: Context): SharedPreferences = + context.getSharedPreferences(prefsName, Context.MODE_PRIVATE) + + /** + * 获取全量列表(命中内存缓存时直接 O(1) 返回;首次加载时支持通过 [onFirstLoad] 执行默认初始化或版本迁移)。 + */ + fun getAll( + context: Context, + onFirstLoad: ((SharedPreferences, List) -> List)? = null + ): List { + cached?.let { return it } + synchronized(lock) { + cached?.let { return it } + val sp = prefs(context) + val parsed = parseJson(sp.getString(key, null)) + val finalItems = if (onFirstLoad != null) onFirstLoad(sp, parsed) else parsed + cached = finalItems + return finalItems + } + } + + fun parseJson(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return try { + val arr = JSONArray(json) + val out = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + decode(obj)?.let { out.add(it) } + } + out + } catch (_: Exception) { + emptyList() + } + } + + fun save(context: Context, list: List) { + val snapshot = if (maxEntries > 0 && list.size > maxEntries) { + list.take(maxEntries) + } else { + list.toList() + } + synchronized(lock) { + cached = snapshot + val arr = JSONArray() + snapshot.forEach { item -> arr.put(encode(item)) } + prefs(context).edit().putString(key, arr.toString()).apply() + } + } + + fun clear(context: Context) { + synchronized(lock) { + cached = emptyList() + prefs(context).edit().remove(key).apply() + } + } +} diff --git a/app/src/main/java/com/tv/browser/NetworkChecker.kt b/app/src/main/java/com/tv/browser/NetworkChecker.kt index 7da7c30..3cdc1d7 100644 --- a/app/src/main/java/com/tv/browser/NetworkChecker.kt +++ b/app/src/main/java/com/tv/browser/NetworkChecker.kt @@ -8,14 +8,43 @@ import java.net.HttpURLConnection import java.net.Inet4Address import java.net.NetworkInterface import java.net.URL +import java.util.Calendar +import kotlin.math.abs /** - * 检查网络连接状态、系统验证结果与互联网实际可达性。 + * 检查网络连接状态、系统验证结果、互联网实际可达性与系统时钟偏差。 */ object NetworkChecker { private const val INTERNET_CHECK_URL = "https://www.baidu.com" private const val INTERNET_CHECK_TIMEOUT_MS = 8000 + /** 允许的设备时钟与服务器 Date 响应头最大偏差(7 天),超过则判定为电视时钟未同步 */ + private const val MAX_CLOCK_SKEW_MS = 7L * 24 * 3600 * 1000L + /** 最低合理年份基线(仅在尚未收到任何 HTTP Date 响应头时兜底) */ + private const val MIN_VALID_YEAR = 2026 + + @Volatile + private var lastObservedSkewMs: Long? = null + + /** 根据服务器返回的 Date 头毫秒时间戳更新时钟偏差记录 */ + fun updateServerDate(serverDateMs: Long) { + if (serverDateMs > 0L) { + lastObservedSkewMs = abs(System.currentTimeMillis() - serverDateMs) + } + } + + /** + * 判定电视系统时钟是否明显异常: + * 优先对比最近一次 HTTPS/HTTP 响应 Date 头与本地时间的偏差(不随年份推移腐烂); + * 若尚未发起过网络请求,则用最低合理年份兜底(如电视断电重置回 1970/2015 年)。 + */ + fun isSystemClockAbnormal(): Boolean { + val skew = lastObservedSkewMs + if (skew != null) { + return skew > MAX_CLOCK_SKEW_MS + } + return Calendar.getInstance().get(Calendar.YEAR) < MIN_VALID_YEAR + } /** * 返回链路状态文案:已连接时区分「系统已验证互联网」与「未验证」。 @@ -71,31 +100,20 @@ object NetworkChecker { } /** - * 实际尝试访问互联网(需在后台线程调用)。 + * 实际尝试访问互联网(需在后台线程调用),并顺带利用响应 Date 头校准系统时钟异常状态。 * @return true 表示能连上目标 URL,false 表示超时或失败。 */ fun isInternetReachable(): Boolean { return try { val url = URL(INTERNET_CHECK_URL) val conn = url.openConnection() as? HttpURLConnection ?: return false - if (conn is javax.net.ssl.HttpsURLConnection) { - conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } - val trustAll = arrayOf( - object : javax.net.ssl.X509TrustManager { - override fun getAcceptedIssuers(): Array? = null - override fun checkClientTrusted(chain: Array?, authType: String?) {} - override fun checkServerTrusted(chain: Array?, authType: String?) {} - } - ) - val sc = javax.net.ssl.SSLContext.getInstance("TLS") - sc.init(null, trustAll, java.security.SecureRandom()) - conn.sslSocketFactory = sc.socketFactory - } + PlayerSsl.applyTo(conn, lenient = true) conn.requestMethod = "HEAD" conn.connectTimeout = INTERNET_CHECK_TIMEOUT_MS conn.readTimeout = INTERNET_CHECK_TIMEOUT_MS conn.instanceFollowRedirects = true val code = conn.responseCode + updateServerDate(conn.date) conn.disconnect() code in 200..399 } catch (_: Exception) { diff --git a/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt b/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt index e81c448..e82a63e 100644 --- a/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt +++ b/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt @@ -1,9 +1,8 @@ package com.tv.browser import android.content.Context -import android.content.SharedPreferences -import org.json.JSONArray import org.json.JSONObject +import java.util.Locale /** * 视频播放记录项:用于断点续播与继续观看推荐 @@ -50,51 +49,21 @@ data class PlaybackRecord( val min = (totalSec / 60) % 60 val hour = totalSec / 3600 return if (hour > 0) { - String.format("%02d:%02d:%02d", hour, min, sec) + String.format(Locale.US, "%02d:%02d:%02d", hour, min, sec) } else { - String.format("%02d:%02d", min, sec) + String.format(Locale.US, "%02d:%02d", min, sec) } } } /** - * 播放历史持久化管理(内存缓存 + SharedPreferences 异步落盘,避免播放中高频重复解析 JSON) + * 播放历史持久化管理(基于 [JsonListStore] 内存缓存 + SharedPreferences 异步落盘,避免播放中高频重复解析 JSON) */ class PlaybackHistoryRepository(context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val appContext = context.applicationContext - fun getAll(): List { - cachedRecords?.let { return it } - synchronized(Companion) { - cachedRecords?.let { return it } - val json = prefs.getString(KEY_RECORDS, null) - val loaded = if (json.isNullOrBlank()) { - emptyList() - } else { - try { - val arr = JSONArray(json) - List(arr.length()) { i -> - val obj = arr.getJSONObject(i) - PlaybackRecord( - url = obj.optString("url", ""), - title = obj.optString("title", ""), - positionMs = obj.optInt("positionMs", 0), - durationMs = obj.optInt("durationMs", 0), - timestamp = obj.optLong("timestamp", 0), - series = obj.optString("series", ""), - seriesTitle = obj.optString("seriesTitle", ""), - episodeName = obj.optString("episodeName", "") - ) - }.filter { it.url.isNotBlank() } - } catch (_: Exception) { - emptyList() - } - } - cachedRecords = loaded - return loaded - } - } + fun getAll(): List = store.getAll(appContext) /** * 保存播放进度:若播放时间超过 5 秒且未播完,则保存或更新。 @@ -115,7 +84,7 @@ class PlaybackHistoryRepository(context: Context) { // 如果已经播到最后 10 秒,视为播放结束,不再存为断点 if (durationMs > 0 && positionMs >= durationMs - 10000) { - save(list) + store.save(appContext, list) return } @@ -130,8 +99,7 @@ class PlaybackHistoryRepository(context: Context) { episodeName = episodeName ) list.add(0, record) - while (list.size > MAX_RECORDS) list.removeAt(list.size - 1) - save(list) + store.save(appContext, list) } /** @@ -142,8 +110,7 @@ class PlaybackHistoryRepository(context: Context) { val list = getAll().toMutableList() list.removeAll { it.url == url || it.series == series } list.add(0, PlaybackRecord(url, title.ifBlank { seriesTitle }, 0, 0, System.currentTimeMillis(), series, seriesTitle, episodeName)) - while (list.size > MAX_RECORDS) list.removeAt(list.size - 1) - save(list) + store.save(appContext, list) } /** 获取最近一条未看完的播放记录 */ @@ -167,23 +134,22 @@ class PlaybackHistoryRepository(context: Context) { fun remove(url: String) { val list = getAll().toMutableList() list.removeAll { it.url == url } - save(list) + store.save(appContext, list) } - fun clearAll() { - synchronized(Companion) { - cachedRecords = emptyList() - prefs.edit().remove(KEY_RECORDS).apply() - } - } + fun clearAll() = store.clear(appContext) - private fun save(list: List) { - val snapshot = list.toList() - synchronized(Companion) { - cachedRecords = snapshot - val arr = JSONArray() - snapshot.forEach { r -> - arr.put(JSONObject().apply { + companion object { + private const val PREFS_NAME = "tv_browser_playback_history" + private const val KEY_RECORDS = "records" + private const val MAX_RECORDS = 30 + + private val store = JsonListStore( + prefsName = PREFS_NAME, + key = KEY_RECORDS, + maxEntries = MAX_RECORDS, + encode = { r -> + JSONObject().apply { put("url", r.url) put("title", r.title) put("positionMs", r.positionMs) @@ -192,17 +158,21 @@ class PlaybackHistoryRepository(context: Context) { if (r.series.isNotBlank()) put("series", r.series) if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle) if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName) - }) + } + }, + decode = { obj -> + val url = obj.optString("url", "") + if (url.isBlank()) null else PlaybackRecord( + url = url, + title = obj.optString("title", ""), + positionMs = obj.optInt("positionMs", 0), + durationMs = obj.optInt("durationMs", 0), + timestamp = obj.optLong("timestamp", 0), + series = obj.optString("series", ""), + seriesTitle = obj.optString("seriesTitle", ""), + episodeName = obj.optString("episodeName", "") + ) } - prefs.edit().putString(KEY_RECORDS, arr.toString()).apply() - } - } - - companion object { - private const val PREFS_NAME = "tv_browser_playback_history" - private const val KEY_RECORDS = "records" - private const val MAX_RECORDS = 30 - @Volatile - private var cachedRecords: List? = null + ) } } diff --git a/app/src/main/java/com/tv/browser/PlayerActivity.kt b/app/src/main/java/com/tv/browser/PlayerActivity.kt index eec49b1..1c8095f 100644 --- a/app/src/main/java/com/tv/browser/PlayerActivity.kt +++ b/app/src/main/java/com/tv/browser/PlayerActivity.kt @@ -5,11 +5,13 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration +import android.net.TrafficStats import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.Process import android.util.Log import android.util.Rational import android.view.KeyEvent @@ -21,6 +23,7 @@ import android.widget.SeekBar import android.widget.Toast import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes @@ -29,18 +32,18 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.AspectRatioFrameLayout -import android.net.TrafficStats -import android.os.Process -import androidx.media3.exoplayer.DefaultRenderersFactory -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView -import java.util.Calendar import com.tv.browser.databinding.ActivityPlayerBinding import com.tv.browser.databinding.ItemEpisodeBinding +import java.io.File +import java.lang.ref.WeakReference +import java.util.Calendar import java.util.Locale import kotlin.math.ceil import kotlin.math.min @@ -105,6 +108,7 @@ class PlayerActivity : AppCompatActivity() { private data class PreloadedEpisode(val index: Int, val pageUrl: String, val source: VideoSource, val time: Long) private val mainHandler = Handler(Looper.getMainLooper()) + private val finishCalendar = Calendar.getInstance() private val speeds = floatArrayOf(1.0f, 1.25f, 1.5f, 2.0f, 0.75f) private var currentSpeedIndex = 0 @@ -272,7 +276,7 @@ class PlayerActivity : AppCompatActivity() { binding.tvStatus.visibility = View.GONE stallHintShown = false readySinceReResolve = true - updateProgress() + updateProgress(forceUi = true) maybeSkipIntro() failedSources.clear() updatePauseOverlay() @@ -393,7 +397,7 @@ class PlayerActivity : AppCompatActivity() { .setReadTimeoutMs(PlayerTuning.READ_TIMEOUT_MS) // 清单命中缓存时直接用网页下载过的内容(一次性 m3u8 链接再请求会失效),分片等仍走网络 val dataSourceFactory = PlaylistCacheDataSource.Factory(DefaultDataSource.Factory(this, httpFactory)) - Log.d(TAG, "playlist cached=${PlaylistCache.contains(source.url)}") + if (BuildConfig.DEBUG) Log.d(TAG, "playlist cached=${PlaylistCache.contains(source.url)}") val itemBuilder = MediaItem.Builder().setUri(source.url) if (source.isHls) itemBuilder.setMimeType(MimeTypes.APPLICATION_M3U8) @@ -420,7 +424,7 @@ class PlayerActivity : AppCompatActivity() { if (!canPlayNow) p.pause() if (carried <= 0 && record != null && startPos > 0) showFeedback("已自动续播至 ${record.formatPosition()}") - Log.d(TAG, "play: ${source.url} hls=${source.isHls} canPlayNow=$canPlayNow headers=${source.headers.keys}") + if (BuildConfig.DEBUG) Log.d(TAG, "play: ${source.url} hls=${source.isHls} canPlayNow=$canPlayNow headers=${source.headers.keys}") lastSavedSec = 0 suppressSave = false introChecked = false @@ -563,7 +567,7 @@ class PlayerActivity : AppCompatActivity() { if (pre != null && pre.index == index && pre.pageUrl == ep.url && System.currentTimeMillis() - pre.time < PRELOAD_VALID_MS ) { - Log.d(TAG, "play preloaded episode #$index ${ep.name}") + if (BuildConfig.DEBUG) Log.d(TAG, "play preloaded episode #$index ${ep.name}") player?.stop() currentPageUrl = ep.url updateEpisodeIndex() @@ -654,7 +658,7 @@ class PlayerActivity : AppCompatActivity() { binding.tvVideoTitle.text = videoTitle updateEpisodeUi() val status = if (auto) "线路「$fromName」$reason,自动切换到「${target.name}」…" else "正在切换到「${target.name}」…" - Log.d(TAG, "switch source -> #$index ${target.name} ep=${ep.name} auto=$auto") + if (BuildConfig.DEBUG) Log.d(TAG, "switch source -> #$index ${target.name} ep=${ep.name} auto=$auto") resolveAndPlay(ep.url, needSiteInfo = false, status = status) return true } @@ -689,13 +693,13 @@ class PlayerActivity : AppCompatActivity() { preloadingIndex = nextIndex val r = preloadResolver ?: VideoResolver(this, binding.resolverHost, BrowserActivity.currentUserAgent(this)) .also { preloadResolver = it } - Log.d(TAG, "preload next episode #$nextIndex ${ep.name}") + if (BuildConfig.DEBUG) Log.d(TAG, "preload next episode #$nextIndex ${ep.name}") r.resolve(ep.url, false, object : VideoResolver.Callback { override fun onResolved(source: VideoSource, siteInfo: SiteInfo?) { if (preloadingIndex != nextIndex) return preloadingIndex = -1 preloaded = PreloadedEpisode(nextIndex, ep.url, source, System.currentTimeMillis()) - Log.d(TAG, "preloaded #$nextIndex: ${source.url}") + if (BuildConfig.DEBUG) Log.d(TAG, "preloaded #$nextIndex: ${source.url}") } override fun onFailed(reason: String) { @@ -733,7 +737,7 @@ class PlayerActivity : AppCompatActivity() { if (outro <= 0 || outro >= duration) return if (current >= duration - outro) { outroTriggered = true - Log.d(TAG, "outro reached at ${formatTime(current)}, play next") + if (BuildConfig.DEBUG) Log.d(TAG, "outro reached at ${formatTime(current)}, play next") showFeedback("已跳过片尾") onPlaybackEnded() } @@ -843,7 +847,7 @@ class PlayerActivity : AppCompatActivity() { appendLine("🎬 视频标题:$videoTitle") if (seriesTitle.isNotBlank()) appendLine("📺 剧集归属:$seriesTitle") appendLine("⏱️ 播放进度:${formatTime(cur)} / ${if (dur > 0) formatTime(dur) else "--:--"}") - appendLine("🌐 实时网速:$currentSpeedStr") + appendLine("🌐 实时网速:${updateNetworkSpeed()}") appendLine("📦 已缓冲时长:${bufferedAheadSec} 秒 (${p?.bufferedPercentage ?: 0}%)") val down = diagnostics.downloadBytesPerSec val need = diagnostics.mediaBytesPerSec @@ -1052,7 +1056,7 @@ class PlayerActivity : AppCompatActivity() { p.seekTo(target) val sign = if (offsetMs > 0) "+" else "" showFeedback("$sign${offsetMs / 1000}s (${formatTime(target)})") - updateProgress() + updateProgress(forceUi = true) } /** 长按方向键加速:10s → 30s → 60s */ @@ -1083,7 +1087,9 @@ class PlayerActivity : AppCompatActivity() { private fun isOsdVisible() = binding.playerOsd.visibility == View.VISIBLE private fun showOsdTemporarily(focusPlay: Boolean = false) { + val wasHidden = binding.playerOsd.visibility != View.VISIBLE binding.playerOsd.visibility = View.VISIBLE + if (wasHidden) updateProgress(forceUi = true) if (focusPlay) binding.btnPlayPause.requestFocus() mainHandler.removeCallbacks(hideOsdRunnable) // 正在播放时 5 秒后自动隐藏;暂停时控制栏常驻显示,方便查看进度与操作 @@ -1129,18 +1135,11 @@ class PlayerActivity : AppCompatActivity() { return currentSpeedStr } - private fun updateProgress() { + private fun updateProgress(forceUi: Boolean = false) { val p = player ?: return val current = p.currentPosition.coerceAtLeast(0L) val duration = p.duration - val bufferedPos = p.bufferedPosition.coerceAtLeast(0L) - val bufferedAheadSec = ((bufferedPos - current).coerceAtLeast(0L)) / 1000L - // 缓冲够了播放器会暂停下载,这时流量为 0 是正常的,不要显示成「网速 0」 - val rawSpeed = updateNetworkSpeed() - val speed = buildString { - append(if (!p.isLoading && bufferedAheadSec >= 10) "缓冲充足,暂停下载" else rawSpeed) - diagnostics.summary()?.let { append("(").append(it).append(")") } - } + // 播着播着卡住:给出原因(片源速度跟不上 / 分片下载失败),而不是只转圈 if (p.playbackState == Player.STATE_BUFFERING && diagnostics.isStallAfterPlaying()) { diagnostics.stallHint()?.let { @@ -1152,26 +1151,41 @@ class PlayerActivity : AppCompatActivity() { binding.tvStatus.visibility = View.GONE } - binding.tvCurrentTime.text = formatTime(current) - if (duration != C.TIME_UNSET && duration > 0) { - binding.tvTotalTime.text = formatTime(duration) - binding.playerSeekBar.progress = ((current * 1000) / duration).toInt() - val bufferPercent = p.bufferedPercentage - val remainingMs = (duration - current).coerceAtLeast(0L) - val remainingStr = formatTime(remainingMs) - val finishCal = Calendar.getInstance().apply { add(Calendar.MILLISECOND, remainingMs.toInt()) } - val finishTimeStr = String.format(Locale.CHINA, "%02d:%02d", finishCal.get(Calendar.HOUR_OF_DAY), finishCal.get(Calendar.MINUTE)) - binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 ($bufferPercent%) · 剩余: $remainingStr · 预计 $finishTimeStr 播完" - } else { - binding.tvTotalTime.text = "--:--" - binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 · 已播放: ${formatTime(current)}" + // 仅当控制栏可见(或显式刷新)时才计算网速与格式化 UI 文案,降低主线程每 500ms 的开销 + if (forceUi || isOsdVisible()) { + val bufferedPos = p.bufferedPosition.coerceAtLeast(0L) + val bufferedAheadSec = ((bufferedPos - current).coerceAtLeast(0L)) / 1000L + val rawSpeed = updateNetworkSpeed() + val speed = buildString { + append(if (!p.isLoading && bufferedAheadSec >= 10) "缓冲充足,暂停下载" else rawSpeed) + diagnostics.summary()?.let { append("(").append(it).append(")") } + } + binding.tvCurrentTime.text = formatTime(current) + if (duration != C.TIME_UNSET && duration > 0) { + binding.tvTotalTime.text = formatTime(duration) + binding.playerSeekBar.progress = ((current * 1000) / duration).toInt() + val bufferPercent = p.bufferedPercentage + val remainingMs = (duration - current).coerceAtLeast(0L) + val remainingStr = formatTime(remainingMs) + finishCalendar.timeInMillis = System.currentTimeMillis() + remainingMs + val finishTimeStr = String.format( + Locale.CHINA, + "%02d:%02d", + finishCalendar.get(Calendar.HOUR_OF_DAY), + finishCalendar.get(Calendar.MINUTE) + ) + binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 ($bufferPercent%) · 剩余: $remainingStr · 预计 $finishTimeStr 播完" + } else { + binding.tvTotalTime.text = "--:--" + binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 · 已播放: ${formatTime(current)}" + } } if (p.playbackState == Player.STATE_READY && current > 0) lastKnownPos = current - // 每隔 5 秒自动持久化保存一次播放断点进度 + // 每隔 15 秒自动持久化保存一次播放断点进度(暂停/切集/退出时立即保存) val currentSec = current / 1000 - if (currentSec >= 5 && currentSec - lastSavedSec >= 5) { + if (currentSec >= 5 && currentSec - lastSavedSec >= 15) { lastSavedSec = currentSec saveCurrentProgress() } @@ -1249,7 +1263,7 @@ class PlayerActivity : AppCompatActivity() { ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) if (isInPictureInPictureMode) { - pipInstanceRef = java.lang.ref.WeakReference(this) + pipInstanceRef = WeakReference(this) pipEnteredAt = android.os.SystemClock.uptimeMillis() hideOsd() binding.tvFeedback.visibility = View.GONE @@ -1315,7 +1329,7 @@ class PlayerActivity : AppCompatActivity() { } } - /** 调用系统或第三方专业播放器(Kodi / MX Player / VLC 等),附带请求头 */ + /** 调用系统或第三方专业播放器(Kodi / MX Player / VLC 等),附带请求头;对本地文件自动转换 FileProvider URI */ private fun launchExternalPlayer() { val source = currentSource if (source == null) { @@ -1323,14 +1337,24 @@ class PlayerActivity : AppCompatActivity() { return } try { + var targetUri = Uri.parse(source.url) + if (targetUri.scheme == "file") { + val path = targetUri.path + if (!path.isNullOrBlank()) { + targetUri = FileProvider.getUriForFile(this, "$packageName.fileprovider", File(path)) + } + } val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(Uri.parse(source.url), if (source.isHls) "application/x-mpegURL" else "video/*") + setDataAndType(targetUri, if (source.isHls) "application/x-mpegURL" else "video/*") putExtra("title", videoTitle) // MX Player 等支持的请求头格式:[key1, value1, key2, value2, ...] if (source.headers.isNotEmpty()) { putExtra("headers", source.headers.flatMap { listOf(it.key, it.value) }.toTypedArray()) } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (targetUri.scheme == "content") { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } startActivity(Intent.createChooser(intent, "选择外部播放器")) } catch (e: Exception) { @@ -1339,6 +1363,51 @@ class PlayerActivity : AppCompatActivity() { } } + /** 局域网手机遥控:当 PlayerActivity 在前台时响应触控板轻击确认 */ + internal fun handleRemoteClick(): Boolean { + val focused = currentFocus + if ((isOsdVisible() || isEpisodePanelVisible()) && focused != null && focused != binding.playerRoot) { + focused.performClick() + return true + } + togglePlayPause() + return true + } + + /** 局域网手机遥控:当 PlayerActivity 在前台时响应手机遥控器按键 */ + internal fun handleRemoteKey(keyCode: String): Boolean { + when (keyCode) { + "back" -> onBackPressed() + "menu" -> if (isEpisodePanelVisible()) hideEpisodePanel() else if (isOsdVisible()) hideOsd() else showOsdTemporarily(focusPlay = true) + "fullscreen" -> toggleAspectRatio() + "refresh" -> { + val page = currentPageUrl + if (page != null) { + if (lastKnownPos > 5000) pendingStartPos = lastKnownPos + resolveAndPlay(page, needSiteInfo = false, status = "正在重新加载当前视频…") + } else { + currentSource?.let { + if (lastKnownPos > 5000) pendingStartPos = lastKnownPos + playSource(it) + } + } + } + "external_play" -> launchExternalPlayer() + "native_play" -> togglePlayPause() + "dpad_left" -> dispatchKeyPair(KeyEvent.KEYCODE_DPAD_LEFT) + "dpad_right" -> dispatchKeyPair(KeyEvent.KEYCODE_DPAD_RIGHT) + "dpad_up" -> dispatchKeyPair(KeyEvent.KEYCODE_DPAD_UP) + "dpad_down" -> dispatchKeyPair(KeyEvent.KEYCODE_DPAD_DOWN) + else -> return false + } + return true + } + + private fun dispatchKeyPair(keyCode: Int) { + dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + } + // --------------------------------------------------------------------------------------- // 遥控器按键 // --------------------------------------------------------------------------------------- @@ -1524,7 +1593,7 @@ class PlayerActivity : AppCompatActivity() { isSeeking = false if (ev.actionMasked == android.view.MotionEvent.ACTION_UP) { player?.seekTo(gestureTargetPos) - updateProgress() + updateProgress(forceUi = true) } return true } else if (mode == 2 || mode == 3) { @@ -1556,6 +1625,7 @@ class PlayerActivity : AppCompatActivity() { override fun onResume() { super.onResume() + activeInstance = WeakReference(this) isInBackground = false hideSystemUI() if (currentSource != null) { @@ -1568,6 +1638,7 @@ class PlayerActivity : AppCompatActivity() { super.onPause() val inPip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) isInPictureInPictureMode else false if (!inPip) { + if (activeInstance?.get() === this) activeInstance = null isInBackground = true pauseForBackground() } else { @@ -1577,6 +1648,7 @@ class PlayerActivity : AppCompatActivity() { override fun onStop() { super.onStop() + if (activeInstance?.get() === this) activeInstance = null isInBackground = true mainHandler.removeCallbacks(updateProgressRunnable) cancelPreload() @@ -1585,7 +1657,9 @@ class PlayerActivity : AppCompatActivity() { } override fun onDestroy() { + if (activeInstance?.get() === this) activeInstance = null if (pipInstanceRef?.get() === this) pipInstanceRef = null + PlayerSsl.restoreSystemDefaults() saveCurrentProgress() mainHandler.removeCallbacksAndMessages(null) resolver?.destroy() @@ -1600,6 +1674,10 @@ class PlayerActivity : AppCompatActivity() { companion object { private const val TAG = "PlayerActivity" + /** 当前处于前台活动状态的 PlayerActivity,用于接收局域网手机遥控按键 */ + @Volatile + internal var activeInstance: WeakReference? = null + /** 选集格子目标宽度(dp),列数 = 屏幕宽度 / 该值,限制在 4~10 列 */ private const val EPISODE_CELL_DP = 110 /** 距结尾多久开始预解析下一集 */ @@ -1645,7 +1723,7 @@ class PlayerActivity : AppCompatActivity() { private const val PIP_RESTORE_GUARD_MS = 3000L /** 当前处于画中画小窗的播放器实例 */ - private var pipInstanceRef: java.lang.ref.WeakReference? = null + private var pipInstanceRef: WeakReference? = null private var pipEnteredAt = 0L /** @@ -1677,8 +1755,8 @@ class PlayerActivity : AppCompatActivity() { /** 加密流(央视网等)原生播放会花屏:拒绝启动并提示 */ private fun rejectWebOnly(context: Context, url: String): Boolean { if (!VideoSnifferHelper.isWebOnlyStream(url)) return false - Log.d(TAG, "reject web-only stream: $url") - android.widget.Toast.makeText(context, VideoSnifferHelper.WEB_ONLY_MESSAGE, android.widget.Toast.LENGTH_LONG).show() + if (BuildConfig.DEBUG) Log.d(TAG, "reject web-only stream: $url") + Toast.makeText(context, VideoSnifferHelper.WEB_ONLY_MESSAGE, Toast.LENGTH_LONG).show() return true } diff --git a/app/src/main/java/com/tv/browser/PlayerSsl.kt b/app/src/main/java/com/tv/browser/PlayerSsl.kt index 5fb566c..75f75a5 100644 --- a/app/src/main/java/com/tv/browser/PlayerSsl.kt +++ b/app/src/main/java/com/tv/browser/PlayerSsl.kt @@ -2,32 +2,61 @@ package com.tv.browser import android.annotation.SuppressLint import android.util.Log +import java.net.HttpURLConnection +import java.security.SecureRandom import java.security.cert.CertPathValidatorException import java.security.cert.CertificateException import java.security.cert.X509Certificate +import javax.net.ssl.HostnameVerifier import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLException +import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager /** - * 原生播放器的 SSL 兼容处理。 + * 原生播放器与内部网络请求的 SSL 兼容管理。 * - * 网页里 WebView 遇到证书错误会继续加载(见 onReceivedSslError),但 ExoPlayer 走的是系统 HttpURLConnection, - * 证书过期 / 旧电视缺少新根证书(如 Let's Encrypt ISRG Root X1)/ 电视时间不准时会直接报 - * ERROR_CODE_IO_NETWORK_CONNECTION_FAILED。这里在检测到 SSL 类错误后开启"宽松模式",与网页行为保持一致。 - * - * 注意:HttpsURLConnection 的默认工厂是进程级的,开启后本进程内的 HttpURLConnection 请求都不再校验证书 - * (WebView 不受影响)。只在播放器确实遇到 SSL 错误时才开启。 + * 1. 保存进程启动时的系统默认 SSLSocketFactory 与 HostnameVerifier,确保宽松模式可随时回滚恢复,不再永久污染全局。 + * 2. 提供单连接级 [applyTo] 方法,供 [VodSearchHelper] 与 [NetworkChecker] 按需独立配置,避免重复定义 TrustManager。 + * 3. 播放器遇到证书错误时按需调用 [enableLenient] 并在切源/退出播放器时调用 [restoreSystemDefaults] 恢复系统默认校验。 */ object PlayerSsl { private const val TAG = "PlayerSsl" + /** 保存系统原始默认 SSL 工厂与域名校验器,支持随时回滚 */ + private val systemSslSocketFactory: SSLSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory() + private val systemHostnameVerifier: HostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier() + + val lenientSslSocketFactory: SSLSocketFactory by lazy { + val trustAll = @SuppressLint("CustomX509TrustManager") object : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted(chain: Array?, authType: String?) {} + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = emptyArray() + } + SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustAll), SecureRandom()) + }.socketFactory + } + + @SuppressLint("BadHostnameVerifier") + val lenientHostnameVerifier: HostnameVerifier = HostnameVerifier { _, _ -> true } + @Volatile var lenient: Boolean = false private set + /** 为单个 HttpURLConnection 显式指定严格或宽松 SSL 配置(不受全局默认状态影响) */ + fun applyTo(conn: HttpURLConnection, lenient: Boolean) { + if (conn is HttpsURLConnection) { + conn.sslSocketFactory = if (lenient) lenientSslSocketFactory else systemSslSocketFactory + conn.hostnameVerifier = if (lenient) lenientHostnameVerifier else systemHostnameVerifier + } + } + /** 错误链中是否包含证书 / SSL 握手问题 */ fun isSslError(error: Throwable?): Boolean { var e = error @@ -54,27 +83,33 @@ object PlayerSsl { return if (msg.isBlank()) e.javaClass.simpleName else "${e.javaClass.simpleName}: $msg" } - /** 开启宽松模式:信任所有证书、不校验主机名。返回 true 表示本次新开启 */ - @SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager", "BadHostnameVerifier") + /** 为当前播放会话开启宽松模式;返回 true 表示本次新开启 */ @Synchronized fun enableLenient(): Boolean { if (lenient) return false return try { - val trustAll = object : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) {} - override fun checkServerTrusted(chain: Array?, authType: String?) {} - override fun getAcceptedIssuers(): Array = emptyArray() - } - val ctx = SSLContext.getInstance("TLS") - ctx.init(null, arrayOf(trustAll), java.security.SecureRandom()) - HttpsURLConnection.setDefaultSSLSocketFactory(ctx.socketFactory) - HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true } + HttpsURLConnection.setDefaultSSLSocketFactory(lenientSslSocketFactory) + HttpsURLConnection.setDefaultHostnameVerifier(lenientHostnameVerifier) lenient = true - Log.w(TAG, "lenient SSL enabled for native player") + Log.w(TAG, "lenient SSL enabled for current playback session") true } catch (e: Exception) { Log.e(TAG, "enableLenient failed", e) false } } + + /** 恢复系统默认严格 SSL 校验(播放器切源或退出时调用,防止进程级永久降级) */ + @Synchronized + fun restoreSystemDefaults() { + if (!lenient) return + try { + HttpsURLConnection.setDefaultSSLSocketFactory(systemSslSocketFactory) + HttpsURLConnection.setDefaultHostnameVerifier(systemHostnameVerifier) + lenient = false + Log.d(TAG, "system default SSL verification restored") + } catch (e: Exception) { + Log.e(TAG, "restoreSystemDefaults failed", e) + } + } } diff --git a/app/src/main/java/com/tv/browser/PlaylistCache.kt b/app/src/main/java/com/tv/browser/PlaylistCache.kt index d8cabd0..2946ace 100644 --- a/app/src/main/java/com/tv/browser/PlaylistCache.kt +++ b/app/src/main/java/com/tv/browser/PlaylistCache.kt @@ -49,16 +49,15 @@ object PlaylistCache { fun isReady(url: String): Boolean { val entry = map[url] ?: return false if (!entry.isMaster) return true - val base = Uri.parse(entry.finalUrl) return entry.text.lineSequence() .map { it.trim() } .filter { it.isNotEmpty() && !it.startsWith("#") } - .any { line -> map.containsKey(resolve(base, line)) } + .any { line -> map.containsKey(resolve(entry.finalUrl, line)) } } - private fun resolve(base: Uri, ref: String): String = + private fun resolve(baseUrl: String, ref: String): String = try { - java.net.URI(base.toString()).resolve(ref).toString() + java.net.URI(baseUrl).resolve(ref).toString() } catch (_: Exception) { ref } @@ -75,6 +74,9 @@ object PlaylistCache { if (!entry.isMaster && !entry.isVod) map.remove(url) return entry } + + @Synchronized + fun clear() = map.clear() } /** diff --git a/app/src/main/java/com/tv/browser/RemoteControlCallback.kt b/app/src/main/java/com/tv/browser/RemoteControlCallback.kt index 070b781..e8350bf 100644 --- a/app/src/main/java/com/tv/browser/RemoteControlCallback.kt +++ b/app/src/main/java/com/tv/browser/RemoteControlCallback.kt @@ -17,5 +17,5 @@ interface RemoteControlCallback { /** 推送网址:在电视端立即打开并加载该网址 */ fun onRemoteOpenUrl(url: String) /** 局域网快传:手机端向电视上传文件(APK 或多媒体文件) */ - fun onRemoteFileUploaded(file: java.io.File, filename: String) + fun onRemoteFileUploaded(file: java.io.File, filename: String, clientIp: String = "") } diff --git a/app/src/main/java/com/tv/browser/RemoteControlDialog.kt b/app/src/main/java/com/tv/browser/RemoteControlDialog.kt index 0d580d5..aa92aef 100644 --- a/app/src/main/java/com/tv/browser/RemoteControlDialog.kt +++ b/app/src/main/java/com/tv/browser/RemoteControlDialog.kt @@ -19,8 +19,6 @@ import android.widget.TextView import androidx.fragment.app.DialogFragment import com.google.zxing.BarcodeFormat import com.google.zxing.qrcode.QRCodeWriter -import java.net.Inet4Address -import java.net.NetworkInterface import java.util.regex.Pattern /** @@ -189,28 +187,6 @@ class RemoteControlDialog : DialogFragment() { return ipPattern.matcher(s).matches() } - private fun getLocalIpAddress(): String? { - try { - val interfaces = NetworkInterface.getNetworkInterfaces()?.toList() ?: return null - val sorted = interfaces.sortedByDescending { ni -> - when { - ni.name.startsWith("wlan", ignoreCase = true) -> 2 - ni.name.startsWith("eth", ignoreCase = true) -> 1 - else -> 0 - } - } - for (ni in sorted) { - if (ni.isLoopback || !ni.isUp) continue - for (addr in ni.inetAddresses) { - if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.hostAddress.orEmpty().startsWith("127.")) { - return addr.hostAddress - } - } - } - } catch (_: Exception) { } - return null - } - private fun generateQrBitmap(content: String, sizePx: Int): Bitmap { val writer = QRCodeWriter() val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx) diff --git a/app/src/main/java/com/tv/browser/RemoteControlHelper.kt b/app/src/main/java/com/tv/browser/RemoteControlHelper.kt index 9b9200d..0ef530f 100644 --- a/app/src/main/java/com/tv/browser/RemoteControlHelper.kt +++ b/app/src/main/java/com/tv/browser/RemoteControlHelper.kt @@ -150,51 +150,62 @@ class RemoteControlHelper( } /** 处理手机快传上传的文件(APK 安装包、视频文件或普通文件) */ - fun handleFileUploaded(file: File, filename: String) { + fun handleFileUploaded(file: File, filename: String, clientIp: String = "") { + val targetActivity = PlayerActivity.activeInstance?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } ?: activity val lower = filename.lowercase() if (lower.endsWith(".apk")) { - promptInstallApk(file, filename) - } else if (lower.endsWith(".mp4") || lower.endsWith(".m3u8") || lower.endsWith(".mkv") || lower.endsWith(".flv")) { - AlertDialog.Builder(activity) + promptInstallApk(targetActivity, file, filename, clientIp) + } else if (lower.endsWith(".mp4") || lower.endsWith(".m3u8") || lower.endsWith(".mkv") || lower.endsWith(".flv") || lower.endsWith(".ts") || lower.endsWith(".webm")) { + val contentUri = try { + FileProvider.getUriForFile( + targetActivity, + "${targetActivity.packageName}.fileprovider", + file + ).toString() + } catch (_: Exception) { + Uri.fromFile(file).toString() + } + AlertDialog.Builder(targetActivity) .setTitle("🎬 收到手机快传视频") .setMessage("文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即全屏播放?") .setPositiveButton("立即播放") { _, _ -> - PlayerActivity.start(activity, Uri.fromFile(file).toString(), filename) + PlayerActivity.start(targetActivity, contentUri, filename) } .setNegativeButton("稍后", null) .show() } else { - Toast.makeText(activity, "收到手机文件:$filename (已存入下载目录)", Toast.LENGTH_LONG).show() + Toast.makeText(targetActivity, "收到手机文件:$filename (已存入快传目录)", Toast.LENGTH_LONG).show() } } - private fun promptInstallApk(file: File, filename: String) { + private fun promptInstallApk(hostActivity: AppCompatActivity, file: File, filename: String, clientIp: String) { try { val uri = FileProvider.getUriForFile( - activity, - "${activity.packageName}.fileprovider", + hostActivity, + "${hostActivity.packageName}.fileprovider", file ) val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) } - AlertDialog.Builder(activity) - .setTitle("📱 收到手机快传 APK 安装包") - .setMessage("应用文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即安装?") - .setPositiveButton("立即安装") { _, _ -> + val senderInfo = if (clientIp.isNotBlank()) "来源设备:$clientIp\n" else "" + AlertDialog.Builder(hostActivity) + .setTitle("📱 收到局域网快传 APK 安装包") + .setMessage("${senderInfo}应用文件:$filename\n文件大小:${file.length() / 1024 / 1024}MB\n\n⚠️ 请确认该安装包为本人操作发送后再继续安装。") + .setPositiveButton("确认并安装") { _, _ -> try { - activity.startActivity(intent) + hostActivity.startActivity(intent) } catch (e: Exception) { Log.e(TAG, "Failed to launch package installer", e) - Toast.makeText(activity, "打开安装器失败: ${e.message}", Toast.LENGTH_LONG).show() + Toast.makeText(hostActivity, "打开安装器失败: ${e.message}", Toast.LENGTH_LONG).show() } } - .setNegativeButton("稍后", null) + .setNegativeButton("取消", null) .show() } catch (e: Exception) { Log.e(TAG, "FileProvider error for APK", e) - Toast.makeText(activity, "解析安装包异常: ${e.message}", Toast.LENGTH_SHORT).show() + Toast.makeText(hostActivity, "解析安装包异常: ${e.message}", Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/com/tv/browser/RemoteControlServer.kt b/app/src/main/java/com/tv/browser/RemoteControlServer.kt index a933db7..8eb3b2f 100644 --- a/app/src/main/java/com/tv/browser/RemoteControlServer.kt +++ b/app/src/main/java/com/tv/browser/RemoteControlServer.kt @@ -9,6 +9,7 @@ import org.json.JSONObject import java.io.ByteArrayInputStream import java.io.File import java.io.IOException +import java.util.UUID import kotlin.jvm.Volatile private const val TAG = "RemoteControlServer" @@ -16,6 +17,11 @@ private const val PORT = 8766 /** * 本地 HTTP 服务:提供遥控网页,接收 POST /cmd 的 JSON 指令并回调到主线程。 + * + * 安全策略: + * 1. 不返回 Access-Control-Allow-Origin: *,阻止电视浏览器中打开的第三方恶意网页通过跨站请求调用本地端口(CSRF) + * 2. 每次服务启动生成随机 Session Token 并注入同源 remote.html,所有 POST /cmd 与 /upload 均校验 X-TV-Token 请求头 + * 3. 上传文件限制扩展名白名单,落盘路径统一使用 getUploadDir(context) */ class RemoteControlServer( private val context: Context, @@ -26,6 +32,9 @@ class RemoteControlServer( @Volatile private var running = false + /** 每次服务实例生成独立的随机同源会话令牌 */ + private val sessionToken: String = UUID.randomUUID().toString().replace("-", "") + /** 服务是否仍在运行(用于状态检测;与 NanoHTTPD.isAlive 区分,避免 override final) */ fun isServerRunning(): Boolean = running @@ -45,6 +54,7 @@ class RemoteControlServer( private val htmlPage: String by lazy { try { context.assets.open("remote.html").bufferedReader().use { it.readText() } + .replace("__TV_SESSION_TOKEN__", sessionToken) } catch (e: Exception) { Log.e(TAG, "Failed to load remote.html", e) "Failed to load remote page." @@ -53,43 +63,44 @@ class RemoteControlServer( val port: Int get() = PORT - private fun newCorsResponse( + private fun newSameOriginResponse( status: NanoHTTPD.Response.IStatus, mimeType: String, data: ByteArray ): NanoHTTPD.Response { return newFixedLengthResponse(status, mimeType, ByteArrayInputStream(data), data.size.toLong()).apply { - addHeader("Access-Control-Allow-Origin", "*") - addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") - addHeader("Access-Control-Max-Age", "86400") + addHeader("X-Content-Type-Options", "nosniff") + addHeader("Cache-Control", "no-store") } } + private fun isAuthorized(session: IHTTPSession): Boolean { + val headerToken = session.headers["x-tv-token"]?.trim() + return !headerToken.isNullOrEmpty() && headerToken == sessionToken + } + override fun serve(session: IHTTPSession): NanoHTTPD.Response { val uri = session.uri - if (session.method == NanoHTTPD.Method.OPTIONS) { - return newCorsResponse( - NanoHTTPD.Response.Status.OK, - "text/plain", - ByteArray(0) - ) - } if (session.method == NanoHTTPD.Method.GET && (uri == "/" || uri == "/remote" || uri.isEmpty())) { val htmlBytes = htmlPage.toByteArray(Charsets.UTF_8) - return newCorsResponse( + return newSameOriginResponse( NanoHTTPD.Response.Status.OK, "text/html; charset=utf-8", htmlBytes ) } - if (session.method == NanoHTTPD.Method.POST && uri == "/cmd") { - return handleCommand(session) + if (session.method == NanoHTTPD.Method.POST && (uri == "/cmd" || uri == "/upload")) { + if (!isAuthorized(session)) { + Log.w(TAG, "Rejected unauthorized request to $uri from ${session.remoteIpAddress}") + return newSameOriginResponse( + NanoHTTPD.Response.Status.FORBIDDEN, + "application/json", + "{\"status\":\"error\",\"message\":\"Forbidden: invalid session token\"}".toByteArray(Charsets.UTF_8) + ) + } + return if (uri == "/cmd") handleCommand(session) else handleUpload(session) } - if (session.method == NanoHTTPD.Method.POST && uri == "/upload") { - return handleUpload(session) - } - return newCorsResponse( + return newSameOriginResponse( NanoHTTPD.Response.Status.NOT_FOUND, "text/plain", "Not Found".toByteArray(Charsets.UTF_8) @@ -109,7 +120,7 @@ class RemoteControlServer( if (body.isNullOrBlank()) { try { val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0 - if (contentLength > 0 && contentLength < 4096) { + if (contentLength in 1..4095) { val buf = ByteArray(contentLength) var offset = 0 val stream = session.inputStream @@ -152,7 +163,7 @@ class RemoteControlServer( Log.e(TAG, "Parse cmd failed: $body", e) } } - return newCorsResponse( + return newSameOriginResponse( NanoHTTPD.Response.Status.OK, "application/json", "{}".toByteArray(Charsets.UTF_8) @@ -172,18 +183,25 @@ class RemoteControlServer( rawFilename = "upload_${System.currentTimeMillis()}" } val cleanFilename = File(rawFilename).name.ifBlank { "uploaded_file" } + if (!isAllowedUploadFilename(cleanFilename)) { + return newSameOriginResponse( + NanoHTTPD.Response.Status.BAD_REQUEST, + "application/json", + "{\"status\":\"error\",\"message\":\"Unsupported file type\"}".toByteArray(Charsets.UTF_8) + ) + } val tempFilePath = files["file"] ?: files.values.firstOrNull() if (tempFilePath != null) { val tempFile = File(tempFilePath) if (tempFile.exists() && tempFile.length() > 0) { - val uploadDir = File(context.getExternalFilesDir("uploads") ?: context.filesDir, "uploads") - uploadDir.mkdirs() + val uploadDir = getUploadDir(context) val targetFile = File(uploadDir, cleanFilename) tempFile.copyTo(targetFile, overwrite = true) + val clientIp = session.remoteIpAddress.orEmpty() mainHandler.post { - callback?.onRemoteFileUploaded(targetFile, cleanFilename) + callback?.onRemoteFileUploaded(targetFile, cleanFilename, clientIp) } val respJson = JSONObject().apply { @@ -191,7 +209,7 @@ class RemoteControlServer( put("filename", cleanFilename) put("size", targetFile.length()) } - return newCorsResponse( + return newSameOriginResponse( NanoHTTPD.Response.Status.OK, "application/json", respJson.toString().toByteArray(Charsets.UTF_8) @@ -204,17 +222,41 @@ class RemoteControlServer( put("status", "error") put("message", e.message ?: "unknown") } - return newCorsResponse( + return newSameOriginResponse( NanoHTTPD.Response.Status.INTERNAL_ERROR, "application/json", errJson.toString().toByteArray(Charsets.UTF_8) ) } - return newCorsResponse( + return newSameOriginResponse( NanoHTTPD.Response.Status.BAD_REQUEST, "application/json", "{\"status\":\"error\",\"message\":\"No file received\"}".toByteArray(Charsets.UTF_8) ) } + + companion object { + private val ALLOWED_EXTENSIONS = setOf( + "apk", "mp4", "m3u8", "mkv", "flv", "ts", "webm", "mov", "avi", + "mp3", "flac", "wav", "aac", "ogg", "m4a", + "jpg", "jpeg", "png", "webp", "gif", + "srt", "ass", "vtt", "txt", "json", "js", "zip" + ) + + fun isAllowedUploadFilename(filename: String): Boolean { + val ext = filename.substringAfterLast('.', "").lowercase() + return ext in ALLOWED_EXTENSIONS + } + + /** + * 统一获取局域网快传目录: + * getExternalFilesDir("uploads") 本身即指向 .../files/uploads,不再重复嵌套子目录。 + */ + fun getUploadDir(context: Context): File { + val dir = context.getExternalFilesDir("uploads") ?: File(context.filesDir, "uploads") + if (!dir.exists()) dir.mkdirs() + return dir + } + } } diff --git a/app/src/main/java/com/tv/browser/SettingsDialog.kt b/app/src/main/java/com/tv/browser/SettingsDialog.kt index 22bbe19..e153847 100644 --- a/app/src/main/java/com/tv/browser/SettingsDialog.kt +++ b/app/src/main/java/com/tv/browser/SettingsDialog.kt @@ -383,7 +383,8 @@ class SettingsDialog : DialogFragment() { bindCardLeftKeyToTab(b.cardKeyGuide, 4) bindCardLeftKeyToTab(b.cardRemoteServer, 4) - b.tvVersionDetail.text = "版本: v${BuildConfig.VERSION_NAME} (Release) · 引擎: Chromium WebView + ExoPlayer Media3" + val buildTag = if (BuildConfig.DEBUG) "Debug" else "Release" + b.tvVersionDetail.text = "版本: v${BuildConfig.VERSION_NAME} ($buildTag) · 引擎: Chromium WebView + ExoPlayer Media3" b.cardRemoteServer.setOnClickListener { act?.showRemoteControlDialog() diff --git a/app/src/main/java/com/tv/browser/TVBrowserApplication.kt b/app/src/main/java/com/tv/browser/TVBrowserApplication.kt index 93f8f82..61b308d 100644 --- a/app/src/main/java/com/tv/browser/TVBrowserApplication.kt +++ b/app/src/main/java/com/tv/browser/TVBrowserApplication.kt @@ -4,20 +4,20 @@ import android.app.Application import android.os.Build import android.util.Log import java.io.File -import java.io.FileWriter import java.io.PrintWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale /** - * 应用入口。安装全局未捕获异常处理器,在崩溃时将日志写入文件。 + * 应用入口。安装全局未捕获异常处理器,在崩溃时将日志写入文件,并定期裁剪保留最近 [MAX_CRASH_LOGS] 条。 */ class TVBrowserApplication : Application() { override fun onCreate() { super.onCreate() installCrashHandler() + pruneCrashLogsAsync() } private fun installCrashHandler() { @@ -32,6 +32,24 @@ class TVBrowserApplication : Application() { } } + private fun pruneCrashLogsAsync() { + val dir = File(filesDir, CRASH_LOG_DIR) + Thread { + try { + pruneCrashLogs(dir) + } catch (_: Throwable) { } + }.start() + } + + private fun pruneCrashLogs(dir: File) { + if (!dir.exists()) return + val files = dir.listFiles { f -> f.isFile && f.name.startsWith("crash_") } ?: return + if (files.size <= MAX_CRASH_LOGS) return + files.sortedByDescending { it.lastModified() } + .drop(MAX_CRASH_LOGS) + .forEach { runCatching { it.delete() } } + } + private fun saveCrashLog(thread: Thread, throwable: Throwable) { val dir = File(filesDir, CRASH_LOG_DIR) if (!dir.exists()) dir.mkdirs() @@ -60,6 +78,7 @@ class TVBrowserApplication : Application() { } pw.flush() } + runCatching { pruneCrashLogs(dir) } Log.w(TAG, "崩溃日志已保存: ${file.absolutePath}") } @@ -67,5 +86,6 @@ class TVBrowserApplication : Application() { private const val TAG = "TVBrowser.Crash" /** 崩溃日志所在子目录,可通过 Context.filesDir/CRASH_LOG_DIR 访问 */ const val CRASH_LOG_DIR = "crash_logs" + private const val MAX_CRASH_LOGS = 20 } } diff --git a/app/src/main/java/com/tv/browser/UserScriptRepository.kt b/app/src/main/java/com/tv/browser/UserScriptRepository.kt index 67edf81..15dadc5 100644 --- a/app/src/main/java/com/tv/browser/UserScriptRepository.kt +++ b/app/src/main/java/com/tv/browser/UserScriptRepository.kt @@ -1,38 +1,18 @@ package com.tv.browser import android.content.Context -import android.content.SharedPreferences -import org.json.JSONArray import org.json.JSONObject import java.util.UUID /** - * 用户脚本持久化:SharedPreferences + JSON。 + * 用户脚本持久化:基于 [JsonListStore] 内存缓存 + SharedPreferences, + * 避免每次页面加载完成(onPageFinished)都在主线程重复解析 JSON。 */ class UserScriptRepository(context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val keyScripts = "user_scripts" + private val appContext = context.applicationContext - fun getAll(): List { - val json = prefs.getString(keyScripts, null) - if (json.isNullOrBlank()) return emptyList() - return try { - val arr = JSONArray(json) - List(arr.length()) { i -> - val obj = arr.getJSONObject(i) - UserScript( - id = obj.optString(KEY_ID, UUID.randomUUID().toString()), - name = obj.optString(KEY_NAME, ""), - urlPattern = obj.optString(KEY_URL_PATTERN, "*"), - script = obj.optString(KEY_SCRIPT, ""), - enabled = obj.optBoolean(KEY_ENABLED, true) - ) - } - } catch (_: Exception) { - emptyList() - } - } + fun getAll(): List = store.getAll(appContext) fun getEnabledForUrl(url: String): List = getAll().filter { it.enabled && it.script.isNotBlank() && it.matchesUrl(url) } @@ -42,7 +22,7 @@ class UserScriptRepository(context: Context) { val newId = script.id.ifBlank { UUID.randomUUID().toString() } list.removeAll { it.id == newId } list.add(0, script.copy(id = newId)) - save(list) + store.save(appContext, list) } fun update(script: UserScript) { @@ -50,7 +30,7 @@ class UserScriptRepository(context: Context) { val idx = list.indexOfFirst { it.id == script.id } if (idx >= 0) { list[idx] = script - save(list) + store.save(appContext, list) } else { add(script) } @@ -61,34 +41,44 @@ class UserScriptRepository(context: Context) { val idx = list.indexOfFirst { it.id == id } if (idx >= 0) { list[idx] = list[idx].copy(enabled = enabled) - save(list) + store.save(appContext, list) } } fun remove(id: String) { - save(getAll().filter { it.id != id }) - } - - private fun save(list: List) { - val arr = JSONArray() - list.forEach { s -> - arr.put(JSONObject().apply { - put(KEY_ID, s.id) - put(KEY_NAME, s.name) - put(KEY_URL_PATTERN, s.urlPattern) - put(KEY_SCRIPT, s.script) - put(KEY_ENABLED, s.enabled) - }) - } - prefs.edit().putString(keyScripts, arr.toString()).apply() + store.save(appContext, getAll().filter { it.id != id }) } companion object { private const val PREFS_NAME = "tv_browser_user_scripts" + private const val KEY_SCRIPTS = "user_scripts" private const val KEY_ID = "id" private const val KEY_NAME = "name" private const val KEY_URL_PATTERN = "urlPattern" private const val KEY_SCRIPT = "script" private const val KEY_ENABLED = "enabled" + + private val store = JsonListStore( + prefsName = PREFS_NAME, + key = KEY_SCRIPTS, + encode = { s -> + JSONObject().apply { + put(KEY_ID, s.id) + put(KEY_NAME, s.name) + put(KEY_URL_PATTERN, s.urlPattern) + put(KEY_SCRIPT, s.script) + put(KEY_ENABLED, s.enabled) + } + }, + decode = { obj -> + UserScript( + id = obj.optString(KEY_ID, UUID.randomUUID().toString()), + name = obj.optString(KEY_NAME, ""), + urlPattern = obj.optString(KEY_URL_PATTERN, "*"), + script = obj.optString(KEY_SCRIPT, ""), + enabled = obj.optBoolean(KEY_ENABLED, true) + ) + } + ) } } diff --git a/app/src/main/java/com/tv/browser/VideoControlHelper.kt b/app/src/main/java/com/tv/browser/VideoControlHelper.kt index 6d64c33..07f192f 100644 --- a/app/src/main/java/com/tv/browser/VideoControlHelper.kt +++ b/app/src/main/java/com/tv/browser/VideoControlHelper.kt @@ -27,11 +27,6 @@ function __tvFindVideo(doc){ } """ - /** 检测页面是否存在 video 元素(主文档 + iframe),返回 "yes" 或 "no";内部 console.log 便于 logcat 排查 */ - val JS_HAS_VIDEO = - "(function(){ $FIND_VIDEO_FN var r=__tvFindVideo(document)?'yes':'no'; try{ console.log('TVBrowser findVideo:', r); }catch(e){} return r; })();" - .replace("$FIND_VIDEO_FN", FIND_VIDEO_FN) - /** 播放/暂停切换,返回 "playing" | "paused" | "no" */ val JS_TOGGLE_PLAY = "(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; if(v.paused){ v.play(); return 'playing'; } else { v.pause(); return 'paused'; } })();" @@ -67,35 +62,6 @@ function __tvFindVideo(doc){ "(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; try { var r=v.getBoundingClientRect(); var x=r.left+r.width/2, y=r.top+r.height/2; var opts={bubbles:true,cancelable:true,view:window,clientX:x,clientY:y}; v.dispatchEvent(new MouseEvent('dblclick',opts)); return 'ok'; } catch(err) { return 'no'; } })();" .replace("$FIND_VIDEO_FN", FIND_VIDEO_FN) - /** 获取当前播放状态,返回 "playing" | "paused" | "no" */ - val JS_GET_STATE = - "(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); return v ? (v.paused ? 'paused' : 'playing') : 'no'; })();" - .replace("$FIND_VIDEO_FN", FIND_VIDEO_FN) - - /** 获取当前 video 的 src/currentSrc,返回 URL 或空;blob: 需在页内播,新页面不可用 */ - val JS_GET_VIDEO_SRC = - "(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return ''; var s=(v.currentSrc||v.src||'').trim(); return s; })();" - .replace("$FIND_VIDEO_FN", FIND_VIDEO_FN) - - /** 从页内 application/ld+json 中取 VideoObject.contentUrl(m3u8/mp4 等),返回 URL 或空 */ - val JS_GET_CONTENT_URL = """ -(function(){ - var scripts=document.querySelectorAll('script[type="application/ld+json"]'); - for(var i=0;i 授予 allowfullscreen 属性及 Permissions Policy,解决聚合站 iframe 视频全屏被拦截的问题 diff --git a/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt b/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt index afb6daa..c909c5d 100644 --- a/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt +++ b/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt @@ -23,6 +23,18 @@ object VideoSnifferHelper { private val VIDEO_EXTENSIONS = arrayOf(".m3u8", ".mp4", ".flv", ".f4v", ".webm") + private fun extractPath(url: String): String? = + runCatching { java.net.URI(url.trim()).path }.getOrNull() + ?: runCatching { Uri.parse(url).path }.getOrNull() + ?: url.trim().substringAfter("://", "").substringAfter('/', "").let { + if (it.isEmpty()) null else "/" + it.substringBefore('?').substringBefore('#') + } + + private fun extractHost(url: String): String? = + runCatching { java.net.URI(url.trim()).host }.getOrNull() + ?: runCatching { Uri.parse(url).host }.getOrNull() + ?: url.trim().substringAfter("://", "").substringBefore('/').substringBefore(':').takeIf { it.isNotEmpty() } + /** * 判断一个请求 URL 是否为多媒体视频流地址 */ @@ -39,11 +51,7 @@ object VideoSnifferHelper { } // 解析 Path - val path = try { - Uri.parse(url).path?.lowercase() ?: "" - } catch (_: Exception) { - lower - } + val path = extractPath(url)?.lowercase() ?: lower for (ext in VIDEO_EXTENSIONS) { if (path.endsWith(ext) || lower.contains("$ext?") || lower.contains("$ext#") || lower.endsWith(ext)) { @@ -66,11 +74,7 @@ object VideoSnifferHelper { * 后台解析 WebView 已开启 blockNetworkImage,能走到这里的图片后缀请求基本是 XHR 加载的伪装分片。 */ fun isMediaSegmentUrl(url: String): Boolean { - val path = try { - Uri.parse(url).path?.lowercase() ?: return false - } catch (_: Exception) { - return false - } + val path = extractPath(url)?.lowercase() ?: return false return SEGMENT_EXTENSIONS.any { path.endsWith(it) } || DISGUISED_SEGMENT_EXTENSIONS.any { path.endsWith(it) } } @@ -111,10 +115,9 @@ object VideoSnifferHelper { * 目前:央视网 CNTV 的 h5e / enc / enc2 清单(如 dh5ws01.v.cntv.cn/asp/h5e/hls/...)。 */ fun isWebOnlyStream(url: String): Boolean { - val uri = try { Uri.parse(url.trim()) } catch (_: Exception) { return false } - val host = uri.host?.lowercase() ?: return false + val host = extractHost(url)?.lowercase() ?: return false if (!host.contains("cntv") && !host.contains("cctv")) return false - return WEB_ONLY_PATH.containsMatchIn(uri.path ?: "") + return WEB_ONLY_PATH.containsMatchIn(extractPath(url) ?: "") } /** @@ -124,16 +127,12 @@ object VideoSnifferHelper { fun isLikelyVariantOf(newUrl: String, existingUrl: String): Boolean { if (newUrl == existingUrl) return true if (!isHls(newUrl) || !isHls(existingUrl)) return false - return try { - val a = Uri.parse(newUrl) - val b = Uri.parse(existingUrl) - if (a.host != b.host) return false - val dirA = a.path?.substringBeforeLast('/') ?: return false - val dirB = b.path?.substringBeforeLast('/') ?: return false - dirA.startsWith(dirB) - } catch (_: Exception) { - false - } + val hostA = extractHost(newUrl) ?: return false + val hostB = extractHost(existingUrl) ?: return false + if (hostA != hostB) return false + val dirA = extractPath(newUrl)?.substringBeforeLast('/') ?: return false + val dirB = extractPath(existingUrl)?.substringBeforeLast('/') ?: return false + return dirA.startsWith(dirB) } // --------------------------------------------------------------------------------------- @@ -1041,12 +1040,21 @@ object VideoSnifferHelper { } } + window.__tvFindVideosNow = function() { findVideos(document); }; findVideos(document); - // 监听后续动态添加或加载的视频 + if (window.__tvDomSnifferInstalled) return; + window.__tvDomSnifferInstalled = true; + + // 节流监听后续动态添加或加载的视频,避免高频 DOM 变动卡顿 + var sniffTimer = 0; try { new MutationObserver(function() { - findVideos(document); + if (sniffTimer) return; + sniffTimer = setTimeout(function() { + sniffTimer = 0; + findVideos(document); + }, 400); }).observe(document.documentElement || document.body, { childList: true, subtree: true }); } catch(e) {} } catch(e) {} diff --git a/app/src/main/java/com/tv/browser/VideoSource.kt b/app/src/main/java/com/tv/browser/VideoSource.kt index 2467f67..03919e9 100644 --- a/app/src/main/java/com/tv/browser/VideoSource.kt +++ b/app/src/main/java/com/tv/browser/VideoSource.kt @@ -38,23 +38,6 @@ data class Episode(val name: String, val url: String) /** 播放线路(如「NB源」「量子资源」),每条线路有自己的选集列表 */ data class PlaySource(val name: String, val episodes: List) { companion object { - /** 线路列表与 JSON 互转(用于 Intent 传递) */ - fun listToJson(list: List): String { - val arr = JSONArray() - list.forEach { s -> - arr.put(JSONObject().apply { - put("name", s.name) - put("episodes", episodesToJson(s.episodes)) - }) - } - return arr.toString() - } - - fun listFromJson(json: String?): List { - if (json.isNullOrBlank()) return emptyList() - return try { parseArray(JSONArray(json)) } catch (_: Exception) { emptyList() } - } - internal fun parseArray(arr: JSONArray?): List { if (arr == null) return emptyList() val out = mutableListOf() @@ -76,12 +59,6 @@ data class PlaySource(val name: String, val episodes: List) { } return eps } - - private fun episodesToJson(list: List): JSONArray { - val arr = JSONArray() - list.forEach { arr.put(JSONObject().put("name", it.name).put("url", it.url)) } - return arr - } } } diff --git a/app/src/main/java/com/tv/browser/VideoWebActivity.kt b/app/src/main/java/com/tv/browser/VideoWebActivity.kt deleted file mode 100644 index 8769831..0000000 --- a/app/src/main/java/com/tv/browser/VideoWebActivity.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.tv.browser - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.ViewGroup -import android.webkit.WebView -import androidx.appcompat.app.AppCompatActivity -import com.tv.browser.databinding.ActivityVideoWebBinding - -/** - * 新页面播放:用 WebView 加载仅含 <video src="url"> 的简单页,用于直链或 contentUrl。 - */ -class VideoWebActivity : AppCompatActivity() { - - private lateinit var binding: ActivityVideoWebBinding - - @SuppressLint("SetJavaScriptEnabled") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityVideoWebBinding.inflate(layoutInflater) - setContentView(binding.root) - - val url = intent.getStringExtra(EXTRA_VIDEO_URL) ?: "" - if (url.isBlank()) { - finish() - return - } - - binding.webView.settings.apply { - javaScriptEnabled = true - domStorageEnabled = true - useWideViewPort = true - loadWithOverviewMode = true - } - - binding.webView.webViewClient = object : android.webkit.WebViewClient() { - override fun onReceivedSslError( - view: WebView?, - handler: android.webkit.SslErrorHandler?, - error: android.net.http.SslError? - ) { - handler?.proceed() - } - } - - val html = """ - - - - - - """.trimIndent() - - binding.webView.loadDataWithBaseURL( - intent.getStringExtra(EXTRA_BASE_URL).orEmpty(), - html, - "text/html", - "UTF-8", - null - ) - } - - override fun onResume() { - super.onResume() - if (::binding.isInitialized) { - binding.webView.onResume() - } - } - - override fun onPause() { - if (::binding.isInitialized) { - try { - binding.webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null) - binding.webView.onPause() - } catch (_: Exception) { } - } - super.onPause() - } - - override fun onStop() { - if (::binding.isInitialized) { - try { - binding.webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null) - binding.webView.onPause() - } catch (_: Exception) { } - } - super.onStop() - } - - override fun onDestroy() { - if (::binding.isInitialized) { - try { - binding.webView.stopLoading() - (binding.webView.parent as? ViewGroup)?.removeView(binding.webView) - binding.webView.destroy() - } catch (_: Exception) { } - } - super.onDestroy() - } - - private fun escapeHtmlAttr(s: String): String { - return s.replace("&", "&") - .replace("\"", """) - .replace("<", "<") - .replace(">", ">") - } - - companion object { - const val EXTRA_VIDEO_URL = "video_url" - const val EXTRA_BASE_URL = "base_url" - } -} diff --git a/app/src/main/java/com/tv/browser/VodSearchHelper.kt b/app/src/main/java/com/tv/browser/VodSearchHelper.kt index 33c2328..e9ad9a0 100644 --- a/app/src/main/java/com/tv/browser/VodSearchHelper.kt +++ b/app/src/main/java/com/tv/browser/VodSearchHelper.kt @@ -10,15 +10,8 @@ import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder -import java.security.SecureRandom -import java.security.cert.X509Certificate import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManager -import javax.net.ssl.X509TrustManager /** 一条聚合搜索结果 */ data class VodResult( @@ -242,12 +235,10 @@ class VodSearchHelper(context: Context) { } private fun httpGet(url: String, ua: String, cookie: String?, referer: String, ajax: Boolean = true): String? { + val useLenient = AppSettings.isLenientSsl(appContext) || NetworkChecker.isSystemClockAbnormal() val conn = URL(url).openConnection() as HttpURLConnection try { - if (conn is HttpsURLConnection) { - conn.sslSocketFactory = lenientSslSocketFactory - conn.hostnameVerifier = lenientHostnameVerifier - } + PlayerSsl.applyTo(conn, lenient = useLenient) conn.connectTimeout = TIMEOUT_MS conn.readTimeout = TIMEOUT_MS conn.instanceFollowRedirects = true @@ -256,7 +247,9 @@ class VodSearchHelper(context: Context) { if (ajax) conn.setRequestProperty("X-Requested-With", "XMLHttpRequest") conn.setRequestProperty("Accept", if (ajax) "application/json, text/javascript, */*" else "text/html,*/*") if (!cookie.isNullOrBlank()) conn.setRequestProperty("Cookie", cookie) - if (conn.responseCode !in 200..299) return null + val code = conn.responseCode + NetworkChecker.updateServerDate(conn.date) + if (code !in 200..299) return null val bytes = conn.inputStream.use { input -> val buf = java.io.ByteArrayOutputStream() val chunk = ByteArray(16 * 1024) @@ -302,22 +295,11 @@ class VodSearchHelper(context: Context) { lastSearchResults = results } - private val lenientHostnameVerifier = HostnameVerifier { _, _ -> true } private val sharedExecutor: ExecutorService by lazy { Executors.newFixedThreadPool(THREADS) { r -> Thread(r, "VodSearchWorker").apply { isDaemon = true } } } - private val lenientSslSocketFactory by lazy { - val trustAll = arrayOf( - object : X509TrustManager { - override fun getAcceptedIssuers(): Array = emptyArray() - override fun checkClientTrusted(chain: Array?, authType: String?) {} - override fun checkServerTrusted(chain: Array?, authType: String?) {} - } - ) - SSLContext.getInstance("TLS").apply { init(null, trustAll, SecureRandom()) }.socketFactory - } /** 常见的苹果 CMS 详情页伪静态规则(最后一个是动态路由,伪静态未开时也能用) */ private val COMMON_TEMPLATES = listOf( diff --git a/app/src/main/res/drawable/btn_focus_bg.xml b/app/src/main/res/drawable/btn_focus_bg.xml index 322d83f..94ce330 100644 --- a/app/src/main/res/drawable/btn_focus_bg.xml +++ b/app/src/main/res/drawable/btn_focus_bg.xml @@ -2,13 +2,13 @@ - + - + diff --git a/app/src/main/res/layout/activity_video_web.xml b/app/src/main/res/layout/activity_video_web.xml deleted file mode 100644 index 5ac0e2b..0000000 --- a/app/src/main/res/layout/activity_video_web.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/dialog_bookmarks.xml b/app/src/main/res/layout/dialog_bookmarks.xml deleted file mode 100644 index afc9433..0000000 --- a/app/src/main/res/layout/dialog_bookmarks.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - -