From 2509ab8a854787552a579deab5339d15718d78db Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 21:42:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=A3=80=E6=B5=8B=E6=9B=B4=E6=96=B0/?= =?UTF-8?q?=E4=B8=8B=E8=BD=BDAPK/=E8=87=AA=E5=8A=A8=E5=AE=89=E8=A3=85?= =?UTF-8?q?=EF=BC=9B=E6=8E=A5=E5=85=A5Gitea=20update.json=E6=B8=85?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 10 ++ .../token/data/repository/UpdateRepository.kt | 112 ++++++++++++++ .../java/com/rainy/token/di/NetworkModule.kt | 11 ++ .../rainy/token/ui/settings/SettingsScreen.kt | 144 ++++++++++++++++++ .../token/ui/settings/UpdateViewModel.kt | 98 ++++++++++++ app/src/main/res/values-b+zh+Hans/strings.xml | 13 ++ app/src/main/res/values-b+zh+Hant/strings.xml | 13 ++ app/src/main/res/values/strings.xml | 13 ++ app/src/main/res/xml/file_paths.xml | 4 + .../main/res/xml/network_security_config.xml | 3 + update.json | 6 + 11 files changed, 427 insertions(+) create mode 100644 app/src/main/java/com/rainy/token/data/repository/UpdateRepository.kt create mode 100644 app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 update.json diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 18632ca..9cd6552 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -76,6 +76,16 @@ android:name="miuiWidgetVersion" android:value="1" /> + + + + \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/UpdateRepository.kt b/app/src/main/java/com/rainy/token/data/repository/UpdateRepository.kt new file mode 100644 index 0000000..32b8151 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/UpdateRepository.kt @@ -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 = 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(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 = 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" + } +} diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index 406b161..c89cf21 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -19,6 +19,7 @@ import com.rainy.token.data.repository.OpenCodeUsageRepository import com.rainy.token.data.repository.OllamaRepository import com.rainy.token.data.repository.Sub2ApiRepository import com.rainy.token.data.repository.TraeRepository +import com.rainy.token.data.repository.UpdateRepository import com.rainy.token.data.repository.WorkBuddyRepository import dagger.Module import dagger.Provides @@ -204,6 +205,16 @@ object NetworkModule { /** * Sub2API 仓库:自托管实例(凭据里的 baseUrl)+ 管理面板 API。 */ + /** + * 更新仓库:读取 Gitea 上的 update.json 并下载 APK。 + */ + @Provides + @Singleton + fun provideUpdateRepository( + okHttpClient: OkHttpClient, + @ApplicationContext context: Context + ): UpdateRepository = UpdateRepository(okHttpClient, context) + @Provides @Singleton fun provideSub2ApiRepository( diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index 1b6c615..b2f740f 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -54,6 +54,11 @@ import com.rainy.token.ui.components.ServiceIcon import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusLevel 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.ui.components.AppTips import com.rainy.token.ui.theme.InkMuted @@ -160,6 +165,9 @@ fun SettingsScreen( item { DebugLogCard(onClick = { onOpenDebugLog() }) } + item { + UpdateCard() + } item { Spacer(modifier = Modifier.padding(top = 8.dp)) 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 private fun AboutCard() { Card( diff --git a/app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt new file mode 100644 index 0000000..6cd9d95 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt @@ -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.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) } + } + } +} diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 9042ee7..97f00ea 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -771,4 +771,17 @@ 删除 API Key 确定删除「%1$s」吗?删除后无法恢复,正在使用该 Key 的客户端会立即失效。 此图为账号整体趋势(无单模型维度) + 检查更新 + 当前版本 %1$s(%2$d) + 检查更新 + 正在检查… + 已是最新版本 + 发现新版本 %1$s + 下载并安装 + 正在下载 %1$d%% + 下载完成 + 安装 + 需要开启「安装未知应用」权限 + 去开启 + 更新失败:%1$s \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 4f2ea3b..c99722d 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -771,4 +771,17 @@ 刪除 API Key 確定刪除「%1$s」嗎?刪除後無法復原,正在使用該 Key 的用戶端會立即失效。 此圖為帳號整體趨勢(無單模型維度) + 檢查更新 + 目前版本 %1$s(%2$d) + 檢查更新 + 正在檢查… + 已是最新版本 + 發現新版本 %1$s + 下載並安裝 + 正在下載 %1$d%% + 下載完成 + 安裝 + 需要開啟「安裝未知應用」權限 + 前往開啟 + 更新失敗:%1$s \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b6076e..15d0bee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -776,4 +776,17 @@ Delete API key Delete "%1$s"? This cannot be undone and any client using this key will stop working. Chart shows the whole account (no per-model breakdown) + App update + Current version %1$s (%2$d) + Check for updates + Checking… + You are on the latest version + New version %1$s available + Download & install + Downloading %1$d%% + Download finished + Install + Please allow installing apps from unknown sources + Open settings + Update failed: %1$s \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..3f23599 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 683208f..5206a5f 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -5,4 +5,7 @@ + + 124.222.97.212 + diff --git a/update.json b/update.json new file mode 100644 index 0000000..ca204f8 --- /dev/null +++ b/update.json @@ -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/数量),失败可当天重试" +}