Compare commits
35 Commits
debug-buil
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 618eecf676 | |||
| 8207285b23 | |||
| 82bd04597d | |||
| 5850fc452c | |||
| c6a75f8fce | |||
| a384309689 | |||
| 8692014340 | |||
| 15c6bc24c4 | |||
| 460bed7279 | |||
| ecbc86fdf2 | |||
| 05b5107f46 | |||
| bccd7bad83 | |||
| c5120c5952 | |||
| 2c5a3b2055 | |||
| c76193e892 | |||
| 6edd68f80e | |||
| 750b9d9f90 | |||
| 0f4d68033e | |||
| 7c7a23f097 | |||
| 7c3809e879 | |||
| 7ad49e994d | |||
| 8f40360b9f | |||
| 08a3b42a82 | |||
| aab50bbe78 | |||
| faf7f40524 | |||
| 342788056b | |||
| b479cb2a7a | |||
| 6e0d67afb7 | |||
| 8d68c3b280 | |||
| 71f3f8d554 | |||
| dfd91a5355 | |||
| 149fc5f125 | |||
| 95cde916ca | |||
| 79f99f5dee | |||
| 2509ab8a85 |
@ -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>
|
||||||
@ -40,7 +40,9 @@ class CredentialRepository @Inject constructor(
|
|||||||
val revision: Long,
|
val revision: Long,
|
||||||
val fingerprint: String,
|
val fingerprint: String,
|
||||||
/** 快照所属账号;单账号服务为 null。 */
|
/** 快照所属账号;单账号服务为 null。 */
|
||||||
val accountId: String? = null
|
val accountId: String? = null,
|
||||||
|
/** 创建快照时调用方请求的账号 ID(多账号服务可能是 null=默认账号),用于会话内精确匹配。 */
|
||||||
|
val requestedAccountId: String? = accountId
|
||||||
) {
|
) {
|
||||||
val service: ServiceType get() = credential.service
|
val service: ServiceType get() = credential.service
|
||||||
}
|
}
|
||||||
@ -237,9 +239,9 @@ class CredentialRepository @Inject constructor(
|
|||||||
if (session.snapshot.service == service) {
|
if (session.snapshot.service == service) {
|
||||||
val matches = if (ServiceType.isMultiAccount(service)) {
|
val matches = if (ServiceType.isMultiAccount(service)) {
|
||||||
val requested = accountId?.takeIf { it.isNotBlank() }
|
val requested = accountId?.takeIf { it.isNotBlank() }
|
||||||
requested == null ||
|
// 只允许读取与快照同一账号的暂存凭据;null=默认账号也要看快照当初请求的是不是默认账号,
|
||||||
session.snapshot.accountId == null ||
|
// 避免 A 账号刷新期间把 A 的暂存凭据误交给默认账号(B)的读请求。
|
||||||
session.snapshot.accountId == requested
|
requested == session.snapshot.requestedAccountId
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@ -368,7 +370,8 @@ class CredentialRepository @Inject constructor(
|
|||||||
credential = credential,
|
credential = credential,
|
||||||
revision = revisionFor(service, resolvedId),
|
revision = revisionFor(service, resolvedId),
|
||||||
fingerprint = credentialFingerprint(credential)!!,
|
fingerprint = credentialFingerprint(credential)!!,
|
||||||
accountId = resolvedId
|
accountId = resolvedId,
|
||||||
|
requestedAccountId = accountId?.takeIf { it.isNotBlank() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -102,6 +102,8 @@ import com.rainy.token.ui.components.AppTips
|
|||||||
import com.rainy.token.ui.components.asString
|
import com.rainy.token.ui.components.asString
|
||||||
import com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager
|
import com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager
|
||||||
import com.rainy.token.ui.theme.inkMuted
|
import com.rainy.token.ui.theme.inkMuted
|
||||||
|
import com.rainy.token.ui.theme.PureWhite
|
||||||
|
import com.rainy.token.ui.theme.inkWarm
|
||||||
import com.rainy.token.ui.theme.StrawberryPink
|
import com.rainy.token.ui.theme.StrawberryPink
|
||||||
import android.app.AppOpsManager
|
import android.app.AppOpsManager
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
@ -347,9 +349,17 @@ fun DashboardScreen(
|
|||||||
// 随机小技巧提示(每次启动随机一条)+ 最右侧自动签到开关
|
// 随机小技巧提示(每次启动随机一条)+ 最右侧自动签到开关
|
||||||
val tipTextRes = remember { AppTips.randomHintRes() }
|
val tipTextRes = remember { AppTips.randomHintRes() }
|
||||||
val autoCheckinEnabled by viewModel.autoCheckinEnabled.collectAsStateWithLifecycle()
|
val autoCheckinEnabled by viewModel.autoCheckinEnabled.collectAsStateWithLifecycle()
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(horizontal = 4.dp)
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "💡 " + stringResource(tipTextRes),
|
text = "💡 " + stringResource(tipTextRes),
|
||||||
@ -360,15 +370,24 @@ fun DashboardScreen(
|
|||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.auto_checkin_switch_label),
|
text = stringResource(R.string.auto_checkin_switch_label),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = inkMuted()
|
color = inkWarm(),
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
Switch(
|
Switch(
|
||||||
checked = autoCheckinEnabled,
|
checked = autoCheckinEnabled,
|
||||||
onCheckedChange = viewModel::setAutoCheckinEnabled,
|
onCheckedChange = viewModel::setAutoCheckinEnabled,
|
||||||
colors = SwitchDefaults.colors(checkedThumbColor = StrawberryPink)
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = PureWhite,
|
||||||
|
checkedTrackColor = StrawberryPink,
|
||||||
|
checkedBorderColor = StrawberryPink,
|
||||||
|
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
uncheckedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val items = rememberDashboardItems(
|
val items = rememberDashboardItems(
|
||||||
cards = uiState.cards,
|
cards = uiState.cards,
|
||||||
@ -922,6 +941,8 @@ private fun DashboardCard(
|
|||||||
text = stringResource(R.string.account_default_badge),
|
text = stringResource(R.string.account_default_badge),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = StrawberryPink,
|
color = StrawberryPink,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(StrawberryPink.copy(alpha = 0.12f))
|
.background(StrawberryPink.copy(alpha = 0.12f))
|
||||||
|
|||||||
@ -296,6 +296,8 @@ private fun AccountRow(
|
|||||||
text = stringResource(R.string.account_default_badge),
|
text = stringResource(R.string.account_default_badge),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = StrawberryPink,
|
color = StrawberryPink,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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 & 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>
|
||||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
@ -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
6
update.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"versionCode": 42,
|
||||||
|
"versionName": "1.7.22",
|
||||||
|
"apkUrl": "http://124.222.97.212:8443/attachments/03955cf3-112f-4453-bac7-91bba405e40b",
|
||||||
|
"notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user