storehop

Changelog

Notable user-facing changes per release. The format follows Keep a Changelog, and the project follows Semantic Versioning.

For the high-level roadmap and earlier-than-0.5.0 history, see the “Roadmap” section in the README.

[0.9.0] - 2026-05-26

One-off stores (Android). A new flag on the Store entity lets the user designate a store as “one-off” for non-recurring purchases (a new couch, drying rack, bathroom shelves). Items tagged only to one-off stores are hidden from the master Items list — they live inside their one-off store’s Shop view only — so the grocery flow stays uncluttered for ongoing shopping.

Added

Behavior preserved

Architecture

Tests

iOS

Bundles into the eventual TestFlight ship (mirrored at the entity + DAO + repo layers; same EXISTS-clause filter, same chip-suffix UI).

Versions

[0.9.0-ios] - 2026-05-26

iOS catches up to Android v0.9.0 — one-off stores. Cross-platform data-model parity: the new isOneOff field on the Store entity is written to Firestore via StoreDto, so multi-device households (you, Mike, Amanda) converge correctly on whichever device flipped the flag last. Without this build, iOS could overwrite an Android-toggled one-off flag back to false on the next sync (LWW on the older iOS write), silently losing the user’s classification.

iOS marketing version bumps 0.8.1 → 0.9.0 to track Android’s version naming. CURRENT_PROJECT_VERSION 54 → 55.

Added: one-off stores

Mirrors Android’s v0.9.0 feature behavior 1:1. Users mark a store as “one-off” (a non-recurring store: “Hardware (One Off)”, “Online (One Off)” — Mike’s design) and items tagged to only one-off stores are hidden from the master Items list. Specifics:

Data layer

Repository

Cross-platform note

Schema-version-number cosmetic divergence: Android’s v8 → v9 introduces the alive_item_store_xref SQL view; iOS skipped that view because GRDB queries already filter deletedAt IS NULL explicitly at every join site (no Room @Junction to fool). iOS’s migration v9_stores_one_off adds the same isOneOff column that Android’s v9 → v10 migration adds — final stores table schema is identical across platforms.

Versions

[0.8.1.5-ios] - 2026-05-26

App Store Review rejected build 53 with three issues. Two are user-side App Store Connect / Firebase Console state (not code), one is a real bug in the upgrade card’s UX that this build fixes.

Rejection summary

Fixed: Unlock button silently swallowed purchase failures

UpgradeToPremiumCard’s Unlock button had two interacting flaws that, combined with App Review’s sandbox state, produced the “unresponsive button” complaint:

  1. The button was always enabled, even before Product.products(for: ["premium_lifetime_v2"]) returned. If the product hadn’t loaded — either due to network delay or, as in the reviewer’s case, because the IAP wasn’t attached to the submission’s sandbox — tapping invoked StoreKitManager.purchase() against a nil product, which returned .failed(reason: "Product not loaded yet — try again in a moment.").
  2. The view discarded the purchase outcome (_ = await storeKit.purchase()), so the failed-reason string went nowhere. From the reviewer’s perspective: tap, nothing.

Build 54 changes:

The alert copy is hardcoded English in this build to avoid churning the xcstrings file under a hotfix deadline. A follow-up will move these into Localizable.xcstrings with all four locales.

User-side follow-ups before resubmission

  1. Firebase Console — Storehop project → Authentication → Sign-in method → Apple → toggle Enable, save. (Note: the Services ID and OAuth callback URL fields the Console prompts for are web-only configuration and are NOT required for native iOS Sign in with Apple. Skip them.)
  2. App Store Connect — Features → In-App Purchases → click Storehop Premium → click Submit for Review at top right. This is a separate action from attaching the IAP to the app version submission.
  3. Upload build 54 via Transporter, attach the (now submitted) IAP to the version, reply to App Review in the Messages panel with the recovery summary, click Resubmit.

Versions

[0.8.1.4-ios] - 2026-05-26

Premium IAP product ID rotation — premium_lifetimepremium_lifetime_v2.

Self-inflicted recovery: while addressing Apple’s 2.1(b) flag in the build-51 rejection (reviewer “couldn’t locate the In-App Purchases, such as Storehop Premium and English”) the premium_lifetime in-app purchase was deleted from App Store Connect, in an attempt to clear a suspected phantom entry. Apple permanently retires deleted IAP product IDs — the same string cannot be reused or recreated. The error attempting to recreate confirmed this: “The Product ID you entered is already being used by another in-app purchase associated with this team.”

