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)` 括号)
|
||||
|
||||
## 三、关键实现约束(避免踩坑)
|
||||
@@ -139,6 +139,23 @@ $BT/apksigner verify 目标名.apk
|
||||
客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error`
|
||||
保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、
|
||||
设备名、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()`
|
||||
新建时按 `fullScreenChromeVisible` 初始化。**不要**再按「手机是否在点歌」自动隐藏
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.local.ktv"
|
||||
minSdk 21
|
||||
targetSdk 25
|
||||
versionCode 15
|
||||
versionName "1.3.9"
|
||||
versionCode 16
|
||||
versionName "1.4.0"
|
||||
ndk {
|
||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||
|
||||
@@ -47,6 +47,22 @@ class KtvJsBridge(context: Context) {
|
||||
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
|
||||
private val DEVICE_MAC: String get() = DeviceMac.effective()
|
||||
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
|
||||
@@ -119,6 +135,23 @@ class KtvJsBridge(context: Context) {
|
||||
|
||||
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(
|
||||
musicNo: 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 fullScreenQrOverlay: View? = null
|
||||
/** 播放页面右上角「网络未连接」提示。 */
|
||||
private var fullScreenNetworkBadge: TextView? = null
|
||||
private var updateCheckStarted = false
|
||||
private var compactUi = false
|
||||
private val remoteNetworkMonitor = object : Runnable {
|
||||
override fun run() {
|
||||
val ip = localIp().takeIf { LocalRemoteServer.isPrivateIpv4(it) }
|
||||
// 无可用局域网 IP(离线/切换中): 不做重启与刷新, 避免每 5s 空转(网络恢复由
|
||||
// KtvNetworkMonitor 的回调负责)。
|
||||
if (ip.isNullOrEmpty()) {
|
||||
main.postDelayed(this, 5000)
|
||||
return
|
||||
}
|
||||
if (ip != remoteServer.address()) {
|
||||
remoteServer.stop()
|
||||
startRemoteServer()
|
||||
updateMobileQrOverlay()
|
||||
updateFullScreenQrOverlay()
|
||||
// 网络切换后重新解析当前网卡 MAC,并同步日志上报的 IP/设备标识。
|
||||
DeviceMac.refresh()
|
||||
refreshLogDeviceContext()
|
||||
// 换网/换 IP 后出口 IP 可能变化, 歌曲源会话必须重建(见 refreshNetworkState)。
|
||||
refreshNetworkState("ip-change:$ip", notify = false)
|
||||
}
|
||||
main.postDelayed(this, 5000)
|
||||
}
|
||||
@@ -498,6 +503,8 @@ class MainActivity : AppCompatActivity() {
|
||||
startRemoteServer()
|
||||
updateMobileQrOverlay()
|
||||
main.postDelayed(remoteNetworkMonitor, 5000)
|
||||
// 网络状态监测: 断网提示 + 恢复后重建歌曲源会话/重试下载。
|
||||
KtvNetworkMonitor.start(applicationContext) { online -> onNetworkStateChanged(online) }
|
||||
// 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。
|
||||
sendDeviceHeartbeat(boot = true)
|
||||
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
|
||||
@@ -682,6 +689,7 @@ class MainActivity : AppCompatActivity() {
|
||||
main.removeCallbacks(homeBannerRunnable)
|
||||
main.removeCallbacks(autoFullscreenRunnable)
|
||||
main.removeCallbacks(remoteNetworkMonitor)
|
||||
KtvNetworkMonitor.stop()
|
||||
stopRecording(false)
|
||||
unregisterMicInputWatcher()
|
||||
micActivator.stop()
|
||||
@@ -2234,6 +2242,22 @@ class MainActivity : AppCompatActivity() {
|
||||
val song = label(currentAndNextText(), 18, Color.WHITE)
|
||||
fullScreenSongInfo = song
|
||||
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)
|
||||
back.setGravity(Gravity.CENTER)
|
||||
back.setBackgroundResource(R.drawable.bg_btn_glass)
|
||||
@@ -2400,6 +2424,7 @@ class MainActivity : AppCompatActivity() {
|
||||
fullScreenControls = null
|
||||
fullScreenProgressRow = null
|
||||
fullScreenQrOverlay = null
|
||||
fullScreenNetworkBadge = null
|
||||
fullScreenChromeVisible = false
|
||||
main.removeCallbacks(hideFullScreenChromeRunnable)
|
||||
scheduleAutoFullscreen()
|
||||
@@ -3579,6 +3604,56 @@ class MainActivity : AppCompatActivity() {
|
||||
/** 当前设备 MAC(12 位小写十六进制): 本机当前在用网卡, 读不到时回退历史抓包值。 */
|
||||
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)。 */
|
||||
private fun refreshLogDeviceContext() {
|
||||
MiliLog.updateDeviceContext(ip = localIp(), deviceId = deviceMac())
|
||||
|
||||
@@ -106,8 +106,22 @@ object SongApiClient {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置歌曲源会话(清 JS 侧 token / API 实例)。
|
||||
*
|
||||
* 注意:这里原来是**空实现**,导致断网重连后仍复用旧 token —— 现象是
|
||||
* 「日志提示歌曲源获取成功(地址拿到了),但实际下载失败/403」。
|
||||
*/
|
||||
@JvmStatic
|
||||
fun clearTokenCache() {}
|
||||
fun clearTokenCache() {
|
||||
bridge?.resetApiSession()
|
||||
}
|
||||
|
||||
/** 语义更明确的别名:断网恢复后重建歌曲源会话。 */
|
||||
@JvmStatic
|
||||
fun resetSourceSession() {
|
||||
bridge?.resetApiSession()
|
||||
}
|
||||
|
||||
/** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */
|
||||
@JvmStatic
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.liulishuo.okdownload.core.cause.EndCause
|
||||
import com.liulishuo.okdownload.core.cause.ResumeFailedCause
|
||||
import com.liulishuo.okdownload.core.listener.DownloadListener2
|
||||
import java.io.File
|
||||
import java.util.Collections
|
||||
import java.util.LinkedHashMap
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -16,6 +18,8 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
object SongOkDownloadManager {
|
||||
private const val TAG = "SongDownload"
|
||||
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 STALL_TIMEOUT_MS = 25_000L
|
||||
|
||||
@@ -96,6 +100,8 @@ object SongOkDownloadManager {
|
||||
}
|
||||
if (url.isEmpty()) {
|
||||
pending.remove(key)
|
||||
// 取不到地址通常也是网络/源站不可达, 记下来供网络恢复后自动重试。
|
||||
if (isNetworkFailure(null, 0)) networkFailed[key] = song
|
||||
callback?.onDownloadFailed(song, "无法生成下载地址")
|
||||
} else {
|
||||
startDownloadTask(song, url, callback, 0)
|
||||
@@ -113,6 +119,7 @@ object SongOkDownloadManager {
|
||||
pending.remove(key)
|
||||
tasks.remove(key)?.let { task ->
|
||||
manuallyCanceled.add(key)
|
||||
networkFailed.remove(key)
|
||||
task.cancel()
|
||||
clearBrokenDownload(task, getLocalFile(song))
|
||||
}
|
||||
@@ -124,6 +131,43 @@ object SongOkDownloadManager {
|
||||
@JvmStatic
|
||||
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 {
|
||||
if (!forceRefresh) {
|
||||
song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached ->
|
||||
@@ -222,6 +266,10 @@ object SongOkDownloadManager {
|
||||
} else {
|
||||
pending.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" } ?: ""))
|
||||
}
|
||||
return
|
||||
@@ -292,6 +340,7 @@ object SongOkDownloadManager {
|
||||
}
|
||||
pending.remove(key)
|
||||
progress.remove(key)
|
||||
networkFailed.remove(key)
|
||||
song.path = target.absolutePath
|
||||
callback?.onDownloadProgress(song, 100)
|
||||
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)
|
||||
?: song.filename?.takeIf(String::isNotEmpty)
|
||||
?: song.title.orEmpty()
|
||||
|
||||
Reference in New Issue
Block a user