Commit Graph
56 Commits
Author SHA1 Message Date
M57andM57 a62bc7e16c refactor: remove device Pairing feature entirely (issue #645) (#650)
* refactor: remove device Pairing feature entirely (issue #645)

- Delete Pairing.kt model + pairing API endpoints (get/approve/revoke/clear)
- Delete PairingScreen/PairingViewModel + PairingCodeEntryScreen/ViewModel
- Remove NavKeys, ScreenRegistry drawer entry, Navigation.kt entry + Landing pairing button
- Remove pairing string resources (en + ko); keep connect_* config-parse strings
- Keep Telegram onboarding pairing_id flow (separate concept, per issue)
- Fix related doc comments + remaining test reference

* test: fix racy CONNECTED read in gated-mode WS test (issue #635 test)

testGatedMode_attemptsTicketFetchWithoutBareCookie asserted CONNECTED
immediately after the server-side onOpen latch fired, racing the client
handshake (WsListenerImpl sets CONNECTED on receipt of the 101). Await the
real status transition via connectionStatus.first{==CONNECTED} like the
other connect tests do. No product behavior changed.

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 23:23:29 +03:00
M57andhy4ri 26b104b2bb [feature] issue #634: HTTPS + reverse-proxy server endpoint support (#638)
* [feature] issue #634: HTTPS + reverse-proxy server endpoint support

- Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy)
- AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs
- ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://)
- Data migration: ServerUrlMigration converts legacy host/port to baseUrl
- UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning
- Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev
- Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API

Co-Authored-By: M57 <Hy4ri@users.noreply.github.com>

* fix(authlogin): basic-auth derives token automatically, no manual entry

connectBasicAuth() built a fresh OkHttpClient for the password-login call and
a different one for the ws-ticket call, so the Set-Cookie from
/auth/password-login (the session token lives in the cookie, not the JSON body)
never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null,
and the UI fell back to asking the user to paste a token.

Both calls now use the shared OkHttpProvider.probe client (same CookieManager
jar), so the session cookie flows through and the WS ticket mints automatically —
matching the dashboard contract (routes.py: password-login sets Set-Cookie,
ws-ticket requires it).

Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared
client (and that ws-ticket 401s without it).

Fixes the basic-auth token prompt reported against issue #634.

* chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature)

* ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs)

* feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login

- Replace 302-sniff probe with authoritative GET /api/status read
  (auth_required + auth_providers) to derive DashboardAuthMode.
- Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639).
- Render login UI by detected mode: token (loopback), username+password
  (basic/gated), or OAuth notice.
- Remove legacy pairing-string box + onPairingString/applyLegacyPairing.
- Extract internal deriveAuthMode() + add 5 unit tests.

Closes nothing; OAuth tracked in #639.

* fix(chat): don't block WS connect on empty token in gated (ticket) mode

connectWebSocket() gated on getToken() != null, correct for loopback
(token) mode but wrong for gated (ticket) mode: the ticket is minted
fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted
session cookie, so getToken() is expected empty at connect time. The
early return left the WS unopened -> DISCONNECTED -> chat showed
'reconnect' immediately after basic-auth login.

Only require a non-blank token in non-gated mode.

Fixes #640.

* test: remove onPairingString test cases for deleted pairing-login path

The pairing-string login (onPairingString/applyLegacyPairing) was removed
from ConnectViewModel, so the 5 tests that exercised it (3 in
ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove
them. Also drop the now-orphaned connect_pairing_string from values-ko.

Fixes the Unit & Integration Tests CI failure on PR #638.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-07-20 17:26:10 +03:00
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
M57andM57 00c0ddfb92 feat(billing): adopt subscription.* + usage.bars WS methods (issue #628) (#629)
* 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.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-19 15:06:09 +03:00
M57andM57 d484d535ce fix(#613): remove 77 unused string resources (#616)
Independently verified dead weight: computed defined vs used set across
all source sets (R.string.* in Kotlin + @string/* in XML/manifest).
Zero references in app/src/main, androidTest, or test fixtures.

- values/strings.xml: -77 unused <string> entries (818 -> 741)
- values-ko/strings.xml: -67 corresponding entries (704 -> 637)

Build: ./gradlew assembleDebug BUILD SUCCESSFUL (no missing-resource errors).

True KO translation gap after prune = 741 used EN - 637 translated KO = 104
(previously mis-stated as 114; the 67 removed were never translated anyway).

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-17 17:49:24 +03:00
sid10042003-design 4314bddaae Add Korean translation (values-ko/strings.xml) (#602)
* Add Korean translation (values-ko/strings.xml)

* Disable MissingTranslation lint check
2026-07-15 23:47:36 +03:00