* feat(billing): adopt subscription.* + usage.bars WS methods (issue #628) Add Billing/Plan screen + WS client surface for the backend release audit (hermes-agent 0bf44d557..614dc194e). credits.view was removed upstream; subscription.{state,preview,change,resume,upgrade} + usage.bars replace it. - WsMethods: add SUBSCRIPTION_* + USAGE_BARS constants - BillingModels: @Serializable request/response (snake_case, ignoreUnknownKeys) - BillingRepository: WS dispatch via HermesWsClient.request + typed decode; HermesRpcException (-32601) surfaces as featureUnavailable, not a crash - BillingViewModel: plan/usage load + upgrade/change/resume/cancel actions - BillingScreen: HermesScaffold composable (plan card, usage bars, actions) - NavigationKeys + ScreenRegistry + strings (en/ko) wire BillingScreen into the INSPECT drawer section Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL. * fix(billing): align models + UI to live gateway wire shape (issue #628) Live-probed the 6 subscription.* / usage.bars RPCs against the running backend (hermes-agent dashboard :9119, 2026-07-19). Findings: - subscription.state returns logged_in / current / tiers / usage (nested), NOT the legacy subscription_type_id / renews_at fields the models assumed. - Mutating RPCs (preview/change/resume/upgrade) return ok:false *results* with error/message/payload (e.g. invalid_token when not portal-authed), not RPC-level errors — so the ViewModel's HermesRpcException guard never fired and the screen silently half-decoded null fields. - subscription.upgrade also returns an idempotency_key not in the model. Changes: - BillingModels: re-shape SubscriptionStateResponse around current/tiers/ usage; add ok/error/message/payload envelope + idempotency_key to the action responses; add SubscriptionCurrent / SubscriptionTier / preview payload types. All nullable + ignoreUnknownKeys (graceful degrade). - BillingViewModel: detect ok:false results on every call and route to featureUnavailable / errorMessage instead of swallowing them. - BillingScreen: read plan from subscription.current; render a clean 'no active plan' / 'log in to Nous Portal' card on the logged-out path instead of a broken null plan card. - strings: add billing_no_active_plan / billing_login_required (en + ko). Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL. * fix(billing): address independent review (agy) — error routing + icon Triage of the independent AGY review on PR #629: Fixed (real issues): - BillingViewModel: usage.bars RPC failure (-32601) no longer marks the whole feature unavailable (was blocking the plan card despite the 'non-fatal' comment); it is now correctly non-fatal. - BillingViewModel: a failed primary subscription load now surfaces errorMessage even when usage bars loaded (was swallowed by 'sub == null && bars == null'). - BillingScreen: render state.errorMessage as an error card inside BillingContent so failed mutating actions (upgrade/cancel) are visible instead of silently disappearing. - BillingScreen: wrap the dead AccountBalanceWallet expression in a real Icon() composable in FeatureUnavailableState. Declined (with rationale, recorded for maintainer): - Tier-select / upgrade UI (review issue #4): explicitly out of scope per the PR body — 'Plan-selection UI ... is not built ... left for a follow-up once backend returns the available plans.' - Rename snake_case Kotlin props to camelCase (review nit #2): incorrect — OkHttpProvider uses JsonNamingStrategy.SnakeCase, which maps snake_case wire keys onto snake_case properties; the props are correct. Fixed the misleading doc comment instead. * fix(billing): match REAL logged-in wire shapes + kill null-result crash The live gateway probe (logged-in Nous Portal session, 2026-07-19) showed the earlier models assumed the WRONG shape: - subscription.state.current is {tier_id, tier_name, monthly_credits, credits_remaining, cycle_ends_at, pending_downgrade_*} — NOT the legacy subscription_type_id/status/renews_at fields. Screen rendered 'Free / no plan' for a real logged-in user. - usage.bars is {available, status, plan_name, plan_bar{remaining_display, total_display, pct_used, fill_fraction}, topup_bar} — NOT a List<UsageBar>. - Mutating RPCs return error:'insufficient_scope' when the Portal token lacks billing:manage scope (ok:false result, not an RPC error). Crash fix (root cause of 'Billing RPC returned no result payload'): - BillingRepository.decode() now returns null on a missing/null result instead of requireNotNull() -> IllegalStateException. All repo methods return nullable; ViewModel handles null as a non-fatal 'No billing data returned' instead of crashing. - BillingScreen.PlanCard / UsageBarRow rewritten to the real fields; the resume/cancel branch now gates on can_change_plan (no status field exists on current anymore). - UsageBarsResponse + UsageBar reshaped to plan_bar/topup_bar; added billing_credits_remaining / billing_plan / billing_topup strings (en+ko). Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. Decode sim against the captured live responses confirms PlanCard/UsageBarRow/error paths all resolve with no null-crash. * fix(billing): decode WS result that arrives as toAny() Map, not JsonElement Root cause of 'No billing data returned' on a logged-in server: the backend returns a valid subscription.state result, but HermesWsClient.request() yields result already converted via JsonElement.toAny() (a Map<String,Any?>), not a raw JsonElement. BillingRepository.decode() only accepted JsonElement, so was null and the repo returned null -> ViewModel showed the generic 'No billing data returned' message. The server DOES have billing (logged_in:true, tier_name:Free, /usr/bin/bash.10 credits) — the app just couldn't parse it. Fix: decode() now normalizes whatever arrives — a kotlinx JsonElement OR the toAny() Map/List — back into a JsonElement via a recursive Any->JsonElement walk, then deserializes. Both shapes decode correctly now. This also means the previous 'Billing RPC returned no result payload' crash and the generic 'no data' message were both symptoms of this same parse gap, not a missing backend feature. Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. * perf(analytics): preload on launch + split-fetch + disk cache (issue #537) Cut perceived analytics-tab latency for every user regardless of backend DB size: A. Preload analytics (7/30/90) in the background on app launch (AnalyticsPreloader) so the tab renders instantly when opened. B. Split the two endpoint fetches: /models (~0.1s, carries the totals card) now renders immediately while the slow /usage (insights engine over full message history) streams in behind a placeholder. C. Persist last successful response to DataStore so a cold app launch serves last session's data instantly, then refreshes (AnalyticsCacheStore). Root cause of the slowness is server-side (/api/analytics/usage cold read of a multi-GB state.db); these client changes make the common case instant and never block the UI on the slow call. * fix(analytics): address agy review — null-safety, profile-keyed cache, no main-thread block Triage of independent agy review (High model) on PR #630: Fixed (real): - Blank-on-cold-launch: AnalyticsContent early-returned on null usage, blanking the tab while the slow /usage call loaded. Now renders the fast /models half + placeholders; usage-only cards (totals, daily chart) and Skills/Tools tabs guard on usage != null. - Profile isolation: cache was keyed by days only -> cross-profile data leak on switch/cold-start. Now keyed "$days:$profile". - Main-thread block: ViewModel.init used runBlocking(IO) on the UI thread at startup. Moved disk-seed into viewModelScope.launch. - Serialization parity: cache used bare kotlinx Json; switch to OkHttpProvider.json (ignoreUnknownKeys) so a backend field addition can't break on-disk decode. Declined (not introduced by this PR / protected by viewModelScope): - "safeApiCall swallows CancellationException": pre-existing NetworkResult.kt behavior; viewModelScope cancellation already prevents post-clear state updates. Out of scope for this change. * fix(analytics): crash on tab open — LocalContext is Activity, not Application AnalyticsScreen cast LocalContext.current as Application, but under Compose the composition context is the *Activity*, so the cast threw ClassCastException the moment the tab composed (on tap). Use .applicationContext, which resolves to the Application instance. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
22 lines
881 B
Kotlin
22 lines
881 B
Kotlin
package com.m57.hermescontrol
|
|
|
|
import android.app.Application
|
|
import com.m57.hermescontrol.data.local.AuthManager
|
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
|
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
|
|
|
class HermesControlApp : Application() {
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
AuthManager.init(this)
|
|
// Issue #478: guarantee a "Default" profile is always selected so there is no
|
|
// separate standalone/default code path anywhere in the app.
|
|
AuthManager.ensureDefaultProfile()
|
|
NetworkMonitor.init(this)
|
|
// Issue #537 follow-up (A): preload analytics in the background after launch
|
|
// so the tab renders instantly when opened (the usage endpoint is slow on a
|
|
// cold backend). Fire-and-forget; never blocks UI startup.
|
|
AnalyticsPreloader.preload(this)
|
|
}
|
|
}
|