Build 53 ships a new product ID. Existing purchasers (anyone who already bought before the deletion, including the VIP allowlist’s Mike + Amanda + Mom) stay entitled because StoreKit2 keys Transaction.currentEntitlements by product ID — those transactions still exist on Apple’s side under premium_lifetime even though the product is gone from App Store Connect.

Changed: StoreKitManager.productIdPremium is now premium_lifetime_v2

New purchases route to the v2 product. The legacy premium_lifetime ID stays in code as productIdPremiumLegacy, used only for the entitlement scan, never for Product.products(for:) (lookups on the deleted ID would return nothing). A new StoreKitManager.entitlementGrantingProductIds static set is the source of truth for “which product IDs unlock Premium” — currently two members. See StoreKitManager.swift.

refreshFromCurrentEntitlements() and the live Transaction.updates handler both now check entitlementGrantingProductIds.contains(transaction.productID) rather than a single-ID equality. Result: any existing purchaser keeps .premium on cold launch and across StoreKit refreshes; new purchasers get .premium immediately after the v2 purchase completes.

EntitlementRepository doesn’t reference the product ID directly, so no behavior change there beyond a doc-comment update for clarity.

Cross-platform

Android keeps premium_lifetime unchanged. Entitlements are per-platform — a user buying on Android does not get iOS for free, and vice versa — so this ID divergence is invisible to users.

App Store Connect (no code change)

Required follow-up before resubmission:

  1. Create a new in-app purchase in App Store Connect with product ID premium_lifetime_v2, reference name “Storehop Premium”, non-consumable, US$7.99 (or the closest Apple price tier), English localization with display name + description, and a review screenshot of the in-app upgrade card.
  2. Attach the new IAP to the build 53 submission.
  3. Reply to App Review (Messages panel) explaining the rotation + the recovered nav path to the upgrade card.

The previously-emailed Apple Developer Support request to restore premium_lifetime is no longer needed (Path 2 supersedes Path 1).

Versions

[0.8.1.3-ios] - 2026-05-26

App Store Review rejection of build 51 — Sign in with Apple added. Apple rejected build 51 under Guideline 4.8 (Login Services) because the app offered Google Sign-In but no equivalent option that limits data collection to name + email, lets the user keep their email private from the developer, and refrains from advertising-purpose tracking. Sign in with Apple satisfies all three. They also flagged Guideline 2.1(b) — couldn’t locate the In-App Purchase from the running app — but that’s an App Store Connect configuration issue (no code change), addressed via a reply to the reviewer with the exact navigation steps.

Build 52 ships only the SIWA code change. MARKETING_VERSION stays at 0.8.1.

Added: Sign in with Apple

New SignInWithAppleUseCase mirrors GoogleSignInUseCase structurally — anonymous→link or plain-sign-in dance, same SignInOutcome enum, same fallback when the Apple ID is already attached to another Firebase user. Two implementation details that differ from Google:

  1. Nonce flow. Firebase requires a raw nonce + Apple’s ID token to construct the credential. Apple receives only the SHA256 hash of the raw nonce; on the Firebase side, Firebase verifies the supplied raw nonce hashes to the value embedded in the ID token as a replay-attack guard. The view-local @State holds the raw value between prepareNonce() (set on the ASAuthorizationAppleIDRequest) and completeSignIn(...).
  2. Presentation. SwiftUI’s first-party SignInWithAppleButton owns the sheet presentation (Apple’s HIG requires that specific button appearance for App Store approval). The use case owns the two halves the button can’t: nonce generation and the Firebase exchange.

FirebaseAuthClient gains three methods mirroring the Google trio: makeAppleCredential(idToken:rawNonce:), linkAnonymousWithApple(credential:), signInWithApple(credential:). LiveFirebaseAuthClient implements them via Firebase’s OAuthProvider.appleCredential(...). NoOpAuthClient (preview/E2E) and MockFirebaseAuthClient (unit tests) get parity no-op stubs.

SettingsViewModel gains prepareAppleNonce() + handleAppleSignInResult(result:rawNonce:) mirroring signInWithGoogle’s busy / error / wait-for-uid-flip lifecycle. ASAuthorizationError.canceled is silent (user dismissed the sheet), same as GIDSignInError.canceled on Google.

SettingsView’s AccountCard renders the SwiftUI SignInWithAppleButton directly below the Google button — Apple HIG layout. Button style flips black↔white on the system colorScheme.

Added: capability + entitlement

