feat(update): 设置页新增检测更新/下载APK/自动安装;接入Gitea update.json清单

This commit is contained in:
Liuxinyu176 2026-10-08 21:42:45 +08:00
parent ceb5e6a268
commit 2509ab8a85
11 changed files with 427 additions and 0 deletions

View File

@ -76,6 +76,16 @@
android:name="miuiWidgetVersion" android:name="miuiWidgetVersion"
android:value="1" /> android:value="1" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@ -0,0 +1,112 @@
package com.rainy.token.data.repository
import android.content.Context
import com.rainy.token.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* 应用更新仓库:从 Gitea 仓库的 update.json 读取版本清单,
* 下载 APK 到应用缓存目录供 FileProvider 安装。
*/
@Singleton
class UpdateRepository @Inject constructor(
private val okHttpClient: OkHttpClient,
@ApplicationContext context: Context
) {
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
/** 检查是否有新版本。null = 已是最新;非空 = 远端有更新。 */
suspend fun checkForUpdate(): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url(UPDATE_MANIFEST_URL)
.get()
.build()
val text = okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
return@withContext Result.failure(IOException("HTTP " + resp.code))
}
resp.body?.string().orEmpty()
}
val manifest = json.decodeFromString<UpdateManifest>(text)
val hasUpdate = manifest.versionCode > BuildConfig.VERSION_CODE
Result.success(if (hasUpdate) {
UpdateInfo(
versionCode = manifest.versionCode,
versionName = manifest.versionName,
apkUrl = manifest.apkUrl,
notes = manifest.notes
)
} else {
null
})
} catch (e: Exception) {
Result.failure(e)
}
}
/** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */
suspend fun downloadApk(
info: UpdateInfo,
onProgress: (downloaded: Long, total: Long) -> Unit
): Result<File> = withContext(Dispatchers.IO) {
try {
val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() }
val file = File(dir, "rainytoken-" + info.versionName + ".apk")
val request = Request.Builder().url(info.apkUrl).get().build()
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
return@withContext Result.failure(IOException("HTTP " + resp.code))
}
val body = resp.body ?: return@withContext Result.failure(IOException("empty body"))
val total = body.contentLength()
file.outputStream().buffered().use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var downloaded = 0L
var read: Int
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
downloaded += read
onProgress(downloaded, total)
}
}
}
}
Result.success(file)
} catch (e: Exception) {
Result.failure(e)
}
}
@Serializable
data class UpdateManifest(
val versionCode: Int,
val versionName: String,
val apkUrl: String,
val notes: String = ""
)
data class UpdateInfo(
val versionCode: Int,
val versionName: String,
val apkUrl: String,
val notes: String
)
companion object {
const val UPDATE_MANIFEST_URL =
"http://124.222.97.212:8443/maya/Rainytoken/raw/branch/dev/update.json"
}
}

View File

