feat(update): 已下载 APK 直接复用 + 安装失败可重试不重下
Some checks are pending
Release / build (push) Waiting to run

- downloadApk 检测同版本已完成文件,存在则跳过下载
- 下载先写 .part 再改名,避免半截文件被误用
- install 失败保留 Ready(file, error),UI 显示重试安装
- NeedInstallPermission 携带文件,开启权限后可继续安装
This commit is contained in:
Liuxinyu176 2026-10-09 10:11:26 +08:00
parent 571b270cb6
commit 481c57c535
4 changed files with 34 additions and 10 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token" applicationId = "com.rainy.token"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 20 versionCode = 21
versionName = "1.7.0" versionName = "1.7.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -56,7 +56,7 @@ class UpdateRepository @Inject constructor(
} }
} }
/** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */ /** 下载 APK 到缓存目录;同版本已下载完成时直接复用,不重复下载。 */
suspend fun downloadApk( suspend fun downloadApk(
info: UpdateInfo, info: UpdateInfo,
onProgress: (downloaded: Long, total: Long) -> Unit onProgress: (downloaded: Long, total: Long) -> Unit
@ -64,6 +64,11 @@ class UpdateRepository @Inject constructor(
try { try {
val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() } val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() }
val file = File(dir, "rainytoken-" + info.versionName + ".apk") val file = File(dir, "rainytoken-" + info.versionName + ".apk")
if (file.exists() && file.length() > 0L) {
return@withContext Result.success(file)
}
// 先写 .part,下载完整后再替换正式文件名,避免半截文件被当成完整包
val part = File(dir, file.name + ".part")
val request = Request.Builder().url(info.apkUrl).get().build() val request = Request.Builder().url(info.apkUrl).get().build()
okHttpClient.newCall(request).execute().use { resp -> okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
@ -71,7 +76,7 @@ class UpdateRepository @Inject constructor(
} }
val body = resp.body ?: return@withContext Result.failure(IOException("empty body")) val body = resp.body ?: return@withContext Result.failure(IOException("empty body"))
val total = body.contentLength() val total = body.contentLength()
file.outputStream().buffered().use { output -> part.outputStream().buffered().use { output ->
body.byteStream().use { input -> body.byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var downloaded = 0L var downloaded = 0L
@ -84,6 +89,8 @@ class UpdateRepository @Inject constructor(
} }
} }
} }
if (file.exists()) file.delete()
part.renameTo(file)
Result.success(file) Result.success(file)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)

View File

@ -508,9 +508,23 @@ private fun UpdateCard(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = InkMuted color = InkMuted
) )
if (phase.error != null) {
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = phase.error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.padding(top = 8.dp)) Spacer(modifier = Modifier.padding(top = 8.dp))
Button(onClick = { viewModel.install(phase.file) }) { Button(onClick = { viewModel.install(phase.file) }) {
Text(stringResource(R.string.update_install)) Text(
if (phase.error == null) {
stringResource(R.string.update_install)
} else {
"重试安装"
}
)
} }
} }
is UpdateViewModel.Phase.NeedInstallPermission -> { is UpdateViewModel.Phase.NeedInstallPermission -> {
@ -529,6 +543,9 @@ private fun UpdateCard(
}) { }) {
Text(stringResource(R.string.update_enable_permission)) Text(stringResource(R.string.update_enable_permission))
} }
TextButton(onClick = { viewModel.install(phase.file) }) {
Text("已开启,继续安装")
}
} }
is UpdateViewModel.Phase.Error -> { is UpdateViewModel.Phase.Error -> {
Text( Text(

View File

@ -30,8 +30,8 @@ class UpdateViewModel @Inject constructor(
object Latest : Phase object Latest : Phase
data class Available(val info: UpdateRepository.UpdateInfo) : Phase data class Available(val info: UpdateRepository.UpdateInfo) : Phase
data class Downloading(val progress: Float) : Phase data class Downloading(val progress: Float) : Phase
data class Ready(val file: File) : Phase data class Ready(val file: File, val error: String? = null) : Phase
object NeedInstallPermission : Phase data class NeedInstallPermission(val file: File) : Phase
data class Error(val message: String) : Phase data class Error(val message: String) : Phase
} }
@ -83,7 +83,7 @@ class UpdateViewModel @Inject constructor(
val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() } val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() }
.getOrDefault(false) .getOrDefault(false)
if (!canInstall) { if (!canInstall) {
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) } _uiState.update { it.copy(phase = Phase.NeedInstallPermission(file)) }
return return
} }
_uiState.update { it.copy(phase = Phase.Ready(file)) } _uiState.update { it.copy(phase = Phase.Ready(file)) }
@ -91,7 +91,7 @@ class UpdateViewModel @Inject constructor(
val uri = try { val uri = try {
FileProvider.getUriForFile(context, authority, file) FileProvider.getUriForFile(context, authority, file)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.update { it.copy(phase = Phase.Error("安装文件无效:" + (e.message ?: "未知错误"))) } _uiState.update { it.copy(phase = Phase.Ready(file, "安装文件无效:" + (e.message ?: "未知错误"))) }
return return
} }
val actionView = Intent(Intent.ACTION_VIEW).apply { val actionView = Intent(Intent.ACTION_VIEW).apply {
@ -105,7 +105,7 @@ class UpdateViewModel @Inject constructor(
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
} }
runCatching { context.startActivity(actionInstall) }.onFailure { e -> runCatching { context.startActivity(actionInstall) }.onFailure { e ->
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "无法启动安装器")) } _uiState.update { it.copy(phase = Phase.Ready(file, "无法启动安装器:" + (e.message ?: "未知错误"))) }
} }
} }
} }