New ios/Storehop/Storehop.entitlements declares com.apple.developer.applesignin = [Default]. Wired into ios/project.yml via CODE_SIGN_ENTITLEMENTS. Re-run xcodegen generate (or, locally without xcodegen, sed the build setting into .pbxproj) to pick it up. The auto-managed provisioning profile picks up the capability on next archive — no Apple Developer Portal click-through needed.

App Store Connect (no code change)

Builds 52’s submission also clears up Apple’s 2.1(b) flag via the Messages panel. The “English” entry the reviewer reported is not in our code — Billing/StoreKitManager.swift only references the single premium_lifetime product ID. Reply text posted to the reviewer details:

Versions

[0.8.1.2-ios] - 2026-05-21

TestFlight-QA follow-ups after v0.8.1.1-ios shipped build 41. Six shippable fixes uncovered while running build 41 through TestFlight on real devices. The Apple-Developer-side workflow stays the same; only in-app behavior changes. MARKETING_VERSION stays at 0.8.1; CURRENT_PROJECT_VERSION walked from 41 → 51 over the course of the QA cycle (each TestFlight upload needs a fresh build number; builds 42–50 are intermediate steps captured below for traceability). Build 51 is the one in App Review. No Android changes.

Fixed: Google Sign-In silently no-op’d (build 42)

com.googleusercontent.apps.<reversed_client_id> is required as a CFBundleURLSchemes entry so the OAuth callback can deep-link back into the app. project.yml referenced $(GOOGLE_REVERSED_CLIENT_ID) but the build setting was never defined, so the URL scheme expanded to the empty string and Apple’s OAuth round-trip never delivered the auth response. Symptom: the Google sheet completed successfully on the device but the app stayed on the anonymous-user screen. Fix: define GOOGLE_REVERSED_CLIENT_ID under settings.base in project.yml (value mirrors the REVERSED_CLIENT_ID key in GoogleService-Info.plist).

Fixed: locked Premium actions were silent no-ops (build 43)

Tapping “Export Items” / “Export Categories” / “Generate Invite” while un-entitled did nothing — no upsell, no toast, no feedback. Reason: the un-entitled branch in each ViewModel just return-ed early. Fix: present the new PremiumUpgradeSheet, which mirrors the existing Settings upgrade card (price from StoreKit2, Unlock button, Restore button, Apple footnote) but appears as a modal triggered at the moment the user tries to use a locked feature. Matches the Android upsell-on-tap pattern.

Fixed: “Cloud sync incomplete” banner after Google Sign-In (build 46)

Real Android-written Firestore docs were missing fields that the iOS DTOs declared as non-optional Swift String / Int64 / Bool. Swift’s synthesized Codable decoder does NOT respect property- level default values — a missing field still throws DecodingError.keyNotFound, which surfaced as “data could not be read because it is missing.” Earlier attempts (build 44–45) only made householdId and displayOrder tolerant; they were the most- visible failures but not the only ones. Build 46 rewrites every DTO (ItemDto, CategoryDto, StoreDto, ItemStoreXrefDto, StoreCategoryOrderDto, PurchaseRecordDto) with a manual init(from:) that uses decodeIfPresent on every field and falls back to safe defaults (Sync/DTO/Dtos.swift). Pre-cursor build 45 also added a “Show details” tap-to-reveal on the banner (SettingsView.CloudSyncBanner) that surfaces the actual Firestore error string instead of just “sync incomplete” — useful diagnostic that survived the fix because the next pull failure can come from any number of sources.

Fixed: runtime language switching didn’t apply mid-session (builds 47 → 51)

The hardest bug of the cycle. Symptom: picking a language in Settings persisted the choice (cold-launch on next open showed the new language) but the open Settings sheet stayed in the old language until the user killed and reopened the app.

Four wrong fixes before the right one (worth recording so we don’t repeat the dead ends):

  1. Build 47 — added the missing settings_language_es and settings_language_it picker labels (only settings_language_system, _english, _pt_pt had existed) and installed an object_setClass(Bundle.main, LanguageBundle.self) swizzle whose override redirected localizedString(forKey:value:table:) to a per-locale .lproj Bundle. Solved the missing-labels half of the bug; the live-switch half stayed broken.

  2. Build 48 — added .id(localeTag) on the SettingsView inside the sheet closure (the existing .id on TabView didn’t reach the modally-presented sheet) and made Bundle.setActiveLanguage synchronous from SettingsViewModel.setLocale.

  3. Build 49 — also overrode Bundle.preferredLocalizations and Bundle.localizations on LanguageBundle, on the theory that Foundation reads those before asking localizedString to pick a .lproj.

  4. Build 50 — found that the async let child task in observePreferences doesn’t inherit @MainActor, so the self.localeTag = tag @State assignment was happening off-actor and SwiftUI silently dropped the invalidation. Wrapped both assignments in await MainActor.run { … }. Tested — still broken.

