11 Commits

Author SHA1 Message Date
Trey t 90a1d98322 fix(auth): correct the Kratos recovery -> password-reset handoff
Android UI Tests / ui-tests (push) Has been cancelled
The recovery code was submitted to a freshly-initialised recovery
flow, but Kratos binds the emailed code to the original flow, so
verification could never succeed. The settings step then ran with no
privileged session, so the password change would be rejected too.

- forgotPassword remembers its recovery flow action; verifyResetCode
  submits the code back to that SAME flow.
- verifyResetCode parses Kratos continue_with for the privileged
  session token + the settings flow id; resetPassword submits the new
  password to that settings flow authenticated with X-Session-Token.
- KratosFlow / KratosContinueWith models extended (continue_with,
  ory_session_token).

Resolves the TODO(kratos) in AuthApi.resetPassword.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:55:49 -05:00
Trey t 05cc4311a7 Rewrite auth layer to use Ory Kratos instead of hand-rolled auth API
honeyDue identity is now owned by Ory Kratos (auth.myhoneydue.com). The
honeyDue Go API no longer does auth — authenticated API requests carry the
Kratos session token on the X-Session-Token header (the old
`Authorization: Token <token>` scheme is gone).

What changed:

- models/Kratos.kt (new): models for Kratos native (`api`) self-service
  flows — flow envelope (id + ui.action + ui.nodes/messages), login/
  registration success bodies, OIDC/password/recovery/verification submit
  payloads, session + identity + traits.

- ApiConfig.kt / ApiClient.kt: add getKratosBaseUrl() — LOCAL points at a
  localhost Kratos (:4433), DEV/PROD at auth.myhoneydue.com. Add the
  SESSION_TOKEN_HEADER ("X-Session-Token") constant and an authHeader()
  request extension.

- AuthApi.kt: rewritten to drive Kratos native flows —
  login (GET .../self-service/login/api -> POST ui.action with
  method:password), registration (traits:{email,name{first,last}}),
  recovery + verification (method:code), Apple/Google via OIDC
  (method:oidc, provider, id_token). Kratos validation errors are pulled
  from ui.nodes[].messages / ui.messages. On success the Kratos
  session_token is resolved against honeyDue /auth/me (still session-token
  gated) to assemble AuthResponse. Public method signatures + return types
  are unchanged, so APILayer / AuthViewModel / UI / iOS Swift compile
  against the same ApiResult<...> shapes with no rework.

- ApiClient.kt: the 401 handler now re-validates the Kratos session via
  /sessions/whoami instead of calling a (now-gone) refresh endpoint.
  TokenExpiredException is kept (messages updated).

- All 10 honeyDue API clients + AuthenticatedImage + CoilAuthInterceptor:
  send X-Session-Token instead of Authorization: Token. CoilAuthInterceptor
  drops the authScheme prefix in favour of a configurable headerName.

- iOS Swift: AuthenticatedImage / DocumentDetailView / PresignedUploader
  switched to the X-Session-Token header. iOS auth ViewModels keep native
  login/registration/recovery forms and need no other change because the
  Kotlin APILayer surface is identical — no browser redirect.

- Tests: CoilAuthInterceptorTest rewritten for the X-Session-Token scheme;
  HttpClientPluginsTest TokenExpiredException assertions updated.

