Files
aiturk-mobil/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt
T
M57andM57 3363c1e586 perf(analytics): preload on launch + split-fetch + disk cache (issue #537) (#630)
* 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>
2026-07-19 20:41:23 +03:00

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)
}
}