Why none of the above worked: iOS 17+ String(localized: "key") resolves through LocalizedStringResource, which captures the owning bundle reference at compile time and goes through Foundation’s CFBundle-level machinery. That path never calls our Objective-C Bundle.localizedString(forKey:) override and never asks our overridden preferredLocalizations. Cold-launch worked only because iOS reads AppleLanguages once at process start and caches the result for the lifetime of the process — the swizzle was decoration on a path Foundation never consulted.

Build 51 — the actual fix. New top-level L(_:) helper at file scope in BundleLanguageSwizzle.swift:

@inline(__always)
func L(_ key: String) -> String {
    let tag = LanguageBundle.languageTag
    let bundle: Bundle = tag.isEmpty
        ? .main
        : (LanguageBundle.bundleForLanguage(tag) ?? .main)
    return bundle.localizedString(forKey: key, value: key, table: nil)
}

The helper calls bundle.localizedString(forKey:value:table:) directly on the per-locale .lproj Bundle, bypassing Foundation’s LocalizedStringResource machinery and its compile- time bundle baking entirely. Mechanical refactor across 23 files (282 call sites): String(localized: "key")L("key"). Eleven non-literal call sites were hand-fixed (String(localized: String.LocalizationValue(key))L(key), conditional literals → L(cond ? "a" : "b")). Three sites kept String(localized: "key \(arg)", bundle: currentLanguageBundle()) because they need Foundation’s String Catalog plural interpolation (e.g. the bulk-tag sheet’s “Add to %lld selected items” with one/other variations) — there, passing an explicit bundle: parameter is enough to make the lookup read from our .lproj instead of Bundle.main.

The swizzle code stays in place — harmless if not load-bearing, and may still cover any rare Bundle.main.localizedString consumer (third-party library copy, system framework UI). The .id(localeTag) rebuilds, MainActor.run @State assignment, and synchronous Bundle.setActiveLanguage from the picker are still what drive the SwiftUI re-evaluation when the user changes the language — those are correct and stayed.

Documentation

Versions

[0.8.1.1-ios] - 2026-05-20

iOS-only follow-up after the v0.8.1 catch-up landed. Five shippable fixes uncovered while preparing the iOS branch for App Store submission. No Android changes; iOS marketing version stays at 0.8.1.

Fixed: brand palette never actually rendered

The Assets.xcassets/Colors/ group had provides-namespace: true, which meant the real asset names were Colors/Brand/Primary, Colors/Surface/Background, etc. The Swift code does Color("Brand/Primary") (matching only the inner Brand/ / Surface/ / Text/ namespace folders). Every brand-token lookup was silently failing — Color(_ name:) returns a fallback on miss instead of crashing — so the app was rendering with iOS system tints everywhere. Symptoms: toolbar gear/ellipsis icons showing as empty white pills against the white nav bar, “Edit” button with invisible text, FABs as plain system circles instead of sage pills, selected Shop tab’s cart icon + label invisible, “dark mode” only looking roughly dark because UIColor.systemBackground auto-swaps even when the brand palette doesn’t resolve. Fix: drop provides-namespace from Colors/ only. The inner namespaces stay. Single-file change, no Swift edits needed. The bug was purely visual so the existing E2E suite (which asserts on accessibilityLabel, not pixel color) passed throughout.

Fixed: ViewModel rebind on .onAppear

All 8 SwiftUI views created their ViewModel inside if viewModel == nil { ... vm.bind() } on .onAppear, and called vm.teardown() on .onDisappear. NavigationStack pushes (opening the item form, drilling into a store, the bulk-tag sheet) fire .onDisappear on the source view → the SessionBinder cancels its GRDB ValueObservation subscriptions. On the pop back, the nil-guard skipped re-binding, so new writes (an item added through the form, a row checked off in a child screen) never surfaced in the parent list until something else triggered a session-uid event — which never fires under the stable LocalOnly session. Caught by the new ItemAddFlowE2ETest on its first run. Fix is identical in every view: always call viewModel?.bind() on .onAppear (separately from the construction guard). SessionBinder.bind() is idempotent — it cancels any prior subscription before re-subscribing.

Fixed: bulk-tag picker chips ate taps