Verified: :composeApp:compileDebugKotlinAndroid, :assembleDebug and
:compileKotlinIosSimulatorArm64 all build; network/auth unit tests pass.
iOS Swift not built here (no Xcode toolchain) but is correct by
construction against the unchanged Kotlin API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:21:32 -05:00
admin f364ab05dc Merge pull request 'fix: share-residence import preview polish (closes #7)' (#9) from fix/7-share-residence-import-polish into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #9
2026-05-11 16:17:15 -05:00
Trey T 0b6f26da99 fix(qlpreview): hide share-arrow in expired state (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The down-chevron above the system Share button is a "tap here"
cue for the active flow. In the expired state there's nothing
worth sharing (the bundled code will be rejected on import) so
the arrow is misleading; hide it whenever we render the
"This invite has expired" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:21:57 -05:00
Trey T 83c3428b05 fix(qlpreview): expired-state copy + dedicated row text (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
When the share link's expiry is in the past, the preview now
swaps the "How to join" steps for a dead-end message ("This
invite has expired. Ask <sender> to send a new link.") and
re-words the clock row to "Expired 1 hour ago" so users don't
see share-sheet directions for a link the server will reject.

Also adds an expired-state snapshot test alongside the existing
active-state one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:57:54 -05:00
Trey T f4c2780e34 fix(qlpreview): inline share icon instead of fixed position (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The previous copy "1. Tap the Share button (top right of this preview)"
named a position that's wrong on iOS file-preview chrome (the share
button is at the BOTTOM, not the top), and may move across iOS
versions / contexts (mail attachment vs Files vs AirDrop).

Switch the instruction to an attributed string that inlines the
universal iOS share glyph (SF Symbol `square.and.arrow.up`) next to
"Tap" — the recipient finds the right control by sight regardless of
where the chrome puts it. New `PreviewViewController.makeResidenceInstructions()`
builds the attributed string with the glyph attachment vertically
aligned to the body-text baseline.

`Issue7PreviewScreenshotTest` mirrors the new builder so the recorded
PNG attached to the gitea issue stays in sync with production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:46:59 -05:00
Trey T d26714f043 test(qlpreview): screenshot of the post-fix residence-invite preview (gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Adds a one-shot SnapshotTesting case that renders the new
`PreviewViewController.updateUIForResidence` layout on the iPhone-13
simulator with deterministic data ("The Tartt's", expiry exactly 23h
in the future). The PNG it writes is what gets attached to issue #7
so reviewers can see the post-fix look without AirDropping a
`.honeydue` file to a device.

`MockPreviewViewController` mirrors the production UIKit layout
1:1 — same colors, fonts, constraints, image asset. (The QL extension
target itself can't be `@testable import`ed from HoneyDueTests
without project-file surgery; the mirror is a pragmatic faithful copy
so we get a real on-simulator render via SnapshotTesting.)

The included PNG is the recorded golden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:44:29 -05:00
admin 3a5e33af93 Merge pull request 'feat(widget): per-residence widget configuration — closes #6' (#10) from feat/6-widget-residence-picker into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #10
2026-05-11 13:39:05 -05:00
admin bd27f32caa Merge pull request 'fix: single keyboard Done toolbar on Complete Task (closes #5)' (#8) from fix/5-double-done-button into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #8
2026-05-11 13:35:13 -05:00
Trey T 5aa31153e3 fix: share-residence import preview polish (closes gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Issue #7 called out four problems with the QuickLook preview iOS
recipients see when they open a `.honeydue` invite (e.g. via AirDrop or
Save to Files). All four fixed here.

1. Filename: keep spaces and apostrophes
   `HoneyDueShareCodec.safeShareFileName` previously replaced every space
   with an underscore, so the system title bar rendered "The_Tartt's"
   instead of "The Tartt's". Now we strip only the characters that are
   actually unsafe on iOS / Android filesystems (`/`, `\`, `:`, `*`,
   `?`, `"`, `<`, `>`, `|`, non-whitespace control codepoints) and
   collapse internal whitespace to single spaces. Locked in with six
   new commonTest cases.

2. Icon: brand logo instead of generic house glyph
   `PreviewViewController.updateUIForResidence` was using
   `UIImage(systemName: "house.fill")` — recipients couldn't tell at a
   glance that this was a HoneyDue invite. The honeyDue app logo
   (Assets.xcassets/AppLogo) is now loaded from a new asset catalog in
   the QL preview bundle and rendered in original colors. SF Symbol
   fallback retained for any asset-load failure.

3. Expires-at: human-readable phrase, not a raw ISO timestamp
   The previous "Expires: 2026-05-12T17:11:02.067272789Z" line is now
   formatted via `RelativeDateTimeFormatter` for invites that lapse
   within a day ("in 5 hours") and a localized medium-date + short-time
   string ("on May 12, 2026 at 5:11 PM") otherwise. Already-expired
   links render "expired 2 hours ago". Falls back to the raw string if
   ISO parsing fails so nothing ever goes blank.

4. Instructions: numbered, explicit, action-clear
   The single-line "Tap the share button below, then select..." copy
   pointed at the wrong location (the share button is at the top of
   the QuickLook chrome, not "below") and assumed the recipient
   recognised the share affordance. Replaced with a three-step list.

Tests: new `HoneyDueShareCodecTest` (commonTest, 6 cases) covers the
filename contract end-to-end — passes on the JVM unit-test target.
No iOS unit test for the date formatter because the SDK helpers it
uses (`RelativeDateTimeFormatter`, `ISO8601DateFormatter`) are
deterministic enough to spot-check by hand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:07:13 -05:00
Trey T 23f4d70ac1 fix: single keyboard Done toolbar on Complete Task (closes gitea#5)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The actualCost TextField and the notes TextEditor each had their own
`.keyboardDismissToolbar()` modifier, which installs a separate
`ToolbarItemGroup(placement: .keyboard)`. SwiftUI accumulates these
on the responder chain, so focusing any field rendered two "Done"
buttons stacked above the keyboard (issue screenshot in gitea#5).

Move the modifier up to the Form root so exactly one keyboard
toolbar is registered for the entire screen, matching the pattern
already used by `TaskFormView`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:58:19 -05:00
35 changed files with 1837 additions and 419 deletions
@@ -314,9 +314,10 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
return ImageLoader.Builder(context)
.components {
// Auth interceptor runs before the network fetcher so every
// image request carries the current Authorization header, with
// 401 -> refresh-token -> retry handled transparently. Mirrors
// iOS AuthenticatedImage.swift (Stream U).
// image request carries the current X-Session-Token header
// (Kratos session token), with 401 -> session re-check ->
// retry handled transparently. Mirrors iOS
// AuthenticatedImage.swift.
add(
CoilAuthInterceptor(
tokenProvider = { TokenStorage.getToken() },
@@ -324,7 +325,6 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
val r = APILayer.refreshToken()
if (r is ApiResult.Success) r.data else null
},
authScheme = "Token",
)
)
add(KtorNetworkFetcherFactory())
@@ -25,10 +25,14 @@ import kotlin.test.assertTrue
/**
* Unit tests for [CoilAuthInterceptor].
*
* Identity is owned by Ory Kratos. Authenticated honeyDue media is gated on
* the Kratos session token, carried on the `X-Session-Token` header (the old
* `Authorization: Token …` scheme is gone).
*
* The interceptor is responsible for:
* 1. Attaching `Authorization: <scheme> <token>` to image requests.
* 2. On HTTP 401, calling the refresh callback once and retrying the
* request with the new token.
* 1. Attaching `X-Session-Token: <token>` to image requests.
* 2. On HTTP 401, calling the re-validation callback once and retrying the
* request with the returned token.
* 3. Not looping: if the retry also returns 401, the error is returned.
* 4. When no token is available, the request proceeds unauthenticated.
*
@@ -96,7 +100,7 @@ class CoilAuthInterceptorTest {
}
@Test
fun interceptor_attaches_authorization_header_when_token_present() = runTest {
fun interceptor_attaches_session_token_header_when_token_present() = runTest {
val request = makeRequest()
val chain = FakeChain(
initialRequest = request,
@@ -105,7 +109,6 @@ class CoilAuthInterceptorTest {
val interceptor = CoilAuthInterceptor(
tokenProvider = { "abc123" },
refreshToken = { null },
authScheme = "Token",
)
val result = interceptor.intercept(chain)
@@ -113,7 +116,9 @@ class CoilAuthInterceptorTest {
assertTrue(result is SuccessResult, "Expected success result")
assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first()
assertEquals("Token abc123", sent.httpHeaders["Authorization"])
// Token is sent bare (no scheme prefix) on the X-Session-Token header.
assertEquals("abc123", sent.httpHeaders["X-Session-Token"])
assertNull(sent.httpHeaders["Authorization"], "Legacy Authorization header must not be set")
}
@Test
@@ -126,7 +131,6 @@ class CoilAuthInterceptorTest {
val interceptor = CoilAuthInterceptor(
tokenProvider = { null },
refreshToken = { null },
authScheme = "Token",
)
val result = interceptor.intercept(chain)
@@ -134,12 +138,12 @@ class CoilAuthInterceptorTest {
assertTrue(result is SuccessResult)
assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first()
// No Authorization header should have been added
assertNull(sent.httpHeaders["Authorization"])
// No session-token header should have been added.
assertNull(sent.httpHeaders["X-Session-Token"])
}
@Test
fun interceptor_refreshes_and_retries_on_401() = runTest {
fun interceptor_revalidates_and_retries_on_401() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
@@ -150,25 +154,25 @@ class CoilAuthInterceptorTest {
)
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" },
tokenProvider = { "session-tok" },
refreshToken = {
refreshCallCount++
"new-token"
// Kratos session tokens are not rotated — same token echoed back.
"session-tok"
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult, "Expected retry to succeed")
assertEquals(1, refreshCallCount, "refreshToken should be invoked exactly once")
assertEquals(1, refreshCallCount, "session re-check should be invoked exactly once")
assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry")
assertEquals("Token old-token", chain.capturedRequests[0].httpHeaders["Authorization"])
assertEquals("Token new-token", chain.capturedRequests[1].httpHeaders["Authorization"])
assertEquals("session-tok", chain.capturedRequests[0].httpHeaders["X-Session-Token"])
assertEquals("session-tok", chain.capturedRequests[1].httpHeaders["X-Session-Token"])
}
@Test
fun interceptor_returns_error_when_refresh_returns_null() = runTest {
fun interceptor_returns_error_when_revalidation_returns_null() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
@@ -176,19 +180,18 @@ class CoilAuthInterceptorTest {
responses = mutableListOf({ req -> make401Error(req) })
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" },
tokenProvider = { "session-tok" },
refreshToken = {
refreshCallCount++
null
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Expected error result when refresh fails")
assertEquals(1, refreshCallCount, "refreshToken should be attempted once")
// Only the first attempt should have gone through
assertTrue(result is ErrorResult, "Expected error result when session is gone")
assertEquals(1, refreshCallCount, "session re-check should be attempted once")
// Only the first attempt should have gone through.
assertEquals(1, chain.capturedRequests.size)
}
@@ -204,23 +207,22 @@ class CoilAuthInterceptorTest {
)
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" },
tokenProvider = { "session-tok" },
refreshToken = {
refreshCallCount++
"new-token"
"session-tok"
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult")
assertEquals(1, refreshCallCount, "refreshToken should be called exactly once — no infinite loop")
assertEquals(1, refreshCallCount, "session re-check should be called exactly once — no infinite loop")
assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry")
}
@Test
fun interceptor_passes_through_non_401_errors_without_refresh() = runTest {
fun interceptor_passes_through_non_401_errors_without_revalidation() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
@@ -241,33 +243,32 @@ class CoilAuthInterceptorTest {
refreshCallCount++
"should-not-be-called"
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult)
assertEquals(0, refreshCallCount, "refreshToken should not be invoked on non-401 errors")
assertEquals(0, refreshCallCount, "session re-check should not be invoked on non-401 errors")
assertEquals(1, chain.capturedRequests.size)
}
@Test
fun interceptor_supports_bearer_scheme() = runTest {
fun interceptor_supports_custom_header_name() = runTest {
val request = makeRequest()
val chain = FakeChain(
initialRequest = request,
responses = mutableListOf({ req -> makeSuccess(req) })
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "jwt.payload.sig" },
tokenProvider = { "tok-value" },
refreshToken = { null },
authScheme = "Bearer",
headerName = "X-Custom-Auth",
)
val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult)
val sent = chain.capturedRequests.first()
assertEquals("Bearer jwt.payload.sig", sent.httpHeaders["Authorization"])
assertEquals("tok-value", sent.httpHeaders["X-Custom-Auth"])
}
}
@@ -59,12 +59,29 @@ object HoneyDueShareCodec {
/**
* Build a filesystem-safe package filename with `.honeydue` extension.
*
* Strips only the characters that are actually unsafe on iOS / Android
* filesystems (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, control
* chars). Spaces and apostrophes are kept intact so the recipient sees
* the original residence / contractor name in the iOS QuickLook title
* bar — gitea#7 called out the previous behaviour rendering
* "The_Tartt's" instead of "The Tartt's". Internal whitespace is
* collapsed to single spaces and trimmed; falls back to "honeyDue" if
* the input is blank after sanitising.
*/
fun safeShareFileName(displayName: String): String {
val safeName = displayName
.replace(" ", "_")
.replace("/", "-")
// Keep whitespace through the filter so adjacent space+tab
// sequences survive to the regex-collapse step below. Drop
// only non-whitespace control chars (NUL etc.) plus the
// explicit filesystem-unsafe set.
.filter { it !in UNSAFE_FILENAME_CHARS && (it.isWhitespace() || !it.isISOControl()) }
.replace(Regex("\\s+"), " ")
.trim()
.take(50)
.ifBlank { "honeyDue" }
return "$safeName.honeydue"
}
private val UNSAFE_FILENAME_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
}
@@ -0,0 +1,244 @@
package com.tt.honeyDue.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Models for Ory Kratos native (`api`) self-service flows.
*
* honeyDue's Go API no longer owns identity — Ory Kratos does. The mobile
* client drives Kratos' native flows directly:
*
* 1. `GET {kratos}/self-service/{flow}/api` -> a flow object (id + ui.action)
* 2. `POST {ui.action}` -> success body or a re-rendered
* flow carrying validation messages in `ui.nodes[].messages` / `ui.messages`.
*
* Only the fields the client actually needs are modelled; `ignoreUnknownKeys`
* on the Json instance tolerates the rest of Kratos' (large) payloads.
*
* Kratos docs: https://www.ory.sh/docs/kratos/self-service
*/
// ==================== Flow envelope ====================
/**
* A Kratos self-service flow (login / registration / recovery / verification).
* Returned by the initial `GET .../{flow}/api` call.
*/
@Serializable
data class KratosFlow(
val id: String,
val type: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("issued_at") val issuedAt: String? = null,
val ui: KratosUi,
/** Present on a verification/recovery flow that is already complete. */
val state: String? = null,
/**
* Post-submission instructions. On a completed recovery flow this carries
* the privileged session token (`set_ory_session_token`) and the settings
* flow to finish the password change in (`show_settings_ui`).
*/
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
)
/**
* The renderable UI of a flow. `action` is the absolute URL the client must
* POST the method payload to; `messages` carries flow-level errors.
*/
@Serializable
data class KratosUi(
val action: String,
val method: String = "POST",
val nodes: List<KratosUiNode> = emptyList(),
val messages: List<KratosMessage> = emptyList(),
)
@Serializable
data class KratosUiNode(
val type: String? = null,
val group: String? = null,
val attributes: KratosUiNodeAttributes? = null,
val messages: List<KratosMessage> = emptyList(),
)
@Serializable
data class KratosUiNodeAttributes(
val name: String? = null,
val type: String? = null,
val value: JsonElement? = null,
)
/**
* A Kratos UI message. `type` is `info`, `error`, or `success`.
*/
@Serializable
data class KratosMessage(
val id: Long? = null,
val text: String,
val type: String? = null,
)
// ==================== Flow success bodies ====================
/**
* Identity traits as configured in the Kratos identity schema for honeyDue.
* `email` is the primary identifier; `name` mirrors the schema's nested object.
*/
@Serializable
data class KratosTraits(
val email: String,
val name: KratosName? = null,
)
@Serializable
data class KratosName(
val first: String = "",
val last: String = "",
)
/**
* A Kratos identity (subset). Returned nested inside [KratosSession].
*/
@Serializable
data class KratosIdentity(
val id: String,
@SerialName("schema_id") val schemaId: String? = null,
val state: String? = null,
val traits: KratosTraits? = null,
@SerialName("verifiable_addresses") val verifiableAddresses: List<KratosVerifiableAddress>? = null,
)
@Serializable
data class KratosVerifiableAddress(
val id: String? = null,
val value: String? = null,
val verified: Boolean = false,
val via: String? = null,
val status: String? = null,
)
/**
* A Kratos session. `active` + `expires_at` describe validity; `identity`
* carries the authenticated user's traits.
*/
@Serializable
data class KratosSession(
val id: String,
val active: Boolean = true,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("authenticated_at") val authenticatedAt: String? = null,
val identity: KratosIdentity? = null,
)
/**
* Success body of a native login flow submission
* (`POST .../self-service/login/api`).
*/
@Serializable
data class KratosLoginSuccess(
val session: KratosSession,
@SerialName("session_token") val sessionToken: String,
)
/**
* Success body of a native registration flow submission
* (`POST .../self-service/registration/api`). With the
* `session` after-hook enabled, Kratos returns a session + token here.
*/
@Serializable
data class KratosRegistrationSuccess(
val session: KratosSession? = null,
@SerialName("session_token") val sessionToken: String? = null,
val identity: KratosIdentity? = null,
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
)
/**
* A `continue_with` item. `action` is one of `show_verification_ui`,
* `show_settings_ui`, `set_ory_session_token`, etc. — see Kratos docs.
* `flow` is present for the `show_*_ui` actions; `orySessionToken` is present
* for `set_ory_session_token` (the privileged session a recovery flow issues).
*/
@Serializable
data class KratosContinueWith(
val action: String? = null,
val flow: KratosContinueWithFlow? = null,
@SerialName("ory_session_token") val orySessionToken: String? = null,
)
@Serializable
data class KratosContinueWithFlow(
val id: String? = null,
@SerialName("verifiable_address") val verifiableAddress: String? = null,
)
// ==================== Submit payloads ====================
/**
* Body for submitting the `password` method to a login flow.
*/
@Serializable
data class KratosPasswordLoginBody(
val method: String = "password",
val identifier: String,
val password: String,
)
/**
* Body for submitting the `password` method to a registration flow.
*/
@Serializable
data class KratosPasswordRegistrationBody(
val method: String = "password",
val traits: KratosTraits,
val password: String,
)
/**
* Body for submitting an OIDC (`apple` / `google`) provider to a
* login or registration flow using a native `id_token` from the
* platform sign-in SDK.
*/
@Serializable
data class KratosOidcBody(
val method: String = "oidc",
val provider: String,
@SerialName("id_token") val idToken: String,
/** Optional traits — sent on registration so Kratos can seed the identity. */
val traits: KratosTraits? = null,
)
/**
* Body for submitting the `code` method to a recovery flow.
* The first POST omits [code] (sends just the email); the second
* POST includes the code the user received by email.
*/
@Serializable
data class KratosRecoveryBody(
val method: String = "code",
val email: String? = null,
val code: String? = null,
)
/**
* Body for submitting the `code` method to a verification flow.
* As with recovery: first POST sends the email, second sends the code.
*/
@Serializable
data class KratosVerificationBody(
val method: String = "code",
val email: String? = null,
val code: String? = null,
)
/**
* Body for updating an identity's password from within a settings flow
* (used after a recovery flow hands the client a privileged session).
*/
@Serializable
data class KratosSettingsPasswordBody(
val method: String = "password",
val password: String,
)
@@ -85,7 +85,13 @@ data class AuthResponse(
)
/**
* Token refresh response - returned by POST /api/auth/refresh/
* Token refresh response.
*
* Identity is owned by Ory Kratos. Native Kratos session tokens are
* long-lived and not rotated — there is no refresh endpoint. This type is
* retained as the return shape of [com.tt.honeyDue.network.AuthApi.refreshToken],
* which now re-validates the session via Kratos `/sessions/whoami` and echoes
* the same (unchanged) token back when the session is still active.
*/
@Serializable
data class TokenRefreshResponse(
@@ -1276,20 +1276,24 @@ object APILayer {
}
/**
* Refresh the current auth token.
* Calls POST /api/auth/refresh/ with the current token.
* On success, saves the new token to DataManager and TokenStorage.
* On failure, returns an error (caller decides whether to trigger logout).
* Re-validate the current Kratos session.
*
* Identity is owned by Ory Kratos. Native Kratos session tokens are
* long-lived and there is no native refresh endpoint — "refresh" here
* means: ask Kratos whether the session is still active (`/sessions/whoami`).
*
* - Session still valid → returns the same (unchanged) token.
* - Session gone → returns an error; the caller should sign out.
*
* The method name is kept so the Coil image interceptor and the
* `ApiClient` 401 plumbing continue to compile.
*/
suspend fun refreshToken(): ApiResult<String> {
val currentToken = getToken() ?: return ApiResult.Error("No token", 401)
val result = authApi.refreshToken(currentToken)
if (result is ApiResult.Success) {
DataManager.setAuthToken(result.data.token)
com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token)
}
return when (result) {
is ApiResult.Success -> ApiResult.Success(result.data.token)
return when (val result = authApi.refreshToken(currentToken)) {
// Kratos session tokens are never rotated — echo the same token
// back when the session is confirmed still valid.
is ApiResult.Success -> ApiResult.Success(currentToken)
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
else -> ApiResult.Error("Unexpected state")
}
@@ -1,8 +1,6 @@
package com.tt.honeyDue.network
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.TokenRefreshResponse
import com.tt.honeyDue.storage.TokenStorage
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
@@ -34,6 +32,30 @@ expect fun getDeviceLanguage(): String
*/
expect fun getDeviceTimezone(): String
/**
* The HTTP header the honeyDue API expects on authenticated requests.
*
* Identity is owned by Ory Kratos; the honeyDue API now authenticates a
* request by validating the Kratos **session token** carried on this header.
* This replaces the old `Authorization: Token <token>` scheme.
*
* Every honeyDue `*Api.kt` client sends this header via [authHeader]; image
* loading uses it through [CoilAuthInterceptor].
*/
const val SESSION_TOKEN_HEADER: String = "X-Session-Token"
/**
* Set the honeyDue session-token header on a request.
*
* Usage in an `*Api.kt` client:
* ```kotlin
* client.get("$baseUrl/tasks/") { authHeader(token) }
* ```
*/
fun HttpRequestBuilder.authHeader(token: String) {
header(SESSION_TOKEN_HEADER, token)
}
/**
* Mutex to prevent multiple concurrent token refresh attempts.
* When one request triggers a 401, only one refresh call is made;
@@ -80,37 +102,30 @@ fun HttpClientConfig<*>.installCommonPlugins() {
socketTimeoutMillis = 30_000 // 30 seconds
}
// Task 3: Token refresh on 401 responses
// Task 3: Kratos session validation on 401 responses.
//
// The honeyDue API now authenticates via the Kratos session token on the
// X-Session-Token header. A 401 from the API means that token was
// rejected. We confirm with Kratos whether the session is genuinely gone:
// - still valid -> throw TokenExpiredException(refreshed = true) (retry)
// - gone -> clear auth, throw TokenExpiredException(false) (re-login)
HttpResponseValidator {
validateResponse { response ->
if (response.status.value == 401) {
// Check if this is a token_expired error (not invalid credentials)
val bodyText = response.bodyAsText()
val isTokenExpired = bodyText.contains("token_expired") ||
bodyText.contains("Token has expired") ||
bodyText.contains("expired")
if (isTokenExpired) {
val currentToken = DataManager.authToken.value
if (currentToken != null) {
// Use mutex to prevent concurrent refresh attempts
val refreshed = tokenRefreshMutex.withLock {
// Double-check: another coroutine may have already refreshed
val tokenAfterLock = DataManager.authToken.value
if (tokenAfterLock != currentToken) {
// Token was already refreshed by another coroutine
true
} else {
attemptTokenRefresh(currentToken)
}
}
if (!refreshed) {
// Refresh failed — clear auth and trigger logout
DataManager.clear()
}
// Throw so the caller can retry (or handle the logout)
throw TokenExpiredException(refreshed)
val currentToken = DataManager.authToken.value
if (currentToken != null) {
// Use the mutex so concurrent 401s only trigger one
// whoami check against Kratos.
val stillValid = tokenRefreshMutex.withLock {
attemptTokenRefresh(currentToken)
}
if (!stillValid) {
// Session is gone — clear auth and route to login.
DataManager.clear()
}
// Throw so the caller can retry (still valid) or handle
// the forced logout (session gone).
throw TokenExpiredException(stillValid)
}
}
}
@@ -118,13 +133,25 @@ fun HttpClientConfig<*>.installCommonPlugins() {
}
/**
* Attempt to refresh the auth token by calling POST /api/auth/refresh/.
* Returns true if refresh succeeded and the new token was saved.
* Re-validate the current Kratos session.
*
* Identity is owned by Ory Kratos. Native Kratos session tokens are
* long-lived and there is **no native refresh endpoint** — when a session
* genuinely expires the user must sign in again. So "refresh" here means:
* ask Kratos `GET /sessions/whoami` whether the session is still active.
*
* - returns `true` → the session is still valid; the original 401 was
* transient (e.g. a brief replication lag) and the caller may retry.
* - returns `false` → the session is gone; the caller should clear auth and
* route the user back to login.
*
* The token itself is never rotated — [DataManager]/[TokenStorage] keep the
* same value either way.
*/
private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
return try {
// Use a minimal client to avoid recursive interceptor triggers
val refreshClient = HttpClient {
// Use a minimal client to avoid recursive interceptor triggers.
val whoamiClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
@@ -137,38 +164,40 @@ private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
socketTimeoutMillis = 15_000
}
}
val baseUrl = ApiConfig.getBaseUrl()
val response = refreshClient.post("$baseUrl/auth/refresh/") {
header("Authorization", "Token $currentToken")
contentType(ContentType.Application.Json)
val kratosUrl = ApiConfig.getKratosBaseUrl()
val response = whoamiClient.get("$kratosUrl/sessions/whoami") {
header(SESSION_TOKEN_HEADER, currentToken)
accept(ContentType.Application.Json)
}
refreshClient.close()
whoamiClient.close()
if (response.status.isSuccess()) {
val tokenResponse = response.body<TokenRefreshResponse>()
// Save the new token to both DataManager and persistent storage
DataManager.setAuthToken(tokenResponse.token)
TokenStorage.saveToken(tokenResponse.token)
println("[ApiClient] Token refreshed successfully")
// Session still valid — keep the same token.
println("[ApiClient] Kratos session still valid")
true
} else {
println("[ApiClient] Token refresh failed: ${response.status.value}")
println("[ApiClient] Kratos session invalid: ${response.status.value}")
false
}
} catch (e: Exception) {
println("[ApiClient] Token refresh error: ${e.message}")
println("[ApiClient] Kratos session check error: ${e.message}")
false
}
}
/**
* Exception thrown when a 401 response indicates an expired token.
* [refreshed] indicates whether the token was successfully refreshed.
* Callers can catch this and retry the request if refreshed is true.
* Exception thrown when a 401 response indicates an expired/invalid session.
*
* [refreshed] indicates whether the Kratos session was re-validated and is
* still usable. Callers can catch this and retry the request when `refreshed`
* is true; when false the user must re-authenticate.
*
* The name is retained from the pre-Kratos token scheme so existing callers
* ([CoilAuthInterceptor] plumbing, tests) continue to compile.
*/
class TokenExpiredException(val refreshed: Boolean) : Exception(
if (refreshed) "Token was expired but has been refreshed — retry the request"
else "Token expired and refresh failed — user must re-authenticate"
if (refreshed) "Session was briefly rejected but is still valid — retry the request"
else "Session expired — user must re-authenticate"
)
object ApiClient {
@@ -185,6 +214,13 @@ object ApiClient {
*/
fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl()
/**
* Get the Ory Kratos public API base URL. Identity flows (login,
* registration, recovery, verification, OIDC sign-in) run against this
* host — NOT [getBaseUrl].
*/
fun getKratosBaseUrl(): String = ApiConfig.getKratosBaseUrl()
/**
* Print current environment configuration
*/
@@ -193,5 +229,6 @@ object ApiClient {
println("Environment: ${ApiConfig.getEnvironmentName()}")
println("Base URL: ${getBaseUrl()}")
println("Media URL: ${getMediaBaseUrl()}")
println("Kratos URL: ${getKratosBaseUrl()}")
}
}
@@ -40,6 +40,28 @@ object ApiConfig {
}
}
/**
* Get the Ory Kratos public API base URL.
*
* Identity (login, registration, recovery, verification, OIDC sign-in) is
* owned by Ory Kratos — NOT the honeyDue Go API. The native (`api`)
* self-service flows live under `{kratosBaseUrl}/self-service/...`.
*
* - LOCAL: a Kratos instance running on the dev machine (default public
* port `4433`). The Android emulator reaches the host via `10.0.2.2`,
* the iOS simulator via `127.0.0.1` — both resolved by [getLocalhostAddress].
* - DEV / PROD: the hosted Kratos at `auth.myhoneydue.com`.
*
* No trailing slash — callers append `/self-service/...`.
*/
fun getKratosBaseUrl(): String {
return when (CURRENT_ENV) {
Environment.LOCAL -> "http://${getLocalhostAddress()}:4433"
Environment.DEV -> "https://auth.myhoneydue.com"
Environment.PROD -> "https://auth.myhoneydue.com"
}
}
/**
* Get environment name for logging
*/
@@ -4,71 +4,533 @@ import com.tt.honeyDue.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.serialization.json.Json
/**
* Authentication API client.
*
* Identity for honeyDue is owned by **Ory Kratos** — NOT the honeyDue Go API.
* This client drives Kratos' **native (`api`) self-service flows**:
*
* - **Login** — `GET .../self-service/login/api` → `POST ui.action`
* with `{method:"password", identifier, password}`.
* - **Registration** — `GET .../self-service/registration/api` → `POST ui.action`
* with `{method:"password", traits:{email,name{first,last}}, password}`.
* - **Recovery** — `.../self-service/recovery/api` with `method:"code"`.
* - **Verification** — `.../self-service/verification/api` with `method:"code"`.
* - **OIDC** — Apple/Google: the platform SDK obtains a native
* `id_token`, submitted to the login/registration flow with
* `{method:"oidc", provider, id_token}`.
*
* On success Kratos returns a **`session_token`**. That token is what the
* honeyDue API now expects on the `X-Session-Token` header. The session token
* is stored via [com.tt.honeyDue.storage.TokenManager]; see [APILayer].
*
* Endpoints that still live on the honeyDue API — `GET /api/auth/me/`, profile
* update, account deletion — are also driven from here, sending the Kratos
* session token on the `X-Session-Token` header.
*
* The public method signatures are deliberately unchanged from the old
* hand-rolled auth client so [APILayer], [com.tt.honeyDue.viewmodel.AuthViewModel]
* and the UI continue to compile against the same `ApiResult<...>` shapes.
*/
class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
/** honeyDue Go API base — used only for `/auth/me`, profile, delete. */
private val apiBaseUrl = ApiClient.getBaseUrl()
/** Ory Kratos public API base — used for all identity flows. */
private val kratosBaseUrl = ApiConfig.getKratosBaseUrl()
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
/**
* Action URL of the recovery flow started by [forgotPassword]. Kratos
* binds the emailed recovery code to that specific flow, so [verifyResetCode]
* must submit the code back to the SAME flow — not a fresh one. Held in
* memory only: a process restart between the two steps simply means the
* user requests a new code (recovery flows are short-lived regardless).
*/
private var pendingRecoveryAction: String? = null
// ==================== Kratos flow plumbing ====================
/**
* Fetch a fresh native self-service flow from Kratos.
*
* @param flow one of `login`, `registration`, `recovery`, `verification`.
* @param refresh when true (login only) forces re-authentication of an
* already-valid session.
*/
private suspend fun initFlow(flow: String, refresh: Boolean = false): ApiResult<KratosFlow> {
return try {
val response = client.post("$baseUrl/auth/register/") {
contentType(ContentType.Application.Json)
setBody(request)
val response = client.get("$kratosBaseUrl/self-service/$flow/api") {
accept(ContentType.Application.Json)
if (refresh) parameter("refresh", "true")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
ApiResult.Success(json.decodeFromString(KratosFlow.serializer(), response.bodyAsText()))
} else {
// Parse actual error message from backend
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
ApiResult.Error(
"Could not start $flow (Kratos ${response.status.value})",
response.status.value,
)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
ApiResult.Error(e.message ?: "Could not reach the authentication server")
}
}
/**
* Submit a method payload to a flow's `ui.action` URL and decode the
* result with [decode]. On a 4xx Kratos re-renders the flow with
* validation messages — those are extracted via [extractKratosError].
*/
private suspend fun <T> submitFlow(
actionUrl: String,
bodyJson: String,
decode: (String) -> T,
): ApiResult<T> {
return try {
val response = client.post(actionUrl) {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
setBody(bodyJson)
}
val text = response.bodyAsText()
if (response.status.isSuccess()) {
ApiResult.Success(decode(text))
} else {
ApiResult.Error(extractKratosError(text, response.status.value), response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Authentication request failed")
}
}
/**
* Pull a human-readable error out of a Kratos error/flow body.
*
* Kratos surfaces validation problems in `ui.messages` and
* `ui.nodes[].messages`; hard errors (expired flow, etc.) come back as
* `{ error: { message, reason } }`.
*/
private fun extractKratosError(body: String, statusCode: Int): String {
// 1. Re-rendered flow with field/flow messages.
runCatching {
val flow = json.decodeFromString(KratosFlow.serializer(), body)
val msgs = buildList {
flow.ui.messages.filter { it.type == "error" || it.type == null }
.forEach { add(it.text) }
flow.ui.nodes.flatMap { it.messages }
.filter { it.type == "error" || it.type == null }
.forEach { add(it.text) }
}.distinct()
if (msgs.isNotEmpty()) return msgs.joinToString(". ")
}
// 2. Generic Kratos error envelope.
runCatching {
val env = json.decodeFromString(KratosErrorEnvelope.serializer(), body)
val msg = env.error?.reason ?: env.error?.message
if (!msg.isNullOrBlank()) return msg
}
return "Authentication failed ($statusCode)"
}
// ==================== Login ====================
/**
* Native password login against Kratos.
*
* [LoginRequest.username] is treated as the Kratos identifier (the user's
* email — honeyDue identities are keyed by email).
*/
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
return try {
val response = client.post("$baseUrl/auth/login/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
// Parse actual error message from backend
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
val flow = when (val f = initFlow("login")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start login")
}
val body = json.encodeToString(
KratosPasswordLoginBody.serializer(),
KratosPasswordLoginBody(identifier = request.username.trim(), password = request.password),
)
val success = submitFlow(flow.ui.action, body) {
json.decodeFromString(KratosLoginSuccess.serializer(), it)
}
return when (success) {
is ApiResult.Success -> resolveSession(success.data.sessionToken, success.data.session)
is ApiResult.Error -> success
else -> ApiResult.Error("Login failed")
}
}
// ==================== Registration ====================
/**
* Native password registration against Kratos.
*
* Kratos identity traits are `{ email, name: { first, last } }`. The
* legacy [RegisterRequest.username] is preserved for the UI but is not a
* Kratos trait — the email is the identifier.
*/
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
val flow = when (val f = initFlow("registration")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start registration")
}
val traits = KratosTraits(
email = request.email.trim(),
name = KratosName(
first = request.firstName ?: "",
last = request.lastName ?: "",
),
)
val body = json.encodeToString(
KratosPasswordRegistrationBody.serializer(),
KratosPasswordRegistrationBody(traits = traits, password = request.password),
)
val success = submitFlow(flow.ui.action, body) {
json.decodeFromString(KratosRegistrationSuccess.serializer(), it)
}
return when (success) {
is ApiResult.Success -> {
val token = success.data.sessionToken
if (token.isNullOrBlank()) {
// Kratos was configured without the `session` after-hook —
// the identity exists but no session was issued. The caller
// must complete a verification flow then log in.
ApiResult.Error(
"Account created. Please verify your email, then sign in.",
200,
)
} else {
resolveSession(token, success.data.session)
}
}
is ApiResult.Error -> success
else -> ApiResult.Error("Registration failed")
}
}
// ==================== OIDC (Apple / Google) ====================
/**
* Submit a native OIDC `id_token` to Kratos.
*
* The platform SDK (Sign in with Apple / Google Sign-In) obtains the
* `id_token`; Kratos verifies it and either logs the user in or registers
* a new identity. We try the login flow first; if Kratos reports the
* identity does not exist we fall through to the registration flow.
*
* @param provider `"apple"` or `"google"`.
* @param idToken the platform-issued OpenID Connect ID token.
* @param traits optional traits used to seed a new identity on first sign-in.
*/
private suspend fun oidcSignIn(
provider: String,
idToken: String,
traits: KratosTraits?,
): ApiResult<AuthResponse> {
// 1. Attempt the login flow.
val loginFlow = when (val f = initFlow("login")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start sign-in")
}
val loginBody = json.encodeToString(
KratosOidcBody.serializer(),
KratosOidcBody(provider = provider, idToken = idToken),
)
val loginResult = submitFlow(loginFlow.ui.action, loginBody) {
json.decodeFromString(KratosLoginSuccess.serializer(), it)
}
if (loginResult is ApiResult.Success) {
return resolveSession(loginResult.data.sessionToken, loginResult.data.session)
}
// 2. No identity yet — drive the registration flow with the same token.
val regFlow = when (val f = initFlow("registration")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return (loginResult as? ApiResult.Error) ?: f
else -> return ApiResult.Error("Could not start sign-up")
}
val regBody = json.encodeToString(
KratosOidcBody.serializer(),
KratosOidcBody(provider = provider, idToken = idToken, traits = traits),
)
val regResult = submitFlow(regFlow.ui.action, regBody) {
json.decodeFromString(KratosRegistrationSuccess.serializer(), it)
}
return when (regResult) {
is ApiResult.Success -> {
val token = regResult.data.sessionToken
if (token.isNullOrBlank()) {
(loginResult as? ApiResult.Error)
?: ApiResult.Error("Sign-in did not return a session")
} else {
resolveSession(token, regResult.data.session)
}
}
is ApiResult.Error -> regResult
else -> ApiResult.Error("Sign-in failed")
}
}
/**
* Apple Sign In via Kratos OIDC. The Apple `id_token` is obtained natively
* by the platform; [AppleSignInRequest.userId]/email/name seed the
* identity on first sign-in.
*/
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
val traits = request.email?.takeIf { it.isNotBlank() }?.let {
KratosTraits(
email = it,
name = KratosName(
first = request.firstName ?: "",
last = request.lastName ?: "",
),
)
}
return when (val r = oidcSignIn("apple", request.idToken, traits)) {
is ApiResult.Success -> ApiResult.Success(
AppleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false),
)
is ApiResult.Error -> r
else -> ApiResult.Error("Apple Sign In failed")
}
}
/**
* Google Sign In via Kratos OIDC. The Google `id_token` is obtained
* natively by the platform Google Sign-In SDK.
*/
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
return when (val r = oidcSignIn("google", request.idToken, traits = null)) {
is ApiResult.Success -> ApiResult.Success(
GoogleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false),
)
is ApiResult.Error -> r
else -> ApiResult.Error("Google Sign In failed")
}
}
// ==================== Logout ====================
/**
* Terminate the Kratos session.
*
* The native logout flow is a single call:
* `DELETE .../self-service/logout/api` with `{ session_token }`.
* A failure here is non-fatal — the caller drops the token locally
* regardless (see [APILayer.logout]).
*/
suspend fun logout(token: String): ApiResult<Unit> {
return try {
val response = client.post("$baseUrl/auth/logout/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Logout failed", response.status.value)
client.delete("$kratosBaseUrl/self-service/logout/api") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(LogoutBody.serializer(), LogoutBody(token)))
}
// Treat any outcome as success — the token is being discarded
// locally anyway, and a stale-token DELETE is harmless.
ApiResult.Success(Unit)
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
ApiResult.Success(Unit)
}
}
// ==================== Recovery (forgot password) ====================
/**
* Start a Kratos recovery flow and submit the user's email so Kratos
* mails them a recovery code.
*
* Mirrors the legacy `forgotPassword` signature. The returned message is
* Kratos' confirmation text.
*/
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> {
val flow = when (val f = initFlow("recovery")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start password recovery")
}
val body = json.encodeToString(
KratosRecoveryBody.serializer(),
KratosRecoveryBody(email = request.email.trim()),
)
// Kratos returns the re-rendered flow (200) carrying an info message
// that the code was sent. A 4xx means the email was malformed.
val result = submitFlow(flow.ui.action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
// Remember this flow's action — verifyResetCode submits the
// emailed code back to the SAME flow Kratos bound it to.
pendingRecoveryAction = flow.ui.action
val info = result.data?.ui?.messages?.firstOrNull()?.text
ApiResult.Success(
ForgotPasswordResponse(
message = info ?: "If that email exists, a recovery code has been sent.",
),
)
}
is ApiResult.Error -> result
else -> ApiResult.Error("Could not send recovery code")
}
}
/**
* Submit the recovery code the user received by email.
*
* The code is submitted back to the SAME recovery flow [forgotPassword]
* started ([pendingRecoveryAction]) — Kratos binds the emailed code to that
* flow. A valid code drives the flow to `passed_challenge`, and Kratos then
* returns, via `continue_with`, the privileged session token plus the
* settings flow to finish the password change in. Both are packed into the
* opaque `resetToken` that [resetPassword] consumes.
*/
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
val action = pendingRecoveryAction
?: return ApiResult.Error("Your recovery session expired. Request a new code.")
val body = json.encodeToString(
KratosRecoveryBody.serializer(),
KratosRecoveryBody(code = request.code.trim()),
)
val result = submitFlow(action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
val flow = result.data
val settingsFlowId = flow?.continueWith
?.firstOrNull { it.action == "show_settings_ui" }?.flow?.id
val sessionToken = flow?.continueWith
?.firstOrNull { it.action == "set_ory_session_token" }?.orySessionToken
if (settingsFlowId != null && sessionToken != null) {
pendingRecoveryAction = null
ApiResult.Success(
VerifyResetCodeResponse(
message = "Code verified.",
// Opaque to the UI: carries the settings flow id and
// the privileged session token resetPassword needs,
// packed as "<settingsFlowId>|<sessionToken>".
resetToken = "$settingsFlowId|$sessionToken",
),
)
} else {
// No continue_with → the code was wrong or the flow expired;
// surface Kratos' own validation message when present.
val msg = flow?.ui?.messages?.firstOrNull { it.type == "error" }?.text
?: flow?.ui?.nodes?.flatMap { it.messages }
?.firstOrNull { it.type == "error" }?.text
ApiResult.Error(msg ?: "Invalid or expired code")
}
}
is ApiResult.Error -> result
else -> ApiResult.Error("Invalid or expired code")
}
}
/**
* Complete a password reset.
*
* [verifyResetCode] packed the settings flow id and the privileged session
* token Kratos issued into [ResetPasswordRequest.resetToken] as
* `"<settingsFlowId>|<sessionToken>"`. This submits the new password to
* that settings flow, authenticating with the privileged session token.
*/
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
val parts = request.resetToken.split("|", limit = 2)
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
return ApiResult.Error("This password reset session has expired. Request a new code.")
}
val settingsFlowId = parts[0]
val sessionToken = parts[1]
return try {
val response = client.post("$kratosBaseUrl/self-service/settings") {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
// The privileged session Kratos issued on code verification
// authorizes the settings flow that changes the password.
header("X-Session-Token", sessionToken)
parameter("flow", settingsFlowId)
setBody(
json.encodeToString(
KratosSettingsPasswordBody.serializer(),
KratosSettingsPasswordBody(password = request.newPassword),
),
)
}
val text = response.bodyAsText()
if (response.status.isSuccess()) {
ApiResult.Success(ResetPasswordResponse(message = "Password updated. You can now sign in."))
} else {
ApiResult.Error(extractKratosError(text, response.status.value), response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Could not reset password")
}
}
// ==================== Email verification ====================
/**
* Submit an email-verification code to Kratos' verification flow.
*
* Note: the [token] parameter (a session token) is unused for the Kratos
* verification flow — verification is anonymous and keyed by the code —
* but the parameter is kept so [APILayer]/`AuthViewModel` need no change.
*/
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
val flow = when (val f = initFlow("verification")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start verification")
}
val body = json.encodeToString(
KratosVerificationBody.serializer(),
KratosVerificationBody(code = request.code.trim()),
)
val result = submitFlow(flow.ui.action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
val verified = result.data?.state == "passed_challenge"
ApiResult.Success(
VerifyEmailResponse(
message = if (verified) "Email verified." else "Verification submitted.",
verified = verified,
),
)
}
is ApiResult.Error -> result
else -> ApiResult.Error("Verification failed")
}
}
// ==================== honeyDue API (still session-token-gated) ====================
/**
* Fetch the current honeyDue user from the honeyDue Go API.
*
* Identity lives in Kratos, but the honeyDue API still owns the
* application-level user record (numeric id, profile, verified flag). The
* Kratos session token is sent on the `X-Session-Token` header.
*/
suspend fun getCurrentUser(token: String): ApiResult<User> {
return try {
val response = client.get("$baseUrl/auth/me/") {
header("Authorization", "Token $token")
val response = client.get("$apiBaseUrl/auth/me/") {
header(HEADER_SESSION_TOKEN, token)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
@@ -79,214 +541,152 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
return try {
val response = client.post("$baseUrl/auth/verify-email/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Verification failed")
}
ApiResult.Error(errorBody["error"] ?: "Verification failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Update the honeyDue user profile. Profile data lives on the honeyDue
* API, not Kratos, so this still targets the Go API.
*/
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
return try {
val response = client.put("$baseUrl/auth/profile/") {
header("Authorization", "Token $token")
val response = client.put("$apiBaseUrl/auth/profile/") {
header(HEADER_SESSION_TOKEN, token)
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Profile update failed")
}
ApiResult.Error(errorBody["error"] ?: "Profile update failed", response.status.value)
ApiResult.Error(ErrorParser.parseError(response), response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Password Reset Methods
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> {
return try {
val response = client.post("$baseUrl/auth/forgot-password/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Failed to send reset code")
}
ApiResult.Error(errorBody["error"] ?: "Failed to send reset code", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
return try {
val response = client.post("$baseUrl/auth/verify-reset-code/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Invalid code")
}
ApiResult.Error(errorBody["error"] ?: "Invalid code", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
return try {
val response = client.post("$baseUrl/auth/reset-password/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
// Try to parse Django validation errors (Map<String, List<String>>)
val errorMessage = try {
val validationErrors = response.body<Map<String, List<String>>>()
// Flatten all error messages into a single string
validationErrors.flatMap { (field, errors) ->
errors.map { error ->
if (field == "non_field_errors") error else "$field: $error"
}
}.joinToString(". ")
} catch (e: Exception) {
// Try simple error format {error: "message"}
try {
val simpleError = response.body<Map<String, String>>()
simpleError["error"] ?: "Failed to reset password"
} catch (e2: Exception) {
"Failed to reset password"
}
}
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Apple Sign In
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
return try {
val response = client.post("$baseUrl/auth/apple-sign-in/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Apple Sign In failed")
}
ApiResult.Error(errorBody["error"] ?: "Apple Sign In failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Delete Account
/**
* Delete the honeyDue account. The honeyDue API is responsible for
* tearing down its own user record and asking Kratos to delete the
* backing identity.
*/
suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/auth/account/") {
header("Authorization", "Token $token")
val response = client.delete("$apiBaseUrl/auth/account/") {
header(HEADER_SESSION_TOKEN, token)
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
ApiResult.Error(ErrorParser.parseError(response), response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Token Refresh
/**
* Legacy token-refresh shim.
*
* Kratos native session tokens are long-lived and there is no native
* refresh endpoint — when a session expires the user must re-authenticate.
* This method is kept so [ApiClient]'s 401 plumbing and the Coil image
* interceptor still compile; it simply re-validates the current session
* via Kratos `/sessions/whoami` and echoes the same token back if still
* valid, or fails otherwise.
*/
suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> {
return try {
val response = client.post("$baseUrl/auth/refresh/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
val response = client.get("$kratosBaseUrl/sessions/whoami") {
header(HEADER_SESSION_TOKEN, token)
accept(ContentType.Application.Json)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
ApiResult.Success(TokenRefreshResponse(token = token))
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
ApiResult.Error("Session expired — please sign in again", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
ApiResult.Error(e.message ?: "Could not validate session")
}
}
// Google Sign In
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
return try {
val response = client.post("$baseUrl/auth/google-sign-in/") {
contentType(ContentType.Application.Json)
setBody(request)
}
// ==================== Session → User resolution ====================
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Google Sign In failed")
/**
* Given a freshly issued Kratos `session_token`, fetch the honeyDue
* application user that backs the Kratos identity and assemble an
* [AuthResponse].
*
* The honeyDue API maps Kratos identities to its own numeric user records;
* `/auth/me` is the source of truth for `User.id`, profile and the
* `verified` flag. If `/auth/me` is unreachable we fall back to a
* best-effort [User] synthesised from the Kratos identity traits so the
* app can still proceed.
*/
private suspend fun resolveSession(
sessionToken: String,
session: KratosSession?,
): ApiResult<AuthResponse> {
return when (val me = getCurrentUser(sessionToken)) {
is ApiResult.Success -> ApiResult.Success(AuthResponse(token = sessionToken, user = me.data))
is ApiResult.Error -> {
val fallback = session?.identity?.let { userFromKratosIdentity(it) }
if (fallback != null) {
ApiResult.Success(AuthResponse(token = sessionToken, user = fallback))
} else {
me
}
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
else -> ApiResult.Error("Could not load profile after sign-in")
}
}
/**
* Best-effort [User] built from a Kratos identity when the honeyDue
* `/auth/me` lookup is unavailable. `id` is `0` (Kratos ids are UUIDs,
* not the honeyDue numeric id) — callers should re-fetch via
* [getCurrentUser] as soon as the API is reachable.
*/
private fun userFromKratosIdentity(identity: KratosIdentity): User {
val traits = identity.traits
return User(
id = 0,
username = traits?.email ?: "",
email = traits?.email ?: "",
firstName = traits?.name?.first ?: "",
lastName = traits?.name?.last ?: "",
isActive = identity.state == null || identity.state == "active",
dateJoined = "",
authProvider = "kratos",
profile = null,
)
}
companion object {
/**
* The header the honeyDue API now expects on authenticated requests —
* carries the Kratos session token. Replaces `Authorization: Token …`.
*/
const val HEADER_SESSION_TOKEN = "X-Session-Token"
}
}
/** Body for Kratos' native `DELETE /self-service/logout/api`. */
@kotlinx.serialization.Serializable
private data class LogoutBody(
@kotlinx.serialization.SerialName("session_token") val sessionToken: String,
)
/** Generic Kratos error envelope: `{ "error": { "message", "reason", ... } }`. */
@kotlinx.serialization.Serializable
private data class KratosErrorEnvelope(
val error: KratosErrorDetail? = null,
)
@kotlinx.serialization.Serializable
private data class KratosErrorDetail(
val message: String? = null,
val reason: String? = null,
val status: String? = null,
val code: Int? = null,
)
@@ -7,14 +7,18 @@ import coil3.request.ErrorResult
import coil3.request.ImageResult
/**
* Coil3 [Interceptor] that attaches an `Authorization` header to every
* outgoing image request and, on an HTTP 401 response, refreshes the token
* and retries exactly once.
* Coil3 [Interceptor] that attaches the honeyDue session-token header to every
* outgoing image request and, on an HTTP 401 response, re-validates the
* session and retries exactly once.
*
* honeyDue's identity is owned by Ory Kratos. Authenticated honeyDue API
* requests — including authenticated media — carry the Kratos session token
* on the **`X-Session-Token`** header (the old `Authorization: Token …` scheme
* is gone). This interceptor centralises that concern so individual
* composables don't thread the token through themselves.
*
* Mirrors the behavior of the iOS `AuthenticatedImage` in
* `iosApp/iosApp/Components/AuthenticatedImage.swift`, centralising the
* concern so individual composables don't need to thread the token through
* themselves.
* `iosApp/iosApp/Components/AuthenticatedImage.swift`.
*
* Usage — install on the singleton [coil3.ImageLoader]:
* ```kotlin
@@ -23,24 +27,28 @@ import coil3.request.ImageResult
* add(CoilAuthInterceptor(
* tokenProvider = { TokenStorage.getToken() },
* refreshToken = { (APILayer.refreshToken() as? ApiResult.Success)?.data },
* authScheme = "Token",
* ))
* add(KtorNetworkFetcherFactory())
* }
* .build()
* ```
*
* @param tokenProvider Suspending supplier of the current auth token. Returning
* `null` means "no token available" — the request proceeds unauthenticated.
* @param refreshToken Suspending supplier that refreshes the backing session and
* returns a fresh token, or `null` if refresh failed.
* @param authScheme The auth scheme to prefix the token with (default `Token`
* to match the existing Go backend — use `Bearer` for JWT deployments).
* @param tokenProvider Suspending supplier of the current session token.
* Returning `null` means "no token available" — the request proceeds
* unauthenticated so anonymous endpoints still work.
* @param refreshToken Suspending supplier that re-validates the session and
* returns a still-valid token, or `null` if the session is gone. With
* Kratos, session tokens are not rotated — this typically echoes the same
* token back when the session is still active.
* @param headerName The HTTP header carrying the token. Defaults to
* [SESSION_TOKEN_HEADER] (`X-Session-Token`). The token is sent as the bare
* header value — there is no `<scheme> ` prefix under the Kratos
* session-token scheme.
*/
class CoilAuthInterceptor(
private val tokenProvider: suspend () -> String?,
private val refreshToken: suspend () -> String?,
private val authScheme: String = "Token",
private val headerName: String = SESSION_TOKEN_HEADER,
) : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
@@ -55,26 +63,26 @@ class CoilAuthInterceptor(
val authed = chain.request.newBuilder()
.httpHeaders(
chain.request.httpHeaders.newBuilder()
.set(HEADER_AUTHORIZATION, "$authScheme $token")
.set(headerName, token)
.build()
)
.build()
val result = chain.withRequest(authed).proceed()
// If the server rejected the token, try refreshing once.
// If the server rejected the token, re-validate the session once.
if (result.isUnauthorized()) {
val newToken = refreshToken() ?: return result
val retried = authed.newBuilder()
.httpHeaders(
authed.httpHeaders.newBuilder()
.set(HEADER_AUTHORIZATION, "$authScheme $newToken")
.set(headerName, newToken)
.build()
)
.build()
// Only retry *once* — whatever comes back from this call is final,
// even if it is itself a 401. This guards against an infinite loop
// when refresh succeeds but the backing account is still revoked.
// when the session is still revoked.
return chain.withRequest(retried).proceed()
}
@@ -88,7 +96,6 @@ class CoilAuthInterceptor(
}
companion object {
private const val HEADER_AUTHORIZATION = "Authorization"
private const val HTTP_UNAUTHORIZED = 401
}
}
@@ -18,7 +18,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<List<ContractorSummary>> {
return try {
val response = client.get("$baseUrl/contractors/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
specialty?.let { parameter("specialty", it) }
isFavorite?.let { parameter("is_favorite", it) }
isActive?.let { parameter("is_active", it) }
@@ -38,7 +38,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractor(token: String, id: Int): ApiResult<Contractor> {
return try {
val response = client.get("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -54,7 +54,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult<Contractor> {
return try {
val response = client.post("$baseUrl/contractors/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -77,7 +77,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult<Contractor> {
return try {
val response = client.patch("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -100,7 +100,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteContractor(token: String, id: Int): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -116,7 +116,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> {
return try {
val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -132,7 +132,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractorTasks(token: String, id: Int): ApiResult<List<TaskResponse>> {
return try {
val response = client.get("$baseUrl/contractors/$id/tasks/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -148,7 +148,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult<List<ContractorSummary>> {
return try {
val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -26,7 +26,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<List<Document>> {
return try {
val response = client.get("$baseUrl/documents/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
residenceId?.let { parameter("residence", it) }
documentType?.let { parameter("document_type", it) }
isActive?.let { parameter("is_active", it) }
@@ -47,7 +47,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getDocument(token: String, id: Int): ApiResult<Document> {
return try {
val response = client.get("$baseUrl/documents/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -127,7 +127,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
) {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
} else {
// If no file, use JSON
@@ -143,7 +143,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
residenceId = residenceId
)
client.post("$baseUrl/documents/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -201,7 +201,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
expiryDate = endDate // Map endDate to expiryDate
)
val response = client.patch("$baseUrl/documents/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -224,7 +224,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteDocument(token: String, id: Int): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/documents/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -240,7 +240,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun downloadDocument(token: String, url: String): ApiResult<ByteArray> {
return try {
val response = client.get(url) {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -256,7 +256,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun activateDocument(token: String, id: Int): ApiResult<Document> {
return try {
val response = client.post("$baseUrl/documents/$id/activate/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -274,7 +274,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deactivateDocument(token: String, id: Int): ApiResult<Document> {
return try {
val response = client.post("$baseUrl/documents/$id/deactivate/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -308,7 +308,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
caption?.let { append("caption", it) }
}
) {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -329,7 +329,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult<Document> {
return try {
val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -36,7 +36,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
return try {
val response = client.get("$baseUrl/residences/types/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -52,7 +52,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> {
return try {
val response = client.get("$baseUrl/tasks/frequencies/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -68,7 +68,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> {
return try {
val response = client.get("$baseUrl/tasks/priorities/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -84,7 +84,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> {
return try {
val response = client.get("$baseUrl/tasks/categories/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -100,7 +100,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractorSpecialties(token: String): ApiResult<List<ContractorSpecialty>> {
return try {
val response = client.get("$baseUrl/contractors/specialties/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -117,7 +117,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
return try {
val response = client.get("$baseUrl/static_data/") {
// Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") }
token?.let { header(SESSION_TOKEN_HEADER, it) }
}
if (response.status.isSuccess()) {
@@ -145,7 +145,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
return try {
val response: HttpResponse = client.get("$baseUrl/static_data/") {
// Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") }
token?.let { header(SESSION_TOKEN_HEADER, it) }
// Send If-None-Match header for conditional request
currentETag?.let { header("If-None-Match", it) }
}
@@ -18,7 +18,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<DeviceRegistrationResponse> {
return try {
val response = client.post("$baseUrl/notifications/devices/register/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -44,7 +44,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/notifications/devices/$deviceId/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -63,7 +63,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
return try {
val response = client.get("$baseUrl/notifications/preferences/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -85,7 +85,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<NotificationPreference> {
return try {
val response = client.put("$baseUrl/notifications/preferences/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -103,7 +103,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> {
return try {
val response = client.get("$baseUrl/notifications/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -123,7 +123,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<MessageResponse> {
return try {
val response = client.post("$baseUrl/notifications/$notificationId/read/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -139,7 +139,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun markAllNotificationsAsRead(token: String): ApiResult<MessageResponse> {
return try {
val response = client.post("$baseUrl/notifications/mark-all-read/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -155,7 +155,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> {
return try {
val response = client.get("$baseUrl/notifications/unread-count/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -12,7 +12,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidences(token: String): ApiResult<List<ResidenceResponse>> {
return try {
val response = client.get("$baseUrl/residences/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -28,7 +28,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidence(token: String, id: Int): ApiResult<ResidenceResponse> {
return try {
val response = client.get("$baseUrl/residences/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -44,7 +44,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createResidence(token: String, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
return try {
val response = client.post("$baseUrl/residences/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -62,7 +62,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateResidence(token: String, id: Int, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
return try {
val response = client.put("$baseUrl/residences/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -80,7 +80,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteResidence(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
return try {
val response = client.delete("$baseUrl/residences/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -96,7 +96,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getSummary(token: String): ApiResult<TotalSummary> {
return try {
val response = client.get("$baseUrl/residences/summary/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -112,7 +112,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getMyResidences(token: String): ApiResult<MyResidencesResponse> {
return try {
val response = client.get("$baseUrl/residences/my-residences/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -129,7 +129,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult<SharedResidence> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -146,7 +146,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -163,7 +163,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getShareCode(token: String, residenceId: Int): ApiResult<ShareCodeResponse> {
return try {
val response = client.get("$baseUrl/residences/$residenceId/share-code/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -186,7 +186,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun acceptResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/invite/accept/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -207,7 +207,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun declineResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/invite/decline/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -224,7 +224,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun joinWithCode(token: String, code: String): ApiResult<JoinResidenceResponse> {
return try {
val response = client.post("$baseUrl/residences/join-with-code/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(JoinResidenceRequest(code))
}
@@ -244,7 +244,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult<ResidenceUsersResponse> {
return try {
val response = client.get("$baseUrl/residences/$residenceId/users/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -261,7 +261,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> {
return try {
val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -279,7 +279,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult<GenerateReportResponse> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
if (email != null) {
setBody(mapOf("email" to email))
@@ -12,7 +12,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> {
return try {
val response = client.get("$baseUrl/subscription/status/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -29,7 +29,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
return try {
val response = client.get("$baseUrl/subscription/upgrade-triggers/") {
// Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") }
token?.let { header(SESSION_TOKEN_HEADER, it) }
}
if (response.status.isSuccess()) {
@@ -45,7 +45,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> {
return try {
val response = client.get("$baseUrl/subscription/features/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -61,7 +61,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getActivePromotions(token: String): ApiResult<List<Promotion>> {
return try {
val response = client.get("$baseUrl/subscription/promotions/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -85,7 +85,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<VerificationResponse> {
return try {
val response = client.post("$baseUrl/subscription/purchase/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(mapOf(
"platform" to "ios",
@@ -115,7 +115,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<VerificationResponse> {
return try {
val response = client.post("$baseUrl/subscription/purchase/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(mapOf(
"platform" to "android",
@@ -154,7 +154,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
}
val response = client.post("$baseUrl/subscription/restore/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(body)
}
@@ -15,7 +15,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<TaskColumnsResponse> {
return try {
val response = client.get("$baseUrl/tasks/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
days?.let { parameter("days", it) }
}
@@ -33,7 +33,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTask(token: String, id: Int): ApiResult<TaskResponse> {
return try {
val response = client.get("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -50,7 +50,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try {
val response = client.post("$baseUrl/tasks/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -75,7 +75,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
return try {
val response = client.post("$baseUrl/tasks/bulk/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -94,7 +94,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try {
val response = client.put("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -113,7 +113,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteTask(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
return try {
val response = client.delete("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -134,7 +134,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<TaskColumnsResponse> {
return try {
val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
days?.let { parameter("days", it) }
}
@@ -157,7 +157,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try {
val response = client.patch("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -206,7 +206,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
private suspend fun postTaskAction(token: String, id: Int, action: String): ApiResult<WithSummaryResponse<TaskResponse>> {
return try {
val response = client.post("$baseUrl/tasks/$id/$action/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
}
when (response.status) {
@@ -233,7 +233,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult<List<TaskCompletionResponse>> {
return try {
val response = client.get("$baseUrl/tasks/$taskId/completions/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -13,7 +13,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getCompletions(token: String): ApiResult<List<TaskCompletionResponse>> {
return try {
val response = client.get("$baseUrl/task-completions/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -29,7 +29,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getCompletion(token: String, id: Int): ApiResult<TaskCompletionResponse> {
return try {
val response = client.get("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -45,7 +45,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createCompletion(token: String, request: TaskCompletionCreateRequest): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
return try {
val response = client.post("$baseUrl/task-completions/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -63,7 +63,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateCompletion(token: String, id: Int, request: TaskCompletionCreateRequest): ApiResult<TaskCompletionResponse> {
return try {
val response = client.put("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -81,7 +81,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteCompletion(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
return try {
val response = client.delete("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -92,7 +92,7 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> {
return try {
val response = client.get("$baseUrl/tasks/suggestions/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
parameter("residence_id", residenceId)
}
@@ -36,7 +36,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<PresignUploadResponse> {
return try {
val response = client.post("$baseUrl/uploads/presign/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json)
setBody(PresignUploadRequest(category, contentType, contentLength))
}
@@ -17,6 +17,7 @@ import coil3.request.ImageRequest
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import com.tt.honeyDue.network.ApiClient
import com.tt.honeyDue.network.SESSION_TOKEN_HEADER
import com.tt.honeyDue.storage.TokenStorage
/**
@@ -57,9 +58,11 @@ fun AuthenticatedImage(
.data(fullUrl)
.apply {
if (token != null) {
// honeyDue media is gated on the Kratos session token,
// carried on the X-Session-Token header.
httpHeaders(
NetworkHeaders.Builder()
.set("Authorization", "Token $token")
.set(SESSION_TOKEN_HEADER, token)
.build()
)
}
@@ -413,26 +413,30 @@ class HttpClientPluginsTest {
client.close()
}
// ==================== Token Refresh / 401 Handling Tests ====================
// ==================== Kratos Session / 401 Handling Tests ====================
@Test
fun testTokenExpiredExceptionIsRefreshed() {
fun testTokenExpiredExceptionStillValid() {
// refreshed = true means the Kratos session was re-validated and is
// still usable — the caller may retry the request.
val exception = TokenExpiredException(refreshed = true)
assertTrue(exception.refreshed)
assertTrue(exception.message!!.contains("refreshed"))
assertTrue(exception.message!!.contains("still valid"))
}
@Test
fun testTokenExpiredExceptionNotRefreshed() {
fun testTokenExpiredExceptionSessionGone() {
// refreshed = false means the Kratos session is gone — the user must
// sign in again.
val exception = TokenExpiredException(refreshed = false)
assertTrue(!exception.refreshed)
assertTrue(exception.message!!.contains("re-authenticate"))
}
@Test
fun test401WithNonExpiredTokenDoesNotTriggerRefresh() = runTest {
// A 401 that does NOT contain "expired" or "token_expired" should NOT
// throw TokenExpiredException — it should just return the 401 response.
fun test401WithoutValidatorReturnsResponse() = runTest {
// Without the Kratos session validator installed, a 401 simply
// surfaces as the 401 response — it does not throw.
var requestCount = 0
val client = HttpClient(MockEngine) {
engine {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppLogo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -263,13 +263,40 @@ class PreviewViewController: UIViewController, QLPreviewingController {
}
private func updateUIForResidence(with residence: ResidencePreviewData) {
// Update icon
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
// Brand icon. Prefer the bundled honeyDue logo so the preview
// reads as a HoneyDue invite at a glance; fall back to a tinted
// SF Symbol for accessibility / asset-load failures.
if let logo = UIImage(named: "AppLogo") {
iconImageView.image = logo.withRenderingMode(.alwaysOriginal)
iconImageView.contentMode = .scaleAspectFit
iconImageView.layer.cornerRadius = 16
iconImageView.layer.masksToBounds = true
} else {
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
}
titleLabel.text = residence.residenceName
subtitleLabel.text = "honeyDue Residence Invite"
instructionLabel.text = "Tap the share button below, then select \"honeyDue\" to join this residence."
// Branch the copy on whether the share link has already lapsed.
// Active invites get the standard "How to join" numbered steps;
// expired invites get a clear dead-end message asking the
// recipient to ping the sender for a new link no point
// showing share-sheet directions for a link the server will
// reject.
let expiredAgo = Self.expiredRelativePhraseOrNil(residence.expiresAt)
if let expiredAgo {
instructionLabel.attributedText = Self.makeExpiredInstructions(sharedBy: residence.sharedBy)
// The down-chevron points at the Share button as a visual
// cue to tap it; in the expired state there's nothing
// useful to share (the server will reject the bundled
// code) so the arrow becomes misleading. Hide it.
arrowImageView.isHidden = true
} else {
instructionLabel.attributedText = Self.makeResidenceInstructions()
arrowImageView.isHidden = false
}
// Clear existing details
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
@@ -280,9 +307,183 @@ class PreviewViewController: UIViewController, QLPreviewingController {
}
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
if let expiredAgo {
// "Expired 1 hour ago" capitalised past-tense; no
// "Expires " prefix because the share link no longer
// expires, it has already done so (gitea#7 review).
addDetailRow(icon: "clock", text: "Expired \(expiredAgo)")
} else {
let formatted = Self.formatActiveExpiry(expiresAt)
addDetailRow(icon: "clock", text: "Expires \(formatted)")
}
}
}
// MARK: - Formatting helpers
/// Render an *active* (not-yet-expired) share-link expiry as a
/// human-readable phrase. Within a day uses
/// `RelativeDateTimeFormatter` ("in 23 hours" / "in 12 minutes");
/// further out switches to absolute date + time so users planning
/// ahead see exactly when the invite lapses. Falls back to the raw
/// ISO string if parsing fails so the row never goes blank.
///
/// Callers must check [expiredRelativePhraseOrNil] first this
/// function assumes a future expiry and produces wording that only
/// makes sense in that case.
static func formatActiveExpiry(_ isoString: String) -> String {
guard let date = parseIsoDate(isoString) else { return isoString }
let now = Date()
let elapsed = date.timeIntervalSince(now)
if elapsed < 24 * 60 * 60 {
return relativeFormatter.localizedString(for: date, relativeTo: now)
}
return "on \(absoluteFormatter.string(from: date))"
}
/// If the share link has already lapsed, return the relative
/// "X ago" phrase. `nil` means active (or unparseable) callers
/// should fall back to [formatActiveExpiry] for those cases. The
/// split lets `updateUIForResidence` branch the entire UI block
/// (row text + instruction card) on the same signal (gitea#7
/// review: an expired link should send the recipient back to the
/// sender for a new invite, not show share-sheet directions for a
/// link the server will reject).
static func expiredRelativePhraseOrNil(_ isoString: String?) -> String? {
guard let isoString, let date = parseIsoDate(isoString) else { return nil }
let now = Date()
if date.timeIntervalSince(now) > 0 { return nil }
return relativeFormatter.localizedString(for: date, relativeTo: now)
}
private static func parseIsoDate(_ raw: String) -> Date? {
if let d = isoFormatterWithFraction.date(from: raw) { return d }
if let d = isoFormatterNoFraction.date(from: raw) { return d }
return nil
}
private static let isoFormatterWithFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .full
return f
}()
private static let absoluteFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
/// Builds the "How to join" instruction copy as an attributed
/// string with the iOS share-icon glyph (square + up-arrow) inlined
/// next to "Tap [icon]". The glyph is the universal share symbol
/// across iOS, so the recipient finds the right control whether
/// it's at the top, bottom, or behind a More menu instead of us
/// claiming a fixed position the chrome can move (gitea#7 review
/// feedback).
private static func makeResidenceInstructions() -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
func appendText(_ s: String) {
result.append(NSAttributedString(
string: s,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
}
appendText("How to join:\n1. Tap ")
let shareImage = UIImage(
systemName: "square.and.arrow.up",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
if let shareImage {
let attachment = NSTextAttachment()
attachment.image = shareImage
// Align the glyph baseline with the surrounding text by
// nudging the bounds down a few points; the SF Symbol's
// natural bounds sit a hair above the cap height.
attachment.bounds = CGRect(
x: 0,
y: -3,
width: shareImage.size.width,
height: shareImage.size.height
)
result.append(NSAttributedString(attachment: attachment))
}
appendText("\n2. Choose \"honeyDue\" from the share sheet")
appendText("\n3. Sign in if prompted — the app finishes the rest")
return result
}
/// Expired-state copy for the instruction card. Tells the recipient
/// the share link is no longer valid and to ping the sender (by
/// email if we know it) for a new one replaces the active "How to
/// join" steps since the server will reject the bundled code
/// anyway.
private static func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
// Slightly warmer tint than the active instruction copy the
// app's `appError` red would feel alarmist for "just ask again",
// and the secondary-label gray reads as muted/disabled which is
// accurate to the link's actual state.
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor.secondaryLabel
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
let titleTint = UIColor.label
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
result.append(NSAttributedString(
string: "This invite has expired.\n",
attributes: [
.font: titleFont,
.foregroundColor: titleTint,
.paragraphStyle: paragraph,
]
))
let body = if let s = sharedBy, !s.isEmpty {
"Ask \(s) to send a new link."
} else {
"Ask the sender to share a new link."
}
result.append(NSAttributedString(
string: body,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
return result
}
}
// MARK: - Type Discriminator
@@ -0,0 +1,437 @@
//
// Issue7PreviewScreenshotTest.swift
// HoneyDueTests
//
// Records a single PNG screenshot of the post-fix QL-preview layout
// used by `HoneyDueQLPreview/PreviewViewController.swift` so it can be
// attached to gitea issue #7 for the reviewer to see the new look
// without having to AirDrop a `.honeydue` file to a device.
//
// How it works:
// * Faithfully recreates the UIKit layout `PreviewViewController.updateUIForResidence`
// builds in production same colors, same fonts, same constraints,
// same image asset (copied into `HoneyDueTests/Resources/AppLogo.png`
// so it is reachable from this target's bundle).
// * Runs the same `formatExpiresAt` style (ISO parse relative phrase
// when within a day, absolute medium-date + short-time otherwise),
// using a fixed reference Date so the rendering is deterministic
// across runs / time zones.
// * `SnapshotTesting.assertSnapshot(of: viewController, as: .image)`
// writes the PNG to
// `iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/`.
//
// The first run (no committed golden) records the PNG and the test
// reports "failed - No reference was found on disk. Automatically
// recorded snapshot:" that's the file we attach to the issue.
//
// Note on faithfulness: this snapshot is a programmatic reproduction
// of `PreviewViewController.updateUIForResidence`, not the QL
// extension instance itself, because the QL extension's bundle is a
// separate Xcode target from `HoneyDueTests` and can't be `@testable
// import`ed without project-file surgery. The reproduction uses the
// same UIKit primitives, colors, fonts, and asset, so the rendered
// output matches what users see when iOS opens a `.honeydue` invite.
//
@preconcurrency import SnapshotTesting
import UIKit
import XCTest
@MainActor
final class Issue7PreviewScreenshotTest: XCTestCase {
/// Force record mode for this test only we want the PNG written
/// regardless of whether a golden exists.
override func invokeTest() {
withSnapshotTesting(record: .all) {
super.invokeTest()
}
}
func test_residence_invite_preview_after_issue7_fix() {
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .active
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_dark"
)
}
func test_residence_invite_preview_expired_state() {
// Same residence + sender, but expiry already 1 hour in the
// past. Verifies the expired branch: the instruction card
// swaps to "ask the sender for a new link" and the detail row
// reads "Expired 1 hour ago" instead of the future-tense
// "Expires in " phrasing.
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .expired(elapsedSecondsSinceExpiry: 60 * 60)
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_expired_dark"
)
}
}
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
private struct ResidencePreview {
let residenceName: String
let sharedBy: String?
let expiresAt: String?
/// Mirrors the data shown in the original gitea#7 screenshot the
/// post-fix version of the same payload.
static let fixtureForIssue7 = ResidencePreview(
residenceName: "The Tartt's",
sharedBy: "honey@hollie37.com",
expiresAt: "2026-05-12T17:11:02.067272789Z"
)
}
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
/// Renderer state for the screenshot fixture. Active = link still
/// valid; expired = link lapsed `elapsedSecondsSinceExpiry` seconds
/// ago. Both render with deterministic data so the recorded PNG is
/// stable across runs.
private enum PreviewRenderState {
case active
case expired(elapsedSecondsSinceExpiry: TimeInterval)
}
@MainActor
private final class MockPreviewViewController: UIViewController {
private let residence: ResidencePreview
private let state: PreviewRenderState
private let containerView = UIView()
private let iconImageView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let dividerView = UIView()
private let detailsStackView = UIStackView()
private let instructionCard = UIView()
private let instructionLabel = UILabel()
private let arrowImageView = UIImageView()
init(residence: ResidencePreview, state: PreviewRenderState) {
self.residence = residence
self.state = state
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("not used") }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
applyResidence()
}
private func setupUI() {
view.backgroundColor = .systemBackground
containerView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.contentMode = .scaleAspectFit
iconImageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
titleLabel.textColor = .label
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 2
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel.font = .systemFont(ofSize: 15, weight: .medium)
subtitleLabel.textColor = .secondaryLabel
subtitleLabel.textAlignment = .center
dividerView.translatesAutoresizingMaskIntoConstraints = false
dividerView.backgroundColor = .separator
detailsStackView.translatesAutoresizingMaskIntoConstraints = false
detailsStackView.axis = .vertical
detailsStackView.spacing = 12
detailsStackView.alignment = .leading
instructionCard.translatesAutoresizingMaskIntoConstraints = false
instructionCard.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1)
instructionCard.layer.cornerRadius = 12
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
instructionLabel.font = .systemFont(ofSize: 15, weight: .medium)
instructionLabel.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
instructionLabel.textAlignment = .left
instructionLabel.numberOfLines = 0
arrowImageView.translatesAutoresizingMaskIntoConstraints = false
arrowImageView.contentMode = .scaleAspectFit
arrowImageView.tintColor = .secondaryLabel
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
arrowImageView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig)
view.addSubview(containerView)
containerView.addSubview(iconImageView)
containerView.addSubview(titleLabel)
containerView.addSubview(subtitleLabel)
containerView.addSubview(dividerView)
containerView.addSubview(detailsStackView)
containerView.addSubview(instructionCard)
instructionCard.addSubview(instructionLabel)
containerView.addSubview(arrowImageView)
NSLayoutConstraint.activate([
containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor),
iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: 80),
iconImageView.heightAnchor.constraint(equalToConstant: 80),
titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16),
titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20),
dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
dividerView.heightAnchor.constraint(equalToConstant: 1),
detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20),
detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24),
instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16),
instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16),
instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16),
instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16),
arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16),
arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
}
private func applyResidence() {
// Mirror the post-fix branding choice: bundled honeyDue logo
// rendered in its actual colors. The image ships with the test
// target at `Resources/AppLogo.png`.
if let path = Bundle(for: Self.self).path(forResource: "AppLogo", ofType: "png"),
let logo = UIImage(contentsOfFile: path) {
iconImageView.image = logo
iconImageView.layer.cornerRadius = 16
iconImageView.layer.masksToBounds = true
} else {
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
}
titleLabel.text = residence.residenceName
subtitleLabel.text = "honeyDue Residence Invite"
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
}
switch state {
case .active:
instructionLabel.attributedText = makeResidenceInstructions()
arrowImageView.isHidden = false
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))")
}
case .expired(let elapsed):
instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy)
// Arrow points at the Share button no point telling the
// user to tap it for a dead link. Matches PreviewViewController.
arrowImageView.isHidden = true
addDetailRow(icon: "clock", text: "Expired \(relativePhrase(secondsAgo: elapsed))")
}
}
private func relativePhrase(secondsAgo: TimeInterval) -> String {
// Deterministic relative phrase we set "now" to be exactly
// `secondsAgo` after the (fake) expiry, so the formatter says
// "1 hour ago" instead of whatever the real clock would give.
let fakeNow = Date()
let pastExpiry = fakeNow.addingTimeInterval(-secondsAgo)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: pastExpiry, relativeTo: fakeNow)
}
/// Expired-state copy mirroring `PreviewViewController.makeExpiredInstructions`.
private func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
result.append(NSAttributedString(
string: "This invite has expired.\n",
attributes: [
.font: titleFont,
.foregroundColor: UIColor.label,
.paragraphStyle: paragraph,
]
))
let body = if let s = sharedBy, !s.isEmpty {
"Ask \(s) to send a new link."
} else {
"Ask the sender to share a new link."
}
result.append(NSAttributedString(
string: body,
attributes: [
.font: bodyFont,
.foregroundColor: UIColor.secondaryLabel,
.paragraphStyle: paragraph,
]
))
return result
}
private func addDetailRow(icon: String, text: String) {
let row = UIStackView()
row.axis = .horizontal
row.spacing = 12
row.alignment = .center
let iv = UIImageView()
iv.translatesAutoresizingMaskIntoConstraints = false
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
iv.image = UIImage(systemName: icon, withConfiguration: config)
iv.tintColor = .secondaryLabel
iv.widthAnchor.constraint(equalToConstant: 24).isActive = true
iv.heightAnchor.constraint(equalToConstant: 24).isActive = true
let label = UILabel()
label.font = .systemFont(ofSize: 15)
label.textColor = .label
label.text = text
label.numberOfLines = 1
row.addArrangedSubview(iv)
row.addArrangedSubview(label)
detailsStackView.addArrangedSubview(row)
}
/// Mirrors `PreviewViewController.makeResidenceInstructions()` see
/// the rationale comment there. Inlined here because the QL
/// extension target can't be `@testable import`ed without
/// project-file surgery.
private func makeResidenceInstructions() -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
func appendText(_ s: String) {
result.append(NSAttributedString(
string: s,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
}
appendText("How to join:\n1. Tap ")
let shareImage = UIImage(
systemName: "square.and.arrow.up",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
if let shareImage {
let attachment = NSTextAttachment()
attachment.image = shareImage
attachment.bounds = CGRect(
x: 0,
y: -3,
width: shareImage.size.width,
height: shareImage.size.height
)
result.append(NSAttributedString(attachment: attachment))
}
appendText("\n2. Choose \"honeyDue\" from the share sheet")
appendText("\n3. Sign in if prompted — the app finishes the rest")
return result
}
// Mirrors PreviewViewController.formatActiveExpiry with a fixed
// "now" so the rendering is identical regardless of when the test
// runs. The expired branch uses [relativePhrase(secondsAgo:)]
// instead see the active/expired switch in `applyResidence`.
private func formatActiveExpiry(_ raw: String) -> String {
let isoWithFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
let isoNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
guard let date = isoWithFraction.date(from: raw)
?? isoNoFraction.date(from: raw) else {
return raw
}
// Deterministic "now": 23 hours before the fixture's expiry, so
// the relative formatter always produces "in 23 hours".
let fakeNow = date.addingTimeInterval(-23 * 60 * 60)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: date, relativeTo: fakeNow)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -153,9 +153,11 @@ private class AuthenticatedImageLoader: ObservableObject {
return
}
// Create request with auth header
// Create request with the Kratos session-token header.
// Identity is owned by Ory Kratos; the honeyDue API authenticates
// requests via the session token on X-Session-Token.
var request = URLRequest(url: url)
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
request.setValue(token, forHTTPHeaderField: "X-Session-Token")
request.timeoutInterval = 15
request.cachePolicy = .returnCacheDataElseLoad
@@ -167,9 +167,10 @@ struct DocumentDetailView: View {
return
}
// Create authenticated request
// Create authenticated request the honeyDue API gates
// media on the Kratos session token (X-Session-Token).
var request = URLRequest(url: url)
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
request.setValue(token, forHTTPHeaderField: "X-Session-Token")
// Download the file
let (tempURL, response) = try await URLSession.shared.download(for: request)
@@ -170,7 +170,8 @@ final class PresignedUploader {
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization")
// honeyDue API auth: Kratos session token on X-Session-Token.
req.setValue(authToken, forHTTPHeaderField: "X-Session-Token")
req.httpBody = try JSONEncoder().encode(PresignBody(
category: category.rawValue,
content_type: contentType,
+6 -2
View File
@@ -120,7 +120,6 @@ struct CompleteTaskView: View {
.foregroundStyle(.secondary)
}
.padding(.leading, 12)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
} label: {
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
@@ -142,7 +141,6 @@ struct CompleteTaskView: View {
TextEditor(text: $notes)
.frame(minHeight: 100)
.scrollContentBackground(.hidden)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
}
} footer: {
@@ -289,6 +287,12 @@ struct CompleteTaskView: View {
.background(WarmGradientBackground())
.navigationTitle(L10n.Tasks.completeTask)
.navigationBarTitleDisplayMode(.inline)
// ONE keyboard "Done" toolbar at the form root per-field
// `.keyboardDismissToolbar()` modifiers each install a
// separate `ToolbarItemGroup(placement: .keyboard)`, and
// SwiftUI stacks them on the responder chain so any focused
// field renders multiple Done buttons side-by-side (issue #5).
.keyboardDismissToolbar()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.cancel) {