Skip to content

iOS: paginate listVaults(), actionable error copy, unregister push on sign-out, real-time vault WebSocket - #154

Open
willi-d7 wants to merge 1 commit into
ethos-protocol:mainfrom
willi-d7:main
Open

iOS: paginate listVaults(), actionable error copy, unregister push on sign-out, real-time vault WebSocket#154
willi-d7 wants to merge 1 commit into
ethos-protocol:mainfrom
willi-d7:main

Conversation

@willi-d7

@willi-d7 willi-d7 commented Jul 27, 2026

Copy link
Copy Markdown

Summary

Implements four independent iOS issues from the same batch:

What changed

#21 — Pagination

  • APIClient.listVaults(cursor:limit:) now sends cursor/limit query params and returns a VaultPage (vaults + nextCursor, parsed from a new X-Next-Cursor response header). The response body itself stays a bare Vault array — backward compatible with any client that doesn't send pagination params.
  • APIClient.listAllVaults() (paginates internally, concatenates every page) replaces the old unpaginated call for BackgroundRefreshService and TTLWidget, since both need the complete vault set to compute TTL warnings correctly — a single page isn't enough there.
  • VaultStore gains loadMore() / hasMorePages; VaultListView gets a "Load More" row at the bottom of the list.
  • Documented the query-param/response-header contract in shared/api-contract.md under "List Pagination" as the coordination point for the Android client (no Android changes in this PR — it currently calls the same bare-array /vaults endpoint and is unaffected until it opts into the new params).