The BulkStorePickerSheet’s chip layout (the custom Layout protocol-based StoreChipsRow, shared with ItemFormView) wasn’t propagating taps to its Button.action inside a Form sheet on iOS 26+. Replaced with idiomatic Form rows (checkmark.circle.fill/circle + Text), which Form handles natively. StoreChipsRow stays in ItemFormView (renders outside a sheet and works fine there).

Added: AppIcon

The 1024×1024 iOS app icon shipped this session — same artwork as Android, generated by sips upscale of design/shophop-icon-512.png

Added: full E2E suite + design-system tour

iOS goes from one launch smoke (the broken Phase-0 stub) to 13 XCUITest cases mirroring Android’s 10 instrumented flows plus 2 iOS-specific (bulk store-tag, seed canary). Scaffolding: AppContainer.e2e(seedFixtures:seedCriticalCoffee:forceTheme:) factory, E2EFixtureSeeder for the canonical 2-stores/1-category/3-items dataset, E2EBaseTest with launch-arg

Documentation

Versions

[0.8.1] - 2026-05-16

Bulk store-tag from the Items list + tombstone-correct @Junction across all consumers (Android). Two changes that travel together: an architectural fix at the data layer and a new feature that benefits from it.

Added: bulk store-tag on the Items list

Mike asked for a way to add stores in bulk after CSV imports rather than re-opening each item. New flow:

Backed by a new ItemRepository.bulkTagStoresForItems(itemIds, storeIdsToAdd) that wraps the per-pair tagItemToStore calls in one transaction, so partial failures can’t leave half the items tagged. Idempotent (re-applying does nothing), resurrects tombstoned xrefs by primary-key upsert (matches single-tag behavior), no-op on empty inputs. Six new tests pin the contract: 4 repo-layer (union / idempotent / resurrect / no-op) + 6 ViewModel-layer (selection state lifecycle + apply + clears on success + preserves on no-op). The Composables StoreChipsRow (extracted from the form so both surfaces share styling) and BulkStorePickerDialog are new under ui/items/components/. 8 new localized strings (1 plural × 4 locales) for the selection count, X content-desc, action label, dialog title/body/button.

Fixed: tombstoned xrefs no longer leak via the Room @Junction

v0.8.0.5 patched one of three consumers of the leaky ItemWithCategoryAndStores.@Junction (the item form’s selected- stores chips). The other two — CSV export’s per-store column and the Items list hasStores +/- toggle — were still reading tombstoned xrefs through the same un-filtered join. The architectural fix lands here: a new Room @DatabaseView named alive_item_store_xref defined as SELECT itemId, storeId FROM item_store_xref WHERE deletedAt IS NULL becomes the @Junction target instead of the raw table. One fix at the data layer covers every consumer (existing three plus the new bulk-tag flow), so this class of bug can’t return for a fourth consumer.

Schema migration v8 → v9 creates the view with idempotent CREATE VIEW IF NOT EXISTS DDL. New MigrationTest case pins both that the view exists in sqlite_master and that it filters soft-deleted rows. ItemRepositoryImplTest gains an integration case driving the full ItemWithCategoryAndStores load path through the view.

Cleanup of the v0.8.0.5 tactical hack:

iOS catch-up — bulk store-tag (bundled into iOS 0.8.1)

iOS gains the same bulk store-tag flow as Android — long-press an Items-list row to enter selection mode; the toolbar swaps to a contextual “[N] selected” title + an X (exit) + a “Tag to stores…” action. Tapping the action presents the new BulkStorePickerSheet modal (same chip styling as the single-item form, since StoreChipsRow is now a shared component). Picking stores → “Add stores” unions them with each selected item’s existing store set (add-only semantics — nothing removed). FAB and +/- toggle hide in selection mode.

New ItemRepository.bulkTagStoresForItems(itemIds:storeIdsToAdd:) wraps a batch of per-pair tag calls in one GRDB write transaction, so partial failures roll the whole batch back. Idempotent (re-applying does nothing), resurrects tombstoned xrefs via primary-key upsert, no-op on empty inputs. The single-pair tagItemToStore body moved into a private static helper so the bulk path can run inside one outer transaction.

Part A (architectural xref-join fix) is a no-op on iOS. GRDB has no equivalent of Room’s leaky @Junction: iOS has always loaded ItemWithCategoryAndStores.stores via an explicit SQL JOIN with AND isx.deletedAt IS NULL, and CSV export + Items-list hasStores toggle do the same. The three Android consumers that needed the new @DatabaseView were already correct on iOS, so there’s no schema migration, no view, and no need for the v0.8.0.5 tactical hack iOS never wrote.

