Skip to main content

kitty_sdk 0.14.1 copy "kitty_sdk: ^0.14.1" to clipboard
kitty_sdk: ^0.14.1 copied to clipboard

unlisted

Kitty Flutter SDK — device fingerprint attribution (Flare) plus authentication, user management, licenses and subscriptions (Account) for the Kitty Launch platform.

0.14.1 — 2026-10-20 #

  • Add explicit AppStorePurchaseAuthConfig opt-in and getAppStoreCapabilities, loginWithAppStorePurchase, claimAppStorePurchases, and refreshPurchaseAccountInfo. New login/claim requests require fresh verification-v2 capability, bundle and product checks plus Apple JWS format/binding guards; the server verifies signatures and eligibility. No legacy fallback. Typed configuration and real-owner (409) errors preserve the existing AccountErrorType enum.
  • Opted-in account fetches use the expiry-aware current-purchases view with the existing AccountInfo decoder. This takes precedence over useUsersCurrentV2; v1/v2 defaults and explicit legacy login/claim methods remain available.
  • Opted-in iOS observation uses the guarded purchase login/claim flow and finishes only matching successful rows with a license ID. Strict cleanup confirmation records exact transaction IDs, so an accepted upgrade cannot finish a rejected original purchase. Rejected, missing and partial-error receipts stay unfinished without the legacy give-up limit. Existing session race guards and the 0.13.7 transport behavior remain in use. Login/claim never create credits.

0.14.0 #

  • Behaviour change: KittyAuthFlow logs in by email code again, for any email, as before 0.11.0. Since 0.11.0 the login form first looked the email up (users/exist) and routed by account state: password screen, signup screen, or "account not found" (enableSignup: false). Every app that updated the SDK silently got that flow, and new users could no longer get in (PixTuner, 2026-09-29). Now submitting an email always requests a login code, with no account lookup and no password or signup screens; the code logs the user in, and the account service creates the account for an unknown email.
  • New: KittyAuthUiConfig.routeByAccountState (default false) keeps the 0.11–0.13 flow for apps built around it (Nidrana): account lookup first, then password, code, signup or "account not found" per enableSignup. Hosts that rely on the password/signup screens must set routeByAccountState: true when updating to 0.14.0, otherwise their users get a code instead of the password screen.
  • enableSignup only has an effect together with routeByAccountState. onlyProjectExisting still compiles and is still sent, but the account service ignores it since 2026-10-20 (every valid email gets a code), so apps can drop it.
  • The bind-email flow and hosts that drive KittyAuthCubit themselves (sendCode / continueWithEmail) are unchanged.
  • 3 new tests.

0.13.7 #

  • Fixed: login is no longer blocked by "Check your internet connection" on a connected iPhone. 0.13.5 narrowed the connectivity pre-check that runs before every account request, but iOS can still report no usable interface at all ([none]) while the device is online — right after launch or resume, or during a network switch — so the request was refused without being sent and a retry went through. The pre-check is gone: every account request is attempted, and "Check your internet connection" (AccountErrorType.noInternet) now appears only when the request itself fails to reach the server (SocketException / http.ClientException). HTTP errors, timeouts and the App Store purchase observer's retry behaviour are unchanged. connectivity_plus stays a dependency.
  • Error type change for hosts that call KittyAccount directly. The account methods that used to run the pre-check (login, signup, send/verify code, reset password, App Store / Play Store login and claims, isUserExist, canLoginWithPassword, Firebase token, login-code exchange) now throw AccountException(AccountErrorType.noInternet) for a transport failure, where a raw SocketException / http.ClientException used to escape. Code that already handled noInternet needs no change; code that caught those raw exceptions should match noInternet instead.
  • 9 new tests.

0.13.6 #

  • Fixed: the token count on the account page lines up with the chevrons of the other rows. The count was right-aligned to the row edge, a few points to the right of the column the other rows end in with their chevron, which showed on hosts whose menu items have no horizontal padding. It now sits in a column as wide as the chevron (KittySettingsMenuItemTheme.arrowSize, default 20), centred on it. A longer count grows to the left, and the loading indicator uses the same column. The value style is unchanged and still set with KittySettingsTokensTheme.balanceValueStyle.
  • 2 new tests.