@ -19,6 +19,7 @@ import com.rainy.token.data.repository.OpenCodeUsageRepository
import com.rainy.token.data.repository.OllamaRepository import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.Sub2ApiRepository import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.data.repository.TraeRepository import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.UpdateRepository
import com.rainy.token.data.repository.WorkBuddyRepository import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -204,6 +205,16 @@ object NetworkModule {
/** /**
* Sub2API 仓库:自托管实例(凭据里的 baseUrl)+ 管理面板 API。 * Sub2API 仓库:自托管实例(凭据里的 baseUrl)+ 管理面板 API。
*/ */
/**
* 更新仓库:读取 Gitea 上的 update.json 并下载 APK。
*/
@Provides
@Singleton
fun provideUpdateRepository(
okHttpClient: OkHttpClient,
@ApplicationContext context: Context
): UpdateRepository = UpdateRepository(okHttpClient, context)
@Provides @Provides
@Singleton @Singleton
fun provideSub2ApiRepository( fun provideSub2ApiRepository(

View File

@ -54,6 +54,11 @@ import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusChip
import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusLevel
import com.rainy.token.ui.components.StatusStyle import com.rainy.token.ui.components.StatusStyle
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.ui.components.AppTips import com.rainy.token.ui.components.AppTips
import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.InkMuted
@ -160,6 +165,9 @@ fun SettingsScreen(
item { item {
DebugLogCard(onClick = { onOpenDebugLog() }) DebugLogCard(onClick = { onOpenDebugLog() })
} }
item {
UpdateCard()
}
item { item {
Spacer(modifier = Modifier.padding(top = 8.dp)) Spacer(modifier = Modifier.padding(top = 8.dp))
AboutCard() AboutCard()
@ -310,6 +318,142 @@ private fun CredentialStatusCard(
} }
} }
@Composable
private fun UpdateCard(
viewModel: UpdateViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = stringResource(R.string.title_update),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.padding(top = 8.dp))
Text(
text = stringResource(
R.string.update_current_version,
state.currentVersionName,
state.currentVersionCode
),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
Spacer(modifier = Modifier.padding(top = 12.dp))
when (val phase = state.phase) {
is UpdateViewModel.Phase.Idle -> Unit
is UpdateViewModel.Phase.Checking -> {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(18.dp).height(18.dp),
color = StrawberryPink,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.update_checking),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
}
}
is UpdateViewModel.Phase.Latest -> {
Text(
text = stringResource(R.string.update_latest),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
Spacer(modifier = Modifier.padding(top = 8.dp))
TextButton(onClick = { viewModel.checkForUpdate() }) {
Text(stringResource(R.string.update_check))
}
}
is UpdateViewModel.Phase.Available -> {
Text(
text = stringResource(R.string.update_available, phase.info.versionName),
style = MaterialTheme.typography.bodyMedium,
color = StrawberryPink,
fontWeight = FontWeight.SemiBold
)
if (phase.info.notes.isNotBlank()) {
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = phase.info.notes,
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
}
Spacer(modifier = Modifier.padding(top = 12.dp))
Button(onClick = { viewModel.downloadAndInstall(phase.info) }) {
Text(stringResource(R.string.update_download))
}
}
is UpdateViewModel.Phase.Downloading -> {
LinearProgressIndicator(
progress = { phase.progress },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = stringResource(
R.string.update_downloading,
(phase.progress * 100).toInt()
),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
}
is UpdateViewModel.Phase.Ready -> {
Text(
text = stringResource(R.string.update_download_finished),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
Spacer(modifier = Modifier.padding(top = 8.dp))
Button(onClick = { viewModel.install(phase.file) }) {
Text(stringResource(R.string.update_install))
}
}
is UpdateViewModel.Phase.NeedInstallPermission -> {
Text(
text = stringResource(R.string.update_install_permission_tip),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.padding(top = 8.dp))
Button(onClick = {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + context.packageName)
)
runCatching { context.startActivity(intent) }
}) {
Text(stringResource(R.string.update_enable_permission))
}
}
is UpdateViewModel.Phase.Error -> {
Text(
text = stringResource(R.string.update_failed, phase.message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.padding(top = 8.dp))
TextButton(onClick = { viewModel.checkForUpdate() }) {
Text(stringResource(R.string.update_check))
}
}
}
}
}
}
@Composable @Composable
private fun AboutCard() { private fun AboutCard() {
Card( Card(

View File

@ -0,0 +1,98 @@
package com.rainy.token.ui.settings
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.BuildConfig
import com.rainy.token.data.repository.UpdateRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/** 设置页更新模块:检查更新 → 下载 APK → 拉起系统安装器。 */
@HiltViewModel
class UpdateViewModel @Inject constructor(
private val updateRepository: UpdateRepository,
@ApplicationContext private val context: Context
) : ViewModel() {
sealed interface Phase {
object Idle : Phase
object Checking : Phase
object Latest : Phase
data class Available(val info: UpdateRepository.UpdateInfo) : Phase
data class Downloading(val progress: Float) : Phase
data class Ready(val file: File) : Phase
object NeedInstallPermission : Phase
data class Error(val message: String) : Phase
}
data class UiState(
val phase: Phase = Phase.Idle,
val currentVersionName: String = BuildConfig.VERSION_NAME,
val currentVersionCode: Int = BuildConfig.VERSION_CODE
)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
checkForUpdate()
}
fun checkForUpdate() {
_uiState.update { it.copy(phase = Phase.Checking) }
viewModelScope.launch {
updateRepository.checkForUpdate().fold(
onSuccess = { info ->
_uiState.update {
it.copy(phase = if (info == null) Phase.Latest else Phase.Available(info))
}
},
onFailure = { e ->
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
}
)
}
}
fun downloadAndInstall(info: UpdateRepository.UpdateInfo) {
_uiState.update { it.copy(phase = Phase.Downloading(0f)) }
viewModelScope.launch {
updateRepository.downloadApk(info) { downloaded, total ->
val progress = if (total > 0) downloaded.toFloat() / total else 0f
_uiState.update { it.copy(phase = Phase.Downloading(progress)) }
}.fold(
onSuccess = { file -> install(file) },
onFailure = { e ->
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
}
)
}
}
fun install(file: File) {
if (context.packageManager.canRequestPackageInstalls()) {
_uiState.update { it.copy(phase = Phase.Ready(file)) }
val authority = context.packageName + ".fileprovider"
val uri = FileProvider.getUriForFile(context, authority, 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)
}
runCatching { context.startActivity(intent) }.onFailure { e ->
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) }
}
} else {
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
}
}
}

View File

@ -771,4 +771,17 @@
<string name="sub2_panel_delete_confirm_title">删除 API Key</string> <string name="sub2_panel_delete_confirm_title">删除 API Key</string>
<string name="sub2_panel_delete_confirm_body">确定删除「%1$s」吗?删除后无法恢复,正在使用该 Key 的客户端会立即失效。</string> <string name="sub2_panel_delete_confirm_body">确定删除「%1$s」吗?删除后无法恢复,正在使用该 Key 的客户端会立即失效。</string>
<string name="sub2_trend_account_scope">此图为账号整体趋势(无单模型维度)</string> <string name="sub2_trend_account_scope">此图为账号整体趋势(无单模型维度)</string>
<string name="title_update">检查更新</string>
<string name="update_current_version">当前版本 %1$s(%2$d)</string>
<string name="update_check">检查更新</string>
<string name="update_checking">正在检查…</string>
<string name="update_latest">已是最新版本</string>
<string name="update_available">发现新版本 %1$s</string>
<string name="update_download">下载并安装</string>
<string name="update_downloading">正在下载 %1$d%%</string>
<string name="update_download_finished">下载完成</string>
<string name="update_install">安装</string>
<string name="update_install_permission_tip">需要开启「安装未知应用」权限</string>
<string name="update_enable_permission">去开启</string>
<string name="update_failed">更新失败:%1$s</string>
</resources> </resources>

View File

@ -771,4 +771,17 @@
<string name="sub2_panel_delete_confirm_title">刪除 API Key</string> <string name="sub2_panel_delete_confirm_title">刪除 API Key</string>
<string name="sub2_panel_delete_confirm_body">確定刪除「%1$s」嗎?刪除後無法復原,正在使用該 Key 的用戶端會立即失效。</string> <string name="sub2_panel_delete_confirm_body">確定刪除「%1$s」嗎?刪除後無法復原,正在使用該 Key 的用戶端會立即失效。</string>
<string name="sub2_trend_account_scope">此圖為帳號整體趨勢(無單模型維度)</string> <string name="sub2_trend_account_scope">此圖為帳號整體趨勢(無單模型維度)</string>
<string name="title_update">檢查更新</string>
<string name="update_current_version">目前版本 %1$s(%2$d)</string>
<string name="update_check">檢查更新</string>
<string name="update_checking">正在檢查…</string>
<string name="update_latest">已是最新版本</string>
<string name="update_available">發現新版本 %1$s</string>
<string name="update_download">下載並安裝</string>
<string name="update_downloading">正在下載 %1$d%%</string>
<string name="update_download_finished">下載完成</string>
<string name="update_install">安裝</string>
<string name="update_install_permission_tip">需要開啟「安裝未知應用」權限</string>
<string name="update_enable_permission">前往開啟</string>
<string name="update_failed">更新失敗:%1$s</string>
</resources> </resources>

View File

@ -776,4 +776,17 @@
<string name="sub2_panel_delete_confirm_title">Delete API key</string> <string name="sub2_panel_delete_confirm_title">Delete API key</string>
<string name="sub2_panel_delete_confirm_body">Delete "%1$s"? This cannot be undone and any client using this key will stop working.</string> <string name="sub2_panel_delete_confirm_body">Delete "%1$s"? This cannot be undone and any client using this key will stop working.</string>
<string name="sub2_trend_account_scope">Chart shows the whole account (no per-model breakdown)</string> <string name="sub2_trend_account_scope">Chart shows the whole account (no per-model breakdown)</string>
<string name="title_update">App update</string>
<string name="update_current_version">Current version %1$s (%2$d)</string>
<string name="update_check">Check for updates</string>
<string name="update_checking">Checking…</string>
<string name="update_latest">You are on the latest version</string>
<string name="update_available">New version %1$s available</string>
<string name="update_download">Download &amp; install</string>
<string name="update_downloading">Downloading %1$d%%</string>
<string name="update_download_finished">Download finished</string>
<string name="update_install">Install</string>
<string name="update_install_permission_tip">Please allow installing apps from unknown sources</string>
<string name="update_enable_permission">Open settings</string>
<string name="update_failed">Update failed: %1$s</string>
</resources> </resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="apk_downloads" path="apk_downloads/" />
</paths>

View File

@ -5,4 +5,7 @@
<certificates src="system" /> <certificates src="system" />
</trust-anchors> </trust-anchors>
</base-config> </base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">124.222.97.212</domain>
</domain-config>
</network-security-config> </network-security-config>

6
update.json Normal file
View File

@ -0,0 +1,6 @@
{
"versionCode": 14,
"versionName": "1.6.4",
"apkUrl": "http://124.222.97.212:8443/attachments/47c454a6-72ea-4449-9e4a-2875d207c149",
"notes": "包含签到记录重构(日期/状态/id/数量),失败可当天重试"
}