V1.4.0: 修复断网重连后无法下载 + 网络状态检测维护
根因(断网重连后再也下载不了、日志却显示"歌曲源获取成功"):
SongApiClient.clearTokenCache() 一直是**空实现**。JS 侧 window.api.tokens 与
_apiInitPromise 因此被一直复用; 而歌曲源按「出口 IP + device」识别、试用窗口
也记在出口 IP 上(见 Eac19WindowGuard), 断网/重连(尤其换出口 IP)后旧 token 已失效 ——
取址这一步看起来成功, 真正下载却 403/失败。
1) 网络状态检测(新增 KtvNetworkMonitor)
走 ConnectivityManager 回调(不轮询)维护全局 online, 状态变化才回调主线程;
全局可读 online 供下载层判断失败是否由断网引起。
2) 播放页右上角「网络未连接」提示
全屏播放页控制层 header 内、标题右侧新增红色徽标(不遮挡右上角二维码),
断网显示、恢复自动隐藏。
3) 网络恢复/换网后的统一维护 refreshNetworkState()
- 重建歌曲源会话: clearTokenCache() 真正下发 KtvJsBridge.resetApiSession()
(置空 window.api 与 _apiInitPromise, 下次取址重新构造 API 实例、回到默认源顺序
并重新登录拿新 token);
- 复位 Eac19WindowGuard 窗口守卫; 重解析网卡 MAC + 刷新日志上下文;
- 重启本地点歌服务并刷新二维码;
- 自动重试断网期间失败的下载(最多 8 首)。
断网恢复走该路径(带提示), 换网换 IP 也走同一路径(静默)。
4) 下载层网络失败跟踪(SongOkDownloadManager)
结合 KtvNetworkMonitor.online 与异常类型(UnknownHost/Connect/SocketTimeout/
NoRouteToHost/SSL/EOF)及 HTTP 0 判定网络类失败, 记入 networkFailed;
成功后清除、手动取消不入集合; retryNetworkFailed() 供恢复时调用。
5) 顺带修掉一个隐患: 离线时 localIp() 为空会让 remoteNetworkMonitor 每 5s 误判
"IP 变化"而反复重启本地点歌服务; 现已加空值短路, 离线只等网络回调。
版本号 1.3.9 -> 1.4.0(versionCode 15 -> 16)。按约定本次不发布 Release, 仅本地打包测试。
This commit is contained in:
@@ -108,7 +108,7 @@ $BT/apksigner verify 目标名.apk
|
|||||||
|
|
||||||
### 版本号
|
### 版本号
|
||||||
|
|
||||||
- `app/build.gradle`:`versionName "1.3.9"` / `versionCode 15`
|
- `app/build.gradle`:`versionName "1.4.0"` / `versionCode 16`
|
||||||
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
||||||
|
|
||||||
## 三、关键实现约束(避免踩坑)
|
## 三、关键实现约束(避免踩坑)
|
||||||
@@ -139,6 +139,23 @@ $BT/apksigner verify 目标名.apk
|
|||||||
客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error`
|
客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error`
|
||||||
保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、
|
保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、
|
||||||
设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率
|
设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率
|
||||||
|
- **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询)
|
||||||
|
维护全局 `online`;`MainActivity.onNetworkStateChanged()` 负责:
|
||||||
|
- 断网 → 播放页右上角 `fullScreenNetworkBadge` 显示「网络未连接」(在全屏控制层 header 内、
|
||||||
|
标题右侧,不遮挡右上角二维码);
|
||||||
|
- 恢复 → `refreshNetworkState()`:重解析网卡 MAC + 日志上下文、**重建歌曲源会话**、
|
||||||
|
复位 `Eac19WindowGuard`、重启本地点歌服务并刷新二维码、自动重试断网期间失败的下载(最多 8 首)。
|
||||||
|
换网/换 IP 时 `remoteNetworkMonitor` 走同一个 `refreshNetworkState()`(静默,不弹提示)。
|
||||||
|
⚠️ **「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因**:
|
||||||
|
`SongApiClient.clearTokenCache()` 原本是**空实现**,JS 侧 `window.api.tokens` 与
|
||||||
|
`_apiInitPromise` 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上,
|
||||||
|
断网/重连(尤其换出口 IP)后旧 token 已失效 —— 取址这一步看起来成功,真正下载却 403/失败。
|
||||||
|
现在 `clearTokenCache()` 会真正下发 `KtvJsBridge.resetApiSession()`
|
||||||
|
(置空 `window.api` 与 `_apiInitPromise`,下次取址重新构造 API 实例、回到默认源顺序并重新登录)。
|
||||||
|
下载层的网络类失败由 `SongOkDownloadManager` 结合 `KtvNetworkMonitor.online` 与异常类型/HTTP 0
|
||||||
|
判定并记入 `networkFailed`,网络恢复后由 `retryNetworkFailed()` 自动重试(手动取消的不入集合)。
|
||||||
|
⚠️ 顺带修掉一个隐患:离线时 `localIp()` 为空,原 `remoteNetworkMonitor` 会每 5s 误判「IP 变化」
|
||||||
|
而反复重启本地点歌服务(并连带触发刷新);现已加空值短路,离线只等网络回调。
|
||||||
- **播放界面右上角二维码**:跟随全屏控制层一起出现/隐藏 —— 在
|
- **播放界面右上角二维码**:跟随全屏控制层一起出现/隐藏 —— 在
|
||||||
`setFullScreenChromeVisible()` 里同步 `fullScreenQrOverlay` 的可见性,`updateFullScreenQrOverlay()`
|
`setFullScreenChromeVisible()` 里同步 `fullScreenQrOverlay` 的可见性,`updateFullScreenQrOverlay()`
|
||||||
新建时按 `fullScreenChromeVisible` 初始化。**不要**再按「手机是否在点歌」自动隐藏
|
新建时按 `fullScreenChromeVisible` 初始化。**不要**再按「手机是否在点歌」自动隐藏
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "com.local.ktv"
|
applicationId "com.local.ktv"
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 25
|
targetSdk 25
|
||||||
versionCode 15
|
versionCode 16
|
||||||
versionName "1.3.9"
|
versionName "1.4.0"
|
||||||
ndk {
|
ndk {
|
||||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class KtvJsBridge(context: Context) {
|
|||||||
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
|
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
|
||||||
private val DEVICE_MAC: String get() = DeviceMac.effective()
|
private val DEVICE_MAC: String get() = DeviceMac.effective()
|
||||||
private const val DEVICE_SN = "2d7bb4a7d7f7"
|
private const val DEVICE_SN = "2d7bb4a7d7f7"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断网重连后重置歌曲源会话。
|
||||||
|
*
|
||||||
|
* 源站按「出口 IP + device」识别,试用窗口也记在出口 IP 上(见 Eac19WindowGuard)。
|
||||||
|
* 断网/重连(尤其换了出口 IP)后旧 token 已失效,继续复用会表现为
|
||||||
|
* **「取址看起来成功、实际下载 403/失败」**。这里把整个 API 实例与初始化 Promise
|
||||||
|
* 都清掉:下一次取址会重新 new KtvApi(回到默认源顺序)并重新登录拿新 token。
|
||||||
|
*/
|
||||||
|
private const val RESET_SESSION_JS = """(function(){
|
||||||
|
try {
|
||||||
|
window.api = null;
|
||||||
|
window._apiInitPromise = null;
|
||||||
|
} catch (e) {}
|
||||||
|
return 'ok';
|
||||||
|
})()"""
|
||||||
}
|
}
|
||||||
|
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
@@ -119,6 +135,23 @@ class KtvJsBridge(context: Context) {
|
|||||||
|
|
||||||
fun isDestroyed(): Boolean = destroyed
|
fun isDestroyed(): Boolean = destroyed
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置 JS 侧歌曲源会话(断网恢复 / 下载重试时调用)。
|
||||||
|
*
|
||||||
|
* 置空 `window.api` 与 `window._apiInitPromise`,下一次取址会重新构造 API 实例
|
||||||
|
* (恢复默认源顺序)并重新登录,避免继续使用失效 token。
|
||||||
|
*/
|
||||||
|
fun resetApiSession() {
|
||||||
|
val view = webView
|
||||||
|
if (!ready || destroyed || view == null) return
|
||||||
|
mainHandler.post {
|
||||||
|
runCatching {
|
||||||
|
view.evaluateJavascript(RESET_SESSION_JS, null)
|
||||||
|
Log.i(TAG, "reset source api session")
|
||||||
|
}.onFailure { Log.w(TAG, "reset source api session failed", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getSongUrl(
|
fun getSongUrl(
|
||||||
musicNo: String,
|
musicNo: String,
|
||||||
resolution: String,
|
resolution: String,
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.local.ktv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备网络状态监测(断网 / 恢复),供「网络未连接」提示与断网重连后的会话重建使用。
|
||||||
|
*
|
||||||
|
* 只依赖 `ConnectivityManager` 的实时回调,不轮询:
|
||||||
|
* - `onAvailable` / `onLost` / `onCapabilitiesChanged` 维护「缺省网络是否具备互联网能力」;
|
||||||
|
* - 状态**变化**时回调上层(已切到主线程),由上层处理提示与恢复动作。
|
||||||
|
*
|
||||||
|
* 注意区分两件事:
|
||||||
|
* - 这里判断的是**系统级网络可用性**(有没有网);
|
||||||
|
* - 歌曲源的「试用窗口 / 风控」是按**出口 IP** 计的(见 [Eac19WindowGuard]),
|
||||||
|
* 换网/重连后即使有网也可能需要重新建立会话 —— 这由上层在恢复回调里处理。
|
||||||
|
*
|
||||||
|
* 全局可读 [online],便于下载层判断失败是否由断网引起。
|
||||||
|
*/
|
||||||
|
object KtvNetworkMonitor {
|
||||||
|
private const val TAG = "KtvNetworkMonitor"
|
||||||
|
|
||||||
|
/** 当前是否有可用网络(默认 true,避免启动瞬间误报断网)。 */
|
||||||
|
@Volatile
|
||||||
|
var online: Boolean = true
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var manager: ConnectivityManager? = null
|
||||||
|
private var callback: ConnectivityManager.NetworkCallback? = null
|
||||||
|
|
||||||
|
/** 开始监测;[onChange] 只在状态真正变化时回调(主线程)。 */
|
||||||
|
fun start(context: Context, onChange: (online: Boolean) -> Unit) {
|
||||||
|
if (callback != null) return
|
||||||
|
val cm = context.applicationContext
|
||||||
|
.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
manager = cm
|
||||||
|
val initial = currentOnline(cm)
|
||||||
|
online = initial
|
||||||
|
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
publish(true, onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
// 掉了一个网络不代表彻底没网(可能还有以太网/其它网络),重新判定一次。
|
||||||
|
publish(currentOnline(manager), onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||||
|
publish(caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET), onChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback = cb
|
||||||
|
val registered = runCatching {
|
||||||
|
cm?.registerNetworkCallback(
|
||||||
|
NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build(),
|
||||||
|
cb,
|
||||||
|
)
|
||||||
|
true
|
||||||
|
}.getOrElse {
|
||||||
|
Log.w(TAG, "registerNetworkCallback failed: ${it.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!registered) {
|
||||||
|
callback = null
|
||||||
|
manager = null
|
||||||
|
}
|
||||||
|
Log.i(TAG, "monitor started, online=$initial registered=$registered")
|
||||||
|
onChange(initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
val cb = callback ?: return
|
||||||
|
runCatching { manager?.unregisterNetworkCallback(cb) }
|
||||||
|
.onFailure { Log.w(TAG, "unregister failed: ${it.message}") }
|
||||||
|
callback = null
|
||||||
|
manager = null
|
||||||
|
Log.i(TAG, "monitor stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish(value: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
mainHandler.post {
|
||||||
|
if (value == online) return@post
|
||||||
|
online = value
|
||||||
|
Log.i(TAG, "network state changed: online=$value")
|
||||||
|
onChange(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主动复检一次(例如从后台回到前台),有变化才回调。 */
|
||||||
|
fun refresh(onChange: (online: Boolean) -> Unit) {
|
||||||
|
publish(currentOnline(manager), onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentOnline(cm: ConnectivityManager?): Boolean {
|
||||||
|
val manager = cm ?: return true
|
||||||
|
return try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val network = manager.activeNetwork ?: return false
|
||||||
|
val caps = manager.getNetworkCapabilities(network) ?: return false
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
manager.activeNetworkInfo?.isConnected == true
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "currentOnline failed: ${e.message}")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,19 +164,24 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
private var mobileQrOverlay: View? = null
|
private var mobileQrOverlay: View? = null
|
||||||
private var fullScreenQrOverlay: View? = null
|
private var fullScreenQrOverlay: View? = null
|
||||||
|
/** 播放页面右上角「网络未连接」提示。 */
|
||||||
|
private var fullScreenNetworkBadge: TextView? = null
|
||||||
private var updateCheckStarted = false
|
private var updateCheckStarted = false
|
||||||
private var compactUi = false
|
private var compactUi = false
|
||||||
private val remoteNetworkMonitor = object : Runnable {
|
private val remoteNetworkMonitor = object : Runnable {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
val ip = localIp().takeIf { LocalRemoteServer.isPrivateIpv4(it) }
|
val ip = localIp().takeIf { LocalRemoteServer.isPrivateIpv4(it) }
|
||||||
|
// 无可用局域网 IP(离线/切换中): 不做重启与刷新, 避免每 5s 空转(网络恢复由
|
||||||
|
// KtvNetworkMonitor 的回调负责)。
|
||||||
|
if (ip.isNullOrEmpty()) {
|
||||||
|
main.postDelayed(this, 5000)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (ip != remoteServer.address()) {
|
if (ip != remoteServer.address()) {
|
||||||
remoteServer.stop()
|
remoteServer.stop()
|
||||||
startRemoteServer()
|
startRemoteServer()
|
||||||
updateMobileQrOverlay()
|
// 换网/换 IP 后出口 IP 可能变化, 歌曲源会话必须重建(见 refreshNetworkState)。
|
||||||
updateFullScreenQrOverlay()
|
refreshNetworkState("ip-change:$ip", notify = false)
|
||||||
// 网络切换后重新解析当前网卡 MAC,并同步日志上报的 IP/设备标识。
|
|
||||||
DeviceMac.refresh()
|
|
||||||
refreshLogDeviceContext()
|
|
||||||
}
|
}
|
||||||
main.postDelayed(this, 5000)
|
main.postDelayed(this, 5000)
|
||||||
}
|
}
|
||||||
@@ -498,6 +503,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
startRemoteServer()
|
startRemoteServer()
|
||||||
updateMobileQrOverlay()
|
updateMobileQrOverlay()
|
||||||
main.postDelayed(remoteNetworkMonitor, 5000)
|
main.postDelayed(remoteNetworkMonitor, 5000)
|
||||||
|
// 网络状态监测: 断网提示 + 恢复后重建歌曲源会话/重试下载。
|
||||||
|
KtvNetworkMonitor.start(applicationContext) { online -> onNetworkStateChanged(online) }
|
||||||
// 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。
|
// 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。
|
||||||
sendDeviceHeartbeat(boot = true)
|
sendDeviceHeartbeat(boot = true)
|
||||||
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
|
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
|
||||||
@@ -682,6 +689,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
main.removeCallbacks(homeBannerRunnable)
|
main.removeCallbacks(homeBannerRunnable)
|
||||||
main.removeCallbacks(autoFullscreenRunnable)
|
main.removeCallbacks(autoFullscreenRunnable)
|
||||||
main.removeCallbacks(remoteNetworkMonitor)
|
main.removeCallbacks(remoteNetworkMonitor)
|
||||||
|
KtvNetworkMonitor.stop()
|
||||||
stopRecording(false)
|
stopRecording(false)
|
||||||
unregisterMicInputWatcher()
|
unregisterMicInputWatcher()
|
||||||
micActivator.stop()
|
micActivator.stop()
|
||||||
@@ -2234,6 +2242,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val song = label(currentAndNextText(), 18, Color.WHITE)
|
val song = label(currentAndNextText(), 18, Color.WHITE)
|
||||||
fullScreenSongInfo = song
|
fullScreenSongInfo = song
|
||||||
header.addView(song, LinearLayout.LayoutParams(0, dp(48), 1f))
|
header.addView(song, LinearLayout.LayoutParams(0, dp(48), 1f))
|
||||||
|
// 播放页面右上角「网络未连接」提示(仅断网时显示, 网络恢复自动消失)。
|
||||||
|
val networkBadge = label("● 网络未连接", 14, Color.WHITE).apply {
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
cornerRadius = dp(11).toFloat()
|
||||||
|
setColor(Color.rgb(198, 40, 74))
|
||||||
|
setStroke(dp(1), Color.argb(140, 255, 255, 255))
|
||||||
|
}
|
||||||
|
includeFontPadding = false
|
||||||
|
visibility = if (KtvNetworkMonitor.online) View.GONE else View.VISIBLE
|
||||||
|
}
|
||||||
|
fullScreenNetworkBadge = networkBadge
|
||||||
|
header.addView(
|
||||||
|
networkBadge,
|
||||||
|
LinearLayout.LayoutParams(dp(if (compactUi) 108 else 124), dp(28)).apply { marginEnd = dp(8) },
|
||||||
|
)
|
||||||
val back = label("返回", 15, Color.WHITE)
|
val back = label("返回", 15, Color.WHITE)
|
||||||
back.setGravity(Gravity.CENTER)
|
back.setGravity(Gravity.CENTER)
|
||||||
back.setBackgroundResource(R.drawable.bg_btn_glass)
|
back.setBackgroundResource(R.drawable.bg_btn_glass)
|
||||||
@@ -2400,6 +2424,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
fullScreenControls = null
|
fullScreenControls = null
|
||||||
fullScreenProgressRow = null
|
fullScreenProgressRow = null
|
||||||
fullScreenQrOverlay = null
|
fullScreenQrOverlay = null
|
||||||
|
fullScreenNetworkBadge = null
|
||||||
fullScreenChromeVisible = false
|
fullScreenChromeVisible = false
|
||||||
main.removeCallbacks(hideFullScreenChromeRunnable)
|
main.removeCallbacks(hideFullScreenChromeRunnable)
|
||||||
scheduleAutoFullscreen()
|
scheduleAutoFullscreen()
|
||||||
@@ -3579,6 +3604,56 @@ class MainActivity : AppCompatActivity() {
|
|||||||
/** 当前设备 MAC(12 位小写十六进制): 本机当前在用网卡, 读不到时回退历史抓包值。 */
|
/** 当前设备 MAC(12 位小写十六进制): 本机当前在用网卡, 读不到时回退历史抓包值。 */
|
||||||
private fun deviceMac(): String = DeviceMac.effective()
|
private fun deviceMac(): String = DeviceMac.effective()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备网络状态变化处理。
|
||||||
|
*
|
||||||
|
* 断网: 播放页右上角显示「网络未连接」。
|
||||||
|
* 恢复: 刷新对网络敏感的全部状态, 保证歌曲源能重新取址并正常下载/播放 ——
|
||||||
|
* 1. 重新解析当前网卡 MAC 与日志上报 IP;
|
||||||
|
* 2. **重置歌曲源会话**(清 JS 侧 token 与 API 实例)—— 源站按出口 IP 计试用窗口,
|
||||||
|
* 断网/重连后旧 token 失效,若继续复用会出现「地址取到了但下载 403/失败」;
|
||||||
|
* 3. 复位歌曲源窗口守卫,便于下一次到期能再次提示;
|
||||||
|
* 4. 拉起本地点歌服务并刷新二维码;
|
||||||
|
* 5. 自动重试断网期间失败的下载。
|
||||||
|
*/
|
||||||
|
private fun onNetworkStateChanged(online: Boolean) {
|
||||||
|
fullScreenNetworkBadge?.visibility = if (online) View.GONE else View.VISIBLE
|
||||||
|
if (!online) {
|
||||||
|
status?.text = "网络未连接"
|
||||||
|
MiliLog.warn("NET-DOWN 设备网络已断开")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
MiliLog.notice("NET-UP 设备网络已恢复, 重建歌曲源会话")
|
||||||
|
if (!remoteServer.isRunning()) startRemoteServer()
|
||||||
|
refreshNetworkState("net-up", notify = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网络路径变化后的统一维护(断网恢复 / 换网换 IP 都走这里)。
|
||||||
|
*
|
||||||
|
* 关键在于**重建歌曲源会话**:源站按「出口 IP + device」识别,并把试用窗口记在出口 IP 上,
|
||||||
|
* 断网重连或换网后旧 token 已失效 —— 不重置就会出现「日志提示取址成功、实际下载
|
||||||
|
* 403/失败」。同时复位窗口守卫、刷新二维码与日志上下文,并自动重试断网期间失败的下载。
|
||||||
|
*
|
||||||
|
* @param notify 是否弹提示(断网恢复时提示,换 IP 时静默)
|
||||||
|
*/
|
||||||
|
private fun refreshNetworkState(reason: String, notify: Boolean) {
|
||||||
|
DeviceMac.refresh()
|
||||||
|
refreshLogDeviceContext()
|
||||||
|
SongApiClient.resetSourceSession()
|
||||||
|
Eac19WindowGuard.noteUrlOk()
|
||||||
|
updateMobileQrOverlay()
|
||||||
|
updateFullScreenQrOverlay()
|
||||||
|
val retried = SongOkDownloadManager.retryNetworkFailed()
|
||||||
|
MiliLog.notice("NET-REFRESH reason=$reason retried=$retried")
|
||||||
|
if (retried > 0) {
|
||||||
|
status?.text = "网络已恢复,正在重试 $retried 首未完成的下载"
|
||||||
|
if (notify) toast("网络已恢复,正在重试 $retried 首未完成的下载")
|
||||||
|
} else if (notify) {
|
||||||
|
status?.text = "网络已恢复"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 远程日志: 把设备网卡 IP 与设备标识同步给日志客户端(GlogCenter 的 clientip/user/traceid)。 */
|
/** 远程日志: 把设备网卡 IP 与设备标识同步给日志客户端(GlogCenter 的 clientip/user/traceid)。 */
|
||||||
private fun refreshLogDeviceContext() {
|
private fun refreshLogDeviceContext() {
|
||||||
MiliLog.updateDeviceContext(ip = localIp(), deviceId = deviceMac())
|
MiliLog.updateDeviceContext(ip = localIp(), deviceId = deviceMac())
|
||||||
|
|||||||
@@ -106,8 +106,22 @@ object SongApiClient {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置歌曲源会话(清 JS 侧 token / API 实例)。
|
||||||
|
*
|
||||||
|
* 注意:这里原来是**空实现**,导致断网重连后仍复用旧 token —— 现象是
|
||||||
|
* 「日志提示歌曲源获取成功(地址拿到了),但实际下载失败/403」。
|
||||||
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun clearTokenCache() {}
|
fun clearTokenCache() {
|
||||||
|
bridge?.resetApiSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语义更明确的别名:断网恢复后重建歌曲源会话。 */
|
||||||
|
@JvmStatic
|
||||||
|
fun resetSourceSession() {
|
||||||
|
bridge?.resetApiSession()
|
||||||
|
}
|
||||||
|
|
||||||
/** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */
|
/** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import com.liulishuo.okdownload.core.cause.EndCause
|
|||||||
import com.liulishuo.okdownload.core.cause.ResumeFailedCause
|
import com.liulishuo.okdownload.core.cause.ResumeFailedCause
|
||||||
import com.liulishuo.okdownload.core.listener.DownloadListener2
|
import com.liulishuo.okdownload.core.listener.DownloadListener2
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.LinkedHashMap
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -16,6 +18,8 @@ import java.util.concurrent.atomic.AtomicLong
|
|||||||
object SongOkDownloadManager {
|
object SongOkDownloadManager {
|
||||||
private const val TAG = "SongDownload"
|
private const val TAG = "SongDownload"
|
||||||
const val MIN_VALID_FILE_SIZE = SongFileValidator.MIN_VALID_FILE_SIZE
|
const val MIN_VALID_FILE_SIZE = SongFileValidator.MIN_VALID_FILE_SIZE
|
||||||
|
/** 网络恢复后最多自动重试几首(防止一次拉起过多并发下载)。 */
|
||||||
|
private const val MAX_NETWORK_RETRY = 8
|
||||||
private const val MAX_RETRY_COUNT = 3
|
private const val MAX_RETRY_COUNT = 3
|
||||||
private const val STALL_TIMEOUT_MS = 25_000L
|
private const val STALL_TIMEOUT_MS = 25_000L
|
||||||
|
|
||||||
@@ -96,6 +100,8 @@ object SongOkDownloadManager {
|
|||||||
}
|
}
|
||||||
if (url.isEmpty()) {
|
if (url.isEmpty()) {
|
||||||
pending.remove(key)
|
pending.remove(key)
|
||||||
|
// 取不到地址通常也是网络/源站不可达, 记下来供网络恢复后自动重试。
|
||||||
|
if (isNetworkFailure(null, 0)) networkFailed[key] = song
|
||||||
callback?.onDownloadFailed(song, "无法生成下载地址")
|
callback?.onDownloadFailed(song, "无法生成下载地址")
|
||||||
} else {
|
} else {
|
||||||
startDownloadTask(song, url, callback, 0)
|
startDownloadTask(song, url, callback, 0)
|
||||||
@@ -113,6 +119,7 @@ object SongOkDownloadManager {
|
|||||||
pending.remove(key)
|
pending.remove(key)
|
||||||
tasks.remove(key)?.let { task ->
|
tasks.remove(key)?.let { task ->
|
||||||
manuallyCanceled.add(key)
|
manuallyCanceled.add(key)
|
||||||
|
networkFailed.remove(key)
|
||||||
task.cancel()
|
task.cancel()
|
||||||
clearBrokenDownload(task, getLocalFile(song))
|
clearBrokenDownload(task, getLocalFile(song))
|
||||||
}
|
}
|
||||||
@@ -124,6 +131,43 @@ object SongOkDownloadManager {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getDownloadProgress(song: Song): Int = progress[songKey(song)] ?: 0
|
fun getDownloadProgress(song: Song): Int = progress[songKey(song)] ?: 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网络恢复后自动重试「因网络原因失败」的下载(最多 [MAX_NETWORK_RETRY] 首)。
|
||||||
|
*
|
||||||
|
* 断网期间的失败大多是连不上/超时/HTTP 0,属于可恢复;恢复后重试一次,避免用户
|
||||||
|
* 手动重新点歌。手动取消的歌不会进入这个集合。
|
||||||
|
*
|
||||||
|
* @return 实际发起重试的数量
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun retryNetworkFailed(): Int {
|
||||||
|
val retry = synchronized(networkFailed) {
|
||||||
|
networkFailed.values.toList().also { networkFailed.clear() }
|
||||||
|
}
|
||||||
|
if (retry.isEmpty()) return 0
|
||||||
|
val limited = retry.take(MAX_NETWORK_RETRY)
|
||||||
|
Log.i(TAG, "network recovered, retrying ${limited.size} failed download(s)")
|
||||||
|
io.execute {
|
||||||
|
limited.forEach { song -> runCatching { download(song, callbacks[songKey(song)]) } }
|
||||||
|
}
|
||||||
|
return limited.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断一个失败是否属于「网络原因」(断网/超时/连不上/HTTP 0)。 */
|
||||||
|
private fun isNetworkFailure(realCause: Exception?, httpCode: Int): Boolean {
|
||||||
|
if (!KtvNetworkMonitor.online) return true
|
||||||
|
if (httpCode == 0) return true
|
||||||
|
val cause = realCause ?: return false
|
||||||
|
return cause is java.net.UnknownHostException ||
|
||||||
|
cause is java.net.ConnectException ||
|
||||||
|
cause is java.net.SocketTimeoutException ||
|
||||||
|
cause is java.net.NoRouteToHostException ||
|
||||||
|
cause is java.net.SocketException ||
|
||||||
|
cause is javax.net.ssl.SSLException ||
|
||||||
|
cause is java.io.EOFException ||
|
||||||
|
cause is java.io.InterruptedIOException
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String {
|
private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String {
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached ->
|
song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached ->
|
||||||
@@ -222,6 +266,10 @@ object SongOkDownloadManager {
|
|||||||
} else {
|
} else {
|
||||||
pending.remove(key)
|
pending.remove(key)
|
||||||
progress.remove(key)
|
progress.remove(key)
|
||||||
|
if (isNetworkFailure(realCause, httpResponseCode)) {
|
||||||
|
networkFailed[key] = song
|
||||||
|
Log.w(TAG, "download failed by network: ${song.title} (${cause.name})")
|
||||||
|
}
|
||||||
cb?.onDownloadFailed(song, cause.name + (realCause?.message?.let { ": $it" } ?: ""))
|
cb?.onDownloadFailed(song, cause.name + (realCause?.message?.let { ": $it" } ?: ""))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -292,6 +340,7 @@ object SongOkDownloadManager {
|
|||||||
}
|
}
|
||||||
pending.remove(key)
|
pending.remove(key)
|
||||||
progress.remove(key)
|
progress.remove(key)
|
||||||
|
networkFailed.remove(key)
|
||||||
song.path = target.absolutePath
|
song.path = target.absolutePath
|
||||||
callback?.onDownloadProgress(song, 100)
|
callback?.onDownloadProgress(song, 100)
|
||||||
callback?.onDownloadComplete(song, target.absolutePath)
|
callback?.onDownloadComplete(song, target.absolutePath)
|
||||||
@@ -371,6 +420,9 @@ object SongOkDownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 因网络原因失败的歌曲(网络恢复后自动重试一次)。 */
|
||||||
|
private val networkFailed = Collections.synchronizedMap(LinkedHashMap<String, Song>())
|
||||||
|
|
||||||
private fun songKey(song: Song): String = song.id?.takeIf(String::isNotEmpty)
|
private fun songKey(song: Song): String = song.id?.takeIf(String::isNotEmpty)
|
||||||
?: song.filename?.takeIf(String::isNotEmpty)
|
?: song.filename?.takeIf(String::isNotEmpty)
|
||||||
?: song.title.orEmpty()
|
?: song.title.orEmpty()
|
||||||
|
|||||||
Reference in New Issue
Block a user