Tests: 4 new ItemRepositoryTests cases for bulkTagStoresForItems (union / idempotent / resurrect / no-op), mirroring Android’s repo-layer pinning. iOS still doesn’t carry a matching ItemsListViewModelTests suite (a pre-existing test-parity gap unchanged by v0.8.1).

Strings: 6 new keys × 4 locales in Localizable.xcstrings. The body uses Apple’s .xcstrings plural-variation format (variations.plural.{one,other}) — the first such entry in the iOS catalog; matches Android’s <plurals name="items_bulk_tag_body">.

Versions

[0.8.0] - 2026-05-12

Premium IAP — inviter-pays household sharing + gated CSV export (Android). v0.7.1.2 added the Play Billing Library to the classpath; v0.8.0 actually wires it up. The app stays Free at install, but two power-user surfaces now require a one-time $7.99 unlock.

Pricing + scope

Inviter-pays model

Per Apple / Google IAP policy, entitlements are per-platform and device-local — no cloud sync. The “Mike + Amanda” canonical case (Mike asked for shared lists in v0.6.0 planning) works under inviter-pays: Mike buys Premium once → he can generate invite codes → Amanda accepts free → Amanda uses the shared household free. Only invite creation is gated; joining + everything-else- after-joining is unconditionally free. Mirrors how Family Sharing works for paid apps and avoids forcing every household member to pay independently.

Grandfather clause

Two paths grant the silent LegacyUser entitlement (functionally identical to Premium for UI gating):

  1. Date-based: any user whose Firebase account creationTimestamp predates V0_8_RELEASE_DATE_MS (the constant in EntitlementRepository.kt) — covers the closed-test cohort that’s been beta-testing for free.
  2. Email allowlist (PREMIUM_VIP_EMAILS): explicit set of beta-tester emails that bypass the date check entirely so they keep free Premium even after creating fresh Firebase accounts. Currently lists the dev account, Mike (mikehaynes@gmail.com), and Amanda (amandafrost79@gmail.com).

Mechanism: on every uid emission, the check recomputes the flag from scratch — VIP email match OR pre-v0.8 timestamp → grant; else clear. This fixes a sticky-flag bug where the legacy flag, once set, never cleared: if a VIP previously signed in on a device and a non-VIP signed in afterward, the non-VIP would inherit Premium. Now sign-out + sign-in-as-non-VIP correctly flips entitlement back to NotEntitled.

Mike + Amanda + the dev account all hit the VIP-email branch; every other existing tester hits the date-based branch.

Added (Android)

Changed (Android)

Firestore security rules

No changes — entitlement state is local-only and never reaches Firestore. The v0.7.1 rules ship unchanged.

Fixed (mid-cycle patches before final ship)

Open items requiring user action

Tests

Ten new cases in EntitlementRepositoryTest pin the entitlement contract: VIP email matches grant LegacyUser, case-insensitive comparison, date-based grandfather, anonymous user stays NotEntitled, sticky-flag re-evaluation on uid change, purchase precedence over legacy, pending-purchase doesn’t grant. Test infra uses TestScope.backgroundScope for the application scope so the long-lived collectors in start() auto-cancel; DataStore IO runs on the test scheduler so advanceUntilIdle() drains writes.

iOS catch-up + StoreKit2 mirror (bundled into iOS 0.8.0)

iOS skips marketing versions 0.7.0 and 0.7.1 — those never reached TestFlight — and bundles every Android catch-up + the v0.8 IAP work into a single iOS 0.8.0 release. What’s in it:

v0.7.1 catch-up:

v0.8 StoreKit2 + entitlement:

iOS versions:

Open items for iOS ship:

Versions

[0.7.1] - 2026-05-11

Lossless sideload-APK → Play Store transition (Android). Closes the two local-only data surfaces that would otherwise vanish when a user uninstalls a sideloaded APK and reinstalls from Play Closed Testing (signing certs differ → in-place update refused → uninstall mandatory). The load-bearing case is Mike’s beta cycle — he’s been running upload-key-signed APKs since v0.3.x and is now ready to switch to the Play distribution.

Added (Android)

Changed (Android)

Firestore security rules

Sideload → Play migration runbook

See docs/v0.7.1-migration.md for the end-to-end checklist. Summary:

  1. User receives v0.7.1 sideloaded APK (still upload-key signed, so in-place updates over v0.7.0 — no uninstall yet).
  2. User opens v0.7.1. Prefs auto-push to cloud on the first auth tick.
  3. User opens Settings → Data → “Force sync now”. Waits for “Safe to uninstall.”
  4. User uninstalls the sideloaded build.
  5. User installs from Play Closed Testing (app-signing-key signed).
  6. User signs in with the same Google account. Cloud pull rehydrates items + photos + memberships + prefs. Identical UI state to step 3.

