Update AuthGatingAPITests for the backend's new policy (all app-data routes
require a verified email):
- unverified user -> 403 on GET /residences/, /tasks/, /contractors/, /documents/
- unverified user can still reach the sign-up allow-list: GET /auth/me/, and
public lookups (GET /tasks/categories/)
- verified user -> 200 (positive control)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Issue 2 (coverage gap): add HoneyDueAPITests/AuthGatingAPITests — verifies the
backend's RequireVerified gate (unverified -> 403, verified -> 200) at the API
layer, since UI-test mode bypasses verification. NOTE: surfaced that the gate
is applied to only the share-code routes, not residence/task routes — unverified
users are NOT broadly blocked (flagged for product/backend).
- Issue 4: TaskCRUDUITests seedAccountPreconditions now guarantees a residence
(no silent early-return), so the cancelled-task precondition always populates;
XCTUnwrap replaces the misleading "not seeded" skip. The two uncancel tests now
skip with the ACCURATE reason: cancelled tasks are intentionally hidden from the
Tasks Kanban and the iOS Tasks view has no "show cancelled" surface (product gap).
- Issue 3: re-quarantine testF110 after a hardening attempt — the register->verify
transition is irreducibly flaky; coverage is redundant with OnboardingTaskCache
+ the F-series. Skip reason is now precise, with a TODO to stabilize the handoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Product bug: when a user joined a shared residence, the residence appeared but
its tasks (created by the owner) did not show in the Tasks tab until a manual
refresh. Root cause was client-side — APILayer.joinWithCode updated the
residence cache (addResidence) but never refreshed the tasks cache, and the
optimistic addResidence suppressed getMyResidences' count-based task
invalidation, so allTasks stayed a stale pre-join snapshot. The backend was
correct (task list query already joins residence_residence_users).
- APILayer.joinWithCode: call getTasks(forceRefresh = true) on success
(mirrors bulkCreateTasks) so the joined residence's tasks load immediately.
- ResidenceViewModel: corrected an inaccurate comment about join-time refresh.
- SharingUITests.test03: un-quarantined — now passes (verified against live stack).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-injection, 6 workers had occasional UI timeouts (the UI login was
CPU-heavy). With the login skipped via token injection, the machine has
headroom: a 6-worker run of the four heaviest suites passed 59/0/1 (was 1
failure pre-injection). 8 still thrashes. ~33% more parallelism, no coverage
cost.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Measured: ~half of every authenticated test was fixed setup, dominated by the
UI login (typing email+password, keyboard/SecureField dance, ~8-12s). The test
already creates the account via API and holds its real Kratos session token —
so instead of typing credentials, pass the token as a launch arg and boot the
app already authenticated.
- App (UITestRuntime + iOSApp): reads --ui-test-session-token; after the
--reset-state clear, calls DataManager.setAuthToken(token) and replicates the
post-login init the UI login path runs (getCurrentUser + initializeLookups +
getMyResidences + getTasks) so owner-gated/data-gated screens (residence
detail delete + manage-users, pickers, lists) work on boot. Guarded by
UITestRuntime.isEnabled — no effect on production.
- AuthenticatedUITestCase: in fresh-account mode, create the account + seed its
preconditions BEFORE launch, expose the token via additionalLaunchArguments,
and drop the UI login. Legacy (usesFreshAccount=false) suites still UI-login.
Measured per-test medians: Contractor 34s -> 25s; Task (uses lookups) ~34s ->
16s. TESTING.md updated. All affected suites pass; 0 leaked accounts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- DataLayerTests -> DataLayer/DataLayerUITests (cache/ETag/persistence domain)
- FeatureCoverageTests -> CrossCutting/FeatureCoverageUITests (cross-cutting:
profile/theme/notifications/completion/sharing UI)
- Delete the dead HoneyDueUITests/AccessibilityIdentifiers.swift duplicate
(the target compiles the app's Helpers/AccessibilityIdentifiers.swift; this
copy was excluded and stale). Tests/ folder removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the relaunch fix cleared 48/52 flaky failures, 4 genuine ones remained:
- DataLayerTests: logs out + re-logs in as the SAME user mid-test to check
cache/persistence — incompatible with per-test fresh accounts. Opt out with
usesFreshAccount=false (use the stable seeded admin it was designed for).
testDATA005 now passes.
- AuthRegistration.test11_appRelaunchWithUnverifiedUser: untestable in UI-test
mode (the app shortcuts isVerified = isAuthenticated so tests can reach the
app, which defeats unverified-email gating). Skipped — belongs at API/unit.
- Sharing.test03_sharedTasksVisibleInTasksTab: real app gap — a joined member
doesn't see the shared residence's tasks even after refresh. Skipped + noted.
- Onboarding.testF110: flaky end-to-end onboarding flow (fails at different
points per run); its residence-auto-create coverage is provided by
OnboardingTaskCacheUITests + the F-series. Quarantined with a re-enable TODO.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first full 8-worker run surfaced 52 failures, 28 of them "Failed to log out"
(UITestHelpers:86) — forcing a profile-navigation logout between every test (each
test = new account) is fragile, and 8 parallel simulator clones thrashed the
machine (the remaining failures were UI timeouts under that load).
- AuthenticatedUITestCase: relaunchBetweenTests = true. A fresh app launch with
--reset-state lands on the login screen, so each test logs in as its own account
with NO UI logout between tests. Removed the ensureLoggedOut call.
- run_ui_tests.sh: default workers 8 -> 4 (reliable on a Mac mini; each test now
relaunches + creates an account, so the bottleneck is CPU/simulator).
Verified: ContractorUITests (was ~15 logout failures) now passes at 4 workers,
0 leaked accounts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the two-target layout, per-test Kratos account isolation, the
seed-before-login precondition rules (requiresResidence /
seedAccountPreconditions), how to run the phased runner, and how to add a suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the pure-API integration tests (no UI) out of the UITest target into
a dedicated standalone unit-test target that runs in seconds without launching
the simulator app.
- HoneyDueAPITests target: standalone unit-test bundle (no TEST_HOST — touches
no app code), shares the API client/seeder/cleaner support files from the
UITest target via explicit references, with its own shared scheme.
- MultiUserSharingTests -> HoneyDueAPITests/SharingAPITests.swift (18 tests).
Runs in ~2.3s vs. ~40-140s per UI test.
- run_ui_tests.sh: new Phase 1b runs the API target (fast) between Seed and the
parallel UI phase; the helper now takes a scheme so each phase targets the
right one; summary reports the API result.
Both targets build green; SharingAPITests passes (18/18) against the live stack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the XCUITest suite off the legacy shared-account model (and the
prior Django-style auth assumptions) to a parallel-safe, domain-organized
architecture, validated end-to-end against the live Kratos stack.
Isolation (parallel-safe by construction):
- Core/Fixtures/TestAccount.swift: each test mints its own pre-verified
Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds
under its own token, and deletes the identity in teardown (cascading all
data + clearing Kratos). No shared testuser; parallel workers no longer race.
- AuthenticatedUITestCase rewritten to that model (member surface preserved);
adds requiresResidence / seedAccountPreconditions to seed UI-gated data
BEFORE login (a fresh account is empty at login).
Organization (255 tests preserved, none dropped):
- 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/
Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent
<Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild
naming chaos and the overlapping task/residence/auth suites.
Runner + test plans:
- run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The
parallel phase runs the whole target minus phase-managed suites via
-skip-testing, so new suites auto-include (no hand-maintained list to drift).
Drops the 2-worker cap and Suite6 isolation (isolation made them moot).
- HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan.
Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos):
real Mailpit verification codes replace the obsolete fixed "123456"; teardown
deletes Kratos identities; admin-panel login uses the correct seeded password.
Build green; isolation, parallelism, and the precondition/sharing migrations
validated against the live stack (0 leaked accounts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All screen-level empty states now use a single OrganicEmptyScreen that
fills the screen and centers its icon/title/subtitle/action in the dead
middle (both axes), with the three animated FloatingLeaf footer on every
empty screen.
- Add canonical OrganicEmptyScreen (Shared/Components/SharedEmptyStateView)
- Fix ListAsyncContentView: empty/error content used minHeight 60% of the
screen (placeholder sat in the top portion) → use full height so it
centers dead-center regardless of headers
- Hide Contractors' filter bar when the list is empty so the placeholder
stays screen-centered
- Route Properties / Tasks / Contractors / Documents / Warranties empties
through OrganicEmptyScreen; preserve the Tasks empty's branching
(no-residences vs add-task vs upgrade-prompt)
- Remove the duplicate/dead empty components
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kratos serialises an empty `continue_with` as explicit `null` (not `[]` or an
absent key), which crashed the post-register login decode ("Expected start of
the array '[', but had 'n' at $.continue_with"). Make continue_with nullable on
the three Kratos models and add coerceInputValues as a backstop for other
null-vs-default fields.
Tests (all run + passing):
- KratosDecodeTest: null/absent continue_with on login + registration
- AuthFlowDecodeTest: real captured prod bodies (login, /auth/me, verification)
decoded with the real models + the real client Json configs
- LiveAuthIntegrationTest: live HTTP through the actual AuthApi against prod
(register -> login -> /auth/me -> start-verification -> wrong-code), gated by
RUN_LIVE_IT=1 so it never runs on a normal build
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
register() now calls POST /auth/register (admin-create) then logs in for a
session, replacing Kratos self-service registration — which never returns the
verification flow id, so the emailed code could never be matched. The verify
screen now starts its own verification flow and sends the single code on
appear; verifyEmail submits the code to that exact stored flow.
- AuthApi: register -> our API + immediate login; startEmailVerification;
verifyEmail targets DataManager.pendingVerificationFlowId (no codeless fallback)
- DataManager.pendingVerificationFlowId; KratosLoginSuccess.continue_with
- iOS verify screens (standalone + onboarding) send the code on appear + Resend
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Mirrors the iOS implementation. Adds a Glance configuration activity
that launches when the user pins a new honeyDue widget tile and again
on "Edit Widget", lets them pick one of their residences (or "All
residences"), and persists the choice per-`appWidgetId`. Each tile's
`provideGlance` resolves its own scope and filters tasks (and stats,
on the large widget) accordingly.
Pieces:
- `WidgetConfigActivity` — Compose `ComponentActivity` hosting the
residence-picker UI; reads the persisted residences sidecar, reads
any prior scope for the current `appWidgetId`, writes the new
selection on Save, and re-renders every widget tile.
- `WidgetDataStore` — new `widget_residences_json` key + a per-instance
`widget_residence_id_<appWidgetId>` key. `clearAll()` sweeps the
per-instance keys by prefix so logout doesn't leave dangling state.
- `WidgetDataRepository`:
* `saveResidences(_)` / `loadResidences()` for the picker.
* `saveResidenceIdFor(appWidgetId, residenceId)` /
`loadResidenceIdFor(appWidgetId)` /
`clearResidenceIdFor(appWidgetId)` for per-tile scope.
* `loadTasksForResidence(residenceId)` and the
`appWidgetId`-driven `loadTasksForWidget(appWidgetId)`.
* `computeStatsFromTasks(tasks)` so the large widget's tiles
reflect only the scoped task list (instead of the whole cache).
* Pure `Filter.filterTasksForResidence(_, _)` on the companion
object — easy to exercise from unit tests.
- `WidgetTaskDto` already carries `residenceId`. New `WidgetResidenceDto`
added (id + name) — JSON-persisted via the sidecar.
- `WidgetRefreshWorker` / `DefaultWidgetRefreshDataSource` — pull
`myResidences` alongside tasks/tier on each refresh and write the
sidecar (best-effort; non-fatal if the call fails).
- `HoneyDue{Small,Medium,Large}Widget.provideGlance` — resolve
`appWidgetId` via `GlanceAppWidgetManager(context).getAppWidgetId(id)`
and call `loadTasksForWidget(appWidgetId)`.
- `HoneyDue{Small,Medium,Large}WidgetReceiver.onDeleted` — purge the
per-instance residence scope key when the tile is removed.
- Manifest: register the configure activity with the
`APPWIDGET_CONFIGURE` action.
- `honeydue_{small,medium,large}_widget_info.xml` — declare
`android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"`.
Migration / safety:
- A tile that's never been through the picker has no residence id
saved → `loadTasksForWidget` returns every task (legacy "All
residences" behaviour). Existing tiles keep working without the
user touching anything.
- The picker handles an empty residences list (signed-out / first
install before background refresh) with an explicit helper message
pointing at the main app.
Tests: new `WidgetResidenceFilterTest` (commonTest-style under
`androidUnitTest`, 9 cases). All green.
$ ./gradlew :composeApp:testDebugUnitTest \\
--tests "com.tt.honeyDue.widget.WidgetResidenceFilterTest"
BUILD SUCCESSFUL
$ ./gradlew :composeApp:assembleDebug
BUILD SUCCESSFUL
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users with multiple residences can now pick which one a given home-
screen widget shows tasks for. Pinning two widgets — one per house —
lets each surface tasks for only that residence; users who keep the
configuration untouched continue to see all residences (the previous
default), so single-home users see no behavioural change.
Implementation (iOS only — Android Glance follow-up is scoped in the
issue):
* `ConfigurationAppIntent` (HoneyDue widget extension) gains an
optional `@Parameter` of type `WidgetResidenceEntity`. `AppIntents`
renders it as a residence picker in the widget edit sheet.
* `WidgetResidenceEntity` + `WidgetResidenceEntityQuery` resolve the
user's residences from a new `widget_residences.json` sidecar in the
App Group container (avoids a network call at config time).
* `WidgetDataManager.saveResidences(from:)` writes that sidecar from
the main app whenever `DataManagerObservable.myResidences` updates.
Logout clears it along with the rest of the widget cache.
* `WidgetDataManager.WidgetTask` + the widget extension's
`CacheManager.CustomTask` both gain an optional `residence_id`
field. Optional so older app builds that wrote pre-#6 widget cache
continue to decode — those tasks pass through the filter for
unscoped widgets and are hidden from scoped ones (safer than
guessing).
* `CacheManager.getUpcomingTasks(forResidenceId:)` and the pure
helper `WidgetDataManager.filterTasks(_:forResidenceId:)` apply the
filter. `Provider.timeline` / `snapshot` read
`configuration.residence?.intId` and pass it through.
Tests: new `WidgetResidenceFilterTests` (HoneyDueTests target, 5
cases) cover nil-passthrough, matching-id, no-match, missing-residence
on a task, and order preservation. All five green.
No Android changes in this commit — Glance widgets need a separate
configuration activity and an actionStartActivity wiring that's
non-trivial; tracking as a follow-up in the same issue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Backblaze B2's S3-compatible endpoint does not implement the S3 POST
Object operation — every POST returns HTTP 501 regardless of URL form
(path-style or virtual-hosted-style). The previous multipart-POST flow
has been failing for every task-completion image upload.
Server-side companion change (honeyDueAPI master @7cc5448) replaces
PresignedPostPolicy with PresignHeader/PUT and renames the response
field from "fields" to "headers". This commit aligns both clients.
PresignUploadResponse model: field renamed `fields` → `headers`,
added `method` (default "PUT"). Both new fields have defaults so a
build talking to a stale server still decodes — albeit with empty
headers, which would then 403 at signature time. The server is
already on the new shape in prod.
iOS PresignedUploader.swift: dropped the ~70-line multipart body
builder and S3 form-field ordering logic. Replaced with a single PUT
request that applies server-supplied headers verbatim (skipping
Content-Length, which URLSession sets automatically and refuses to
override).
Android UploadApi.kt: same shape change. `postToStorage` →
`putToStorage`. Single Ktor `client.put()` with headers passthrough.
`uploadOne`'s `fileName` parameter kept for source compatibility but
marked @Suppress("UNUSED_PARAMETER") since PUT doesn't need it.
Verified end-to-end against api.myhoneydue.com:
presign → PUT 12 bytes → HTTP 200 in 0.6s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was on Environment.LOCAL — useful for local-against-127.0.0.1 dev but
means a release build off main hits a server the device can't reach.
Switch to Environment.PROD so the app talks to api.myhoneydue.com.
LOCAL/DEV are still one-line toggles in ApiConfig.kt for development.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default is `false` (current session-reuse behaviour) so tests reuse the
existing logged-in session — fast, and resilient to suites where the
current screen lacks a logout affordance (`UITestHelpers.ensureLoggedOut`
times out → tests fail before their bodies run).
Override to `true` in suites that observe transient `Invalid token` 401s
on POST/PATCH while reads continue to work. Recipe added after a 2026-05
incident where the API container was rebuilt mid-suite and in-memory
JWT tokens went stale; the diagnostic value is having an explicit lever
to reach for next time, not flipping the default.
Net effect on a clean simulator + stable API: 244/253 → 244/253 (no
behaviour change in the default path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OOMs were happening at the previous limits (Gradle 4G / Kotlin 3G)
during ComposeApp.framework generation. Bumped to 6G / 4G with a
1G Metaspace cap and G1GC for steadier latency on incremental builds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The XCUITest for gitea#2 (Suite11) was failing for reasons unrelated
to the cache fix — actual bugs in the registration/onboarding code
that real users probably hit too:
1. OrganicOnboardingSecureField + iOS 26 SecureField/autofill bug
On iOS 26, tapping a SwiftUI SecureField with .textContentType(.password)
doesn't reliably bring up the keyboard — the strong-password autofill
panel steals focus. Fix: under --ui-testing, default the visibility
toggle to ON so the field renders as a plain TextField (which has
reliable focus). Real users are unaffected.
2. Email registration didn't propagate auth state
Apple/Google sign-in paths called AuthenticationManager.shared.login(),
but email-registration's onChange(viewModel.isRegistered) handler did
not. As a result, AuthenticationManager.isAuthenticated stayed false
through the entire onboarding flow. OnboardingState.completeOnboarding
has an auth guard that silently no-ops when isAuthenticated is false,
leaving users stuck on the firstTask screen forever (until a
scenePhase event triggered checkAuthenticationStatus to re-sync from
DataManager). Fix: call authManager.login(verified: false) when
isRegistered flips true.
Suite11 now passes 2/2 in 96-107s, exercising the full onboarding flow
and asserting tasks appear on residence detail without restart.
Refs gitea#2
Same screen contract, but the data flows from DataManager.allTasks
through a combine(_allTasks, _currentResidenceId) into the existing
StateFlow. No per-residence network call needed; the upstream
getTasks() refresh propagates and the screen re-renders.
Eliminates the gitea#2 race window on Android — same fix as the iOS
TaskViewModel commit. Both platforms now react to _allTasks changes
without manual refresh.
Replaces the dual-sink ($allTasks when residence-scoped is nil,
$tasksByResidence when set) with a single $allTasks observation
that filters in-memory when currentResidenceId is set.
Eliminates the gitea#2 race window where the per-residence cache slot
could be empty while $allTasks was populated, leaving residence
detail stuck on the empty state. After this commit, every emit of
_allTasks rerenders every observing view — kanban tab, residence
detail, dashboards — atomically.
Refs gitea#2
Was 3 fallback paths (per-residence cache → filter from allTasks →
network). Now: ensure _allTasks fresh, return filter. The per-residence
cache becomes write-only by this path, scheduled for deletion in the
next commit.
Eliminates a class of bugs where the per-residence cache slot could
be missing while _allTasks was stale — the old Path 1+2 would either
return stale data or skip and hit the API redundantly.
Locks down the contract that becomes the primary path for residence
detail in Phase 3:
- filters _allTasks by residenceId
- returns empty shell for residence with no tasks (vs null for cache miss)
- returns null when _allTasks itself is null (caller must hit API)
Server is the authoritative kanban categorizer. After a bulk insert,
re-fetch /api/tasks/ so the kanban view reflects exactly what the
server sees, including any column re-categorizations the client's
in-memory upsert wouldn't compute. One extra round-trip per onboarding
submission, called once per session typically.
Eliminates the entire bug class where DataManager.updateTask had to
correctly compute kanban column placement from the response's
kanbanColumn field. With force-refresh, the server is the source of
truth — fewer ways for the client cache to drift.
Refs gitea#2
Catches re-introduction of the conditional _tasksByResidence write
branch removed in the previous commit. The per-residence cache is
deprecated; updateTask must only mutate _allTasks.
Closes the silent no-op when _allTasks is null on first launch (the
onboarding bulkCreateTasks path). The function now upserts: builds an
empty kanban shell with the standard column names if needed and places
the task in its target column. Unknown column names append a new
column at the end so the task is always reachable.
Also drops the second branch that conditionally wrote to
_tasksByResidence — that cache is being deleted in Phase 3 and
updateTask should not maintain it any more.
The Phase 1 unit tests now pass; the Phase 2 force-refresh in the
next commit replaces the placeholder column metadata (display names,
colors, icons) with authoritative server values.
Captures gitea#2 at the cache layer. Three tests:
- updateTask_seedsAllTasks_whenCacheIsEmpty (the core bug)
- updateTask_distributesAcrossColumns_whenSeedingThenAdding
- updateTask_replacesExistingTaskById_acrossColumns
All three FAIL on this commit because updateTask is a conditional
?.let{} that no-ops when _allTasks is null. Phase 1 fix in the next
commit makes them green.
Scaffolding for the gitea#2 regression XCUITest. No user-visible
change — pure metadata for UI automation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix the bug where tasks created during onboarding don't appear on
the Residence Detail screen until app restart. Root cause:
DataManager.updateTask is a no-op when both _allTasks is null AND
_tasksByResidence[residenceId] is empty — the case after a fresh
register-then-bulk-create flow.
Approach: collapse the dual cache into a single source of truth
(_allTasks). Residence detail observes it directly and filters by
residenceId in-memory. After mutations, force-refresh _allTasks from
the server (one round-trip eliminates a class of bugs).
Plan covers 14 tasks across 4 phases plus a regression XCUITest
that captures the user-visible bug end-to-end.
Carries the rebrand from the backend (APPLE_CLIENT_ID, APNS_TOPIC) all
the way through the iOS targets:
- All target PRODUCT_BUNDLE_IDENTIFIERs: com.tt.honeyDue.* → com.myhoneydue.honeyDue.*
- DEVELOPMENT_TEAM: V3PF3M6B6U → X86BR9WTLD (across every target)
- APP_GROUP_IDENTIFIER: group.com.tt.honeyDue.* → group.com.myhoneydue.honeyDue.*
- BGTaskSchedulerPermittedIdentifiers + BackgroundTaskManager constant
- KeychainHelper service identifier
- StoreKit fallback product IDs + Info.plist IAP product ID keys
- ExportOptions.plist teamID
- NSCamera / NSPhotoLibrary usage descriptions reworded
- Onboarding suggestion strings reworked (new %lld%% match copy,
dropped old "Great match" / "Good match" / "Generating suggestions"
strings — replaced by relevance-percentage labels)
- xctestplan + settings.local.json housekeeping
App-group rename means UserDefaults / shared-container data written to
the old group ID is abandoned. Acceptable since this is pre-launch.
The KMP shared layer's task-completion-with-images path now exclusively
uses the presigned-URL flow: each image is compressed, uploaded directly
to B2 via APILayer.uploadImage, and the resulting upload_ids are passed
to /api/task-completions/ as JSON. Bytes never traverse our API server.
Changes:
- TaskCompletionViewModel.createTaskCompletionWithImages now does the
presign→POST→collect-ids dance internally. The signature stays the
same so the three Android UI call sites (TasksScreen, AllTasksScreen,
ResidenceDetailScreen, CompleteTaskDialog, CompleteTaskScreen) need
no changes.
- APILayer.createTaskCompletionWithImages removed (dead).
- TaskCompletionApi.createCompletionWithImages removed (the multipart
HTTP helper that posted to the legacy POST /api/task-completions/
multipart endpoint).
- TaskCompletionCreateRequest.imageUrls field removed.
- Three Swift call sites (CompleteTaskView, WidgetActionProcessor,
PushNotificationManager) updated to drop the imageUrls argument.
- Two Kotlin call sites (CompleteTaskDialog, CompleteTaskScreen) updated.
Image uploads now match WhatsApp/Slack-class architecture: client-side
compression + direct-to-storage upload + lightweight JSON entity create.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS (Swift) — primary path, since iOS is the live platform:
- ImageDownsampler.swift: ImageIO/CGImageSourceCreateThumbnailAtIndex
based resize. Pays only the cost of the resized bitmap rather than
decoding the full source — a 12 MP iPhone photo previously
materialized ~50 MB regardless of JPEG size. Profiles: completion
(2048 px / quality 0.85), document_image (2560 px / 0.90).
- PresignedUploader.swift: three-step orchestration (POST /uploads/presign
→ multipart POST direct to B2 with the signed policy fields → return
upload_id). Maps HTTP errors to user-facing copy. Concurrent uploads
via TaskGroup.
- CompleteTaskView.swift: replaces the multipart-with-images path with
downsample → upload-to-B2 → create-completion-with-upload_ids[]. The
no-image branch unchanged.
Android (Kotlin) — parity:
- composeApp/.../media/ImageDownsampler.kt: BitmapFactory inSampleSize
+ proportional scale + JPEG compress. Same profiles as iOS.
- composeApp/.../network/UploadApi.kt: Ktor-based presign + direct-to-B2
POST. Preserves form-field order so the S3 policy signature validates.
- APILayer.uploadImage(category, contentType, bytes, fileName) → upload_id.
UI integration to follow.
Shared (Kotlin):
- models/TaskCompletion.kt: added uploadIds: List<Int>? to
TaskCompletionCreateRequest and a new PresignUploadRequest /
PresignUploadResponse pair matching the Go API DTOs.
- Existing call sites (WidgetActionProcessor, PushNotificationManager)
explicitly pass uploadIds: nil for backwards compatibility — Swift's
bridge to Kotlin doesn't honor Kotlin defaults for required-positional
parameters.
The legacy multipart path remains functional alongside the new one for
soak-test purposes; per-platform feature flags can flip between them at
any time. After zero multipart traffic in production for 7 consecutive
days, the legacy paths can be dropped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>