#23 — Actionable error copy

  • APIError gains recoverySuggestion, isRetryable, and suggestsContactSupport, replacing decode failures' generic "Invalid server response" with copy that tells the user what to actually do.
  • ErrorPresentation (new) turns any error into { message, recoverySuggestion, showsRetry, showsContactSupport }; ErrorActionView renders it with "Try Again" / "Contact Support" (mailto) buttons. Wired into the auth flow (sign-in/register) and VaultListView (which previously didn't surface vaultStore.error to the user at all).
  • DecodingFailureLogger logs decode failures (path, expected type, and the response body with known-sensitive fields like token/credential_id/signature/etc. redacted) for triage, mirroring DeepLinkLogger's existing pattern.

#24 — Unregister push token on sign-out

  • NotificationService.handleDeviceToken(_:) now persists the token via KeychainService once registration succeeds.
  • AuthStore.signOut() is now async: it unregisters the persisted push token (while the auth token is still valid, since the request needs it) before dropping the auth token locally. Best-effort — sign-out always completes locally even if the network call fails.

#20 — Real-time vault events

  • New VaultEventSocket (URLSessionWebSocketTask-based) connects to wss://.../v1/ws?vault_id={id}, reconnects with exponential backoff (capped) on a drop, and reports .fallbackToPolling after exhausting reconnect attempts — existing polling (pull-to-refresh, BackgroundRefreshService) never depended on the socket, so nothing else needs to change for the app to keep working when it's unavailable.
  • VaultStore.subscribeToEvents(vaultID:) / unsubscribeFromEvents() wire incoming vault_updated events into the existing applyUpdate(_:) in-place list update. VaultDetailView subscribes for the vault it's showing, torn down via the same .task lifecycle as its TTL-refresh loop.
  • Documented the WebSocket message contract ({"type": "vault_updated", "vault": {...}}, unrecognized types ignored) in shared/api-contract.md.

Pre-existing bugs fixed (blocking, unrelated to the above)

Two bad merges from PR #137 and #138 had left main not compiling, which I discovered while touching these same files and had to fix to verify anything in this PR:

  • Stores.swift: VaultStore was missing refreshSingle/deposit/withdraw/updateBeneficiary/applyUpdate — all already called from Views.swift — and had a duplicate scheduleReminders().
  • VaultDetailView: missing the ttlRemaining/showDeposit/showWithdraw/showManageBeneficiary @State, its custom init, and refreshTTLPeriodically() — all referenced in its own body — plus a duplicate load2FAStatus().

Both were restored to what the surrounding (already-merged) code clearly intended, using the pre-merge branch tips (6a608cf, 6a82bb0) as reference. No unrelated behavior changes.

Known pre-existing issues (not fixed, out of scope for this batch)

  • Tests/EthosProtocolTests.swift's TTLWidgetTests constructs VaultEntry(date:vaultName:ttlRemaining:isExpiringSoon:), missing the required vaultID: argument — a compile error predating this PR (confirmed on origin/main before any of my changes). This blocks the whole SPM test target from building until fixed; left untouched since it's unrelated to this batch.
  • Tests/APIClientTests.swift's APIClient.makeTestInstance uses setValue(forKey:)/responds(to:) on APIClient, which doesn't inherit from NSObject — those KVC calls don't exist on it, so this test file doesn't compile either, independent of the above. Also pre-existing; the one call site whose type changed under listVaults() (Add Pagination Support to listVaults() #21) was kept internally consistent, but the underlying KVC issue wasn't fixed (out of scope).

Verification performed

No macOS/Xcode toolchain is available in this environment (ios-ci.yml requires macos-latest; this sandbox is Linux with no swift/xcodebuild), so I could not run xcodebuild test or swift build directly. Verification was done via:

  • Careful manual review of every changed/added file against Swift/SwiftUI/Combine semantics (actor isolation for VaultEventSocket/VaultStore, closure capture, Codable key mapping, etc.)
  • Brace/paren balance checks across all touched files
  • Cross-referencing every call site of changed signatures (listVaults(), execute(), decode(), VaultStore.error/AuthStore.error type change, VaultDetailView.init) to confirm no stale usages remain
  • Checked project.yml's TTLWidget target's curated Services file list and added DecodingFailureLogger.swift (needed transitively by APIClient.decode())
  • Grepped for duplicate declarations / dangling references (the class of bug the two pre-existing regressions above turned out to be)

This PR's actual compilation and test results should be confirmed by CI on push.

Test plan

New/updated tests (all in the SPM EthosProtocolTests bundle unless noted):

Since CI requires macOS, please confirm the iOS CI workflow is green on this PR before merge.

Closes #20
closes #21
closes #23
closes #24

…h on sign-out, real-time vault WebSocket

- APIClient.listVaults() takes cursor/limit query params and returns a page
  plus an opaque next-page cursor (X-Next-Cursor response header, documented
  in shared/api-contract.md). VaultStore exposes loadMore()/hasMorePages, and
  VaultListView gets a "Load More" row. listAllVaults() (paginating internally)
  replaces the old unpaginated call for BackgroundRefreshService/TTLWidget,
  which need the complete vault set to compute TTL warnings correctly.

- APIError gains recoverySuggestion/isRetryable/suggestsContactSupport, and
  ErrorPresentation turns those into a "Try Again" / "Contact Support"
  affordance (ErrorActionView), wired into the auth flow and vault list load.
  Decode failures are logged (redacted of tokens/secrets/credentials) via
  DecodingFailureLogger for triage.

- AuthStore.signOut() now unregisters the device's push token before dropping
  the auth token (KeychainService persists the last-registered token so
  sign-out has something to unregister).

- Add VaultEventSocket: a URLSessionWebSocketTask-based client for the
  documented `wss://.../v1/ws?vault_id={id}` real-time event stream, with
  exponential-backoff reconnect and a `.fallbackToPolling` state once
  reconnects are exhausted (existing pull-to-refresh/BackgroundRefreshService
  polling never depended on it). VaultDetailView subscribes for the vault
  it's showing and applies incoming updates in place via VaultStore.applyUpdate.

Also fixes two pre-existing bad-merge regressions from PR ethos-protocol#137/ethos-protocol#138 that
independently broke compilation and were blocking verification of this work:
Stores.swift was missing refreshSingle/deposit/withdraw/updateBeneficiary/
applyUpdate (already called from Views.swift) and had a duplicate
scheduleReminders(); VaultDetailView was missing the ttlRemaining/showDeposit/
showWithdraw/showManageBeneficiary state, its custom init, and
refreshTTLPeriodically(), plus had a duplicate load2FAStatus().

Closes ethos-protocol#20, ethos-protocol#21, ethos-protocol#23, ethos-protocol#24
@drips-wave

drips-wave Bot commented Jul 27, 2026

Copy link
Copy Markdown

@willi-d7 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant