diff --git a/README.en.md b/README.en.md index 4721367..4dd2140 100644 --- a/README.en.md +++ b/README.en.md @@ -59,7 +59,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family | ➕ **One-tap add to home screen** | Tap + inside the app to add the widget directly, no need to browse the system list; double confirmation + permission check | | 💡 **Usage tips** | Home page shows a random operation tip (refreshed on each launch); settings page lists all 13 hidden tips | | ⚡ **Room database** | Usage records stored in Room (indexed on workspaceId+timeCreated); DAO queries replace full JSON serialization; auto-migration from the legacy DataStore on first launch | -| 🎀 **Rainy pink theme** | Material Design 3 · Strawberry Pink #FF85A2 · Sakura Pink #FFD1DC | +| 🎀 **Rainy pink theme** | Material Design 3 · Refined Rose primary #C9436C · Blush white background #FDEFF4 | | 🔐 **Codex OAuth sign-in** | Headless OAuth PKCE: the app generates an authorization link → sign in in an external browser → paste the callback URL to complete authorization; no manual auth.json export needed | | ⚡ **Codex one-tap usage activation** | Codex detail page can send a short request to the ChatGPT API to trigger usage tracking; model list fetched dynamically from models.dev and persisted, with manual refresh; response dialog is copyable | | ⚡ **OCGO / Ollama one-tap usage activation** | OCGO detail page can send a request to `opencode.ai/zen/v1`, Ollama detail page to `ollama.com/v1` to trigger usage tracking; API keys are entered manually in settings; model lists fetched dynamically from models.dev | diff --git a/README.md b/README.md index 00b1515..cf9a4c8 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil | ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 | | 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 | | ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 | -| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC | +| 🎀 **雨晴粉主题** | Material Design 3 · 精致玫瑰粉 主色 #C9436C · 粉白背景 #FDEFF4 | | 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json | | ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 | | ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 | diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fc63127..18632ca 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + + + + + + + result.getOrNull()?.let { s -> - if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS || - s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY - ) { - checkinStatusStore.markChecked(ServiceType.WORKBUDDY, accountId) + val isChecked = s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS || + s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY || + s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY + if (isChecked) { + checkinStatusStore.record( + service = ServiceType.WORKBUDDY, + accountId = accountId, + status = if (s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY) { + CheckinStatusStore.STATUS_ALREADY + } else { + s.outcome.name + }, + checkinId = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) { + java.util.UUID.randomUUID().toString() + } else { + null + }, + credits = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) { + s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit + } else { + 0 + } + ) } } } @@ -123,7 +143,17 @@ class RefreshBalanceUseCase @Inject constructor( if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS || r.outcome == TraeRepository.CheckinOutcome.ALREADY ) { - checkinStatusStore.markChecked(ServiceType.TRAE, accountId) + checkinStatusStore.record( + service = ServiceType.TRAE, + accountId = accountId, + status = r.outcome.name, + checkinId = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) { + java.util.UUID.randomUUID().toString() + } else { + null + }, + credits = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) r.points else 0 + ) } } } diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt index 277c7ad..903d253 100644 --- a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt @@ -66,6 +66,7 @@ import com.rainy.token.ui.components.ServiceIcon import com.rainy.token.ui.components.formatAmount import com.rainy.token.ui.components.resolve import com.rainy.token.ui.theme.StrawberryPink +import com.rainy.token.ui.theme.StatusGreen import com.rainy.token.ui.theme.StatusOrange import com.rainy.token.ui.theme.inkMuted import java.text.SimpleDateFormat @@ -420,7 +421,7 @@ private fun ApiRowCard( @Composable private fun StatusDot(state: CredentialStatus.State) { val color = when (state) { - CredentialStatus.State.OK -> Color(0xFF3BB273) + CredentialStatus.State.OK -> StatusGreen CredentialStatus.State.WARNING -> StatusOrange CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f) diff --git a/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt index 15d7cb8..ee4ec33 100644 --- a/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt @@ -39,6 +39,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R import com.rainy.token.data.debug.DebugLog import com.rainy.token.ui.theme.InkMuted +import com.rainy.token.ui.theme.StatusOrange +import com.rainy.token.ui.theme.StatusRed import com.rainy.token.ui.theme.StrawberryPink @OptIn(ExperimentalMaterial3Api::class) @@ -103,8 +105,8 @@ fun DebugLogScreen(onBack: () -> Unit) { items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry -> val levelColor = when (entry.level) { DebugLog.Level.INFO -> InkMuted - DebugLog.Level.WARN -> Color(0xFFFFA726) - DebugLog.Level.ERROR -> Color(0xFFE91E63) + DebugLog.Level.WARN -> StatusOrange + DebugLog.Level.ERROR -> StatusRed } Card( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/rainy/token/ui/components/RainyBackground.kt b/app/src/main/java/com/rainy/token/ui/components/RainyBackground.kt index f2c7057..103b960 100644 --- a/app/src/main/java/com/rainy/token/ui/components/RainyBackground.kt +++ b/app/src/main/java/com/rainy/token/ui/components/RainyBackground.kt @@ -13,10 +13,10 @@ import com.rainy.token.ui.theme.DarkSurface import androidx.compose.foundation.isSystemInDarkTheme /** - * 雨晴风格全局背景。 + * 雨晴风格全局背景(精致粉调)。 * - * Light 模式:樱粉渐变(#FFF0F5 → #FFD1DC,从左上到右下) - * Dark 模式:暖深渐变(#1F1419 → #2A1F25) + * Light 模式:低饱和粉白渐变(#FFF9FB → #FDEFF4,从左上到右下) + * Dark 模式:暖深渐变(#1A1417 → #251C21) * * 放在 Scaffold 容器层,所有页面共享同一层渐变。 */ diff --git a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt index 0072828..13941d3 100644 --- a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt +++ b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt @@ -44,7 +44,7 @@ fun ServiceIcon( modifier = modifier .size(size.dp) .clip(RoundedCornerShape(8.dp)) - .background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White), + .background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White), contentAlignment = Alignment.Center ) { Image( @@ -59,7 +59,7 @@ fun ServiceIcon( modifier = modifier .size(size.dp) .clip(RoundedCornerShape(8.dp)) - .background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White), + .background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White), contentAlignment = Alignment.Center ) { Image( diff --git a/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt b/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt index 4ae9658..cab238d 100644 --- a/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt +++ b/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt @@ -1,6 +1,7 @@ package com.rainy.token.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -44,11 +45,14 @@ fun StatusChip( style: StatusStyle, modifier: Modifier = Modifier ) { + // 暗色模式下胶囊底色是状态色 12% alpha 叠加暗色卡片, + // 深色文字几乎不可读,故暗色使用同色系亮色文字。 + val dark = isSystemInDarkTheme() val (dotColor, textColor) = when (style.level) { - StatusLevel.OK -> StatusGreen to Color(0xFF1B5E20) - StatusLevel.WARNING -> StatusOrange to Color(0xFF8A4A00) - StatusLevel.ERROR -> StatusRed to Color(0xFF8B0033) - StatusLevel.INFO -> StatusBlue to Color(0xFF0D47A1) + StatusLevel.OK -> StatusGreen to if (dark) Color(0xFF9CE8BC) else Color(0xFF1B5E20) + StatusLevel.WARNING -> StatusOrange to if (dark) Color(0xFFFFDBA6) else Color(0xFF8A4A00) + StatusLevel.ERROR -> StatusRed to if (dark) Color(0xFFFFB6CA) else Color(0xFF8A1F40) + StatusLevel.INFO -> StatusBlue to if (dark) Color(0xFFAED7FF) else Color(0xFF0D47A1) } val bgColor = dotColor.copy(alpha = 0.12f) Row( diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index fa035fe..c9fe07f 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -13,6 +13,7 @@ import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase import com.rainy.token.R import com.rainy.token.ui.components.UiText +import com.rainy.token.ui.dashboard.autocheckin.CHECKIN_SERVICES import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -62,6 +63,8 @@ class DashboardViewModel @Inject constructor( // 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。 // 用 try/catch 而非 runCatching:后者会吞掉 CancellationException, // 导致 ViewModel 销毁取消后协程仍继续往下跑。 + // 2.5) 打开 App 时把零点后台闹钟重排到下次零点(开机/升级/后台被杀后的兜底) + autoCheckinManager.refreshSchedule() try { autoCheckinManager.runDailyIfDue() } catch (cancelled: CancellationException) { @@ -475,9 +478,6 @@ private data class AccountKey( val accountId: String? = null ) -/** 支持每日签到的服务(首页角标与自动签到的统一口径)。 */ -internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY) - /** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */ private fun errorToUiText(error: Throwable): UiText = when (error) { is RepositoryError.SessionExpired -> diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt index 64f5a28..1c365fa 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt @@ -76,7 +76,7 @@ import java.util.Locale // 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型) internal val modelColors = listOf( Color(0xFFF44336), // Red - Color(0xFFE91E63), // Pink + Color(0xFFC9436C), // Pink(玫瑰主色) Color(0xFF9C27B0), // Purple Color(0xFF673AB7), // Deep Purple Color(0xFF3F51B5), // Indigo @@ -89,11 +89,11 @@ internal val modelColors = listOf( Color(0xFF607D8B) // Blue Grey ) -// Token 堆叠色板:命中缓存(浅粉·顶)、未命中输入(粉·中)、输出(深粉·底) +// Token 堆叠色板:命中缓存(浅樱粉·顶)、未命中输入(玫瑰粉·中)、输出(深玫瑰·底) internal val tokenColors = listOf( - Color(0xFFFFD1DC), // cache hit — 浅粉(上面) - Color(0xFFFF85A2), // cache miss input — 粉色(中间) - Color(0xFFE91E63) // output — 深粉(下面) + Color(0xFFFFE3EC), // cache hit — 浅樱粉(上面) + Color(0xFFFFA3BC), // cache miss input — 玫瑰粉(中间) + Color(0xFFC9436C) // output — 深玫瑰(主色,下面) ) @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinEntryPoint.kt b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinEntryPoint.kt new file mode 100644 index 0000000..21f2e68 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinEntryPoint.kt @@ -0,0 +1,16 @@ +package com.rainy.token.ui.dashboard.autocheckin + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * 后台广播(自动签到闹钟 / 开机重排)获取 Hilt 单例的入口。 + * 与 ui.widget.WidgetRefreshEntryPoint 同一模式:广播接收器不依赖 + * @AndroidEntryPoint,通过 EntryPointAccessors 拿 SingletonComponent 里的依赖。 + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface AutoCheckinEntryPoint { + fun autoCheckinManager(): AutoCheckinManager +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt index 8ca1919..515d0ae 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt @@ -1,6 +1,9 @@ package com.rainy.token.ui.dashboard.autocheckin +import android.app.AlarmManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import com.rainy.token.data.repository.CheckinStatusStore import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.RepositoryError @@ -11,23 +14,32 @@ import com.rainy.token.domain.model.ServiceAccountInfo import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase import dagger.hilt.android.qualifiers.ApplicationContext -import java.time.LocalDate +import java.time.ZonedDateTime import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * 每日自动签到编排器(小组件开关控制启用)。 * - * 行为契约(用户需求 2026-09-13 / 多账号版 2026-10): - * - 开启后,每天首次打开 App 自动为所有「已配置凭据」的可签服务的【默认账号】各签一次; - * - 当日只签一次:[CheckinStatusStore] 有当日记录的账号直接跳过(手动签过也算); + * 行为契约(用户需求 2026-09-13 / 多账号版 2026-10 / 零点后台版 2026-11): + * - 开启后,每天零点后由 AlarmManager 自动触发([AutoCheckinReceiver]), + * 为所有「已配置凭据」的可签服务的【全部账号】各签一次; + * - 兜底:每天首次打开 App(DashboardViewModel)也会补签一次(错过闹钟/被杀进程场景); + * - 当日每账号只签一次:本地有「今日已成功签到」记录的账号直接跳过(手动签过也算); + * 失败/限流/设备拦截等非成功记录不占位,下次打开 App 会再次唤起签到流程; * - 结果汇报:签完把每个账号的结果暴露为 [report],首页据此弹「xx 已签到,积分增加 xx」; * - 开关持久化在 SharedPreferences "auto_checkin"(小组件与 App 内共用同一份)。 * * 仅负责编排与记录,不做 UI:签到复用 RefreshBalanceUseCase(带凭据快照会话)。 + * + * 调度说明:使用框架自带的 AlarmManager(非精确 setAndAllowWhileIdle)实现「每日零点后 + * 后台签到」,零新增依赖;重启设备后闹钟由 BOOT_COMPLETED 广播重排。国产 ROM(如 vivo/ + * iQOO)可能因电池优化延迟或抑制该闹钟,届时由「首次打开 App」兜底补签。 */ @Singleton class AutoCheckinManager @Inject constructor( @@ -54,52 +66,54 @@ class AutoCheckinManager @Inject constructor( ) /** - * 自动签到汇总结果。null=没有可展示的内容(未开启/无默认账号/全部跳过)。 + * 自动签到汇总结果。null=没有可展示的内容(未开启/无候选账号/全部账号已签或静默跳过)。 * 弹窗消费后由 [consumeReport] 置 null。 */ private val _report = MutableStateFlow?>(null) val report: StateFlow?> = _report.asStateFlow() + /** 防同日并发重复签到:闹钟广播(IO 线程)与 Dashboard init(viewModelScope)可能同时进入。 */ + private val runMutex = Mutex() + private fun prefs() = context.getSharedPreferences("auto_checkin", Context.MODE_PRIVATE) - /** 自动签到开关(小组件 CheckBox 与 App 内共用)。 */ + /** 自动签到开关(小组件 CheckBox 与 App 内共用)。开关变更时同步排/撤零点闹钟。 */ var enabled: Boolean get() = prefs().getBoolean(KEY_ENABLED, false) set(value) { prefs().edit().putBoolean(KEY_ENABLED, value).apply() - } - - /** 已执行过「今日自动签到」的日期(防止同日多次开 App 重复跑)。 */ - private var lastRunDate: String? - get() = prefs().getString(KEY_LAST_RUN, null) - set(value) { - prefs().edit().putString(KEY_LAST_RUN, value).apply() + refreshSchedule() } /** - * 每日首次打开 App 时调用:开启且今日未跑过 → 为已配置凭据的可签服务默认账号各签一次。 + * 每日签到执行入口(后台闹钟与首次打开 App 共用): + * 开启时 → 为已配置凭据的可签服务中【今日尚未成功签到】的账号各签一次。 * 返回 true 表示本次实际执行了签到(无论结果)。 */ - suspend fun runDailyIfDue(): Boolean { + suspend fun runDailyIfDue(): Boolean = runMutex.withLock { if (!enabled) return false - val today = LocalDate.now().toString() - if (lastRunDate == today) return false - // 先占位再执行:即使执行中崩溃/杀进程,同日再开也不会重跑(宁可少签不重复打扰) - lastRunDate = today - + // 不再使用全局 lastRunDate 占位:只有真正「今日签到成功」的账号才跳过, + // 失败/限流/设备拦截等不会消费当天机会,重开 App 可再次唤起签到流程。 val results = mutableListOf() for (service in CHECKIN_SERVICES) { - val defaultAccount = credentialRepository.getDefaultAccount(service) ?: continue - if (credentialRepository.statusFor(service, defaultAccount.id).state == - CredentialStatus.State.NOT_CONFIGURED - ) { - continue + // 多账号服务遍历全部账号;无账号文档时 getDefaultAccount 同样返回 null, + // 候选为空 = 不签,与旧版「只签默认账号」的边界行为一致,不会误签旧凭据 + val accounts = credentialRepository.accountsFor(service) + val candidates = accounts.ifEmpty { + listOfNotNull(credentialRepository.getDefaultAccount(service)) + } + for (account in candidates) { + if (credentialRepository.statusFor(service, account.id).state == + CredentialStatus.State.NOT_CONFIGURED + ) { + continue + } + // 今日已有成功签到记录(手动签过也算)→ 跳过,不再发网络请求 + if (checkinStatusStore.isCheckedToday(service, account.id)) continue + // runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目 + runOne(service, account)?.let { results += it } } - // 当日已有签到记录(手动签过)→ 跳过,不再发网络请求 - if (checkinStatusStore.isCheckedToday(service, defaultAccount.id)) continue - // runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目 - runOne(service, defaultAccount)?.let { results += it } } if (results.isNotEmpty()) { _report.value = results @@ -107,6 +121,39 @@ class AutoCheckinManager @Inject constructor( return true } + /** + * 注册/取消每日零点后台闹钟(非精确 setAndAllowWhileIdle,零点后几分钟内触发)。 + * 设备重启后闹钟丢失,由 BOOT_COMPLETED 广播再次调用本方法重排。 + */ + fun refreshSchedule() { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, AutoCheckinReceiver::class.java) + .setAction(ACTION_DAILY_CHECKIN) + val pendingIntent = PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + if (!enabled) { + alarmManager.cancel(pendingIntent) + return + } + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + nextRunTimeMillis(), + pendingIntent + ) + } + + /** 下一次零点(00:05,本地时区)的运行时间戳。 */ + private fun nextRunTimeMillis(): Long { + val now = ZonedDateTime.now() + var next = now.toLocalDate().atTime(CHECKIN_HOUR, CHECKIN_MINUTE).atZone(now.zone) + if (!next.isAfter(now)) next = next.plusDays(1) + return next.toInstant().toEpochMilli() + } + /** * 返回 null 表示该账号无需出现在汇报弹窗里(如 INTL 不支持签到、静默跳过)。 */ @@ -132,15 +179,24 @@ class AutoCheckinManager @Inject constructor( ServiceResult(service, label, false, true, 0, "", false) // INTL 不支持签到:静默跳过,不进弹窗(避免误报「已签到」) TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> null - TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> + TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> { + recordFailure(service, account.id, CheckinStatusStore.STATUS_DEVICE_BLOCKED) ServiceResult(service, label, false, false, 0, r.message, failed = true) - TraeRepository.CheckinOutcome.RETRYABLE -> + } + TraeRepository.CheckinOutcome.RETRYABLE -> { + recordFailure(service, account.id, CheckinStatusStore.STATUS_RETRYABLE) ServiceResult(service, label, false, false, 0, r.message, failed = true) - TraeRepository.CheckinOutcome.FAILED -> + } + TraeRepository.CheckinOutcome.FAILED -> { + recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED) ServiceResult(service, label, false, false, 0, r.message, failed = true) + } } }, - onFailure = { e -> failureResult(service, label, account.id, e) } + onFailure = { e -> + recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED) + failureResult(service, label, account.id, e) + } ) ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(account.id).fold( onSuccess = { s -> @@ -158,11 +214,16 @@ class AutoCheckinManager @Inject constructor( ServiceResult(service, label, false, true, 0, "", false) WorkBuddyRepository.CheckinOutcome.QUERY -> ServiceResult(service, label, false, true, 0, "", false) - WorkBuddyRepository.CheckinOutcome.FAILED -> + WorkBuddyRepository.CheckinOutcome.FAILED -> { + recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED) ServiceResult(service, label, false, false, 0, s.message, failed = true) + } } }, - onFailure = { e -> failureResult(service, label, account.id, e) } + onFailure = { e -> + recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED) + failureResult(service, label, account.id, e) + } ) // CHECKIN_SERVICES 只含上述两者;穷尽兜底(未来加服务忘了实现时安全返回失败) else -> null @@ -173,7 +234,7 @@ class AutoCheckinManager @Inject constructor( * 签到请求失败的汇报结果。 * * 竞态兜底:签到请求在服务端已成功([RefreshBalanceUseCase.checkInTrae] 的 also 块 - * 已 markChecked),但与并发余额刷新争抢凭据快照导致结果被 [RepositoryError.CredentialChanged] + * 已写 CheckinStatusStore 成功记录),但与并发余额刷新争抢凭据快照导致结果被 [RepositoryError.CredentialChanged] * 丢弃时——若本地记录确认今日已签,按「今日已签到」汇报,而不是报「凭据」错误 * (用户会看到积分实际到账却提示失败的矛盾)。 */ @@ -199,16 +260,37 @@ class AutoCheckinManager @Inject constructor( ) } + /** + * 落一条非成功签到记录(仅当今天还没有成功记录时写入)。 + * 失败/限流/设备拦截不消费当天签到机会,重开 App 仍会重试; + * 但若在一次 CredentialChanged 竞态中服务端实际已成功,保留成功记录不被覆盖。 + */ + private fun recordFailure( + service: ServiceType, + accountId: String, + status: String + ) { + if (!checkinStatusStore.isCheckedToday(service, accountId)) { + checkinStatusStore.record(service, accountId, status) + } + } + /** 弹窗关闭时调用,清除汇报状态(下次执行前不再重复弹)。 */ fun consumeReport() { _report.value = null } - private companion object { + companion object { const val KEY_ENABLED = "enabled" - const val KEY_LAST_RUN = "last_run_date" + + /** 每日自动签到闹钟的广播 action(供 [AutoCheckinReceiver] 分发)。 */ + const val ACTION_DAILY_CHECKIN = "com.rainy.token.action.DAILY_AUTO_CHECKIN" + + /** 每日签到目标时间:零点后 5 分钟(避开日切边界),本地时区。 */ + const val CHECKIN_HOUR = 0 + const val CHECKIN_MINUTE = 5 } } -/** 支持每日签到的服务(与 Dashboard 的 CHECKIN_SERVICES 同一口径,避免两处漂移)。 */ +/** 支持每日签到的服务(DashboardViewModel 与自动签到共用的唯一来源,避免两处漂移)。 */ internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinReceiver.kt b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinReceiver.kt new file mode 100644 index 0000000..dc7f5a7 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinReceiver.kt @@ -0,0 +1,60 @@ +package com.rainy.token.ui.dashboard.autocheckin + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.EntryPointAccessors +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * 每日自动签到后台触发点。 + * + * 两种入口共用同一个接收器: + * - [AutoCheckinManager.ACTION_DAILY_CHECKIN]:AlarmManager 每日零点后发出的闹钟广播, + * 到点就为该服务的全部已配置账号签到; + * - ACTION_BOOT_COMPLETED:设备重启后闹钟会被系统清空,这里只负责重新排程,不抢签。 + * + * 通过 EntryPointAccessors 获取 Hilt 单例,不依赖 @AndroidEntryPoint。 + */ +class AutoCheckinReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + val appContext = context.applicationContext + val pendingResult = goAsync() + + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + val manager = EntryPointAccessors.fromApplication( + appContext, AutoCheckinEntryPoint::class.java + ).autoCheckinManager() + + when (action) { + AutoCheckinManager.ACTION_DAILY_CHECKIN -> { + // 到点签到(关闭时 runDailyIfDue 自身会短路返回); + // 无论本次签到是否抛异常,都必须把一次性闹钟重排到明天 + try { + manager.runDailyIfDue() + } finally { + manager.refreshSchedule() + } + } + Intent.ACTION_BOOT_COMPLETED -> { + // 重启后闹钟丢失,重新排程(今天是否签到由首次打开 App 兜底) + manager.refreshSchedule() + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // 后台静默:签到/排程失败不阻塞下次闹钟,等下次打开 App 再补救 + } finally { + pendingResult.finish() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt index 8bc860e..0069700 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt @@ -66,24 +66,24 @@ import kotlinx.coroutines.flow.first // ── 热力图颜色 ────────────────────────────────────────────── -/** 亮色模式 6 级粉色梯度 */ +/** 亮色模式 6 级玫瑰粉梯度(精致粉调,最高级与主色一致) */ private val LightHeatmapColors = listOf( - Color(0xFFEFE0E5), // Level 0: 极浅暖灰 - Color(0xFFFFD6E0), // Level 1: 浅樱粉 - Color(0xFFFFB3C6), // Level 2: 草莓粉柔 - Color(0xFFFF85A2), // Level 3: 草莓粉 - Color(0xFFFF6B8E), // Level 4: 草莓粉深 - Color(0xFFE84973), // Level 5: 玫红 + Color(0xFFF3E9EE), // Level 0: 极浅暖灰 + Color(0xFFFFE3EC), // Level 1: 浅樱粉 + Color(0xFFFFC9D9), // Level 2: 浅玫瑰柔 + Color(0xFFFFA3BC), // Level 3: 玫瑰粉 + Color(0xFFF27E9F), // Level 4: 玫瑰粉深 + Color(0xFFC9436C), // Level 5: 深玫瑰(主色) ) -/** 暗色模式 6 级粉色梯度 */ +/** 暗色模式 6 级玫瑰粉梯度 */ private val DarkHeatmapColors = listOf( - Color(0xFF3A2A30), // Level 0: 暖深灰 - Color(0xFF4A2E3A), // Level 1 - Color(0xFF6B3D4A), // Level 2 - Color(0xFF8B4D5A), // Level 3 - Color(0xFFB86880), // Level 4 - Color(0xFFD67F9A), // Level 5 + Color(0xFF3A2B33), // Level 0: 暖深灰 + Color(0xFF4A3239), // Level 1 + Color(0xFF643D4A), // Level 2 + Color(0xFF855062), // Level 3 + Color(0xFFA9627A), // Level 4 + Color(0xFFE07C9A), // Level 5 ) /** 月份名称资源(按月索引 1~12,locale-aware) */ diff --git a/app/src/main/java/com/rainy/token/ui/theme/Color.kt b/app/src/main/java/com/rainy/token/ui/theme/Color.kt index 467e195..8502a18 100644 --- a/app/src/main/java/com/rainy/token/ui/theme/Color.kt +++ b/app/src/main/java/com/rainy/token/ui/theme/Color.kt @@ -3,40 +3,41 @@ package com.rainy.token.ui.theme import androidx.compose.ui.graphics.Color /** - * 雨晴风格配色(RainyStyle Palette)。 + * 雨晴风格配色(RainyStyle Palette)— 精致粉调。 * - * 视觉基调:元气樱粉 / 草莓粉主调,搭配纯白卡片与暖灰文本。 + * 视觉基调:低饱和樱粉白背景 + 纯白卡片 + 深玫瑰主色点缀, + * 通过克制用色与留白呈现精致感,保留"雨晴粉"品牌识别度。 * 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。 * - * 背景使用樱粉渐变(#FFF0F5 → #FFD1DC),主元素采用草莓粉 - * (#FF85A2 / #FF6B8E),与白色形成层次。 + * 背景使用柔和粉白渐变(#FFF9FB → #FDEFF4),主元素采用深玫瑰 + * (#C9436C / #B3305B),与白色形成优雅层次。 */ // ─── 品牌主色 ─── -val CherryPinkLight = Color(0xFFFFF0F5) // 樱粉浅(背景起始) -val CherryPinkDeep = Color(0xFFFFD1DC) // 樱粉深(背景结束) -val StrawberryPink = Color(0xFFFF85A2) // 草莓粉(主色 / 强调) -val StrawberryPinkDark = Color(0xFFFF6B8E)// 草莓粉深(按下态 / 主按钮) -val StrawberryPinkSoft = Color(0xFFFFB3C6)// 草莓粉柔(hover / 副按钮) +val CherryPinkLight = Color(0xFFFFF9FB) // 粉白(背景起始) +val CherryPinkDeep = Color(0xFFFDEFF4) // 浅樱粉(背景结束) +val StrawberryPink = Color(0xFFC9436C) // 深玫瑰(主色 / 强调,白底对比度 ≥ 4.5:1) +val StrawberryPinkDark = Color(0xFFB3305B)// 深玫瑰按下态(主按钮) +val StrawberryPinkSoft = Color(0xFFFFC9D8)// 浅玫瑰柔(容器 / 选中背景) // ─── 中性色 ─── val PureWhite = Color(0xFFFFFFFF) -val SnowWhite = Color(0xFFFAF6F8) // 卡片次级背景 -val InkWarm = Color(0xFF3D2C35) // 暖黑(主文本) -val InkMuted = Color(0xFF7A6B72) // 暖灰(次要文本) -val InkOutline = Color(0xFFD9CFD3) // 暖灰(分隔线 / 描边) +val SnowWhite = Color(0xFFFAF5F7) // 卡片次级背景 +val InkWarm = Color(0xFF3A2B31) // 暖黑(主文本) +val InkMuted = Color(0xFF7A6870) // 暖灰(次要文本,白底 ≥ 4.5:1) +val InkOutline = Color(0xFFE8DCE1) // 暖灰(分隔线 / 描边) // ─── 状态色 ─── -val StatusGreen = Color(0xFF66BB6A) // 正常 +val StatusGreen = Color(0xFF4CAF50) // 正常 val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登 -val StatusRed = Color(0xFFE91E63) // 错误 / 异常 +val StatusRed = Color(0xFFD64A6E) // 错误 / 异常(玫红,与粉调统一) val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存) // ─── 暗色模式 ─── -val DarkBackground = Color(0xFF1F1419) // 暖深背景 -val DarkSurface = Color(0xFF2A1F25) // 卡片 -val DarkOnSurface = Color(0xFFEFE0E5) // 卡片文本 -val DarkPrimary = Color(0xFFFFB3C6) // 暗色下主色更亮 -val DarkInkWarm = Color(0xFFEFE0E5) // 暗色下暖黑→浅粉白 -val DarkInkMuted = Color(0xFFC9B8BE) // 暗色下暖灰→浅灰粉 -val DarkInkOutline = Color(0xFF4D3A42) // 暗色下描边 +val DarkBackground = Color(0xFF1A1417) // 暖深背景 +val DarkSurface = Color(0xFF251C21) // 卡片 +val DarkOnSurface = Color(0xFFF2E6EB) // 卡片文本 +val DarkPrimary = Color(0xFFFFA9BF) // 暗色下主色更亮 +val DarkInkWarm = Color(0xFFF2E6EB) // 暗色下暖黑→浅粉白 +val DarkInkMuted = Color(0xFFCBB8C0) // 暗色下暖灰→浅灰粉 +val DarkInkOutline = Color(0xFF4C3B44) // 暗色下描边 diff --git a/app/src/main/java/com/rainy/token/ui/theme/Theme.kt b/app/src/main/java/com/rainy/token/ui/theme/Theme.kt index c375f84..e297063 100644 --- a/app/src/main/java/com/rainy/token/ui/theme/Theme.kt +++ b/app/src/main/java/com/rainy/token/ui/theme/Theme.kt @@ -13,21 +13,22 @@ import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat /** - * 雨晴风格主题。 + * 雨晴风格主题(精致粉调)。 * - * - **不使用** dynamicColor(系统主题色),固定使用雨晴樱粉品牌色。 + * - **不使用** dynamicColor(系统主题色),固定使用雨晴玫瑰粉品牌色。 * 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性; * 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。 - * - 暗色模式:暖深背景 + 亮草莓粉强调,跟 Light 模式同色系不同明度。 + * - 主色:深玫瑰 #C9436C;容器色:浅玫瑰粉;背景:低饱和粉白渐变。 + * - 暗色模式:暖深背景 + 亮玫瑰粉强调,跟 Light 模式同色系不同明度。 */ private val LightColors = lightColorScheme( primary = StrawberryPink, onPrimary = PureWhite, primaryContainer = StrawberryPinkSoft, - onPrimaryContainer = InkWarm, - secondary = StrawberryPinkDark, + onPrimaryContainer = Color(0xFF5C112C), + secondary = Color(0xFFB7698B), onSecondary = PureWhite, - secondaryContainer = Color(0xFFFFE4EC), + secondaryContainer = Color(0xFFFFE7EE), onSecondaryContainer = InkWarm, tertiary = StatusGreen, onTertiary = PureWhite, @@ -38,32 +39,32 @@ private val LightColors = lightColorScheme( surfaceVariant = SnowWhite, onSurfaceVariant = InkMuted, outline = InkOutline, - outlineVariant = Color(0xFFEFE0E5), + outlineVariant = Color(0xFFF2E7EB), error = StatusRed, onError = PureWhite ) private val DarkColors = darkColorScheme( primary = DarkPrimary, - onPrimary = DarkBackground, - primaryContainer = StrawberryPinkDark, - onPrimaryContainer = PureWhite, + onPrimary = Color(0xFF4A1025), + primaryContainer = Color(0xFF6B2C42), + onPrimaryContainer = DarkOnSurface, secondary = StrawberryPink, onSecondary = DarkBackground, secondaryContainer = Color(0xFF4A2E3A), onSecondaryContainer = DarkOnSurface, - tertiary = StatusGreen, - onTertiary = DarkBackground, + tertiary = Color(0xFF7BCF9E), + onTertiary = Color(0xFF0D2B1C), background = DarkBackground, onBackground = DarkOnSurface, surface = DarkSurface, onSurface = DarkOnSurface, - surfaceVariant = Color(0xFF352329), - onSurfaceVariant = Color(0xFFC9B8BE), - outline = Color(0xFF4D3A42), - outlineVariant = Color(0xFF3A2A30), - error = Color(0xFFFF6B8E), - onError = PureWhite + surfaceVariant = Color(0xFF32262C), + onSurfaceVariant = Color(0xFFCBB8C0), + outline = Color(0xFF4C3B44), + outlineVariant = Color(0xFF3B2C33), + error = Color(0xFFFF8194), + onError = Color(0xFF4A1025) ) @Composable diff --git a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt index 2d9d026..6a52d99 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt @@ -122,7 +122,7 @@ fun CodexOAuthScreen( Text( uiState.error!!.asString(), style = MaterialTheme.typography.bodyLarge, - color = androidx.compose.ui.graphics.Color(0xFFE91E63) + color = MaterialTheme.colorScheme.error ) TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) { Text(stringResource(R.string.action_retry), color = StrawberryPink) @@ -286,7 +286,7 @@ private fun HeadlessOAuthContent( Text( text = error.asString(), style = MaterialTheme.typography.bodySmall, - color = androidx.compose.ui.graphics.Color(0xFFE91E63), + color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 4.dp) ) TextButton(onClick = onRetry) { diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 56e54d1..f3d94cd 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -339,9 +339,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { /** 根据用量百分比动态改进度条颜色 */ private fun setProgressColor(views: RemoteViews, barViewId: Int, pct: Int) { val color = when { - pct >= 80 -> 0xFFE91E63.toInt() // 玫红 + pct >= 80 -> 0xFFD64A6E.toInt() // 玫红 pct >= 50 -> 0xFFFFA726.toInt() // 暖橙 - else -> 0xFFFF85A2.toInt() // 草莓粉 + else -> 0xFFC9436C.toInt() // 深玫瑰(主色) } views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color)) } diff --git a/app/src/main/res/drawable-night/rainytoken_wordmark.png b/app/src/main/res/drawable-night/rainytoken_wordmark.png index b4d65ba..974ef9f 100644 Binary files a/app/src/main/res/drawable-night/rainytoken_wordmark.png and b/app/src/main/res/drawable-night/rainytoken_wordmark.png differ diff --git a/app/src/main/res/drawable-night/widget_bg.xml b/app/src/main/res/drawable-night/widget_bg.xml index f99fb09..73f621a 100644 --- a/app/src/main/res/drawable-night/widget_bg.xml +++ b/app/src/main/res/drawable-night/widget_bg.xml @@ -3,8 +3,8 @@ - + + android:color="#4C3B44" /> \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/rainytoken_wordmark.png b/app/src/main/res/drawable-nodpi/rainytoken_wordmark.png index d8435c8..0990a3b 100644 Binary files a/app/src/main/res/drawable-nodpi/rainytoken_wordmark.png and b/app/src/main/res/drawable-nodpi/rainytoken_wordmark.png differ diff --git a/app/src/main/res/drawable-nodpi/widget_preview.png b/app/src/main/res/drawable-nodpi/widget_preview.png index 187a472..79c3064 100644 Binary files a/app/src/main/res/drawable-nodpi/widget_preview.png and b/app/src/main/res/drawable-nodpi/widget_preview.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 2722a46..b78f5ee 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,5 +1,5 @@ - + - - - + + + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 6a60b13..1347412 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -7,14 +7,14 @@ android:viewportHeight="108"> - + + android:fillColor="#C9436C" /> - + + android:fillColor="#FFC9D8" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_rainy_wordmark.xml b/app/src/main/res/drawable/ic_rainy_wordmark.xml index 2a110f1..1c4eaf3 100644 --- a/app/src/main/res/drawable/ic_rainy_wordmark.xml +++ b/app/src/main/res/drawable/ic_rainy_wordmark.xml @@ -1,9 +1,9 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_bg.xml b/app/src/main/res/drawable/widget_bg.xml index 8e73ed4..67925d2 100644 --- a/app/src/main/res/drawable/widget_bg.xml +++ b/app/src/main/res/drawable/widget_bg.xml @@ -2,8 +2,8 @@ - + + android:color="#F2DCE4" /> \ No newline at end of file diff --git a/app/src/main/res/layout-night/widget_opencode_go.xml b/app/src/main/res/layout-night/widget_opencode_go.xml index 06e632d..b551c7e 100644 --- a/app/src/main/res/layout-night/widget_opencode_go.xml +++ b/app/src/main/res/layout-night/widget_opencode_go.xml @@ -1,5 +1,5 @@ - + @@ -44,7 +44,7 @@ android:layout_width="48dp" android:layout_height="24dp" android:text="OCGO" - android:textColor="#FF85A2" + android:textColor="#FFA9BF" android:textSize="10sp" android:textStyle="bold" android:layout_marginStart="6dp" @@ -75,7 +75,7 @@ android:layout_width="40dp" android:layout_height="24dp" android:text="↻" - android:textColor="#FF85A2" + android:textColor="#FFA9BF" android:textSize="20sp" android:layout_marginStart="6dp" android:gravity="center" /> @@ -99,7 +99,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/widget_title_opencode" - android:textColor="#C4A0AB" + android:textColor="#CBB8C0" android:textSize="11sp" /> @@ -113,10 +113,10 @@ + android:text="5h" android:textColor="#CBB8C0" android:textSize="11sp" /> + android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" /> @@ -139,10 +139,10 @@ + android:text="@string/window_weekly" android:textColor="#CBB8C0" android:textSize="11sp" /> + android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" /> @@ -165,10 +165,10 @@ + android:text="@string/window_monthly" android:textColor="#CBB8C0" android:textSize="11sp" /> + android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" /> @@ -200,14 +200,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/widget_deepseek_balance" - android:textColor="#C4A0AB" + android:textColor="#CBB8C0" android:textSize="11sp" /> @@ -233,7 +233,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/widget_brand" - android:textColor="#8A7A82" + android:textColor="#CBB8C0" android:textSize="9sp" /> diff --git a/app/src/main/res/layout/widget_opencode_go.xml b/app/src/main/res/layout/widget_opencode_go.xml index 6f121ef..73f9f0e 100644 --- a/app/src/main/res/layout/widget_opencode_go.xml +++ b/app/src/main/res/layout/widget_opencode_go.xml @@ -36,7 +36,7 @@ android:layout_width="10dp" android:layout_height="24dp" android:text=">" - android:textColor="#C4A0AB" + android:textColor="#7A6870" android:textSize="14sp" android:gravity="center" /> @@ -45,7 +45,7 @@ android:layout_width="48dp" android:layout_height="24dp" android:text="OCGO" - android:textColor="#FF85A2" + android:textColor="#C9436C" android:textSize="10sp" android:textStyle="bold" android:layout_marginStart="6dp" @@ -69,7 +69,7 @@ android:maxLines="1" android:ellipsize="end" android:text="--" - android:textColor="#C4A0AB" + android:textColor="#7A6870" android:textSize="10sp" /> @@ -105,7 +105,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/widget_title_opencode" - android:textColor="#8A7A82" + android:textColor="#7A6870" android:textSize="11sp" /> @@ -120,11 +120,11 @@ + android:text="5h" android:textColor="#7A6870" android:textSize="11sp" /> + android:text="--" android:textColor="#7A6870" android:textSize="10sp" /> @@ -150,11 +150,11 @@ + android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" /> + android:text="--" android:textColor="#7A6870" android:textSize="10sp" /> @@ -180,11 +180,11 @@ + android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" /> + android:text="--" android:textColor="#7A6870" android:textSize="10sp" /> @@ -221,7 +221,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/widget_deepseek_balance" - android:textColor="#8A7A82" + android:textColor="#7A6870" android:textSize="11sp" /> @@ -258,7 +258,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/widget_brand" - android:textColor="#D4C4CC" + android:textColor="#7A6870" android:textSize="9sp" /> diff --git a/app/src/main/res/layout/widget_opencode_go_preview.xml b/app/src/main/res/layout/widget_opencode_go_preview.xml index a33984a..95d0c15 100644 --- a/app/src/main/res/layout/widget_opencode_go_preview.xml +++ b/app/src/main/res/layout/widget_opencode_go_preview.xml @@ -42,7 +42,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/widget_auto_refresh" - android:textColor="#C4A0AB" + android:textColor="#7A6870" android:textSize="10sp" /> @@ -64,7 +64,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/widget_title_opencode" - android:textColor="#8A7A82" + android:textColor="#7A6870" android:textSize="11sp" /> @@ -79,11 +79,11 @@ + android:text="5h" android:textColor="#7A6870" android:textSize="11sp" /> + android:progressTint="#C9436C" /> @@ -105,11 +105,11 @@ + android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" /> + android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" /> + android:progressTint="#C9436C" /> @@ -165,14 +165,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/widget_deepseek_balance" - android:textColor="#8A7A82" + android:textColor="#7A6870" android:textSize="11sp" /> diff --git a/app/src/main/res/values/widget_colors.xml b/app/src/main/res/values/widget_colors.xml index 0559e10..45d3fa4 100644 --- a/app/src/main/res/values/widget_colors.xml +++ b/app/src/main/res/values/widget_colors.xml @@ -1,7 +1,7 @@ - - #FF85A2 + + #C9436C #FFA726 - #E91E63 + #D64A6E diff --git a/docs/签到核心请求.md b/docs/签到核心请求.md new file mode 100644 index 0000000..9209f4d --- /dev/null +++ b/docs/签到核心请求.md @@ -0,0 +1,136 @@ +# WB(WorkBuddy)与 Trae 每日签到核心请求 + +> 本文档整理自仓库现有实现(源码为准): +> - `app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt` +> - `app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt` +> +> 其中 **WB = 腾讯 WorkBuddy**(非微博)。所有请求均为 `POST`,body 默认为 `{}`。 + +## 一、端点汇总 + +| 服务 | 用途 | URL | Body | +| --- | --- | --- | --- | +| Trae(CN) | 查询签到状态 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/status` | `{}` | +| Trae(CN) | 执行签到 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim` | `{}`(9074 时换 `{"req_source":2}` → `{"req_source":1}`) | +| WorkBuddy | 查询签到状态(优先) | `https://www.codebuddy.cn/v2/billing/meter/checkin-activity-status` | `{}` | +| WorkBuddy | 查询签到状态(兜底) | `https://www.codebuddy.cn/v2/billing/meter/checkin-status` | `{}` | +| WorkBuddy | 执行签到 | `https://www.codebuddy.cn/v2/billing/meter/daily-checkin` | `{}` | +| WorkBuddy | accessToken 刷新 | `https://copilot.tencent.com/v2/plugin/auth/token/refresh` | 头 `X-Refresh-Token` | + +## 二、Trae 核心请求源码 + +认证头:`Authorization: Cloud-IDE-JWT `,`x-device-id` 必须是账号绑定的官方客户端设备 ID。 + +```kotlin +// 官方签到接口路径 +const val CHECKIN_STATUS_PATH = "/trae/api/v2/ug/checkin_credits/status" +const val CHECKIN_CLAIM_PATH = "/trae/api/v2/ug/checkin_credits/claim" + +private fun checkinRequest( + jwt: String, + deviceId: String, + deviceSrc: String, + path: String, + bodyJson: String = "{}" +): Result { + val requestId = java.util.UUID.randomUUID().toString() + val request = Request.Builder() + .url("https://api.trae.cn" + path) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "*/*") + .addHeader("User-Agent", "VSCode 1.107.1 (TRAE SOLO CN)") + .addHeader("Authorization", "Cloud-IDE-JWT " + jwt) + .addHeader("X-User-Region", "CN") + .addHeader("Accept-Language", "zh-CN") + .addHeader("Package-Type", "stable_cn") + .addHeader("X-Lgw-Req-Sdk-Type", "3") + .addHeader("X-Market-Client-Id", "VSCode 1.107.1") + .addHeader("X-Device-Brand", "83DG") + .addHeader("X-Device-Type", "windows") + .addHeader("X-OS-Version", "Windows 11 Pro") + .addHeader("App-Version", "0.1.61") + .addHeader("X-Request-Id", requestId) + .addHeader("X-TT-Trace-Id", ttTraceId(requestId)) + .addHeader("X-Device-Id", deviceId) + .addHeader("Sec-Fetch-Dest", "empty") + .addHeader("Sec-Fetch-Mode", "no-cors") + .addHeader("Sec-Fetch-Site", "none") + .post(bodyJson.toRequestBody("application/json".toMediaType())) + .build() + // ... +} +``` + +### Trae 签到结果判定 + +- `code = 0 / 200` → **SUCCESS**(成功) +- `code = 1001 / 10001`,或文案含 `已签到 / 已经签到 / 明日再来 / 今日已完成 / 已领取 / already / checked / claimed` → **ALREADY**(今日已签) +- 文案含 `设备 / device / machine` → **DEVICE_BLOCKED**(设备级拦截) +- 文案含 `参与 / 稍后重试 / 火爆 / 繁忙 / 限流 / 排队 / too many / retry / busy / popular / later` → **RETRYABLE**(临时限流) +- 其余 → **FAILED** + +### Trae 注意事项 + +- UA 必须是 `VSCode 1.107.1 (TRAE SOLO CN)`,这是签到走的 VSCode 插件进程身份;与 IDE 主进程 / Web 混用会触发风控。 +- `X-Device-Id` 不可用固定 ClientID `en1oxy7wnw8j9n`(所有用户相同),会按设备风控拒绝。 +- 仓库内置默认设备 ID:`ACCOUNT_BOUND_DEVICE_ID = "895482260673817"`,账号级,换设备需更新。 +- INTL 区签到接口未实测,仓库实现直接不支持。 + +## 三、WorkBuddy(WB)核心请求源码 + +认证头:`Authorization: Bearer `。签到域名是 `www.codebuddy.cn`(余额主查询才是 `www.workbuddy.cn`)。 + +```kotlin +// 查询今日签到状态(两条路径依次 fallback,共享 token 轮换状态) +suspend fun fetchCheckinStatus(accountId: String? = null): Result { + val body = authedPostFirstSuccessful( + credential, + accountId, + "/v2/billing/meter/checkin-activity-status", + "/v2/billing/meter/checkin-status" + ) ?: return Result.failure(RepositoryError.Unknown()) + return runCatching { parseCheckin(body) }... +} + +// 执行签到 +suspend fun checkIn(accountId: String? = null): Result { + val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) { + is AuthedPostResult.Ok -> r.body + is AuthedPostResult.Failed -> r.body + // ... + } + // code=0 成功;code=10001 或文案含「已签/今日/already」→ 已签 +} + +// 带自动 refresh 一次的 POST 核心 +private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): AuthedPostResult { + var access = credential.accessToken + var refresh = credential.refreshToken + repeat(2) { attempt -> + val request = Request.Builder() + .url("https://www.codebuddy.cn" + path) + .addHeader("Authorization", "Bearer " + access) + .addHeader("Accept", "application/json") + .addHeader("Content-Type", "application/json") + .post("{}".toRequestBody(jsonMedia)) + .build() + // 2xx → Ok + // 401/403 且第一次尝试 → tryRefesh(refresh) 换新 token 后重试 + // refresh 请求头携带 X-Refresh-Token + // 轮换出的新凭据必须先落库再用,否则旧 refreshToken 单次消费后失效 + } +} +``` + +### WorkBuddy 签到结果判定 + +- `code = 0` → **SUCCESS** +- `code = 10001`,或文案含 `已签 / 已经签到 / 今日 / already / checked / claimed` → **ALREADY**(今日已签) +- 其余 → **FAILED**(HTTP 400 body 也可能携带签到业务语义,需透传解析,不能直接当网络错误丢弃) + +## 四、通用约定 + +1. Trae 用 `Cloud-IDE-JWT`,WorkBuddy 用 `Bearer`,两者凭据类型不同,不能混用。 +2. 两者签到都幂等:今日已签时服务端返回已签语义,客户端本地也会记录 `lastCheckinDate` 短路,避免重复请求。 +3. 401/403:Trae 直接视为凭据失效;WorkBuddy 会尝试用 refreshToken 轮换一次。 +4. 不要随机生成设备 ID(Trae)或篡改客户端头,否则会被风控(Trae 9074「参与用户太多」)。