0.13.5 #

  • Fixed: a first login no longer fails with "Check your internet connection" on a connected iPhone. throwIfNoInternet, which runs before every account request, treated any ConnectivityResult.none in the list as offline. iOS can report [none, wifi] transiently (a down interface next to a working one, or before NWPathMonitor's first real path), so the first send-code / login-password / login-appstore was refused and the second attempt went through. Offline is now only a non-empty list that is entirely none; an empty list is treated as online and the request itself reports any real network error. New pure helper isOffline(List<ConnectivityResult>). Behaviour on a genuinely offline device is unchanged.
  • The Apple button now follows the Kitty Sign In design and the host's primary button. Default copy is Continue with Apple (was Sign in with Apple; set appleButtonText to keep the old one). Unset, its height follows the host's primaryButtonStyle (fixedSize, then minimumSize) and its corners follow that style's shape (StadiumBorder = pill, RoundedRectangleBorder = its radius), so the two buttons read as a pair without configuration; with no host style the design applies: as wide as the form, 56 high, pill. The gap above it is 16 (was loginSubtitleSpacing ?? 24). New opt-in overrides on KittyAuthTheme: appleButtonWidth (fixed width, centred), appleButtonHeight, appleButtonBorderRadius, appleButtonStyle (SignInWithAppleButtonStyle, re-exported) and appleButtonSpacing. KittyAppleSignInButton takes an optional theme. Hosts that do not enable Apple sign-in see no change.
  • A host with no primaryButtonStyle gets the Kitty primary button: as wide as the form, 56 high, pill (KittyAuthTheme.kittyDefaultPrimaryButtonStyle, via effectivePrimaryButtonStyle), instead of Material's default sizing. Colours still come from the host theme. A host that sets primaryButtonStyle sees no change.
  • Fixed: the password and signup forms no longer show a dangling "Wrong password. Please try again or". The default wrongPasswordError was written to end in the contactUsText link, like genericErrorText on the login form, but the password form put the string into InputDecoration.errorText, which cannot hold a link. The error now renders below the field exactly as on the login form: prefix plus the underlined "contact us" that opens the support email (errorTextStyle, errorTopPadding apply). A failed signup shows genericErrorText instead of wrongPasswordError. A host that overrode wrongPasswordError with a full sentence still sees that sentence, now followed by the link.
  • Fixed: settings pages no longer show the previous page through the push transition when the host's scaffold background is translucent. The flow pushes its pages inside its own Navigator, so a translucent Scaffold ghosts the outgoing page's rows and arrows during the slide. Unset KittySettingsPageTheme.backgroundColor now resolves to the host's scaffold colour when it is opaque and to ColorScheme.surface when it is not (KittySettingsPageTheme.resolveBackground). An explicit backgroundColor, Colors.transparent included, is used as given, so a host that paints its own shell behind the flow keeps it.
  • Fixed: the Tokens row on the account page lines up with the other rows when the host themes KittySettingsMenuItemTheme. The row read its padding, icon size, icon colour, icon-to-text gap (fixed 14) and text style only from KittySettingsTokensTheme, so a host that themed the menu items but not the tokens theme got a misaligned coin icon. Unset balancePadding / balanceIconSize / balanceIconColor / balanceTextStyle now fall back to the menu item theme's padding / iconSize / iconColor / textStyle, and the icon-to-label gap follows iconTextSpacing. The gap is also settable on its own with the new KittySettingsTokensTheme.balanceIconSpacing. A host that sets the tokens theme explicitly sees no change.
  • 7 new tests.

0.13.4 #

  • New, opt-in: AI data consent (opt-in via consentConfig): SDK-owned local consent store, first-visit and generate dialogs, Data → Manage Data settings screen. Pass a KittyConsentConfig to KittySdk.initialize(consentConfig: ...) and the SDK creates KittySdk.consent (a KittyConsent), awaiting its first storage read before initialize returns. Hosts that pass no config see no change: KittySdk.isConsentEnabled is false, no Data row is shown, and KittySdk.consent throws a StateError. The module covers Apple App Review Guideline 5.1.2(i) (disclose the third-party AI provider and ask before the first transfer) and 5.1.1(ii) (an easy way to withdraw).
  • Storage is local only, per Kitty account per device. Records live in SharedPreferences under kitty_ai_consent_<projectId>_<accountId|guest>, as {status, updatedAt, policyVersion}. There is no backend call and no cross-device sync. Consent never moves across identities: a guest's answer does not carry over after login. Logout keeps the record; deleting the account through KittySettingsFlow removes it (a failure is tracked as ConsentClearFailed and does not stop the deletion). Calling KittyAccount.deleteAccount() or markForDeletion() directly bypasses that cleanup and leaves the key behind; since deletion is a reversible mark, a restored account would read its old choice again. Such hosts should capture the account id first and then await KittySdk.consent.clearForAccount(id). A record written under a different policyVersion (default 'ai-v1') reads as unknown, so changing it asks every user again.
  • Fail closed. Any uncertainty counts as not granted: an unreadable or corrupt record, a storage error, and a granted save the platform has not confirmed all read as not granted. The status is derived on every read from the live identity's stored record, so an identity change takes effect at once. During a login, until the account fetch completes, the identity is still guest and the guest's answer applies (a guest granted included); the account's own record applies once it has loaded. A failed grant throws KittyConsentWriteException and leaves the status unchanged; the dialog stays open with saveErrorText. A failed withdrawal is never rolled back: the status stays declined, the key is removed so a restart cannot read a stale granted, and the write is retried on the next one (in this session only). All writes run on one serialized chain, and a dialog writes its answer under the identity that was active when it opened.
  • Host calls. KittySdk.consent.promptIfUnknown(context) shows the blocking first-visit dialog when the status is unknown. await KittySdk.consent.ensureConsent(context) gates each user-initiated AI action: it returns true at once when granted, otherwise shows the generate dialog and returns true only after "Agree & Generate" has been persisted. The SDK does not block generation itself; host repositories that send data to the AI provider throw the exported AiConsentRequiredException when !KittySdk.consent.isGranted.
  • Settings. KittySettingsFlow shows a Data row after Legal Info (guests too) when consent is enabled, or when a consent: is passed to the flow. It opens KittyManageDataPage, also exported: a switch that grants on ON and, on OFF, asks in a centred "Turn off AI features?" pop-up (Keep Enabled, Turn Off) before declining; a tap outside or the system back button keeps consent on.
  • Styling and custom UI. Every string is a KittyConsentConfig field with an English default, with {appName} and {providerName} placeholders. Visual tokens are KittyConsentTheme, passed to the dialogs directly or through the new KittySettingsTheme.consent. With no theme every colour and text style comes from the host's Theme (ColorScheme, TextTheme, DialogTheme); the SDK hardcodes no brand colour, and cardColor, barrierColor, primaryGradient/primaryColor, secondaryColor, outlineColor (button border), destructiveColor, toggleActiveGradient/toggleActiveColor, toggleInactiveColor, toggleThumbColor and toggleBorderColor (off-track border, none unless set) set every colour a design needs. The turn-off pop-up and the Manage Data switch follow the Figma layout and type sizes. Three optional builders on KittyConsentConfig replace the UI while the SDK keeps state, persistence, the busy guard, error handling, results and analytics: dialogBuilder (content of both dialogs, with a KittyConsentDialogController), turnOffDialogBuilder (the whole turn-off pop-up, KittyTurnOffDialogController) and manageDataBuilder (body of the Manage Data page, KittyManageDataController).
  • Analytics through the existing onAnalyticsEvent: AiConsentShown, AiConsentGranted, AiConsentDeclined, AiConsentWithdrawn, ManageDataOpened, SettingsDataClicked, AiConsentStorageError and ConsentClearFailed. Properties are limited to status, source and policy version (storage errors carry the operation and error type); no images, prompts or emails.
  • Layout hooks for an exact design. New, all optional, on KittyConsentTheme: paragraphSpacing (splits the dialog body on blank lines, \r\n included, into paragraphs this far apart; still one text for screen readers), titleBodySpacing, bodyLinksSpacing, contentButtonsSpacing (first-visit and generate dialogs only), linkGradient (link text and underline), manageDataPadding, manageDataCardColor, manageDataTitleStyle, manageDataSubtitleStyle and turnOffDialogTopOffset (top-anchors the turn-off pop-up; null keeps it centred; never closer than 24 to an edge; ignored with turnOffDialogBuilder). New on KittySettingsPageTheme: actionIconTheme, applied to every page inside KittySettingsFlow (e.g. a custom back button).
  • Default changes.
    • Both consent dialogs are at most 343 wide with a 16 side inset, like the turn-off pop-up. They were 327 wide with a 24 inset on a 375 screen, and nearly full width on tablets and in landscape.
    • The Manage Data card always pads by 16 and rounds by 16. It no longer takes menuItem.padding / menuItem.borderRadius, so a vertical-only menu padding no longer puts the text and the switch on the card's edges.
    • Consent dialog buttons stay pinned below the text while the text keeps at least a third of the card; otherwise text and buttons scroll together, so nothing overflows even at 3x text.
  • No new runtime dependencies and no new minimums.

0.13.3 #

  • Fixed: a purchase made while a login is still loading its account is now claimed for that login (S1). This is the known issue of 0.13.1. The App Store observer now claims under any stored Kitty session: an authenticated one, or Kitty credentials whose account is not loaded, because a login is still fetching it, its fetch failed, or a launch could not refresh it. Until now such a purchase took the signed-out path, and its automatic App Store login replaced the credentials just stored. The failure policy is unchanged: a refused refresh holds the purchase without counting an attempt, and the next launch's session restore clears the refused credentials, so the hold ends. A legacy session (adoptLegacySession) whose account is not loaded still takes the signed-out path.
  • Fixed: a stale automatic App Store login no longer replaces a newer session or finishes its purchase (S1b). A login, a logout, a legacy session adoption (adoptLegacySession) or the loss of a stored session can land while the observer's automatic login is in flight. That is a session change, and whatever the automatic login's request then returns is discarded: its credentials are not stored, a "no account" or refused answer no longer finishes the purchase, and a failed request is not reported as a failed login. The same holds when the observer's 30-second timeout fires after such a change. The automatic login stays under this rule after it has stored its own credentials: a session change landing while it fetches its account, or while it attaches its sid after a successful fetch, discards that outcome too — whether the fetch succeeds, fails (404, a transport error, 5xx, 429, a timeout inside it) or ends in a verdict on those credentials, before that verdict clears them or while the clear persists. Its account is then not reported (onAppStoreAutoLogin does not fire), a failed fetch neither finishes the purchase nor counts as a failed login, and the purchase goes the way described next. Only the automatic login's own credential install and the clear its own refused fetch performs are its own changes, and never count. If a Kitty session exists when an outcome is discarded, the observer claims the purchase under it at once, a single time, without waiting for StoreKit. If that session ends before the claim runs, the purchase is held; it never starts another automatic login. Without a Kitty session, the purchase is held without counting an attempt, and StoreKit redelivers it. Nothing else counts as a session change: a token refresh and a refresh while signed out end as before, and so does a failure of the automatic login's own account fetch with no other session change meanwhile. A claim that succeeds still binds the purchase to the session that sent it.
  • New: KittyAccount.appAccountToken (S2). It is the id of the signed-in, loaded account when that id is a UUID, and null otherwise. Apps with their own StoreKit code pass it as appAccountToken (StoreKit 2) or applicationUserName (StoreKit 1), and pass nothing when it is null. It is the value the SDK's own paywall purchases send. KittyAccount.I still throws before initialization.
  • Backend change hosts will see, on 0.13.2 too: a signed-out restore can be refused with 409. With the Kitty backend's App Store attribution update, login-appstore no longer signs a device in to an account that has a real email. When the purchase already belongs to such an account (for example, a signed-in user claimed it, its anonymous buyer later attached an email, or the account was imported with one), the backend answers 409 (appstore_purchase_claimed). The user then has to sign in to that account with one of the SDK's logins (email code, password or Apple), and the SDK claims the purchase for it. The SDK handles the 409 like any other refused automatic login, with no code change: it finishes the transaction without signing in (a restored StoreKit 2 copy cannot be finished and comes back on the next launch), emits AppStoreAutoLoginFailed (error: ApiException) and LoginFailed (method: appstore), and passes ApiException(statusCode: 409) to onAppStoreObserverError. The observer restores purchases at every start, so a signed-out device whose subscription belongs to such an account reports this once per launch (and again after each paywall Restore) until the user signs in; treat it as expected rather than as an error to report. An explicit KittyAccount.loginWithAppStore call throws the same ApiException. The paywall's Restore (InAppBuyNowBloc) reports success whenever StoreKit returns a purchase it recognizes; it never signs the user in, so after a restore on a signed-out device ask the user to sign in.
  • Changes a host app can notice:
    • New analytics event AppStoreAutoLoginSuperseded, with replay (whether the purchase was queued for an immediate claim). After AppStoreAutoLoginStarted, a superseded automatic login emits only AppStoreAutoLoginSuperseded: no LoginFailed, AppStoreAutoLoginFailed, AppStoreAutoLoginNoAccount or AppStoreAutoLoginSuccess. Two account events can still precede it, each reporting something that did happen: LogInSuccess (method: appstore) when the session changed while the login was attaching its sid after fetching its account, and SessionLost when it changed while the login's own refused fetch was clearing its credentials.
    • onAppStoreObserverError receives a SessionSupersededException when a superseded automatic login leaves its purchase held. A purchase queued for the immediate claim reports nothing.
    • With stored Kitty credentials but no loaded account, an iOS purchase is now claimed for that session. The observer calls claim-appstore-subscriptions with the session's bearer, and only onAppStoreAutoClaim fires. Before, it logged in automatically with login-appstore: onAppStoreAutoLogin fired before onAppStoreAutoClaim, and the app switched to the purchase's virtual txn_*@appstore.internal account.
    • A superseded automatic login never fires onAppStoreAutoLogin. When its purchase is replayed, the claim fires onAppStoreAutoClaim for the newer session.
  • Known gaps:
    • A stored legacy session that is exchanged for a Kitty session (by a refresh) while an automatic App Store login is in flight can still be replaced by that login. The legacy follow-up covers it.
  • The suite grows from 498 to 563 tests:
    • stored-session routing on the real account stack, including legacy pairs;
    • superseded automatic logins for every outcome (credentials, no account, refusal, transport failure, timeout, and an offline connectivity check before the request), and an offline check with no session change still held as before;
    • failures of the automatic login's own account fetch, a timeout inside it included, which supersede nothing;
    • a session change after the automatic login stored its credentials: during its own account fetch (a success, a 404, a transport error, a 500, a 429, a timeout inside the client, and a 401 or 403 verdict, before and during its clear) and during the sid attach that follows a success — a login replays the purchase under its session, a logout or a lost session holds it;
    • the single replay and a replay merged into a queued delivery, each ahead of a post-login re-claim that never answers, and the merged replay holding after a logout;
    • logouts during the flight, a stopped and a restarted observer, and a legacy adoption;
    • a signed-out refresh that supersedes nothing, and the session change at each session-lost clear;
    • appAccountToken on a facade built by KittyAccount.initialize, including a persisted account with no credentials.

Corrections to the 0.13.0 notes #

  • "The observer first auto-logs in with the purchase; the backend finds or creates the App Store account for any transaction it can verify." — Since the Kitty backend's App Store attribution update, the backend does so only when no account with a real email owns the purchase. Otherwise it answers 409, a deterministic refusal, and the purchase is finished without a login (see the 409 note above).

0.13.2 #

  • New, opt-in: KittySettingsConfig.licenseTitleBuilder to choose the title a licence is shown under. App Store and Google Play licences carry the raw store product id as their name (for example info.holdstill.app.coins.300), and the backend has no display name to offer, so the settings flow showed that id to users. The host knows its own product ids, so it can now map them: licenseTitleBuilder: (l) => titles[l.productId] ?? l.name. When set, the builder's value replaces License.name as the title on the "Manage subscriptions" cards and list, the one-time purchases page, the subscription detail page (app bar and card), the cancel page, and the plan name in the post-deletion store-subscriptions notice. It is a render-time mapping only: analytics payloads, the account cache and every comparison keep using the licence as the backend sent it. Off by default (null), and with it off nothing changes: every screen shows License.name exactly as in 0.13.1. The new helper licenseTitleOf(license, config) resolves the title, and resolveWithSdkConfig carries the builder through.
  • The suite grows from 490 to 498 tests: the helper with and without a builder, the builder threaded through resolveWithSdkConfig and the store-subscriptions label, and pumped settings flows covering the one-time purchases page (mapped and raw), the subscription card, the detail page, the cancel page and the legacy list.

0.13.1 #

  • Fixed: KittyBuyNow purchases abort on Android (since 0.10.0). BuyNowHelper.buySubscription, used by InAppBuyNowBloc and BuyNowMessageHandler, ran its iOS-only StoreKit queue cleanup before every purchase, on every platform. On Android the first StoreKit call threw PlatformException(channel-error), so the purchase never reached Google Play. Outside iOS and macOS the StoreKit queue reads now return an empty queue without calling the plugin, so the cleanup does nothing there and the purchase goes to Google Play. The StoreKit queue reads on iOS and macOS are unchanged.
  • Fixed: the pre-purchase cleanup could finish an unconfirmed renewal. In 0.13.0, BuyNowHelper treated a pending transaction as confirmed when its own id or its original transaction id was in the session's confirmed set. A transaction now counts as confirmed only when its own id is in that set. Every claim confirmation records the confirmed transaction's own id, decoded from its signed transaction, even though the backend keys its rows on the original id. What a host can see:
    • A held renewal is no longer finished because another transaction of its subscription was confirmed in the session. In 0.13.0 it was finished as soon as any new purchase started — for any product, from the in-app or the web paywall — and StoreKit stopped redelivering it.
    • It now waits for its own confirmation, retried when StoreKit redelivers it on the next launch, or for the give-up on the fifth counted launch.
    • Meanwhile, buying the same product ends in "purchase pending" (see the 0.13.0 known limitation).
    • This also covers an older unfinished renewal superseded by a confirmed current one: the current one's confirmation no longer finishes it.
    • A renewal confirmed by its own claim is still finished. A subscription's original purchase still counts as confirmed once any later transaction of that subscription is confirmed, because the backend keys the subscription by its original transaction.
  • Known issue — a purchase processed while a login is still loading its account takes the signed-out path. From the moment a login stores its credentials until its account fetch commits — and, if that fetch fails transiently, until a later refreshAccountInfo() succeeds — isAuthenticated is false. A purchase the observer processes then is auto-logged-in with its own App Store account instead of being claimed under the stored credentials: the purchase is credited to that App Store account and its session replaces the one just stored. This already applied to logins from a signed-out state; since 0.13.0 it also applies when a login replaces a signed-in user. A follow-up release claims under the stored Kitty session instead.
  • The suite grows from 478 to 490 tests: the StoreKit channel boundary with no handler registered (Android, Windows, Linux), no StoreKit call on Android, and the cleanup still running on iOS and macOS; own-id renewal matching in the cleanup for StoreKit 1 and StoreKit 2, end to end through the purchase observer and through the duplicate-purchase guard; and the credential install's account drop, null emit and token swap, pinned while both of its persistence writes are parked.

Corrections to the 0.13.0 notes #

  • "It finishes only backend-confirmed transactions, across the StoreKit 1 queue and StoreKit 2's unfinished list." — It finishes only transactions confirmed in the current session, across the StoreKit 1 queue and StoreKit 2's unfinished list. In 0.13.0 a transaction counted as confirmed when its own id or its original transaction id was confirmed, so the cleanup could finish a renewal whose own claim was still held (fixed in 0.13.1, above).
  • "Android purchase behavior is unchanged; no Google Play code was touched." — Android purchase behavior was unchanged by 0.13.0, and that included a KittyBuyNow failure present since 0.10.0: BuyNowHelper.buySubscription, used by InAppBuyNowBloc and BuyNowMessageHandler, runs an iOS-only StoreKit queue cleanup before buying. On Android that call throws PlatformException(channel-error), so the purchase never reaches Google Play (fixed in 0.13.1, above).
  • "The unnamed constructor is unchanged, and so is its toString(); on SDK-raised instances toString() now also prints credentialsAbsent and judgedEpoch." — The unnamed ReauthRequiredException constructor stays source-compatible; it gains an optional second positional originStatusCode. Its toString() changes from 0.12.4's ReauthRequiredException(<message>) to ReauthRequiredException(<message>, originStatusCode: <status>), and SDK-raised instances also print credentialsAbsent and judgedEpoch.
  • "…and refreshAccountInfo() (run by the next launch's initialize) completes the session without a new login." — This left out purchases. Until that refresh succeeds, purchases also see a signed-out device: an iOS purchase made or redelivered in that state goes through the signed-out path, whose auto-login signs in to the App Store account the backend finds or creates for the transaction, replacing the new user's stored credentials. A first login whose account fetch fails has always behaved this way. Call refreshAccountInfo() after a failed login to restore the session sooner (see the known issue above).

0.13.0 #

  • iOS App Store purchases are finished only after the backend confirms them. AppStorePurchaseObserver now owns transaction finishing on iOS while it runs. It syncs each purchased/restored transaction first (an auto-login when signed out, then the claim), decodes its JWS payload client-side, and calls completePurchase only once a claim row for that transaction has status created / existing / transferred. The claim endpoint answers HTTP 200 even when it rejects individual transactions: it reports failures per row, and error rows carry originalTransactionId: "unknown". So a 200 alone no longer finishes anything. Unconfirmed transactions stay unfinished, and StoreKit redelivers them once per launch — that redelivery is the retry loop. The observer finishes a transaction without a confirming row only in these cases: a non-entitling update (canceled, failed); an entitling one with no signed transaction to send (empty serverVerificationData); a signed-out purchase whose auto-login is deterministically refused (see the failure policy below); and a purchase given up after the launch bound. Without a running observer, the paywalls finish immediately as before (see finisher ownership below).
  • Failure policy. These failures hold the transaction with no attempt counted: offline, transport errors, timeouts, 5xx, rate limiting (429), and a session superseded by a concurrent login (SessionSupersededException, below). Other 4xx rejections count toward the bound. That includes a reauth failure originating from a 403, which ReauthRequiredException now reports via its new originStatusCode. A reauth failure with a 401 origin, or with no origin status at all, holds free: the dead session self-heals, and the receipt stays claimable after re-login (a logout racing the claim is one such case; see the next bullet). A claim that answers but does not confirm a row (error row, missing row, undecodable JWS) also counts. A counted transaction is held for a bounded number of launches. The count is kept per transaction in SharedPreferences (key kitty_purchase_claim_attempts, with try/catch-wrapped reads and writes). On the fifth such launch the transaction is finished, so it cannot block same-product purchases forever. The give-up is logged, reported to onAppStoreObserverError as a StateError, and tracked as the AppStoreClaimGivenUp analytics event. Only finishable copies spend that budget. The restored copies that StoreKit 2 redelivers on every launch cannot be finished. When such a copy is unconfirmed, it neither burns an attempt nor triggers the give-up, so it cannot reset the counter for the real purchase. Signed-out buyers go through the same gate. The observer first auto-logs in with the purchase; the backend finds or creates the App Store account for any transaction it can verify. The observer then claims the purchase under that account. Such a purchase is finished without confirmation only when the login is deterministically refused: a 404 (no transaction the backend could verify), or another 4xx except 429. A relaunch retry would fail the same way, and holding the purchase would lock same-product rebuys out through StoreKit's duplicate guard. A transient login failure (offline, timeout, transport, 5xx, 429) holds the purchase, with no attempt counted.
  • A logout during a purchase claim does not count as a backend rejection. When a logout lands while a claim's request is in flight (typically while it is reauthorizing after a 401/403), the request fails with a credentials-absent ReauthRequiredException: isCredentialsAbsent is true and there is no originStatusCode (see below). It does not fail with a rejection carrying the request's 401/403. So the observer holds the purchase: no launch attempt is spent, the purchase is not finished, and it is retried when StoreKit redelivers it on the next launch. It does not move toward the give-up.
  • StoreKit finishes and the observer's backend calls are time-bounded. The plugin's StoreKit 2 finish(id:) never answers its callback when the id has already left Transaction.all (finished consumables do). That could hang the serial drain worker, which would leave iOS with no finisher at all. These calls now have bounds: every StoreKit finish the SDK makes (observer, queue seam, buy-now cleanup, BuyNowHelper.completePurchase) times out after 10s; the observer's login and claim round-trips (the claim including its post-claim account refresh) after 30s; and BuyNowHelper's whole pre-purchase cleanup, queue reads included, after 10s. Every observer timeout is logged and reported to onAppStoreObserverError. A login or claim timeout is classified as transport: the transaction stays unfinished, no attempt is burned, and it redelivers next launch. That covers the anonymous auto-login too: a timed-out login HOLDS the batch instead of taking the signed-out finish path. The login may still succeed after the deadline, and finishing then would strand the claimed receipt. A timed-out pre-purchase cleanup is swallowed, not propagated: it logs and proceeds to product.buy() rather than aborting the purchase. Not every await is bounded, though. These have no timeout of their own: the restorePurchases() that KittySdk.initialize awaits when the observer starts (unchanged from earlier releases); the post-login sweep's restorePurchases(), where a hang stalls the observer's queue until the next launch; the StoreKit queue reads in the duplicate-purchase recovery; and BuyNowMessageHandler's purchase call (the in-app paywall keeps its 60s bound). And because a timed-out await abandons the result but not the repository's side effects, overlapping account refreshes are generation-guarded: a stale refresh completing after a newer one committed can no longer overwrite the newer snapshot (which would hide a just-claimed entitlement locally). The login-path account fetches (loginWithAppStore, loginWithPlayStore and the email/code/Apple logins) share that guard: an abandoned login whose fetch straggles past a second login's post-claim commit no longer saves or emits its stale pre-claim snapshot over the newer state, and a stale fetch arriving after a logout leaves the cleared session cleared.
  • FIFO purchase queue replaces the drop guard. The observer used to drop any purchase batch that arrived while another was processing. Batches are now enqueued synchronously and drained by one serial worker; deduplication is keyed on the JWS string itself (transaction ids regenerate on SK1 restores; deduping on originalTransactionId would suppress legitimate renewals), and a queued restored copy is upgraded by its purchased copy, never the reverse. Authentication is read at dequeue time, so a login mid-queue governs later batches.
  • Finisher ownership is per platform — and per observer. The buy-now bloc's and the web-paywall handler's finally-finishers defer to the observer only on iOS AND while an observer is actually running (it takes ownership in start() after its guards pass and releases it in stop()); with enableAppStoreObserver: false, or KittyBuyNow used without KittyAccount, those finishers keep finishing immediately as before. On Android they always finish immediately — completePurchase is Play's acknowledgement, and unacknowledged purchases auto-refund in ~3 days. BuyNowHelper's pre-purchase queue cleanup no longer drains the StoreKit queue. It finishes only backend-confirmed transactions, across the StoreKit 1 queue and StoreKit 2's unfinished list. A storekit_duplicate_product_object with a still-unconfirmed transaction for that product now surfaces the existing "purchase pending" outcome (BuyNowPurchaseNotCompletedException). Before, the SDK drained the queue, the paid purchase included, and retried. This part is not gated on the observer, and only a running observer confirms transactions. So in three setups the cleanup finishes nothing: enableAppStoreObserver: false, KittyBuyNow without KittyAccount, and macOS. There, a leftover unfinished transaction for the product being bought always ends in "purchase pending" (see the known limitation below). Android purchase behavior is unchanged; no Google Play code was touched.
  • iOS purchases carry the user id. This applies when KittyAccount is initialized and authenticated, and the user id is a valid UUID. StoreKit 2 purchases then send the id as appAccountToken (SK2ProductPurchaseOptions), and StoreKit 1 purchases send it as PurchaseParam.applicationUserName. Otherwise the option is omitted with a debug log, because StoreKit silently drops invalid tokens. The id lets the backend attribute App Store server notifications to the account; nothing changes on the device.
  • Post-login consolidation. On every signed-out → signed-in transition (the observer's own auto-login included), the observer runs one restorePurchases() sweep. Held subscriptions and non-consumables bought before the login are then claimed under the new account. Consumables are not swept: the StoreKit 2 restore walks Transaction.currentEntitlements, which excludes them. A held consumable (a coin pack) bought before the login is claimed when StoreKit redelivers it on the next launch.
  • Floors raised (breaking for consumers on older toolchains): in_app_purchase_storekit ^0.4.10 (the logic relies on 0.4.8+ purchase-stream semantics and the 0.4.10 duplicate guard; ^0.4.0 already resolved there) and, following that package's own requirements, Dart SDK >=3.10.0 / Flutter >=3.38.0 (previously >=3.3.0 / >=3.19.0).
  • Scope note — StoreKit 1. Confirmation-gated finishing is built for the plugin's StoreKit 2 default. in_app_purchase_storekit has used SK2 since 0.4.0 unless an app calls InAppPurchaseStoreKitPlatform.enableStoreKit1(). Under StoreKit 1 the observer still owns finishing. But an SK1 purchase carries the app receipt rather than a signed transaction, and the backend cannot confirm a receipt. A signed-in purchase is held and then finished with an error on the fifth launch. A signed-out one is finished once its auto-login finds no account. Aligning SK1 receipt handling is a ticketed follow-up.
  • Known trade-offs, accepted by design: (a) only a running observer populates the confirmed-transaction set. On macOS (the observer is iOS-only) and on iOS without a running observer, the duplicate-guard recovery therefore degrades to the "purchase pending" surface instead of queue cleanup. The paywalls still finish those transactions immediately when they are delivered. (b) The 5-launch bound on held transactions is best-effort: permanently broken SharedPreferences degrades it to session-local counting, so such a device holds longer than 5 launches. (c) After a login, the post-login sweep grants a subscription or non-consumable immediately. The StoreKit finish of an already-delivered transaction can still lag one launch: SK2 restored copies are not finishable in-session, and the relaunch redelivery finishes it.
  • Known limitation — "purchase pending" never reaches the user. The in-app paywall (InAppBuyNowBloc) handles the outcome as a cancellation and returns to the product list; it also tracks BuySubscriptionPending. BuyNowMessageHandler closes the web paywall as an error. So while a purchase for a product is held, buying that product again fails silently. That lasts until the hold resolves: on confirmation, or with the give-up on the fifth counted launch.
  • Other changes a host app can observe. onAppStoreAutoClaim now also fires after an anonymous auto-login. The observer claims right after it logs in; an auto-login used to stop at the login. The AppStoreAutoClaimStarted, AppStoreAutoClaimSuccess and AppStoreAutoClaimFailed analytics events follow it as well. onAppStoreObserverError now also receives a StateError when a purchase is given up, and errors from the post-login sweep. It also receives the TimeoutExceptions of finish (10s), auto-login and claim (30s) timeouts. There is a new analytics event, AppStoreClaimGivenUp (productId, attempts). SessionLostIgnored, and SessionLost now also firing from the login paths, are described below. ReauthRequired, TokenRefreshFailed and LegacyExchangeFailed can now carry reason: superseded_by_login (on ReauthRequired without an endpoint). It means a login overtook the reauthorization and the caller got SessionSupersededException. No session was lost, so dashboards that count ReauthRequired as forced logouts may want to filter it out. There is a new SharedPreferences key, kitty_purchase_claim_attempts. It holds a JSON map of per-transaction launch counts, capped at 100 entries.
  • A transient token-refresh failure no longer logs the user out. ReauthRequiredException is now reserved for a session the server has deterministically refused. That means one of four cases: the refresh endpoint judged the refresh token and rejected it (4xx other than 429); there was no token to offer; the session was explicitly ended mid-refresh; or, as before, a request kept getting 401/403 through the retry limit despite successful refreshes (unless a login replaced the session mid-chain; see SessionSupersededException below). Its new optional originStatusCode carries the 401 or 403 that triggered it, when known; the isCredentialsAbsent bullet below says when it is null. A refresh can also fail without a verdict: 5xx, 429, the 15s refresh timeout, a transport error, or an unreadable 2xx body. That now surfaces as the underlying error instead of masquerading as a dead session: ApiException for HTTP statuses, TimeoutException, the transport exception, or a FormatException/TypeError from the body. AccountRepository then keeps the tokens and cache (the failure lands in the existing keep-the-cache path), and the next request simply refreshes again. Coalesced concurrent requests all receive that same transient error, never a false "dead". Some hosts read ReauthRequiredException from an authenticated call as "the refresh failed". They now see these types for transient failures instead and should not treat them as a logout. The purchase observer needed no change: these shapes already classify as transient there, so held transactions stay held.
  • New transient error a host can see: SessionSupersededException, exported from package:kitty_sdk/kitty_sdk.dart. A login can replace the session while a token refresh or legacy exchange for the old session is still in flight. That reauthorization now fails with SessionSupersededException instead of reporting the new session as dead (see the session-epoch bullet below). So does a request whose 401/403 retry chain began under the old session and ran out of retries after the login: it no longer ends in ReauthRequiredException, which callers answer destructively. The SDK's own consumers treat it as transient. In that rare login race it can surface from calls made through KittyAccount.authorizedHttpClient or the tokens module; catch it by type and retry, and the retry runs under the new session.
  • ReauthRequiredException tells a refused credential from an absent one. The new isCredentialsAbsent is true when there was no credential left to offer: none was stored, or a logout ended the session while the request was in flight. Nothing was judged then, so originStatusCode is always null. Otherwise the server refused a credential. originStatusCode then carries the request's 401/403 only when the refusal concerns the session the request was sent under; when the 401/403 and the refused refresh token belong to two different sessions, it is null. Hosts should branch on isCredentialsAbsent and originStatusCode only. judgedEpoch is an opaque SDK-internal counter, null on an exception the SDK did not raise. The new ReauthRequiredException.rejected and ReauthRequiredException.credentialsAbsent constructors and judgedEpoch are annotated @internal (package:meta): hosts should not use them, and the analyzer warns if they do. The unnamed constructor is unchanged, and so is its toString(); on SDK-raised instances toString() now also prints credentialsAbsent and judgedEpoch.
  • The reauth-triggered session clear acts only on a verdict about the current session. Every ReauthRequiredException the SDK raises records which session it judged. The destructive clear in refreshAccountInfo (tokens + cached account) fires only when that session is still the current one. A stale request whose delayed verdict lands after a login or logout replaced the session it judged defers to the newer state instead of wiping it (the forced-logout race). A verdict on the current session still clears, even when a login started after the failing fetch: its reauthorization may have judged the login's own credential. A verdict the SDK did not raise clears as before, and an explicit logout() still clears unconditionally. Every login path now applies the same rule to its own account fetch. When that fetch fails with a verdict on the login's own session, the login clears the session and emits SessionLost before the exception reaches the caller; previously the refused credential stayed stored next to the previously cached account and read as signed in. Skipped clears, on either path, are observable as a SessionLostIgnored analytics event.
  • Logout invalidates every in-flight account fetch, synchronously. logout() (and the reauth-triggered clear) used to clear tokens and cache across several awaits without touching the refresh-generation counters, so an account fetch still in flight — an abandoned login's GET, say — could commit its snapshot after the clear and resurrect the dead session's account locally; and a login landing inside the clear's await windows could have its freshly committed state erased and a stale null emitted after its own emit. The whole logical clear is now one synchronous block — session invalidation, generation invalidation (every older fetch yields at its commit check), the in-memory token/cache clears and the null emit — with only persistence awaited after it. A login that commits during those awaits survives the logout that was already in flight, in state and in stream order.
  • Logins declare their session boundary before saving tokens; their snapshot keeps fetch-start ordering. The three login commit paths (loginWithAppStore, loginWithPlayStore and the email/code/Apple logins via the shared auth-success path) declare the credential replacement to the authorized client, advancing its session epoch, in the same synchronous block in which tokenStorage.save swaps the new pair into memory. From that moment, a verdict on any credential read before the swap is attributed to the replaced session: a stale fetch's reauth verdict landing inside the save await cannot clear the login's just-saved tokens (the forced-logout race, closed at its narrowest window), and a reauthorization still in flight for the old session resolves as superseded. The login fetch's own snapshot generation is claimed only inside the commit fetch, immediately before its GET. Claiming it before the save's await would hand the login's fetch a generation older than that of any refresh starting during the await, whose pre-login snapshot could then legally out-commit the login's fresher one — hiding a just-granted entitlement ({A,B} → {A}), the exact class the ordering guard forbids. Generation order always matches fetch-start order.
  • A login that replaces a signed-in user drops the previous account at the credential boundary. In the same synchronous block that declares the replacement and swaps the new pair into memory, every login commit path now also invalidates each account fetch already in flight (it yields at its commit check) and, when an account is cached, clears it (in memory and on disk) and emits null on accountStream. Previously, when the new user's account fetch failed transiently (a 5xx or a transport error), the error propagated with the previous user's account still cached next to the new credentials: isAuthenticated stayed true, currentAccount was the previous user, and the next iOS purchase sent the previous user's id as appAccountToken / applicationUserName while backend calls carried the new user's credentials. A fetch for the previous session landing after the login could also restore its account. Now the new credentials survive such a failure, but nothing presents an account until one is fetched for them: isAuthenticated reads false, currentAccount and the purchase attribution are null, and refreshAccountInfo() (run by the next launch's initialize) completes the session without a new login. Listeners see the replacement as a sign-out followed by a sign-in, so the purchase observer runs its post-login sweep for the new user, as it already did for a logout followed by a login. A login from a signed-out state emits nothing extra.
  • A token refresh that completes — or is rejected — after a login no longer harms the login's session. The client's session epoch now advances on credential replacement (login), not only on logout: invalidateSession({credentialsReplaced}) is the one wired seam, so the stale refresh's save is rejected by the existing epoch check. The supersession check also runs before every deterministic exit: a refresh or legacy exchange whose token is rejected (4xx except 429), or an exchange answered with an empty session, after a login replaced the credentials, resolves the same way — its caller and every coalesced waiter get the transient SessionSupersededException, never ReauthRequiredException, which callers answer destructively (the repository by clearing the new session's tokens, the purchase observer by burning a launch attempt on a 403-origin claim). Refresh coalescing is scoped to the session epoch too: a request issued under the new session never inherits a stale flight's outcome — it waits the flight out and refreshes again under the new session's own refresh token. A login followed by a logout mid-reauthorization still reads as a dead session: a credentials-absent ReauthRequiredException.
  • Overlapping fetches can no longer emit backwards. Both commit sites (refreshAccountInfo and the login fetch) re-check the generation after the cache-save await: when a newer fetch committed while the older snapshot was persisting, the older one neither emits nor returns its stale state — the higher generation wins the final cache and the last emit.
  • The legacy exchange now follows the refresh's transient/deterministic taxonomy. A 5xx, 429, timeout, transport error or unreadable 2xx body from /vault/login-legacy surfaces as the underlying error instead of collapsing to false, which surfaced as ReauthRequiredException and cleared the session over a server hiccup; the purchase observer now holds purchases through such a failure without spending a launch attempt (end-to-end tested through the real client and repository). Deterministic refusals (4xx except 429, empty-token bodies) keep the once-per-launch memoization; a transient failure deliberately resets it so one hiccup cannot poison the whole launch for a legacy user, and coalesced waiters all receive the same transient error.
  • BuyNowHelper.completePurchase is bounded too. The direct finish used by the observer-disabled iOS route was the one unbounded StoreKit finish left; it now carries the same 10s bound, and the expiry is logged and swallowed so one stuck finish never aborts the caller's batch.
  • Vault raw posts now send x-project-id. loginWithApple, logout and validateResetPasswordCode used to post with only content-type. So did VaultApiClient.refreshToken, a helper the SDK itself never calls. They now send the same base headers as the other vault calls (logout keeps its bearer). One exception remains: the token refresh the SDK actually performs, inside its authorized HTTP client, still sends only content-type. The backend currently uses the header on these routes for logging at most. Native Sign in with Apple checks the token's audience against a server-configured bundle-id list, not against the project's App Store settings.
  • New purchase test harness (controllable purchase stream, spy completePurchase, fakeable StoreKit-1/StoreKit-2 queues) and the suite grows from 296 (0.12.4) to 478 tests across the observer, the confirmation gate, the JWS decoder, the attempt store, the finish-ownership gate, the buy-now finishers, the refresh failure taxonomy, the stale-reauth clear guard, the vault project header, the round-5 ordering races (logout vs in-flight fetches, login vs stale refresh/reauth, interleaved cache saves, the legacy-exchange taxonomy end to end, and the bounded direct finish), the round-6 supersession races (a deterministically rejected refresh/exchange landing after a login — client-level, end to end through the repository, and at the purchase observer — plus a login's snapshot racing an independent refresh that starts during its token-save await, in both completion orders), and the round-7 verdict attribution (each reauth outcome stamped with the session it judged; a logout during a reauthorization surfacing as credentials-absent — client-level and at the purchase observer, the last budgeted launch and the anonymous auto-login included; a max-retries chain crossing a login; the epoch-based clear guard in refreshAccountInfo and in the login fetch, unit-level and through the real client and repository; and the exception's public surface and barrel export). The logout-resurrection test specifically removes the interposed refresh that previously masked the missing invalidation.

0.12.4 #

  • New, opt-in: KittyAuthUiConfig.onlyProjectExisting for apps that only let customers of their own project in. Off by default, and with it off nothing changes: same screens, same requests. With true, KittyAuthFlow stops branching on account state. Submitting an email no longer calls users/exist or can-login-with-password, so the password and signup screens are never reached, and goes straight to POST /account/vault/send-code with onlyProjectExisting: true (the first send and "resend" both). The account service then mails a code only if the email has a purchase in the configured project (a relation or any licence, expired ones included), and otherwise sends nothing and answers exactly like a real send. The code screen is therefore shown for every valid email, so the app does not reveal whether an email has a purchase; set codeSubtitle to wording that does not promise a code was sent. The bind-email flow is unaffected. Needs a backend with onlyProjectExisting on send-code; an older backend ignores the field and sends as before.
  • New optional onlyProjectExisting parameter (default false) on KittyAccount.sendLoginCode, AccountRepository.sendLoginCode and VaultApiClient.sendCode. It is added to the request body only when true. A host that subclasses or fakes KittyAccount.sendLoginCode must accept the new named parameter.
  • Why the signup and password screens are skipped rather than just hidden: with the backend's default ACCOUNT_PASSWORD_SIGNUP_AUTOCONFIRM=false, signup-password answers 201 with session: null and emails a verification code, which signupWithPassword cannot use, so signup there ends in an error and an unusable account. Apps that keep the default flow are not touched by this release.
  • 8 new tests (296 in the suite).

0.12.3 #

  • New, opt-in: one-time purchases listed apart from subscriptions. A coin pack bought on the web is stored as a licence whose paymentType is still subscription, so the settings flow showed it under "Manage subscriptions" with a "Next charge date" and a Cancel action — neither of which is true for a purchase that is final. With the new KittySettingsConfig.separateOneTimePurchases: true, "Manage subscriptions" lists only subscriptions, as cards with the cancel (or renew) action on each card, and one-time purchases get a separate read-only page (oneTimePurchasesLabel, default Purchases) listing them newest first with no action and a configurable oneTimePurchasesNotice (default "One-time purchases are final and non-refundable."). The delete-account page stops counting one-time purchases as subscriptions to cancel. New copy/UI fields: oneTimePurchasesLabel, oneTimePurchasesNotice, oneTimePurchaseIconWidget, tokensIncludedSuffix; new card theme fields KittySettingsCardTheme.titleStyle / bodyStyle / actionStyle / actionActiveColor (hover / press / focus); new pages KittyManageSubscriptionsPage / KittyOneTimePurchasesPage; new helpers subscriptionsOf / oneTimePurchasesOf; KittySettingsState.licenseStatusOf(licenses).
  • New: License.isOneTime (from isOneTime / is_one_time, default false, round-trips through the account cache). The backend sends it only on v2 /users/current, derived from the plan's isOnetime flag, so new KittySdkConfig.useUsersCurrentV2 / KittyAccountConfig.useUsersCurrentV2 (default false) makes UsersApiClient.getCurrentUser() call v2 — the v1 contract plus tokensPerCycle, supportsTokens, isOneTime. A host enabling separateOneTimePurchases must enable this too.
  • Both flags default to off and nothing changes without them: same endpoint, same menus, the single-licence shortcut to the detail page, the same list and detail pages. Tests pin the default path (a one-time licence still appears in the old list, a single licence still opens its detail page).
  • 20 new tests (288 in the suite).

0.12.2 #

  • hasEntitlement(key) now returns true for every key when the loaded account's effective email is test@<domain> (case-insensitive). App Store reviewers get access even without licenses or server entitlement keys, including with expired or deactivated licenses.
  • Ordinary accounts still use the server-provided entitlement list. An unloaded account or logout still returns null from KittyAccount.hasEntitlement. The raw entitlements list is unchanged.

0.12.1 #

  • Add KittyAccount.isUserInProject(email: ...) for apps that choose between Kitty and a legacy authentication flow before login. It checks for a Kitty project relation or any license in the SDK's configured project, including an expired license. A lookup error throws so callers cannot accidentally route a Kitty user to legacy auth during an outage. Requires Account API GET /account/vault/users/in-project.
  • Keep the existing global isUserExist behavior unchanged.

0.12.0 #

  • New: Feature Access entitlements — gate features on server-resolved keys instead of plan names. The Kitty platform lets a billing plan declare stable entitlement keys (premium_stories, hd_export, …), and GET /account/users/current and /account/v2/users/current have been returning them per licence and at the account level. The SDK parsed none of them; it does now. AccountInfo gains entitlements (the server-resolved union) and hasEntitlement(key); License gains entitlements (the keys that licence's plan declares) and planId, parsed from either planId or plan_id. All three default to empty, so a v1 payload, an older backend and a previously cached account keep parsing unchanged. Additive: no host changes are required beyond a version bump, and nothing behaves differently for an app that does not call them. No server work is needed — the keys are already on the wire.
  • The account-level array is the server's answer and the SDK takes it at face value. AccountInfo.hasEntitlement reads only AccountInfo.entitlements; it never re-derives a union from licenses. Which licences count towards an entitlement — inactive, hidden, expired, in a grace period — is decided server-side from state the client cannot see, so a client-side union would quietly disagree with the server in exactly the cases that matter. An account whose entitlements is empty reports false even when a licence it carries lists the key, and a test pins that.
  • The gate is tri-state, so callers fail closed. KittyAccount.hasEntitlement(key) returns bool? and KittyAccount.entitlements returns List<String>?. null means no account is loaded — a fresh install before the first login, or after logout() — and must never be read as "granted"; collapsing it into false is the design's one forbidden move, and a behavioural test now fails if an implementation does. The true/false answers describe the most recently loaded account, which is not the same thing as the server's current answer: AccountRepository.initialize() loads the persisted cache before it fetches and then awaits a refresh whenever a session is restored, so a host normally reads the server's answer rather than the disk. When that startup refresh fails — an offline launch — it falls back to the same cache rather than erroring, and nothing in the SDK retries it: the automatic refresh points are login, claim/attach/restore, the settings cubit and web-purchase completion, none of which a cold offline start passes through. A false can therefore be stale until the host itself calls refreshAccountInfo() — on resume, say. That is sharpest right after upgrading, since a cache written by an SDK before 0.12.0 carries no entitlements at all, so a user who genuinely holds a key reads false until such a refresh lands. That is deliberate and it fails closed; the alternative, treating an unknown as granted, would hand out premium access on every cold start and every failed /users/current. Both KittyAccount members delegate to AccountRepository.entitlements / .hasEntitlement, like every other member of the facade, so there is a single source of truth and no second lifecycle to invalidate. Callers should rebuild on accountStream rather than reading once at startup. AccountInfo.hasEntitlement stays a plain bool — at that level an account is loaded, so "no" is an answer rather than an absence.
  • Both fields round-trip through the account cache. License.toJson/AccountInfo.toJson now carry entitlements (and planId), which is load-bearing rather than cosmetic: the cached account is rehydrated through fromJson, so a field missing from toJson would be silently dropped and the gate would flip to false on the next app start. Covered by round-trip tests at both levels, including the nesting.
  • Purl-based attribution is unchanged. LicenseAttribution and the license.attribution?.purl ?? license.productId matching keep working exactly as before and are untouched here; entitlements coexist with them rather than replacing them, and the two parse independently. Deprecating the purl path is a later, separate step.
  • 48 new tests (258 in the suite): 42 under test/account/models/, 5 under test/account/repository/, plus a compile-time surface pin on the facade's two nullable return types. They cover parsing, the empty defaults, malformed payloads (null, string, number, boolean, object, and a list of mixed types), both planId spellings, the cache round-trips, exact key matching, and the "trust the server" invariant. planId is also read through an is String guard rather than a bare cast, so a non-string value yields '' instead of throwing out of AccountInfo.fromJson and taking the login path down with it; tests pin that at both the License and the enclosing AccountInfo level. The 5 repository tests are the behavioural half of the tri-state: they pin null with nothing loaded, false for a loaded account lacking the key, true for one holding it, false/[] (not null) for a loaded account with no keys at all, and a return to null after logout() — so the forbidden ?? false implementation fails a test rather than compiling quietly.

0.11.1 #

  • Fix — account deletion destroyed host data before the account was marked. KittySettingsCubit.deleteAccount() ran the host's onAccountDeleted hook (in Pixtuner: an irreversible purge of the user's photos from R2) before POST /account/users/mark-for-deletion, and swallowed the hook's failure. If the server call then failed or was refused, the user's data was gone and their account untouched — the live production failure. The order is now mark-for-deletion → onAccountDeleted → logout: the hook runs only once the server has durably marked the account, and a refused or failed mark neither invokes it nor logs out. It still runs before logout, on purpose: AccountRepository.deleteAccount() revokes the session and clears tokens, and a hook that authenticates against the host's backend with the live Kitty bearer would fail every time after that. A hook failure remains non-fatal and is still tracked as AccountDataCleanupFailed. The cubit also no longer throws when a host closes it while reacting to the logout.
  • New: KittyAccount.markForDeletion() / AccountRepository.markForDeletion() — the durable half of deletion on its own, without the logout, returning the server's report. deleteAccount() -> Future<bool> is unchanged for existing callers and is now exactly markForDeletion() followed by logout().
  • New: the deletion report — MarkForDeletionResult / MarkForDeletionLicense. mark-for-deletion now also cancels every subscription the server can cancel and reports each licence as cancelled, requiresUserAction (App Store / Google Play — Apple and Google do not let a server cancel these) or failed (the server tried and could not cancel — the gateway's cancel returned false or threw, Stripe included, as it has no server-side cancel here; MarkForDeletionLicense.reason says why), plus alreadyMarked. UsersApiClient.markForDeletionWithReport() parses it; markForDeletion() keeps returning bool. Backward compatible with a backend that has not shipped the report: a 200 with only {success:true}, an empty body, or a malformed body still reads as marked with every list empty — the report is never a precondition. An explicit success:false on a 200 is honoured as a refusal. AccountDeleteSuccess now carries per-bucket counts.
  • Settings: users are told which store subscriptions were not cancelled. After a deletion whose report lists requiresUserAction, the delete page shows a notice naming each App Store / Google Play subscription (plan name when the account knew it, store name otherwise) with a "Manage subscriptions" action that reuses the existing onCancelAppStoreSubscription callback and falls back to the platform's subscriptions page. New showKittyStoreSubscriptionsDialog takes a NavigatorState captured before the deletion — deletion logs the user out, and a host that tears the settings flow down on that signal has unmounted every context inside it by the time the report is back; the root navigator is what survives. New KittySettingsCubit.deleteAccountWithReport() returns the report (deleteAccount() stays Future<void>); new KittySettingsCubit.openStoreSubscriptions(License); new helper storeSubscriptionsToCancel(result, licenses). New analytics: DeleteAccountStoreSubscriptionsShown, DeleteAccountStoreSubscriptionsManageClicked.
  • New KittySettingsConfig copy fields (overridable, English defaults, threaded through resolveWithSdkConfig): storeSubscriptionsDialogTitle, storeSubscriptionsDialogContent, storeSubscriptionsDialogManage, appStoreLabel, googlePlayLabel, plus the storeSubscriptionLabel(License) method.
  • Known gap — the delete/restore copy still promises what nothing implements. The defaults still tell users they "permanently lose access", may restore "within the next 24 hours", and that the account "will be permanently deleted". No 24-hour timer exists and nothing hard-deletes an account: deletion is a reversible mark. The wording is left unchanged in this release by decision; the doc comments on deleteAccountPermanentText, restoreAccountPromptSuffix and restoreAccountPermanentText now say so, and hosts can override all three. The new store-subscriptions notice IS held to the rule and says only what is true — deliberately "will keep renewing until you cancel", never "you will not be charged again": across 14,064 suspended subscriptions in production one still saw an invoice.payment_status_updated after suspension, so a charge already in flight can settle after renewals have stopped.
  • License.isGooglePlay additionally accepts the camel-cased googlePlay spelling the deletion report uses for its gateway, alongside gpm / googleplay.
  • Additive and backward-compatible: no public signature changed; deleteAccount() keeps its Future<bool> (facade, repository) and Future<void> (cubit) contracts. New tests under test/account/ (210 in the suite) cover the ordering (hook not invoked on refusal; hook invoked with a still-valid session, before logout), the report parsing and its defaults, the copy constraints, and the notice.
  • Added nullable License.attribution with LicenseAttribution.purl for premium-content matching via license.attribution?.purl ?? license.productId. Attribution survives JSON cache round-trips. Missing, null, non-object attribution and missing or non-string purl values parse to null without affecting existing license fields.

0.11.0 #

  • New: Google Play client purchase — KittyAccount.loginWithPlayStore() and KittyAccount.claimPlayStoreSubscriptions(). The Play analog of the App Store pair, for apps whose Android purchases must land on the Kitty account layer. Android hands the client an opaque purchase token rather than a signed transaction, so both take {packageName, purchaseToken} and authenticity comes from server-side verification against the Google Play Developer API — loginWithPlayStore returns null when no account matches the purchase, and throws when the server could not verify it (409 not-entitling, 503 verification unavailable) so a purchase can never read as "logged in" on an unconfirmed token. claimPlayStoreSubscriptions binds a verified purchase to the signed-in account and transfers the licence off the virtual txn_*@googleplay.internal user. New public models PlayStoreLoginResult, ClaimPlayStoreResult, ClaimPlayStoreDetail. Additive: no host changes required, and no behaviour changes for apps that do not call them.
  • New: KittyAccount.getFirebaseToken() — mints a Firebase custom token via the new authenticated GET /api/account/vault/firebase-login (factory /account/vault/firebase-login wire parity: {accessToken}), so the Firestore-backed legacy media gallery keeps working when an app runs on the Kitty account backend. Token-only by design: the SDK carries no firebase_auth dependency — the app performs FirebaseAuth.signInWithCustomToken itself. The bearer is attached and refreshed by AuthorizedHttpClient; x-project-id selects the per-project Firebase credentials server-side.
  • Added example/ — a rehearsal host that exercises the legacy auth bridge end to end against a local stack.
  • Legacy auth bridge: users are no longer logged out at backend cutover. An app updated onto the Kitty backend is holding an access/refresh pair that Kitty never issued and cannot validate, so the first authenticated call after cutover returns 401 and the user lands on a login screen. The SDK can now exchange that pair for a real Kitty session. New KittyAccount.adoptLegacySession({accessToken, refreshToken}): call it once at startup with the pair read from the app's own pre-Kitty storage, and the SDK stores it as TokenOrigin.legacy, exchanges it at POST /account/vault/login-legacy on the first 401, retries the original request, and returns the restored account. It returns null when the exchange is refused — show the ordinary login screen then. Calling it while a Kitty session already exists is a no-op, so it is safe to call unconditionally at startup.
  • A failed exchange is always recoverable. The legacy pair is never cleared on failure — not on a 401, not on a 5xx, not on a malformed response — so a refusal is retried on the next launch rather than stranding the user permanently. This also required a fix in AccountRepository.refreshAccountInfo: its ReauthRequiredException handler cleared all stored tokens, which would have destroyed the legacy pair on the very first failed exchange, including on the initialize() launch path. It now keeps a legacy pair while still dropping the cached account, so the app shows login and the next launch can retry. An explicit logout() still clears everything.
  • The exchange runs at most once per launch, never in a loop. The attempt is recorded before the first await, so concurrent 401s coalesce into a single request and every later caller sees that attempt's outcome — including its failure — rather than triggering another.
  • Ships dark. No behaviour changes for any app that does not call adoptLegacySession: without it nothing is ever tagged TokenOrigin.legacy, the new branch is unreachable, and no bridge request is made. The exchange is additionally inert unless the account config supplies a project id, and x-project-id is sent on the exchange only — deliberately not added to defaultHeaders, which would put it on every authenticated request.
  • Fix — a failed token refresh was reported to concurrent callers as a success. AuthorizedHttpClient completed its refresh waiters with a hardcoded true in a finally block, while the refresh itself returned false on all three failure paths (no refresh token, non-2xx, exception). Waiters were told the refresh had succeeded and retried with the still-stale token, hitting 401 again and re-entering refresh — roughly tripling the calls reaching the endpoint on a day when refreshes fail. Waiters now receive the real outcome. (The same defect exists in the pre-Kitty client this SDK forked from.)
  • New public API: TokenOrigin (kitty / legacy) on AuthTokens, plus TokenStorage.origin and TokenStorage.hasLegacyPair. TokenOrigin.legacy is reachable only through adoptLegacySession — AuthTokens.fromJson always yields kitty, so no server response can put the SDK into legacy mode. Origin is local bookkeeping and is not serialized by toJson. An install that predates this change has no stored origin and reads as kitty, so no migration is needed.
  • Additive and backward-compatible: no host changes are required, and the refresh-coalescing fix applies to every app on a version bump alone. 44 new tests under test/account/ (75 total).
  • New pre-login checks + password-reset facade surface (for the mobile-nidrana account-layer swap). VaultApiClient gains userExist (GET /account/vault/users/exist — the pre-login existence check the mobile login/signup UX branches on) and canLoginWithPassword (GET /account/vault/can-login-with-password; note it returns false for both "no such user" and "user exists but passwordless", so pair it with userExist where that distinction matters). Both are exposed on AccountRepository (isUserExist / canLoginWithPassword, deliberately silent in analytics — they run on every login-screen step) and on KittyAccount, which additionally now surfaces the existing repository methods sendResetPasswordCode, validateResetPasswordCode and resetPassword (resetPassword also logs the user in — the server mints a session, matching the legacy flow). 5 new client tests.

0.10.19 #

  • Account: v2 /users/current client + per-license token support. New UsersApiClient.getCurrentUserV2() calls GET /api/account/v2/users/current — same contract as v1, plus every license carries tokensPerCycle (renewable allowance per billing cycle) and supportsTokens (plan-level flag, tokensPerCycle > 0). License gains both fields with safe defaults (0 / false), so v1 responses and previously cached accounts keep parsing unchanged; toJson roundtrips them through the SDK's account cache. Note supportsTokens: false does not mean a zero balance — one-time top-up tokens live in GET /api/account/tokens/balance. Requires a backend with the /api/account/v2/users/* routes deployed; additive and backward-compatible.

0.10.18 #

  • Auth: valid login codes are no longer rejected as "Please enter a valid code" when PostHog isn't ready. Root cause: _resolveSid() threw a StateError when analytics wasn't initialized, or produced an empty sid when PostHog wasn't actually ready (systematic on web, where initPostHogWeb silently no-ops if the posthog JS global hasn't loaded yet while the SDK still reports analytics as initialized). The empty sid went on the wire and failed the backend's verify-code validation with a 400, and the UI treated every error as a wrong code — so a correct code produced a code-blame message, sometimes without any network call at all. Fixes: (1) sid is resolved defensively — never throws, never empty; when unavailable it is omitted from verify-code/signup-password (the verify-code backend treats sid as optional attribution metadata, so login without it is fully supported); (2) after a successful code login that went out without a sid, the real distinct id is attached best-effort via attach-sid (the same pattern App Store login already used, now shared); (3) verify-code maps 400 → AccountErrorType.badRequest and 429 → tooManyAttempts (401 stays loginCodeNotValid); (4) the code screen now shows a generic "Something went wrong… contact us" message for non-code failures instead of blaming the code.
  • Behaviour change — KittyAuthCodeState.isWrongCode is narrower (exported public API). It was errorType != null && errorType != noInternet, i.e. true for any error except offline. It is now true only for a real code rejection (loginCodeNotValid / invalidCode). Two new getters accompany it: isOffline and hasGenericError (any error that is neither a code rejection nor offline). Hosts that branched on isWrongCode to mean "something failed" must switch to errorType != null.
  • Offline stays delegated to the host. hasGenericError renders inline under the code input; noInternet deliberately renders nothing inline, matching the existing login form — the auth flows already invoke the host's onNoInternet callback, and duplicating the message inline would double up with the host's own snackbar.
  • API restored — sid is back on KittyAccount.verifyLoginCode. It was removed in 0.2.2, when sid resolution moved entirely inside AccountRepository on the assumption that analytics is always available. That assumption is what broke login here, so the caller-supplied override returns: an explicit sid wins over auto-resolution, and a blank/whitespace value is normalized to "absent" rather than sent as an empty string. Additionally KittyAccount.initialize / KittySdk.initialize accept a SidResolver (sidResolver / accountSidResolver) — an async String? Function() — so hosts that already know their analytics id can supply it directly instead of depending on KittyAnalytics. Exported as SidResolver.
  • Failure-mode change for signupWithPassword without analytics. It previously threw a client-side StateError before any request. It now omits the sid and reaches the server, which still requires sid for signup-password, so the call fails with a 400 ApiException carrying a clear validation message. Hosts catching StateError around signup must widen to ApiException. Relaxing the server-side signup schema is tracked separately; only verify-code is fixed end-to-end here.
  • Attribution can never break or stall login. Sid resolution and the post-auth attach are both bounded: getDistinctId() is capped at 500 ms (a half-booted PostHog on web can leave that Future pending forever, which would hang login before any network call — the one original failure mode with no fallback), and the late attach-sid is capped at 2 s (it runs through the authorized client, whose 401 retry ladder adds 1 s + 2 s of backoff while the already-authenticated user waits). Both failures are swallowed by design — sid is attribution-only — and a regression test pins the invariant from both sides.
  • New diagnostics via the existing onAnalyticsEvent host callback (deliberately host-side, since these fire exactly when PostHog is unavailable): LoginSidAttachedAfterAuth when the late attach succeeded, and LoginSidUnavailable with a reason property distinguishing resolver_threw / analytics_uninitialized / analytics_timeout / analytics_blank — so a host whose sidResolver throws is not indistinguishable from PostHog merely not being ready.
  • Additive and backward-compatible apart from the isWrongCode narrowing and the signup failure-mode change noted above: no host changes are required to get the login fix, only a version bump. First tests for the account client, repository, auth state and code form (27 new tests under test/account/).

0.10.17 #

  • Settings: hide an untrusted billing period instead of showing a misleading "1 year". KittySubscriptionPage now derives the card's billing period via the new reliableBillingPeriod(License) helper. A web/Lago license whose duration is exactly the Account API's un-populated default (365 days) is ambiguous — a genuine yearly plan vs the old default that was never populated — so the period is treated as unknown and omitted (the card falls back to the plan name). Healed/real Lago periods (weekly / monthly / …) and all App Store / Google Play durations — including a genuine yearly 365 — are shown as before. This guarantees no user sees a wrong billing cycle while legacy web licenses back-fill their real duration on renewal. Trade-off: a genuine Lago yearly license hides its period until its duration is populated. New helpers hasReliableBillingPeriod(License) / reliableBillingPeriod(License) in license_helpers.dart (exported via kitty_sdk.dart); first unit tests added under test/account/ui/settings/helpers/.
  • Additive and backward-compatible: no API/schema change; apps get the safer display with a kitty_sdk version bump.

0.10.16 #

  • Settings: Web2Wave "manage subscription" card. KittySubscriptionPage now renders a bordered card instead of a flat list: a body header, a current-phase block (status badge + trial/plan title + price + expiry), and — for a trial that will auto-convert — a separate Upcoming block showing the post-trial charge (amount + next-charge date). Non-trial or non-renewing licenses collapse to a single block. The cancel/renew action and its analytics events (CancelSubscriptionButtonClicked / RenewSubscriptionButtonClicked) are unchanged. A legal-links footer (Privacy Policy / Terms of Use / Subscription Policy) now appears on the page whenever those URLs are configured — required for web2app funnel compliance ("manage without leaving").
  • Settings: top-level "Manage subscription" entry. KittySettingsPage now shows a Manage-subscription item directly on the Settings screen (new optional onSubscriptionTap / onSubscriptionsTap; visible when logged-in with ≥1 license — a single license opens the detail card, multiple opens the list). Previously it was reachable only via Account → Manage subscriptions. Backward-compatible: the entry is hidden when the callbacks aren't wired.
  • New reusable widget KittyLegalLinks. The three legal links are extracted from KittyLegalInfoPage into a shared widget (same labels, analytics events, and launchUrl behaviour) so they also render as the subscription-card footer.
  • New helpers (exported via kitty_sdk.dart): shouldShowUpcomingBlock(License) and trialLengthLabel(License, {suffix}). The latter derives the trial length from expiresAt - createdAt, falling back to a bare label when the dates are missing or the span is non-positive.
  • Status-fidelity badge. The current-phase badge reflects the true license status — Active / Cancelling / Suspended / Expired — rather than a binary, so a dunning (suspended) or cancelling license no longer reads as green "Active". The header ("You have 1 active subscription") is shown only for active licenses; a trial always displays its expiry (the charge, when known, lives in the Upcoming block).
  • Theme: KittySettingsCardTheme + KittySettingsBadgeTheme added to KittySettingsTheme (as card / badge). All fields are nullable with sensible fallbacks, so the card renders correctly with no host theming; override card/badge to brand the container, divider, and the Active / Upcoming / Cancelling / Suspended / Expired badge colors.
  • New KittySettingsConfig copy fields: manageSubscriptionHeader, subscriptionActiveBadgeLabel, subscriptionUpcomingBadgeLabel, subscriptionExpiredBadgeLabel, subscriptionCancellingBadgeLabel, subscriptionSuspendedBadgeLabel, trialLabel, subscriptionSuffix (all with English defaults, threaded through resolveWithSdkConfig), plus a hasLegalLinks getter. The old flat-row copy fields currentPlanPrefix, statusPrefix, pricePrefix, billingPeriodPrefix, and nextChargeAmountPrefix are no longer rendered by the card and are now @Deprecated (kept for source compatibility).
  • Additive and backward-compatible: apps get sensible defaults with no changes beyond a kitty_sdk version bump; theming the card and overriding copy are optional.

0.10.15 #

  • Settings: onAccountDeleted cleanup hook on KittySettingsConfig. New optional Future<void> Function()? onAccountDeleted. KittySettingsCubit.deleteAccount() invokes it immediately before KittyAccount.deleteAccount() — while the session/token is still valid — so host apps can wipe product-specific user data (e.g. images stored in their own backend) as part of account deletion. The call is wrapped in a guard: any error it throws is caught (tracked as AccountDataCleanupFailed) and never blocks account deletion. Additive and backward-compatible — apps that don't set the callback are unaffected.

0.10.14 #

  • Fix: the "contact us" link in login-form error messages did nothing unless KittyAuthUiConfig.onSupportEmailTap was set. The code form already fell back to opening a mailto: link with KittySdkConfig.supportEmail when the callback was not provided, but the login form wired the tap recognizer straight to the (possibly null) callback, so with only supportEmail configured the link was dead. Both forms now share one helper: onSupportEmailTap if provided, otherwise mailto: to the SDK-configured support email.

0.10.13 #

  • Fix: Apple Pay button missing on the web buy-now checkout (Flutter web). On Flutter web KittyWebBuyNowPage renders the checkout inside an iframe (flutter_inappwebview web implementation). Safari exposes ApplePaySession to cross-origin iframes only when the iframe carries allow="payment", so the embedded checkout silently hid the Apple Pay button while Google Pay and card stayed visible (the checkout's supportsApplePay() gate saw typeof ApplePaySession === 'undefined'). InAppWebViewSettings now sets iframeAllow: 'payment'. Native iOS builds are unaffected: WKWebView never exposes Apple Pay JS — the StoreKit IAP path covers native. Note: consumer apps that still ship their own pre-migration copy of this page (e.g. mobile-renovio) need the same one-line change locally.

0.10.12 #

  • Settings: Subscription Policy link on the legal info page. New subscriptionPolicyUrl on KittySdkConfig / KittySettingsConfig (+ subscriptionPolicyLabel, default "Subscription Policy"). When set, KittyLegalInfoPage renders it next to Privacy Policy and Terms of Use (tracked as SettingsSubscriptionPolicyClicked). Required for web2app funnel compliance (refund policy visibility).
  • Settings: subscription page now shows renewal info. KittySubscriptionPage renders Price (license.price + currency), Billing period (derived from license.duration) and — for active auto-renewing subscriptions only — Next charge date (subscriptionNextChargeDate, falling back to expiresAt) and Next charge amount (subscriptionNextChargeAmount, when > 0). Cancelled/cancelling plans keep showing only the expiry date. New label overrides: pricePrefix, billingPeriodPrefix, nextChargeDatePrefix, nextChargeAmountPrefix.
  • Fix: "Current plan" value was clipped on the subscription page. The default currentPlanPrefix puts the value on a second line ('Current plan:\n'), but KittySettingsMenuItem hard-capped text at one line, so the plan name never showed. The menu item now takes a maxLines parameter (default 1) and the current-plan row uses 2.
  • Added formatDate, formatLicensePrice and formatBillingPeriod helpers to license_helpers.dart (exported via kitty_sdk.dart).

0.10.11 #

  • Fix: on mobile web browsers the on-screen keyboard did not appear when tapping the login code input. The code screen appears after the async send-code call, so KittyCodeInput's autofocus grabbed focus outside a user gesture — mobile browsers focus the hidden input but keep the keyboard hidden, and a tap on the already-focused field was a focus no-op (the user had to tap elsewhere and tap the input again). Tapping the code input while it already holds focus now forces a blur → refocus cycle within the tap's own event task (web only; native platforms unchanged), so the keyboard pulls up on the first tap.

0.10.10 #

  • Fix: AccountDeleteRequested (and LogOutSuccess) never reaching PostHog. The PostHog native SDKs batch events (flush at 20 events / every 30s) and account deletion is a terminal flow — the queued event was waiting for a batch flush that never happened because the app was killed or uninstalled right after. AccountRepository.deleteAccount() and logout() now force an immediate flush of queued events before tearing down. Additionally, LogOutSuccess is now tracked before KittyAnalytics.reset() so it is attributed to the logging-out user instead of a freshly rotated anonymous distinct ID.
  • Added KittyAnalytics.flush() — flushes queued PostHog events immediately (no-op on web, where the JS SDK manages its own send cycle). Use it before any flow where the app may be killed before the next scheduled batch flush.

0.10.9 #

  • Fix (follow-up to 0.10.8): buy-now loader still spun forever on StoreKit 2 cancel with in_app_purchase_storekit 0.4.4–0.4.6. 0.10.8 only handled the case where SK2Product.purchase returns userCancelled/pending (0.4.7+). On 0.4.4 the native layer instead throws a PlatformException (storekit2_purchase_cancelled / storekit2_purchase_pending), which BuyNowHelper.buySubscription silently swallowed (its on PlatformException handler only covered storekit_duplicate_product_object), so the bloc never left PurchaseInProgressState. Now IOSSK2ExtendedProductDetails.buy() handles cancel/pending whether reported as a thrown PlatformException or a returned result, and buySubscription rethrows any other PlatformException instead of swallowing it. Fixes the loader for the whole ^0.4.0 range.

0.10.8 #

  • Fix: in-app buy-now loader spinning forever when the user cancels the native payment sheet (StoreKit 2). KittyInAppBuyNowPage kept its loading overlay up indefinitely if the user dismissed the StoreKit payment dialog. Under StoreKit 2 (the default in in_app_purchase_storekit >= 0.4) the idiomatic buyNonConsumable discards the purchase result and StoreKit emits nothing on purchaseStream for a user cancellation, so the bloc never left PurchaseInProgressState. The SK2 purchase path now reads the SK2ProductPurchaseResult directly and surfaces userCancelled / pending as a BuyNowPurchaseNotCompletedException, which the buy-now bloc handles by clearing the loader (and tracking BuySubscriptionCanceled / BuySubscriptionPending). StoreKit 2 stays enabled; StoreKit 1 and Android behaviour are unchanged (cancellation already arrives via the purchase stream there).

0.10.7 #

  • TokenBalance now exposes a per-license breakdown. GET /account/tokens/balance?projectId=... now returns an additional licenses array (one entry per license belonging to the authenticated user in the project, with licenseId, balance, subscriptionBalance and total). The top-level aggregate fields keep the same shape as before, so existing code continues to work.
  • For single-license responses (?licenseId=...) the same licenses array is returned with a single entry mirroring the top-level balance.
  • Added LicenseTokenBalance model and TokenBalance.licenses / TokenBalance.licenseBalance(licenseId) helpers (exported from kitty_sdk.dart).

0.10.6 #

  • Aggregated balance is now a single backend request. GET /account/tokens/balance accepts projectId (in addition to licenseId) and the server returns the sum of balance / subscriptionBalance / total across the user's licenses in that project. KittyTokens.getBalance() (without licenseId) makes one HTTP call instead of one per license — no more fan-out from the client.
  • Added TokensRepository.getAggregateBalanceForProject({required String projectId}) and the matching TokensApiClient method for SDK consumers that need the aggregate from a custom repository.
  • Aggregated TokenBalance.licenseId is now null (returned as such by the backend).

0.10.5 #

  • KittyTokens.getBalance() now aggregates across all of the user's licenses when called without licenseId. Pass licenseId: explicitly to get the balance for a single license.
  • TokenBalance extended: now exposes licenseId, subscriptionBalance and total (= balance + subscriptionBalance) returned by the backend. licenseId is null for aggregated balances.
  • TokensState.tokenCount now returns balance.total instead of balance.balance, so the built-in account-page UI shows the full spendable amount (top-up + subscription cycle tokens). If you relied on tokenCount to show only the top-up part, switch to state.balance?.balance.
  • Returns a zero TokenBalance (instead of throwing) when the authenticated user has no licenses.

0.10.4 #

  • Tokens module aligned with new backend contract (breaking): /account/tokens/balance and /account/tokens/operations now require a licenseId (UUID) query param instead of projectId. KittyTokens.getBalance() / getOperations() and TokensCubit.loadBalance() / loadOperations() now accept an optional licenseId: argument; when omitted the SDK picks the first usable license of the currently authenticated user.
  • Removed KittySdk.tokens.addTokens / removeTokens: those endpoints are no longer exposed to clients — they live under internal-tokens and require an internal API key or a per-project kap_ bearer token. Call them from your backend instead.
  • KittyTokens.initialize no longer takes a projectId (licenses already carry their own project association).

0.10.2 #

  • Logout cleanup: Account logout now resets PostHog and clears local auth state; KittySettingsConfig.onLogout lets host apps run custom settings-page logout handling.
  • Auto-identify control: Added enableAutoIdentify to KittySdk.initialize() so apps can disable startup fingerprint identify and prevent automatic PostHog aliasing.

0.10.0 #

  • Buy Now module migrated from mobile-renovio: full in-app and web buy-now flow with bloc state management, web purchase bridge and restore helpers.
  • Buy Now configuration: new KittyBuyNowConfig with productIds, webBuyNowUrl, pwaBuyNowUrl(Stage), domain, eulaUrl, privacyPolicyUrl, customerSupportEmail, isStaging, isAutoRestorePurchaseEnabled. Wire it through KittySdkConfig.buyNow and access globally via KittySdk.buyNow / KittyBuyNow.I.
  • Buy Now UI theming: added KittyBuyNowTheme for KittyInAppBuyNowPage and KittyWebBuyNowPage, split into logical blocks: page, navBar, mainBlock, subscription, buttons, legal, and webView. Replaces the previous InAppBuyNowTheme.
  • KittyBuyNowTheme and KittyBuyNowThemeProvider are exported from kitty_sdk.dart.

0.9.0 #

  • Account UI theming: KittyTheme now accepts a direct ThemeData override via KittyTheme(themeData: ...). The SDK merges it over the default SDK theme when provided.
  • Auth UI theming: Added KittyAuthTheme for KittyAuthFlow and KittyBindEmailFlow. It supports per-flow styling for background, AppBar, header widgets, titles, inputs, buttons, code input boxes, resend/support sections, spacings, and errors.
  • Settings UI theming: Added KittySettingsTheme for KittySettingsFlow, split into logical blocks: page, menuItem, buttons, destructive, subscription, tokens, dialog, and bindEmail.
  • KittyAuthUiConfig.icon is deprecated. Use KittyAuthTheme.loginHeaderWidget or KittyAuthTheme.codeHeaderWidget instead.
  • KittyAuthTheme and KittySettingsTheme are exported from kitty_sdk.dart.

0.8.0 #

  • Tokens module: New module for managing user token balances and operations.
  • KittyTokensConfig — configure isDebug to show/hide debug UI in settings.
  • KittySdk.tokens — static accessor for the Tokens client with getBalance(), addTokens(), removeTokens(), and getOperations(). (Note: addTokens/removeTokens were removed from the client in 0.10.3 — see above.)
  • Integrated into KittyAccountPage to automatically display the user's token balance (and debug controls if enabled) when the module is initialized.
  • Conditional initialization — Tokens module only starts when KittySdkConfig.tokens is provided and the user is authenticated.

0.6.0 #

  • KittyOtel module: New observability module for sending logs, traces, and metrics via OTEL to the Kitty Launch platform.
  • KittyOtelConfig — configure endpoint, token, batching, compression, and queue size.
  • KittySdk.otel — static accessor for the OTEL client with addLog(), addTrace(), addMetric(), and manual flush().
  • Automatic batching with configurable interval and batch size, gzip compression, and retry logic.
  • Conditional initialization — OTEL module only starts when KittySdkConfig.otel is provided.

0.5.5 #

  • Fingerprint auto-login: On SDK init, if Flare identifies a strong fingerprint match (IP_v4 / GEO_UA) with a loginCode in userData, the SDK automatically exchanges it for auth tokens — seamless web-to-mobile login.
  • onFingerprintAutoLogin callback: New optional parameter in KittySdk.initialize() — notifies the host app when fingerprint auto-login succeeds.
  • Safe userData casts: distinctId and loginCode from fingerprint userData now use ?.toString() instead of as String? to prevent runtime type errors.

0.5.4 #

  • KittySdkConfig: Added productName, privacyPolicyUrl, termsOfUseUrl, shareAppText, shareAppLink fields so all app-level config lives in one place.
  • KittySettingsFlow: Automatically resolves empty settings config fields from KittySdkConfig — no need to duplicate values between SDK init and settings config.

0.5.3 #

  • Fix: Bumped posthog_flutter minimum to ^5.15.0 — fixes PostHogFeatureFlagResult not found compile error when resolving to older 5.x versions.

0.5.2 #

  • Auto-identify on init: KittySdk.initialize() now automatically calls flare.identify() if no previous result is cached. On a match, aliases the web PostHog distinctId from the fingerprint with the current mobile session — linking web and mobile analytics.

0.5.1 #

  • PostHog per-project routing: SDK now sends x-ph-api-key and x-ph-host headers in every API request (both authenticated and unauthenticated). The backend uses these to route identify/alias calls to the correct PostHog project — no shared PostHog instance needed.
  • KittyAccountConfig.baseHeaders centralizes common headers (content-type, x-project-id, PostHog keys) to avoid duplication across API clients.

0.5.0 #

  • AccountInfo.primaryEmail: Backend now sends primaryEmail in /users/current response; SDK uses it for the email getter instead of emails[0]. Fixes email not updating after bind-email flow.
  • Bind-email UI: Title moved to AppBar for consistent design; embedded mode prevents duplicate Scaffold/AppBar.
  • Delete account: Now performs a client-side logout instead of calling the non-existent mark-for-deletion API.
  • Contact Support button: Upgraded to FilledButton.tonal for better visibility on the code page; fixed mailto: URI construction that was dropping the email domain.
  • Code page support section: Hidden on initial code entry, shown only after the user taps "Resend Code".
  • PostHog: Session replay and lifecycle events disabled by default for lightweight event-only tracking.

0.4.0 #

  • KittyTheme: New theming system with auto light/dark detection from platform brightness. All SDK forms (auth + settings) now render with a consistent minimalist design.
  • KittySdkConfig.theme accepts a KittyTheme(primaryColor: ...) to customize button, link, and accent colors across all SDK UI.
  • Redesigned code input boxes, settings menu items, and form layouts for a cleaner look.
  • Breaking: widgets now use SDK theme instead of inheriting host app theme. Override via KittySdkConfig.theme.

0.3.3 #

  • Updated auth form texts to match design: "Access Your Purchased Account" title, "Enter email..." hint, "Log In" button, "Check Your Email" code page, "RESEND CODE (XX sec)" timer.
  • Restructured code form support section with contact prompt, tappable support link, and outside hours message.
  • Added supportEmail to KittySdkConfig for global configuration — no longer needed per-form.
  • Resend timer default changed from 5 minutes to 30 seconds.

0.3.2 #

  • Fix: claimAppStoreSubscriptions now sends x-project-id header — licenses are correctly associated with the project instead of being created with project_id=null.

0.3.1 #

  • Removed sign-up mode from auth flow — login + 2FA code is a single unified flow with no login/signup distinction.
  • Removed isSignUp, toggleSignUp(), signUpTitle, dontHaveAccountText, signUpLinkText, alreadyHaveAccountText, logInLinkText, emailAlreadyInUseError, tryLoggingInText, emailTakenError, showSignUpToggle from public API.

0.3.0 #

  • Account UI — Auth flow: Configurable login and 2FA code forms (KittyAuthFlow, KittyLoginForm, KittyCodeForm) with KittyAuthUiConfig for customizing icon, support email, all text content, timer duration, and code length.
  • Account UI — Settings flow: Full settings module (KittySettingsFlow) with account management, subscription details, cancel/renew, delete/restore account, legal info, logout — all driven by KittySettingsConfig. No dependency on auto_route or external analytics/environment packages.
  • New dependencies: flutter_bloc, url_launcher, share_plus.

0.2.3 #

  • KittyAnalytics.initialize() now calls posthog.init() via JS interop on web, so PostHog API key is only configured in Dart — no need to hardcode it in index.html.

0.2.2 #

  • AccountRepository now auto-resolves sid from KittyAnalytics (PostHog distinctId) for verifyLoginCode and signupWithPassword.
  • Removed sid parameter from KittyAccount.verifyLoginCode() public API.
  • Removed canLoginWithPassword from AccountRepository and VaultApiClient.

0.2.1 #

  • Fix: add web platform support — guard Platform calls with kIsWeb to prevent dart:io crashes on web.
  • DeviceInfoCollector now returns browser metadata on web instead of throwing UnsupportedError.
  • FlarePurchaseObserver and AppStorePurchaseObserver gracefully no-op on web.

0.2.0 #

  • Analytics module: PostHog event tracking, user identification, feature flags, session replay.
  • KittySdkConfig.analytics parameter for configuring PostHog via unified initialization.

0.1.0 #

  • Initial release.
  • Flare module: device fingerprint identification and purchase attribution.
  • Account module: authentication, user management, licenses, subscriptions.
  • Unified KittySdk.initialize() entry point.
0
likes
90
points
1.36k
downloads

Documentation

API reference

Publisher

verified publisherkitty-dev.org

Weekly Downloads

Kitty Flutter SDK — device fingerprint attribution (Flare) plus authentication, user management, licenses and subscriptions (Account) for the Kitty Launch platform.

Repository (GitHub)

License

unknown (license)

Dependencies

connectivity_plus, darq, device_info_plus, flutter, flutter_bloc, flutter_inappwebview, http, in_app_purchase, in_app_purchase_android, in_app_purchase_platform_interface, in_app_purchase_storekit, meta, package_info_plus, posthog_flutter, share_plus, shared_preferences, sign_in_with_apple, url_launcher, uuid, web

More

Packages that depend on kitty_sdk