Tests

iOS (deferred to v0.7.2 or a future session)

The iOS port carries the v0.7.0 multi-user Phase 5 code on main but the v0.7.1 cloud-prefs + Force-sync work hasn’t been mirrored yet — that’s a separate Mac-side session. iOS stays at marketing version 0.6.10 until both ship together.

Fixed (mid-cycle patches before final ship)

Versions

[0.7.0] - 2026-05-11

Multi-user account sharing — household model (Android). Mike asked for it in v0.6.0 planning: “allowing multiple people to access one account is probably a good one. I could see allowing Amanda to access my list and add items and check off items.” This release lands the full data + sync + invite + UI stack to support that flow on Android. The iOS port carries the matching Phase 5 code on main (schema v8, HouseholdRepository, HouseholdView, household-scoped DAOs, parity unit tests) but is still tagged at v0.6.10 marketing version pending a Mac-side xcodebuild + 2-device smoke test before the version bump ships to TestFlight. The Android 0.7.0 client + the iOS 0.6.10 build

Added (Android)

Changed (Android)

Downgrade safety (important)

v0.7.0 → v0.6.9 reverts must uninstall + reinstall, not sideload on top. The schema migration v7 → v8 leaves the local DB at a version v0.6.9’s Room doesn’t recognise — sideloading the v0.6.9 APK over a v0.7.0 install crashes on every launch (IllegalStateException from Room). The safe revert path is:

  1. Tap “Uninstall” on the device’s app info screen (Android wipes the local DB).
  2. Install v0.6.9 fresh.
  3. Sign in with the same Google account; Firestore re-populates every row. v0.6.9 silently ignores the new householdId field in cloud docs.
  4. Cost: any pendingSync = 1 rows that never reached Firestore before the uninstall are lost. Anything cloud-side is fine.

For future downgrades (v0.7.x → v0.7.0 etc.), v0.7.0’s Room builder now calls fallbackToDestructiveMigrationOnDowngrade() so the fallback path is automatic — local DB wipes + re-pulls instead of crashing. The flag only helps once v0.7.0 itself is in users’ hands; the v0.6.9 revert above is the one path it can’t retroactively fix.

iOS port (Phase 5)

Deferred to v0.7.x

[0.6.10] - 2026-05-11

iOS-only parity catch-up: closes three long-standing gaps where the iOS port lagged behind Android. Android: unchanged (no version bump, no behaviour change — the Android code shipped these features in v0.5.6 and v0.6.8 already).

Added (iOS)

Changed (iOS)

iOS test parity (still pending)

Versions

[0.6.9] - 2026-05-11

Mike-reported follow-up to v0.6.7: the Store Picker was reporting “10 critical items needed” with 5 per store, but tapping into a store showed only 1. The in-store screen was correct; the picker was over-counting.

Fixed

Tests

Total: 443 unit tests, 0 failures.

Re-classified

Versions

Tests-only — 2026-05-11

New :benchmark module for Macrobenchmark cold-start + scroll-FPS measurement. No version bump (no app behaviour change).

Added

How to run

./gradlew :benchmark:connectedBenchmarkAndroidTest

on a connected physical device (emulators give noisy results — Pixel 6+ recommended). Results land under benchmark/build/outputs/connected_android_test_additional_output/.

Not in CI

Macrobenchmark needs a real device or a properly-configured AVD. GitHub Actions ubuntu runners don’t have either; opt-in locally pre-release. Initial baseline numbers will land in CHANGELOG once we run on a Pixel.

Tests-only — 2026-05-11

Android CI workflow + a small defensive-cast tweak in StorehopTheme. No version bump (no user-visible changes).

Added

Changed

Out of scope (attempted, deferred)

[0.6.8] - 2026-05-11

UX polish bundle: empty-state illustrations, a dark-theme contrast fix, and a Settings reorg with section headers + a new About section.

Added

Changed

Tests

Versions

[0.6.7] - 2026-05-11

Mike-reported: “Something is amiss re the display of critical items in the ‘where are you shopping?’ and store listing pages. Some items are not showing. Some items are showing when they shouldn’t.” Two intersecting bugs — fixed together.

Fixed

iOS port mirrors the SQL + repo partition change line-for-line. The DAO test that inlined the picker SQL was updated to match.

