For Hermes / coding agents: read
AGENTS.mdfirst. Implement this spec task-by-task with strict TDD for production logic. Do not start production code for a behavior until a failing test for that behavior exists.
Goal: build the first Android MVP of CodexMeter, a self-use / small-scale sideloaded Android 12+ app for monitoring official Codex quota usage.
Architecture: single-module Android app with strict package boundaries. Codex is the first provider, but domain, storage, refresh, widget, notification and diagnostics models must remain provider-aware from v1.
Tech stack: Kotlin, Jetpack Compose, Material 3, Room, DataStore, Android Keystore AES-GCM, OkHttp, kotlinx.serialization, WorkManager, Jetpack Glance, hand-written AppContainer.
Status: implementation contract for MVP v0.1.
Read in this order before implementation:
AGENTS.mddocs/PRD.mddocs/ARCHITECTURE.mddocs/CODEX_DEVICE_CODE_LOGIN_SPEC.mdwhen touching Codex login, OAuth session, account connection, auth notifications orauth.jsonmigration.docs/SPEC.mdRULES.mdDESIGN.mdwhen touching UI, Widget, notification or UX
If this spec conflicts with PRD, architecture, rules or design, stop and patch the documents or ask for a decision. Do not silently choose the convenient interpretation.
Codex login migration note: docs/CODEX_DEVICE_CODE_LOGIN_SPEC.md supersedes older MVP statements that describe auth.json as a user-facing fallback or embedded WebView auth. New account connection must use the Hermes-aligned device-code external-browser flow. Existing saved OAuth sessions, including sessions originally imported from auth.json, remain valid and must not be deleted automatically.
This file defines the implementation contract and work order. It does not replace:
docs/PRD.mdfor product scope.docs/ARCHITECTURE.mdfor system boundaries and data flow.RULES.mdfor maintainability and code quality.DESIGN.mdfor UI / Widget / Notification appearance.
The MVP must support:
- Android app display name:
CodexMeter. - Package:
com.kmnexus.codexmeter. - Debug package:
com.kmnexus.codexmeter.debug. - Android 12+ only (
minSdk = 31). - Multi-provider UI: Codex, DeepSeek, z.ai Coding Plan, MiniMax, Cursor, Kimi, Kimi Code API, Claude, Antigravity, z.ai API, Grok (SuperGrok device-code login); all providers use a shared provider-aware domain layer.
- Hermes-aligned Codex device-code login through an external browser verification handoff, per
docs/CODEX_DEVICE_CODE_LOGIN_SPEC.md. - No new user-facing
auth.jsonfile / full JSON import path; existing saved OAuth sessions continue to refresh normally. - Official usage API validation before saving a newly connected account.
- Home dashboard with 5h quota, weekly quota, 24h trend, freshness and refresh state.
- Account tab with current account, account switching, add account, rename and delete.
- Settings tab with persistent notification configuration, thresholds, refresh, account-error notifications, retention, data management, diagnostics, and manual GitHub Releases APK update checks.
- Settings tab includes an appearance preference (Light / Dark / Follow System, default Follow System) that persists independently of accounts and applies immediately to the app. The home-screen widget ALWAYS follows the system theme (independent of the app preference): its glass background is a
drawable/drawable-nightresource and its text uses GlanceColorProvider(day, night), so the launcher re-resolves them instantly on a system night-mode change with no app process involvement. - Resizable home-screen widget driven by persisted widget state, with optional per-widget account and compact primary-window configuration.
- Optional persistent status notification.
- Threshold behavior uses remaining quota percent: 30/10/0 defaults; 30% only changes state color/copy; 10% and 0% may notify.
- Notification channels: status, quota alerts, account/errors.
- Local history retention: default 30 days, options 7/30/90/permanent.
- Simplified Chinese and English resources, default following the system language, unsupported system languages falling back to English, and no in-app manual language override.
- Redacted diagnostics safe to copy.
Do not implement:
- Android 12- support.
- Public-store distribution assumptions.
- Lock-screen quota surface.
- Web page scraping or dashboard parsing.
- Token/cost local estimation.
- Manual token input.
- Cookie Header input.
- Manual browser OAuth / local callback flows outside the Codex device-code verification handoff.
- Cloud sync, remote logging, analytics or crash upload.
- Foreground service for refresh.
- Dynamic plugin system.
- Real-time multi-account monitoring; periodic background refresh may refresh all Active accounts with bounded concurrency.
- Independent history page.
- Hilt, Retrofit or Ktor.
Build in this order unless the spec is revised:
- Android project skeleton and build health.
- Core package structure, resource baseline and theme tokens.
- Domain models and common error model.
- Redaction and diagnostics primitives.
- Room, DataStore and secure session storage.
- Provider registry and Codex provider contracts.
- Codex usage fetch, mapper and token refresh, including optional refresh-token rotation handling.
- Codex device-code login client/use case and official usage API validation, per
docs/CODEX_DEVICE_CODE_LOGIN_SPEC.md. - Remove user-facing
auth.jsonimport UI/routes while preserving already-saved OAuth sessions. - Refresh coordinator, WorkManager and current-state derivation.
- Alert policy and notification orchestration, including login-attempt notification behavior.
- Compose UI navigation and screens.
- Glance widget.
- Data retention, deletion and final manual acceptance.
Rationale: device-code login is now the only product login path. Existing session, usage, storage and refresh pipelines stay reusable, but new account connection must no longer depend on auth.json import. The migration must be reversible and test-first because it touches auth, notification and account persistence boundaries.
Create the Android project in the existing repo root:
settings.gradle.ktsbuild.gradle.ktsgradle.propertiesgradlewgradlew.batgradle/wrapper/gradle-wrapper.jargradle/wrapper/gradle-wrapper.propertiesapp/build.gradle.ktsapp/src/main/AndroidManifest.xmlapp/src/main/java/com/kmnexus/codexmeter/MainActivity.ktapp/src/main/res/values/strings.xmlapp/src/main/res/values-zh-rCN/strings.xmlapp/src/main/res/values/colors.xmlapp/src/main/res/values/themes.xmlapp/src/main/res/xml/backup_rules.xmlapp/src/main/res/xml/data_extraction_rules.xml- launcher icon resources or clearly marked placeholder adaptive icons
Required:
- Single module:
:app. - Namespace:
com.kmnexus.codexmeter. minSdk = 31.debugandreleasebuild types only.debugusesapplicationIdSuffix = ".debug".releasemust not be debuggable.- Use Kotlin official style.
- Enable AndroidX.
- Enable Compose.
- Use a version catalog if the implementation adds more than a minimal dependency set.
Dependency boundaries:
- Allowed: Compose, Material 3, lifecycle ViewModel, navigation-compose, Room, DataStore, WorkManager, Glance, OkHttp, kotlinx.serialization, coroutines, AndroidX test libraries.
- Allowed only if justified in the commit: KSP for Room.
- Not allowed: Hilt, Retrofit, Ktor, dynamic plugin frameworks, analytics SDKs, crash upload SDKs.
Permissions:
- Required:
android.permission.INTERNET. - Required:
android.permission.ACCESS_NETWORK_STATE. - Optional/runtime:
android.permission.POST_NOTIFICATIONSfor Android 13+. - Optional:
android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, only to offer (never force) the user a system dialog so Doze defers periodic refresh less aggressively. Surfaced from the background-refresh settings card when the app is not yet exempt. - Not allowed: storage permissions, exact alarm, foreground service, location, contacts, phone, SMS.
Security manifest rules:
- Disable Auto Backup for MVP.
- Disable data extraction of sessions and quota data.
- All non-launcher components must explicitly set
android:exported="false", except system-launched AppWidget configuration activities which must be exported and handle onlyAppWidgetManager.EXTRA_APPWIDGET_IDscoped state. - Cleartext traffic must remain disabled.
After skeleton creation:
./gradlew assembleDebug
./gradlew testIf Android SDK, JDK or Gradle wrapper is unavailable, record the blocker in the final response and do not claim build success.
Expected first commit:
git commit -m "chore: initialize android project skeleton"Use this root package:
app/src/main/java/com/kmnexus/codexmeter/
Required package groups:
core/
app/
clock/
dispatchers/
network/
result/
security/
strings/
domain/
model/
provider/
quota/
refresh/
settings/
diagnostics/
data/
local/db/
local/entity/
local/dao/
local/mapper/
preferences/
secure/
repository/
providers/
codex/
auth/
dto/
mapper/
network/
session/
error/
refresh/
notification/
widget/
ui/
navigation/
theme/
components/
home/
account/
settings/
auth/
diagnostics/
Rules:
uidepends on domain-facing state and ViewModels only.widgetconsumesWidgetQuotaStateand optionalWidgetQuotaConfiguration; it must not call providers, network or decrypt sessions.notificationconsumesCurrentQuotaStateandAlertPolicy; WorkManager glue inrefreshand repositories must not directly create notifications.providers.codexowns Codex DTOs, endpoint details, token refresh and provider-private session payload.- Common domain code must never reference Codex DTO classes.
- Use
AppContainerfor dependency wiring. Do not introduce Hilt.
Create typed wrappers or strongly named data classes for:
ProviderIdProviderAccountIdLocalAccountIdQuotaWindowIdSnapshotIdRefreshAttemptId
Do not pass provider/account IDs as anonymous String across layers unless the value object is impractical at an Android framework boundary.
ProviderAccount must represent a user-visible account without exposing credentials.
Required fields:
localAccountIdproviderIdproviderAccountId?displayNameavatarInitialavatarColorKeystatus: active, needsReauth, disabled, deletedcreatedAtupdatedAtlastSuccessfulRefreshAt?
Rules:
displayNamemust be editable by user.- Default display name must not include token, email from unverified sources, or raw ID token payload.
- Avatar is circular solid color + first display character, per
DESIGN.md.
QuotaSnapshot represents one successful official usage response after normalization.
Required fields:
snapshotIdproviderIdlocalAccountIdproviderAccountId?fetchedAtsource: deviceCodeLogin, legacyAuthJsonImport, backgroundRefresh, manualRefresh, widgetRefresh, appOpenRefreshplanType?windows: List<QuotaWindow>credits?responseDigest?redacted hash or summary only; never raw response body
Rules:
- Persist snapshots only after a successful provider response.
- A failed refresh must not overwrite or delete the last successful snapshot.
- If a provider field is missing, represent the relevant window as unavailable instead of inventing a value.
Home does not render a card for a
missingwindow (the provider never sent that slot);decodeFailedwindows still render as unavailable so a broken payload stays noticeable.
QuotaWindow supports all providers including balance-based and usage-count-based ones.
Required fields:
windowId: for Codex usefive_hourandweekly.titleKey: resource key / enum reference for UI copy.usedPercent?: official percent when available.remainingPercent: derived as100 - usedPercent.coerceIn(0, 100).resetAt?: provider reset timestamp when available.limitWindowSeconds?: provider window duration when available.isPrimaryCandidate: whether user may choose it as primary quota.availability: available, depleted, missing, decodeFailed, unsupported.displayKind:Percent(default) /Balance/UsageCount/MultiModelFraction.balanceAmount?/balanceCurrency?: raw balance for balance-type windows.originalBalanceAmount?/originalBalanceCurrency?: pre-conversion balance (presentation-only, never persisted).grantedBalance?/toppedUpBalance?: balance breakdown (e.g. DeepSeek granted vs topped-up); persisted.usedCount?/limitCount?: usage-count windows.subLabel?: auxiliary display label.modelBuckets: List<QuotaModelBucket>: per-model fractions for MultiModelFraction windows.usesModelBucketSum: when true, the home usage chart sums per-model used-fraction deltas instead of diffingusedPercent(Antigravity).
QuotaModelBucket: modelId, displayName, remainingFraction, resetAt?.
Codex mapping:
rate_limit.primary_windowmaps tofive_hour.rate_limit.secondary_windowmaps toweekly.- These two ids are positional, not semantic: Codex assigns windows to the primary/secondary slot and
may change what a slot holds (it temporarily removed the 5-hour window, after which
primary_windowcarried the weekly quota). Window ids stay stable so saved widget, alert and primary-window preferences keep matching; the display label is chosen fromlimit_window_secondsinstead, and falls back to the neutral label when the duration has no name or is not reported at all (a missing slot must never reuse the positional label, otherwise it duplicates the sibling slot's real label). Provider-named window ids (Claude, z.ai, MiniMax, Cursor, Antigravity) keep their own label, since several of them share a duration. Kimi uses the explicit rules below. used_percentmaps tousedPercent.reset_atmaps toresetAt.limit_window_secondsmaps tolimitWindowSeconds.
Kimi mapping (GetUsages):
- Keep
kimi_rate_windowand legacykimi_weekly_windowids stable for history, widget, notification selection and alert de-duplication; the latter is a cycle slot, not proof of a weekly period. - Read the rate period from
limits[].window.duration/timeUnit; accept positive, Int-seconds-safe durations with verifiedTIME_UNIT_MINUTE,TIME_UNIT_HOUR,TIME_UNIT_DAYunits. Unknown units, missing/nonpositive values and overflow yield unknown duration, not a guessed 5h period. - The current verified
usages[].detailschema has no duration or full start/end interval. Map its duration to null and displayCycle quota / 周期额度, including old snapshots with fabricated 7-day duration. Do not replace the 7-day assumption with a 30-day assumption. - Use actual period metadata, or a full start/end interval once those fields are verified for this
endpoint. Never infer the period from
resetAt - now; reset time remains available for existing countdown/date presentation and alerts even when the period is unknown. - Home, Account, widget/configuration and notification settings share
quotaWindowLabelRes; Kimi rate labels follow known durations, otherwise use the neutral cycle label. Notification bodies already show quota percentage/status without a period label and need no separate inference. - Source verification: 2026-10-07 raw CodexBar main
KimiModels.swift
and KimiUsageFetcher.swift.
KimiUsage/KimiUsageDetailexpose no start field; onlyKimiRateLimit.windowhas duration metadata. The separate Code API'slimit_month_totalpool is not the GetUsages endpoint used here and must not be invented on this DTO. No authenticated response was collected for this fix.
Kimi Code mapping (kimi_code, GET <base>/coding/v1/usages, Bearer API key):
- Ratio pools win per slot:
usages.limit_5h→kimi_code_5h(18000s, primary candidate),usages.limit_7d→kimi_code_7d(604800s),usages.limit_month_total→kimi_code_month_total(duration null — a calendar month has no fixed second count). - Count fallbacks are used only when the slot's ratio pool is absent: no
limit_5h→limits[0].detailaskimi_code_rate(duration fromwindow.duration × timeUnit, overflow-safe, primary candidate); nolimit_7d→ top-levelusageaskimi_code_usagewith a null duration. Never infer weekly/monthly from the fallback path and never back-compute a period fromresetTime − now. - Ratio windows fill no
usedCount/limitCount; count fallback used =used, elselimit − remaining(not below 0), else 0. Alimit <= 0count window is missing and skipped. Count fields accept JSON strings, ints and doubles;used_ratiois a 0–1 fraction clamped to 0–100%.resetTimemay arrive asresetTime/resetAt/reset_time/reset_at. - Zero usable windows (
{},{"usages":{}},{"usages":{"limit_5h":{}}}) fail the fetch withkimi_code_no_quota_windowsinstead of persisting an empty snapshot. - Labels:
kimi_code_5h→ 5h quota,kimi_code_7d→ 7-day quota,kimi_code_month_total→ monthly,kimi_code_usage→ neutral cycle,kimi_code_ratefollows the reported duration with the cycle label as fallback. The cookie-provider (kimi_*) label branches are untouched.
Display rules:
- Percent windows: show remaining percent, clamp to 0..100.
- Balance windows: show
balanceAmount; apply currency conversion when target currency differs. - UsageCount windows: show used/limit counts.
- MultiModelFraction windows: show per-model fraction tiles.
- Alerts only run for available windows; balance windows alert when balance > 0; percent/count windows require valid data and reset timestamp.
RefreshAttempt records every refresh try, success or failure.
Required fields:
attemptIdproviderIdlocalAccountIdtriggerstartedAtfinishedAt?status: success, failed, skipped, cancellederrorCode?httpStatus?retryable?userActionRequired?diagnosticsDigest?
Rules:
- Do not store raw response bodies.
- Do not store Authorization headers, cookies, OAuth code, callback query or raw auth JSON.
- Failed attempts can update freshness/error UI but cannot erase last-known-good quota.
CurrentQuotaState is the only rich read model for App UI, Widget projection, notifications and alert policy.
It must derive from:
- Current account preference.
- Account metadata.
- Latest successful snapshot.
- Latest refresh attempt.
- Provider capabilities.
- User settings.
- Current clock.
State categories:
- unauthenticated
- loading
- fresh
- possiblyStale
- expired
- authRequired
- errorWithLastKnownGood
- noData
Freshness policy:
- 0–30 minutes: fresh.
- 30 minutes–2 hours: possibly stale.
- 2 hours+: expired.
- 401/403 or refresh token invalid/expired/reused/revoked: auth required.
Database name:
codexmeter.db
Required tables:
provider_accountsquota_snapshotsrefresh_attemptsalert_states
Recommended columns:
provider_accounts:
local_account_idprimary keyprovider_idprovider_account_iddisplay_nameavatar_initialavatar_color_keystatuscreated_atupdated_atlast_successful_refresh_at
quota_snapshots:
snapshot_idprimary keyprovider_idlocal_account_idprovider_account_idfetched_atsourceplan_typewindows_jsoncredits_jsonresponse_digest
refresh_attempts:
attempt_idprimary keyprovider_idlocal_account_idtriggerstarted_atfinished_atstatuserror_codehttp_statusretryableuser_action_requireddiagnostics_digest
alert_states:
alert_state_idprimary keyprovider_idlocal_account_idwindow_idthresholdreset_atlast_notified_at
Indexes:
(provider_id, local_account_id)on account-owned tables.(provider_id, provider_account_id)unique for known provider accounts; nullableprovider_account_idrows remain allowed.(provider_id, local_account_id, fetched_at)on snapshots.(provider_id, local_account_id, started_at)on attempts.(provider_id, local_account_id, window_id, reset_at, threshold)unique for alert de-duplication.
Rules:
windows_jsonis normalized domain JSON, not raw provider response JSON.- Room entities must not contain access token, refresh token, ID token, cookies or raw auth JSON.
- Add migrations from schema version 1 onward; do not use destructive migrations except in debug builds.
Use DataStore for non-sensitive preferences:
- current provider id
- current local account id
- legacy primary quota window for migration only; new default display behavior is owned by each surface
- persistent notification account selection: default follow-current
- persistent notification display window: default
five_hour - per-account quota alert window toggles
- threshold values are remaining-percent based: defaults
30,10,0 - notification toggles
- persistent status notification enabled/disabled
- refresh preference metadata
- no in-app language-mode preference is consumed at runtime; the app follows the system language and falls back to English when unsupported
- retention: 7, 30, 90, forever
- onboarding dismissed flags
Rules:
- DataStore must not store token/session payloads.
- Preferences must be typed behind a repository; UI must not read raw keys.
Use Android Keystore + AES-GCM for provider-private session payloads.
Required public envelope fields:
providerIdlocalAccountIdproviderAccountId?schemaVersionpayloadCiphertextpayloadNoncecreatedAtupdatedAt
Codex private encrypted payload fields:
accessTokenrefreshTokenidToken?accountId?lastRefresh?
Rules:
- Public layers see only envelope metadata.
- Only
providers.codex.sessionmay decode Codex payload fields. - Replacing refreshed tokens must be serial per account.
- Legacy imported
auth.jsonraw content must never be persisted; new product flows must not accept rawauth.jsoninput. - Keystore loss must map to a safe
authRequired/ re-login state.
All 11 providers are wired by hand in AppContainer using ProviderRegistry.
ProviderRegistryholds aProviderConfigper provider (displayName, icon,ProviderAuthKind, capability flags).- Each provider's
<Name>RefreshProviderimplementsRefreshProviderused byRefreshCoordinator. - Each provider's
<Name>SessionImporterimplementsSessionImporter;SessionImportRouterroutes byProviderAuthKind. ProviderSelectionSheetis shown when the user taps "add account"; it lists all registered providers as a bottom sheet that expands from the tab bar. Selecting a provider navigates to the corresponding auth screen.auth.jsonimport must not be exposed; legacy saved sessions remain readable as ordinary encrypted OAuth sessions.
Auth screen routing by kind:
OAuthWebView(Codex) → existing device-code flow (AddAccountScreen/DeviceCodeLoginViewModel).DeviceCodeLogin(Grok) → provider-parameterized device-code flow via thedevicecode:<providerId>[:<reloginAccountId>[:<expectedProviderAccountId>]]route;AddAccountRouteresolves the controller/notifier from thedeviceCodeLoginControllers/deviceCodeLoginNotifiersmaps keyed byProviderId(empty maps fall back to Noop); Codex keeps its legacylogin/codexrelogin:routes on theOAuthWebViewflow.ApiKeyImport→ApiKeyAuthScreen.CookieAuth→WebViewAuthScreenwith cookie capture mode.OAuthPkceLogin→WebViewAuthScreenwith OAuth intercept or loopback mode per provider.
RefreshProvider / current Codex provider path must:
- load/decrypt session through provider session store.
- refresh session if needed.
- fetch official quota data.
- map DTO to
QuotaSnapshot. - map provider-private errors to
QuotaError.
Rules:
- Provider implementations return domain models only.
- Provider DTOs never leave
providers.codex.dto. - Provider-private exceptions never reach UI directly.
CodexDeviceCodeLoginUseCase and CodexSessionImporter must normalize device-code login success into the same saved account/session/snapshot shape used by the rest of the app. See docs/CODEX_DEVICE_CODE_LOGIN_SPEC.md for the complete device-code state machine, notification behavior and account-mismatch handling.
Return a safe login result with:
- provider id
- local account id
- provider account id if known
- display name suggestion
- encrypted session envelope reference
- initial successful quota snapshot
Save account only after the official usage API validates the session.
Rules:
- Do not expose
auth.jsonfile or paste import as new user-facing auth methods. - Existing encrypted sessions that were created by the old import flow must remain readable and refreshable.
- Re-login for an existing account must handle account-id mismatch with an explicit user decision before saving a different account.
Default endpoint:
GET https://chatgpt.com/backend-api/wham/usage
Headers:
Authorization: Bearer <access_token>Accept: application/jsonUser-Agent: CodexMeter/<version>ChatGPT-Account-Id: <account_id>when account id is available
Rules:
- HTTPS only.
- 30 second timeout maximum.
401and403map to auth required.- 5xx maps to retryable provider/server error.
- Network exceptions map to retryable network error.
- Invalid/missing quota fields map to parse/decode error with safe diagnostics.
- Do not log response bodies.
Codex usage DTO must tolerate nullable and partially missing fields.
Observed shape to support:
plan_typerate_limit.primary_window.used_percentrate_limit.primary_window.reset_atrate_limit.primary_window.limit_window_secondsrate_limit.secondary_window.used_percentrate_limit.secondary_window.reset_atrate_limit.secondary_window.limit_window_secondscredits.has_creditscredits.unlimitedcredits.balance
Parsing rules:
- DTO fields should be nullable unless the endpoint contract is proven strict.
- Unknown plan types are preserved as safe strings or mapped to
unknown. credits.balancemay be number or numeric string.- A single bad window must not crash parsing of the whole response if the other window is valid.
- Missing official fields produce unavailable display state, not estimated values.
Refresh endpoint:
POST https://auth.openai.com/oauth/token
Refresh body fields:
client_idgrant_type = refresh_tokenrefresh_tokenscope = openid profile email
Rules:
- The Codex OAuth client id must be stored once in provider-private config with a source comment.
- Refresh should be attempted before usage fetch when provider rules indicate the access token may be stale.
- Staleness is decided by the stored token expiry (
expires_inrecorded at login and at every refresh, minus a 60s skew). Sessions saved before that field existed have no expiry and refresh once on next use. Refreshing on every poll is not allowed: it rotates the refresh token several times an hour, and any rotation whose result is not persisted invalidates the stored credential. - Refresh must also be attempted after a 401/403 when a refresh token exists, unless the error is already known terminal.
refresh_token_expired,refresh_token_reused,invalid_grant,refresh_token_invalidatedmap to auth required.- New access/id tokens replace encrypted payload atomically.
- If a refresh response includes a non-empty new refresh token, replace it atomically.
- If a refresh response omits a new refresh token, keep the existing refresh token, matching Hermes Agent behavior.
- If token refresh fails terminally, keep last-known-good quota visible and mark account needs reauth.
- A single
401/403does not mark the account needs-reauth. Because needsReauth removes the account from background refresh until a manual retry, those statuses must repeat on consecutive attempts before the flag is set. Auth errors that no retry can fix — missing or undecryptable session, rejected refresh token — still flag immediately.
auth.json import is no longer a user-facing MVP login method. It remains relevant only as legacy compatibility for sessions already saved before this migration.
Migration rules:
- Remove or hide the Add Account
导入 auth.json/Import auth.jsonbutton. - Remove or hide the paste-full-JSON import field and navigation route.
- Do not add any new token, cookie or raw JSON manual input path.
- Do not automatically delete existing accounts or sessions that were originally imported from
auth.json. - Treat already-saved sessions as ordinary encrypted Codex OAuth sessions and keep refreshing them normally.
- If implementation removes obsolete parser/import code, delete its tests in the same commit and verify no product path references remain.
- If implementation temporarily keeps parser/import internals for staged removal, they must be unreachable from UI and app navigation.
Required migration checks:
- Add Account UI no longer renders
导入 auth.json/Import auth.json. - Home/auth-required UI no longer offers import as a retry action.
- Existing accounts can still refresh after the import UI is removed.
- Diagnostics and logs still redact the phrase and raw contents of
auth.jsonwhen encountered in legacy paths.
Codex login uses the Hermes-aligned device-code flow defined in docs/CODEX_DEVICE_CODE_LOGIN_SPEC.md.
Required flow summary:
- Start a new login attempt and cancel any previous attempt.
- Request a user-facing device code from the OpenAI Codex device auth endpoint.
- Show the normal Codex verification URI and open it through the external browser.
- Render a compact one-line code row in the app: device code + copy button + open-verification action.
- Publish an account/error-channel login notification. Notification body opens the same external verification URI; notification action copies the latest code.
- Poll according to the server interval until authorized, failed, cancelled or expired.
- Exchange the returned authorization code and code verifier for OAuth tokens.
- Validate tokens through the official usage endpoint.
- Save account, encrypted session and initial snapshot only after validation succeeds.
- Update the login notification to connected briefly, then let the persistent quota notification take over.
Rules:
- Notification permission is not required for login; the page code row is the fallback.
- The verification page must not be embedded in WebView; OpenAI/Codex browser state stays in the user's external browser.
- The normal verification page is opened; do not auto-open a code-filled verification URL in MVP.
- Login attempts are process-bound. A killed app process cancels the attempt; no foreground service or durable background login task is introduced.
- Every attempt has an attempt id; stale async results and stale notification actions must be ignored.
- Re-login account mismatch requires explicit user choice before saving the authorized account as a new account.
- Do not log or diagnose access token, refresh token, id token, authorization code, code verifier, device auth id, raw token response or raw usage response.
All refreshes must go through RefreshCoordinator.
Supported triggers:
- app open / foreground
- manual refresh in Home
- widget tap refresh
- device-code login success
- account switch
- periodic WorkManager refresh
- settings change that affects primary display state
- threshold changes and account alert-toggle changes that require alert re-evaluation
Rules:
- Single-flight per
(providerId, localAccountId). - Periodic WorkManager refresh loads every Active account and refreshes them with bounded concurrency
2. - Disabled, deleted and auth-required accounts are skipped by periodic quota refresh.
- Manual refresh (Home pull-to-refresh and the Account screen pull-to-refresh-all) additionally covers needs-reauth accounts so a transient failure can be retried; a successful manual refresh clears the needs-reauth flag and restores the account to Active. Disabled/deleted accounts are never refreshed by any trigger.
- Account-status eligibility is decided by the account-selection layer (
CurrentQuotaRefreshAccountStore:activeAccountsfor background,manuallyRefreshableAccountsfor manual);MultiAccountRefreshRunnerrefreshes exactly the accounts it is handed. - Token refresh serialized per account.
- WorkManager uses unique work.
- Background refresh interval should respect Android WorkManager limits; target around 15 minutes, not real-time.
- Workers write refresh attempts and snapshots; notification orchestration happens through
NotificationOrchestrator, not directly in provider/network code.
Success:
- Save
QuotaSnapshot. - Save success
RefreshAttempt. - Update account
lastSuccessfulRefreshAt. - Update
CurrentQuotaState. - Update
WidgetQuotaState. - Update status notification if enabled.
- Evaluate alert policy for the refreshed account and every alert-enabled available quota window.
Failure:
- Save failed
RefreshAttempt. - Preserve last successful snapshot.
- Update
CurrentQuotaStatewith error/staleness/auth state. - Update widget/status notification with safe stale/error state.
- Notify via account/error channel only when user action is required or repeated failure crosses policy threshold.
Create CurrentQuotaStateFactory as a pure, testable component.
Inputs:
- account
- latest snapshot
- latest refresh attempt
- preferences
- provider capabilities
- clock
Outputs:
- screen status
- primary window state
- secondary window state
- trend input state
- freshness label key
- next reset label data
- action buttons to show
- widget projection
- notification projection
Required tests:
- unauthenticated when no current account.
- fresh within 30 minutes.
- possibly stale after 30 minutes.
- expired after 2 hours.
- auth required on 401/403 or terminal refresh error.
- failed refresh keeps last-known-good quota visible.
- missing or disabled alert window suppresses quota alert.
Default thresholds use remaining quota percent:
- 30% left: caution state only, no notification.
- 10% left: warning notification.
- 0% left: limit notification.
Rules:
- Any alert-enabled account quota window can trigger threshold alerts.
- Alert de-duplication key:
(providerId, localAccountId, windowId, resetAt, threshold). - A reset time change allows alerts again.
- Increasing remaining quota above a threshold does not spam a new alert for the same reset window.
- Missing percent or missing reset time suppresses alerts.
- User can adjust caution/warning thresholds, but caution remaining percent must stay higher than warning remaining percent; limit remains fixed at 0%.
- The same remaining-percent thresholds also drive Home quota status colors/copy and Widget tone/copy.
- Per-account 5-hour and weekly alert toggles live on the Account screen.
- Quota refresh success, threshold updates, and enabling an account/window alert all re-evaluate alerts.
- Notification permission denial suppresses notification posting and must not mark alert de-dupe state.
Required tests:
- 30% left never creates notification event.
- 10% left creates one event per reset window.
- 0% left creates one event per reset window.
- Duplicate refresh below or at the same remaining-percent threshold does not create duplicate event.
- Reset timestamp change allows a new event.
- Disabled or missing alert window creates no event.
Create channels:
quota_status: quiet persistent status notification.quota_alerts: warning/limit quota threshold alerts.account_errors: reauth and repeated error alerts.
Rules:
- App must still work without notification permission.
- Request notification permission only when enabling notifications or when first needed with rationale.
- Status notification is optional and user-controlled.
- Notification text must come from string resources.
- PendingIntent must use immutable flags unless mutability is required.
- Notification content must not include account secrets or raw diagnostics.
Bottom tabs, fixed order:
- Home
- Account
- Settings
Rules:
- Use real vector icons, not CSS/Canvas-style fake icons.
- Every tab has content description in both supported languages.
- Bottom-tab destination changes must disable NavHost fade/slide transitions, keep the lightweight page cascade, and draw the fixed backdrop cover in full-window coordinates; only the translated content layer consumes Scaffold inner padding so the previous destination never shows through and the status bar has no seam.
- Scrollable content must reserve bottom tab and gesture insets.
- Last content item must be fully scrollable above the tab bar.
Home states:
- Unauthenticated: show login guide card.
- Loading: show skeleton/progress without losing last-known-good data if available.
- Fresh quota: show 5h, weekly, trend and refresh state.
- Stale/expired: show last quota with stale/expired copy.
- Auth required: show re-login action only; do not offer
auth.jsonimport. - Error with last-known-good: show quota plus safe error summary.
Required visible content when authenticated:
- Title
CodexMeter. - Current account identity chip or compact account label.
- 5h quota card.
- Weekly quota card.
- 24h compact trend.
- Refresh status and manual refresh action.
- Widget preview / widget hint per
DESIGN.md.
Rules:
- Do not add ambiguous top-right action button.
- Quota cards are not clickable detail pages in MVP.
- Status color must be paired with status text.
- Percent text should use tabular/monospace digits.
- 24h trend uses fixed hourly buckets in the rolling 24h window; multiple samples in one hour are averaged before drawing rounded bars.
Required content:
- Current account card.
- Account list.
- Add account action.
- Rename account action.
- Delete account action with confirmation.
- Current-account switching through account cards in the Account tab.
Rules:
- Account avatar is circular solid color + initial.
- Account list cards are collapsed by default; the header uses a chevron icon button, and the details drawer expands to reveal plan, credits, quota summaries, alert switches and management actions.
- Delete confirmation must state that session and local history are deleted.
- Deleting current account must select another account if available, otherwise return to unauthenticated state.
- Account screen is the only place for account management; Settings must not duplicate account cards.
Set currentkeeps a visible outline even when disabled for the active account.Deleteuses the same outlined button shape as the other account actions while keeping danger-colored text.
Required groups:
- Appearance: a standalone card with a 3-option segmented pill control (Light / Dark / Follow System); default is Follow System; the selection applies immediately to the app only — the home-screen widget always follows the system theme regardless of this preference; the preference is persisted independently of accounts.
- Persistent notification: status notification switch, notification account selection, notification display quota.
- Alerts: account/error notification switch and thresholds.
- Refresh: background refresh switch and latest-result summary.
- Refresh summary includes the latest current-account periodic background refresh result; the refresh card must not expose a separate
Check now/立即检测button. - Data: history retention, clear current account history, clear all history.
- Diagnostics: collapsed redacted diagnostics card and copy action.
- About: app version and privacy summary.
Rules:
- Destructive actions require confirmation.
- Diagnostics must be safe to copy.
- Long localized labels must not break layout.
Entry points:
- Home unauthenticated card.
- Account tab add button (circular
+icon). - Account re-login action.
Provider selection:
- Tapping "add account" opens
ProviderSelectionSheet, a bottom sheet that expands up from the floating tab bar and collapses back down on dismiss. - The sheet lists all
ProviderRegistry.allproviders with their brand icon and display name. - Selecting a provider navigates to the corresponding auth screen (device-code, API key input, WebView cookie, or OAuth PKCE).
- The full-screen
ProviderSelectionScreenstill exists as a fallback route but the primary entry point is the sheet.
Shared auth chrome:
- All per-provider auth screens use
AuthScaffold: fixed-height center-aligned top bar with a left back icon and trailing action slot (clear/confirm/reload icons). - WebView-based auth (Kimi/Cookie, Claude/Antigravity OAuth) shows a one-time tip dialog on first open; the tip renders the matching top-bar action icon inline.
- OAuth intercept WebViews use a hardware layer; cookie-capture pages use a software layer workaround.
Device-code layout (Codex only):
- After the user starts login and the background polling attempt is created, show the in-app code row and expose the external verification URI.
- Cancelling login clears the user-facing device code, verification URI, expiry, and poll interval from the page before a new login attempt is started.
- Keep the user-facing device code, copy button, and browser-open action readable in the login page.
- Do not let status copy crowd out the code row or primary browser-open action.
- If notification permission is missing or denied, the page code row remains sufficient to complete login.
Fallback behavior:
- Expired/cancelled/failed device-code attempts can generate a new code.
- Token exchange success still requires usage validation before account save.
- Validation failure must show safe reason and not save session.
auth.jsonimport is no longer shown as a fallback.
Implement one horizontally and vertically resizable Glance widget with four layout variants driven by SizeMode.Responsive:
| Variant | Reference size (dp) | Field capacity |
|---|---|---|
ThreeByOne |
180×60 | 1 |
FourByOne |
300×60 | 2 |
ThreeByTwo |
180×150 | 3 |
FourByTwo |
300×150 | 4 |
Content layout:
- Each field tile shows window label, main value (percent / balance / count), and reset/refresh time.
ThreeByOne/FourByOne: horizontal row of field tiles with dividers.ThreeByTwo/FourByTwo: grid of field tiles with horizontal and vertical dividers.- All variants show the provider icon, account name, and status in a header row.
- When no fields are configured, an onboarding hint is shown.
Configuration:
- Launcher reconfiguration opens a configuration activity. Users select up to 4 quota windows (
selectedWindowIdsmulti-select) and the account. - The widget renders the first
fieldCapacitywindows from the ordered selection. - If a widget has no explicit configuration, it follows the current account and uses the provider's default window order.
- Widget configuration is independent from persistent notification account/window settings.
Behavior:
- Tap opens app Home (or add-account flow when unauthenticated).
- Widget reads
WidgetQuotaState(persisted projection fromCurrentQuotaState) and never calls network directly. - Non-current configured accounts use the latest local snapshot from multi-account background refresh.
States:
- no account (onboarding)
- unconfigured (field selection hint)
- fresh
- stale
- expired
- auth required
- error with last-known-good
Rules:
- Widget text is resource-backed.
- Widget must remain readable on light/dark launchers.
- Widget must not show secrets or diagnostics.
- Widget tone and status copy use the configured remaining-percent caution/warning thresholds.
Create a central redactor used by logs, diagnostics and error mapping.
Must redact:
- access token
- refresh token
- ID token
- auth code
- state when associated with OAuth callback
- Cookie
- Authorization header
- complete
auth.json - full OAuth callback query
- raw provider response body
Rules:
- Do not include token prefix/suffix.
- Do not include token length when it helps fingerprint secrets.
- Diagnostics may include safe enum codes, HTTP status, human-readable timestamps with relative age, provider id, account local id hashes, app/build/device/OS info, background-execution signals (battery optimization, background restriction, network type, data saver), WorkManager state, notification permission, app configuration, and per-account refresh/alert summaries.
Required tests:
- Redacts bearer tokens.
- Redacts JSON token fields.
- Redacts callback query.
- Redacts cookies.
- Leaves safe operational fields readable.
Copyable diagnostics are grouped into sections (generated, environment, workmanager, config, accounts, current account) and must include only:
- app version / build type / Android SDK and release
- device model and locale
- background-execution signals: battery-optimization-ignored, background-restricted, network type, data saver
- app first-install / last-update time
- WorkManager state, run-attempt count, next schedule, stop reason; notification permission status
- Room schema version, retention days, refresh interval, notification/alert toggles, alert thresholds, widget count
- per-account alert windows (with provider id and hashed account id)
- per-account one-line summary: provider id, hashed account id, status, last attempt result, last success / snapshot age
- current account detail: provider id, hashed account id or short stable diagnostic id, current state category, session status, consecutive failure count
- last success timestamp, last attempt status, safe error code, HTTP status if present
- device-code login status, hashed attempt id, verification URI category, poll info
Must not include:
- raw auth JSON
- raw API response
- raw request/response headers
- token values
- cookies
- OAuth query
Default retention:
- 30 days.
Options:
- 7 days.
- 30 days.
- 90 days.
- forever.
Rules:
- Retention cleanup removes old successful snapshots and refresh attempts.
- Alert states may be cleaned when their reset window is older than retained history.
- Cleanup must not remove current account/session.
Deleting an account removes:
- provider account record
- secure session envelope and encrypted payload
- quota snapshots for that account
- refresh attempts for that account
- alert states for that account
- widget state if it references the deleted account
- current account preference if it references the deleted account
Rules:
- Requires confirmation.
- Must be transactional where possible.
- Must not affect other accounts.
Clear current account history:
- Deletes snapshots and refresh attempts for current account.
- Keeps account and session.
Clear all history:
- Requires stronger confirmation.
- Deletes snapshots and refresh attempts for all accounts.
- Keeps accounts and sessions.
Supported languages:
- Simplified Chinese.
- English.
Rules:
- Default follows system.
- Unsupported system languages fall back to English.
- The app does not expose an in-app manual language override.
- Startup clears any platform app-locale override left by older builds so a previously selected language cannot keep overriding the system setting.
- All user-visible UI, Widget, notification, error and diagnostics labels must be resource-backed or resolved through a localized string resolver.
Codex,OpenAI,auth.json,OAuth,WebVieware not translated.- Tests should not rely on localized visible strings where stable test tags are possible.
Rules:
- Touch targets minimum 48dp.
- Interactive elements need meaningful content descriptions.
- Status color must be paired with text.
- Percent, reset time and freshness state must be readable by screen readers.
- Destructive actions must be confirmable and cancellable.
- Large font mode must not hide core actions.
Create local JVM tests for:
CodexDeviceCodeClientTestCodexDeviceCodeLoginUseCaseTestCodexDeviceCodeNotificationTestCodexUsageDtoTestCodexUsageMapperTestCodexTokenRefresherTestQuotaErrorMapperTestSecureSessionStoreTestCurrentQuotaStateFactoryTestRefreshCoordinatorTestAlertPolicyTestDiagnosticsRedactorTestRetentionPolicyTest
Use fakes over live network.
Recommended tools:
- JUnit.
- kotlinx-coroutines-test.
- MockWebServer or a small fake HTTP client seam.
- Turbine for Flow/StateFlow.
- Room in-memory database for DAO tests.
- Robolectric only when Android framework behavior is required.
For every production behavior:
- Write the failing test.
- Run the focused test and confirm it fails for the expected reason.
- Implement the minimal code.
- Run the focused test and confirm it passes.
- Run the relevant package test set.
- Commit.
Do not write production logic first and retrofit tests later.
Manual checks are required for:
- Device-code external-browser login on a real device.
- Notification runtime permission flow, including missing-permission login fallback.
- Widget add/update/tap behavior.
- UI layout at normal and large font.
- Light/dark launcher widget readability.
- Account deletion confirmation.
- Diagnostics copy output redaction.
Record manual acceptance notes in commit or handoff summary.
Baseline commands:
./gradlew assembleDebug
./gradlew test
./gradlew :app:testDebugUnitTest
npx -y @google/design.md lint DESIGN.mdIf instrumentation is configured and a device/emulator is available:
./gradlew connectedDebugAndroidTestAcceptance:
- Project builds with
assembleDebug. - Empty Compose shell launches.
- App name and package ids are correct.
- Auto Backup is disabled.
- Permissions are minimal.
Commit:
git commit -m "chore: initialize android project skeleton"Acceptance:
- Domain models compile.
- Room schema v1 exists.
- DataStore preference repository exists.
- Secure session store interface and fake test implementation exist.
- Redactor tests pass.
Commit:
git commit -m "feat: add quota domain and local storage foundations"Acceptance:
- Official usage API fetcher works against fake HTTP server.
- Usage validation can create account, secure session and initial snapshot from a provider-supplied OAuth session.
- Token refresh tests pass, including the case where refresh response omits a new refresh token and the old value is retained.
- Failed validation saves no session.
Commit:
git commit -m "feat: add codex usage and refresh foundations"Acceptance:
- Device-code client/use-case tests pass.
- New login attempts cancel previous attempts and ignore stale results.
- Device-code login validates usage before saving account/session/snapshot.
auth.jsonimport UI/routes are removed or unreachable.- Existing saved sessions continue to refresh.
Commit:
git commit -m "feat: add codex device code login"Acceptance:
- Refresh coordinator single-flight tests pass.
- Failed refresh preserves last-known-good quota.
- WorkManager unique work is registered.
- 401/403 handling attempts refresh/retry once when safe, then maps terminal failures to auth required.
Commit:
git commit -m "feat: add quota refresh pipeline"Acceptance:
CurrentQuotaStateFactorytests pass.- Alert threshold/de-dupe tests pass.
- Notification channels are created.
- App works without notification permission.
Commit:
git commit -m "feat: add quota state and alerts"Acceptance:
- Home / Account / Settings bottom tabs exist in confirmed order.
- UI follows
DESIGN.mdAir Glass Dashboard direction. - Unauthenticated, fresh, stale, auth required and error states render.
- Account add/device-code-login/switch/rename/delete flows are wired to ViewModels.
- Strings are available in Chinese and English.
Commit:
git commit -m "feat: build codexmeter app screens"Acceptance:
- Resizable Glance widget with 3×1 / 4×1 / 3×2 / 4×2 layout variants.
- Widget configuration supports multi-field selection (up to 4 windows) and account selection.
- Widget consumes persisted projection only; does not call network directly.
- Widget displays unconfigured onboarding, no account, fresh, stale and auth-required states.
- Provider brand icon shown in widget header.
- Tap opens app; refresh action, if present, routes through refresh coordinator.
Commit:
git commit -m "feat: add quota home screen widget"Acceptance:
- The login page's browser button opens the normal Codex verification page in the external browser.
- Compact code row remains readable and does not crowd the primary action.
- Notification body opens the external verification page; notification action copies the latest code.
- Login still works when notification permission is denied.
- Success notification is brief and persistent quota notification takes over.
- Real-device or emulator manual acceptance is recorded without exposing secrets.
Commit:
git commit -m "test: record device code login acceptance"Acceptance:
- Retention cleanup works.
- Delete account and clear history flows are confirmed.
- Diagnostics copy is redacted.
assembleDebug, unit tests and design lint pass.- No secrets or generated build outputs are committed.
Commit:
git commit -m "chore: harden codexmeter mvp"MVP implementation is done only when:
- Device-code external-browser login can create a Codex account and fetch official quota.
auth.jsonis no longer available as a new user-facing login/import path, while existing saved OAuth sessions continue to refresh.- Home, Account, Settings, Widget and notifications all derive from shared state.
- Failed refresh never erases last-known-good quota.
- Diagnostics are redacted by test.
- All user-facing strings are localized in Chinese and English.
./gradlew assembleDebugpasses../gradlew testor./gradlew :app:testDebugUnitTestpasses.npx -y @google/design.md lint DESIGN.mdpasses.- Git status is clean after final commit.