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/数量),失败可当天重试"
+}