Not in scope (deferred)

The deeper fix is to auto-renew priority staples at session start (flip isNeeded=1 on every priority staple whose lastPurchasedAt predates the session), per the TODO(0.6) at ShoppingDao.kt:30-41. That would make the in-store row also re-appear as “needs buying” this trip rather than as “purchased / struck-through”. Tracked as a v0.7+ roadmap item per project memory; this release keeps the change surface narrow to the display fix Mike reported.

Tests

Versions

[0.6.6] - 2026-05-10

Fixed

iOS unaffected (uses SwiftUI .onMove which commits immediately through the VM’s commitReorder).

Versions

[0.6.5] - 2026-05-10

Fixed

iOS unchanged — the EditButton + List(selection:) + .onMove pattern mode-gates drag vs. selection through SwiftUI’s edit mode, so the gestures are never live at the same time.

Versions

[0.6.4] - 2026-05-10

Manage Categories gets a real workflow surface: drag to reorder, bulk select to delete, multi-add to seed many at once. All three ship to Android + iOS in the same release.

Added

Tests

Sync

Versions

[0.6.3] - 2026-05-10

Fixed

iOS unchanged (App Store has no equivalent in-app-update API).

[0.6.2] - 2026-05-10

Two more Mike-asks, one bundle.

Changed

Tests

Versions

Tests-only (coverage push) - 2026-05-10

Push toward 100% measured line coverage on the unit-test surface. No version bump.

Coverage delta

The single remaining uncovered line is session.userId.flatMapLatest { uid -> inside ItemRepositoryImpl.observeAll. Every observeAll call in the suite exercises this code path; Kover flags the line as not-covered due to instrumentation quirks around inline coroutine operators (flatMapLatest is inline, so the lambda body inlines but the call site doesn’t get a hit-count). Cosmetic gap, not a behavior gap.

Added (unit tests)

Kover configuration

Aggressive exclusion list for genuinely-untestable code:

The remaining ~29 uncovered lines are SDK-conditional branches (LocaleManager Tiramisu+ paths, AppCompatDelegate fallback) and catch-block fallbacks for exotic exception types. Achievable to 100% only via integration/instrumented tests, not JVM unit tests.

Tests-only - 2026-05-10

Test-coverage hardening; no version bump (no user-visible changes).

Added

Changed

Test infrastructure

[0.6.1] - 2026-05-10

Two more Mike-asks bundled together. Both touch the master Items list, both are small enough that splitting into separate releases would be overhead.

Added

Tests

[0.6.0] - 2026-05-10

Mike’s UX feedback bundle. Six items pulled in from his most recent round; the multi-user account-sharing milestone moves to v0.7.0 since none of these depend on the data-model rework that needs.

Added

Changed

iOS parity (added in a follow-up patch on the v0.6.0 line)

iOS catches up to the v0.6.0 Android UX. Per-feature notes:

iOS marketing version bumped 0.5.15 → 0.6.0; CURRENT_PROJECT_VERSION 15 → 16. Still not shipped to TestFlight.

Out of scope

[0.5.15] - 2026-05-09

Changed

Intentionally divergent (iOS)

[0.5.14] - 2026-05-09

Changed

Fixed

[0.5.13] - 2026-05-09

Changed

[0.5.12] - 2026-05-09

Changed

[0.5.11] - 2026-05-09

Fixed

[0.5.10] - 2026-05-09

Fixed

[0.5.9] - 2026-05-09 [yanked]

Fixed (regressed — see 0.5.10)

[0.5.8] - 2026-05-09

Added

[0.5.7] - 2026-05-09

Added

Changed

Fixed

iOS

The iOS port (in development; not yet on the App Store) catches up to Android parity for: the new undo bar (× + swipe + 3s), QuickAdd autocomplete with the same name-match dedupe, CSV import / export of items + categories, and a full Statistics screen with the 12-week trend chart (Charts framework). The hide-checked-off toggle was added to iOS earlier in the day.

[0.5.6] - 2026-05-08

Changed

[0.5.5] - 2026-05-08

Fixed

[0.5.4] - 2026-05-08

Fixed

[0.5.3] - 2026-05-08

Fixed

[0.5.2] - 2026-05-08

Added

Changed

[0.5.1] - 2026-05-07

Changed

Added

[0.5.0] - 2026-05-07

Added

Fixed

[0.4.0] - 2026-04-30

Added

[0.3.x] - 2026-04 (multiple patch releases)

Added

Fixed

[0.2] - 2026-03

Added

[0.1] - 2026-03

Added