kitty_sdk 0.14.1
kitty_sdk: ^0.14.1 copied to clipboard
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
AppStorePurchaseAuthConfigopt-in andgetAppStoreCapabilities,loginWithAppStorePurchase,claimAppStorePurchases, andrefreshPurchaseAccountInfo. 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 existingAccountErrorTypeenum. - Opted-in account fetches use the expiry-aware
current-purchasesview with the existingAccountInfodecoder. This takes precedence overuseUsersCurrentV2; 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:
KittyAuthFlowlogs 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" perenableSignup. Hosts that rely on the password/signup screens must setrouteByAccountState: truewhen updating to 0.14.0, otherwise their users get a code instead of the password screen. enableSignuponly has an effect together withrouteByAccountState.onlyProjectExistingstill 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
KittyAuthCubitthemselves (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_plusstays a dependency. - Error type change for hosts that call
KittyAccountdirectly. 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 throwAccountException(AccountErrorType.noInternet)for a transport failure, where a rawSocketException/http.ClientExceptionused to escape. Code that already handlednoInternetneeds no change; code that caught those raw exceptions should matchnoInternetinstead. - 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 withKittySettingsTokensTheme.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 anyConnectivityResult.nonein 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 firstsend-code/login-password/login-appstorewas refused and the second attempt went through. Offline is now only a non-empty list that is entirelynone; an empty list is treated as online and the request itself reports any real network error. New pure helperisOffline(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(wasSign in with Apple; setappleButtonTextto keep the old one). Unset, its height follows the host'sprimaryButtonStyle(fixedSize, thenminimumSize) 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 (wasloginSubtitleSpacing ?? 24). New opt-in overrides onKittyAuthTheme:appleButtonWidth(fixed width, centred),appleButtonHeight,appleButtonBorderRadius,appleButtonStyle(SignInWithAppleButtonStyle, re-exported) andappleButtonSpacing.KittyAppleSignInButtontakes an optionaltheme. Hosts that do not enable Apple sign-in see no change. - A host with no
primaryButtonStylegets the Kitty primary button: as wide as the form, 56 high, pill (KittyAuthTheme.kittyDefaultPrimaryButtonStyle, viaeffectivePrimaryButtonStyle), instead of Material's default sizing. Colours still come from the host theme. A host that setsprimaryButtonStylesees no change. - Fixed: the password and signup forms no longer show a dangling "Wrong password. Please try again or". The default
wrongPasswordErrorwas written to end in thecontactUsTextlink, likegenericErrorTexton the login form, but the password form put the string intoInputDecoration.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,errorTopPaddingapply). A failed signup showsgenericErrorTextinstead ofwrongPasswordError. A host that overrodewrongPasswordErrorwith 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
Scaffoldghosts the outgoing page's rows and arrows during the slide. UnsetKittySettingsPageTheme.backgroundColornow resolves to the host's scaffold colour when it is opaque and toColorScheme.surfacewhen it is not (KittySettingsPageTheme.resolveBackground). An explicitbackgroundColor,Colors.transparentincluded, 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 fromKittySettingsTokensTheme, so a host that themed the menu items but not the tokens theme got a misaligned coin icon. UnsetbalancePadding/balanceIconSize/balanceIconColor/balanceTextStylenow fall back to the menu item theme'spadding/iconSize/iconColor/textStyle, and the icon-to-label gap followsiconTextSpacing. The gap is also settable on its own with the newKittySettingsTokensTheme.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 aKittyConsentConfigtoKittySdk.initialize(consentConfig: ...)and the SDK createsKittySdk.consent(aKittyConsent), awaiting its first storage read beforeinitializereturns. Hosts that pass no config see no change:KittySdk.isConsentEnabledisfalse, no Data row is shown, andKittySdk.consentthrows aStateError. 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 throughKittySettingsFlowremoves it (a failure is tracked asConsentClearFailedand does not stop the deletion). CallingKittyAccount.deleteAccount()ormarkForDeletion()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 thenawait KittySdk.consent.clearForAccount(id). A record written under a differentpolicyVersion(default'ai-v1') reads asunknown, 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
grantedsave 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 stillguestand the guest's answer applies (a guestgrantedincluded); the account's own record applies once it has loaded. A failed grant throwsKittyConsentWriteExceptionand leaves the status unchanged; the dialog stays open withsaveErrorText. A failed withdrawal is never rolled back: the status staysdeclined, the key is removed so a restart cannot read a stalegranted, 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 isunknown.await KittySdk.consent.ensureConsent(context)gates each user-initiated AI action: it returnstrueat once when granted, otherwise shows the generate dialog and returnstrueonly after "Agree & Generate" has been persisted. The SDK does not block generation itself; host repositories that send data to the AI provider throw the exportedAiConsentRequiredExceptionwhen!KittySdk.consent.isGranted. - Settings.
KittySettingsFlowshows a Data row after Legal Info (guests too) when consent is enabled, or when aconsent:is passed to the flow. It opensKittyManageDataPage, 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
KittyConsentConfigfield with an English default, with{appName}and{providerName}placeholders. Visual tokens areKittyConsentTheme, passed to the dialogs directly or through the newKittySettingsTheme.consent. With no theme every colour and text style comes from the host'sTheme(ColorScheme,TextTheme,DialogTheme); the SDK hardcodes no brand colour, andcardColor,barrierColor,primaryGradient/primaryColor,secondaryColor,outlineColor(button border),destructiveColor,toggleActiveGradient/toggleActiveColor,toggleInactiveColor,toggleThumbColorandtoggleBorderColor(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 onKittyConsentConfigreplace the UI while the SDK keeps state, persistence, the busy guard, error handling, results and analytics:dialogBuilder(content of both dialogs, with aKittyConsentDialogController),turnOffDialogBuilder(the whole turn-off pop-up,KittyTurnOffDialogController) andmanageDataBuilder(body of the Manage Data page,KittyManageDataController). - Analytics through the existing
onAnalyticsEvent:AiConsentShown,AiConsentGranted,AiConsentDeclined,AiConsentWithdrawn,ManageDataOpened,SettingsDataClicked,AiConsentStorageErrorandConsentClearFailed. 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\nincluded, 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,manageDataSubtitleStyleandturnOffDialogTopOffset(top-anchors the turn-off pop-up;nullkeeps it centred; never closer than 24 to an edge; ignored withturnOffDialogBuilder). New onKittySettingsPageTheme:actionIconTheme, applied to every page insideKittySettingsFlow(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 (onAppStoreAutoLogindoes 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 asappAccountToken(StoreKit 2) orapplicationUserName(StoreKit 1), and pass nothing when it is null. It is the value the SDK's own paywall purchases send.KittyAccount.Istill 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-appstoreno 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), emitsAppStoreAutoLoginFailed(error: ApiException) andLoginFailed(method: appstore), and passesApiException(statusCode: 409)toonAppStoreObserverError. 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 explicitKittyAccount.loginWithAppStorecall throws the sameApiException. 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, withreplay(whether the purchase was queued for an immediate claim). AfterAppStoreAutoLoginStarted, a superseded automatic login emits onlyAppStoreAutoLoginSuperseded: noLoginFailed,AppStoreAutoLoginFailed,AppStoreAutoLoginNoAccountorAppStoreAutoLoginSuccess. 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, andSessionLostwhen it changed while the login's own refused fetch was clearing its credentials. onAppStoreObserverErrorreceives aSessionSupersededExceptionwhen 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-subscriptionswith the session's bearer, and onlyonAppStoreAutoClaimfires. Before, it logged in automatically withlogin-appstore:onAppStoreAutoLoginfired beforeonAppStoreAutoClaim, and the app switched to the purchase's virtualtxn_*@appstore.internalaccount. - A superseded automatic login never fires
onAppStoreAutoLogin. When its purchase is replayed, the claim firesonAppStoreAutoClaimfor the newer session.
- New analytics event
- 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;
appAccountTokenon a facade built byKittyAccount.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.licenseTitleBuilderto choose the title a licence is shown under. App Store and Google Play licences carry the raw store product id as theirname(for exampleinfo.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 replacesLicense.nameas 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 showsLicense.nameexactly as in 0.13.1. The new helperlicenseTitleOf(license, config)resolves the title, andresolveWithSdkConfigcarries the builder through. - The suite grows from 490 to 498 tests: the helper with and without a builder, the builder threaded through
resolveWithSdkConfigand 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 byInAppBuyNowBlocandBuyNowMessageHandler, ran its iOS-only StoreKit queue cleanup before every purchase, on every platform. On Android the first StoreKit call threwPlatformException(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,
BuyNowHelpertreated 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 —isAuthenticatedis 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,
nullemit 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 byInAppBuyNowBlocandBuyNowMessageHandler, runs an iOS-only StoreKit queue cleanup before buying. On Android that call throwsPlatformException(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 instancestoString()now also printscredentialsAbsentandjudgedEpoch." — The unnamedReauthRequiredExceptionconstructor stays source-compatible; it gains an optional second positionaloriginStatusCode. ItstoString()changes from 0.12.4'sReauthRequiredException(<message>)toReauthRequiredException(<message>, originStatusCode: <status>), and SDK-raised instances also printcredentialsAbsentandjudgedEpoch. - "…and
refreshAccountInfo()(run by the next launch'sinitialize) 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. CallrefreshAccountInfo()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.
AppStorePurchaseObservernow 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 callscompletePurchaseonly once a claim row for that transaction has statuscreated/existing/transferred. The claim endpoint answers HTTP 200 even when it rejects individual transactions: it reports failures per row, and error rows carryoriginalTransactionId: "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 (emptyserverVerificationData); 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, whichReauthRequiredExceptionnow reports via its neworiginStatusCode. 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 (keykitty_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 toonAppStoreObserverErroras aStateError, and tracked as theAppStoreClaimGivenUpanalytics event. Only finishable copies spend that budget. Therestoredcopies 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:isCredentialsAbsentis true and there is nooriginStatusCode(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 leftTransaction.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; andBuyNowHelper's whole pre-purchase cleanup, queue reads included, after 10s. Every observer timeout is logged and reported toonAppStoreObserverError. 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 toproduct.buy()rather than aborting the purchase. Not every await is bounded, though. These have no timeout of their own: therestorePurchases()thatKittySdk.initializeawaits when the observer starts (unchanged from earlier releases); the post-login sweep'srestorePurchases(), where a hang stalls the observer's queue until the next launch; the StoreKit queue reads in the duplicate-purchase recovery; andBuyNowMessageHandler'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,loginWithPlayStoreand 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
restoredcopy is upgraded by itspurchasedcopy, 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 instop()); withenableAppStoreObserver: false, or KittyBuyNow used without KittyAccount, those finishers keep finishing immediately as before. On Android they always finish immediately —completePurchaseis 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. Astorekit_duplicate_product_objectwith 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
KittyAccountis initialized and authenticated, and the user id is a valid UUID. StoreKit 2 purchases then send the id asappAccountToken(SK2ProductPurchaseOptions), and StoreKit 1 purchases send it asPurchaseParam.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 walksTransaction.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.0already 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_storekithas used SK2 since 0.4.0 unless an app callsInAppPurchaseStoreKitPlatform.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
finishof 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 tracksBuySubscriptionPending.BuyNowMessageHandlercloses 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.
onAppStoreAutoClaimnow also fires after an anonymous auto-login. The observer claims right after it logs in; an auto-login used to stop at the login. TheAppStoreAutoClaimStarted,AppStoreAutoClaimSuccessandAppStoreAutoClaimFailedanalytics events follow it as well.onAppStoreObserverErrornow also receives aStateErrorwhen a purchase is given up, and errors from the post-login sweep. It also receives theTimeoutExceptions of finish (10s), auto-login and claim (30s) timeouts. There is a new analytics event,AppStoreClaimGivenUp(productId,attempts).SessionLostIgnored, andSessionLostnow also firing from the login paths, are described below.ReauthRequired,TokenRefreshFailedandLegacyExchangeFailedcan now carryreason: superseded_by_login(onReauthRequiredwithout anendpoint). It means a login overtook the reauthorization and the caller gotSessionSupersededException. No session was lost, so dashboards that countReauthRequiredas 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.
ReauthRequiredExceptionis 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; seeSessionSupersededExceptionbelow). Its new optionaloriginStatusCodecarries the 401 or 403 that triggered it, when known; theisCredentialsAbsentbullet 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:ApiExceptionfor HTTP statuses,TimeoutException, the transport exception, or aFormatException/TypeErrorfrom the body.AccountRepositorythen 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 readReauthRequiredExceptionfrom 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 frompackage: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 withSessionSupersededExceptioninstead 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 inReauthRequiredException, which callers answer destructively. The SDK's own consumers treat it as transient. In that rare login race it can surface from calls made throughKittyAccount.authorizedHttpClientor the tokens module; catch it by type and retry, and the retry runs under the new session. ReauthRequiredExceptiontells a refused credential from an absent one. The newisCredentialsAbsentis 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, sooriginStatusCodeis always null. Otherwise the server refused a credential.originStatusCodethen 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 onisCredentialsAbsentandoriginStatusCodeonly.judgedEpochis an opaque SDK-internal counter, null on an exception the SDK did not raise. The newReauthRequiredException.rejectedandReauthRequiredException.credentialsAbsentconstructors andjudgedEpochare annotated@internal(package:meta): hosts should not use them, and the analyzer warns if they do. The unnamed constructor is unchanged, and so is itstoString(); on SDK-raised instancestoString()now also printscredentialsAbsentandjudgedEpoch.- The reauth-triggered session clear acts only on a verdict about the current session. Every
ReauthRequiredExceptionthe SDK raises records which session it judged. The destructive clear inrefreshAccountInfo(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 explicitlogout()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 emitsSessionLostbefore 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 aSessionLostIgnoredanalytics 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 stalenullemitted 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 thenullemit — 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,loginWithPlayStoreand 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 whichtokenStorage.saveswaps 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
nullonaccountStream. 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:isAuthenticatedstayed true,currentAccountwas the previous user, and the next iOS purchase sent the previous user's id asappAccountToken/applicationUserNamewhile 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:isAuthenticatedreads false,currentAccountand the purchase attribution are null, andrefreshAccountInfo()(run by the next launch'sinitialize) 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 transientSessionSupersededException, neverReauthRequiredException, 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-absentReauthRequiredException. - Overlapping fetches can no longer emit backwards. Both commit sites (
refreshAccountInfoand 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-legacysurfaces as the underlying error instead of collapsing tofalse, which surfaced asReauthRequiredExceptionand 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.completePurchaseis 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,logoutandvalidateResetPasswordCodeused to post with onlycontent-type. So didVaultApiClient.refreshToken, a helper the SDK itself never calls. They now send the same base headers as the other vault calls (logoutkeeps its bearer). One exception remains: the token refresh the SDK actually performs, inside its authorized HTTP client, still sends onlycontent-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 inrefreshAccountInfoand 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.onlyProjectExistingfor apps that only let customers of their own project in. Off by default, and with it off nothing changes: same screens, same requests. Withtrue,KittyAuthFlowstops branching on account state. Submitting an email no longer callsusers/existorcan-login-with-password, so the password and signup screens are never reached, and goes straight toPOST /account/vault/send-codewithonlyProjectExisting: 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; setcodeSubtitleto wording that does not promise a code was sent. The bind-email flow is unaffected. Needs a backend withonlyProjectExistingonsend-code; an older backend ignores the field and sends as before. - New optional
onlyProjectExistingparameter (defaultfalse) onKittyAccount.sendLoginCode,AccountRepository.sendLoginCodeandVaultApiClient.sendCode. It is added to the request body only whentrue. A host that subclasses or fakesKittyAccount.sendLoginCodemust 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-passwordanswers201withsession: nulland emails a verification code, whichsignupWithPasswordcannot 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
paymentTypeis stillsubscription, 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 newKittySettingsConfig.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, defaultPurchases) listing them newest first with no action and a configurableoneTimePurchasesNotice(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 fieldsKittySettingsCardTheme.titleStyle/bodyStyle/actionStyle/actionActiveColor(hover / press / focus); new pagesKittyManageSubscriptionsPage/KittyOneTimePurchasesPage; new helperssubscriptionsOf/oneTimePurchasesOf;KittySettingsState.licenseStatusOf(licenses). - New:
License.isOneTime(fromisOneTime/is_one_time, defaultfalse, round-trips through the account cache). The backend sends it only on v2/users/current, derived from the plan'sisOnetimeflag, so newKittySdkConfig.useUsersCurrentV2/KittyAccountConfig.useUsersCurrentV2(defaultfalse) makesUsersApiClient.getCurrentUser()call v2 — the v1 contract plustokensPerCycle,supportsTokens,isOneTime. A host enablingseparateOneTimePurchasesmust 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 returnstruefor every key when the loaded account's effective email istest@<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
nullfromKittyAccount.hasEntitlement. The rawentitlementslist 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 APIGET /account/vault/users/in-project. - Keep the existing global
isUserExistbehavior 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, …), andGET /account/users/currentand/account/v2/users/currenthave been returning them per licence and at the account level. The SDK parsed none of them; it does now.AccountInfogainsentitlements(the server-resolved union) andhasEntitlement(key);Licensegainsentitlements(the keys that licence's plan declares) andplanId, parsed from eitherplanIdorplan_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.hasEntitlementreads onlyAccountInfo.entitlements; it never re-derives a union fromlicenses. 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 whoseentitlementsis empty reportsfalseeven 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)returnsbool?andKittyAccount.entitlementsreturnsList<String>?.nullmeans no account is loaded — a fresh install before the first login, or afterlogout()— and must never be read as "granted"; collapsing it intofalseis the design's one forbidden move, and a behavioural test now fails if an implementation does. Thetrue/falseanswers 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. Afalsecan therefore be stale until the host itself callsrefreshAccountInfo()— 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 readsfalseuntil 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. BothKittyAccountmembers delegate toAccountRepository.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 onaccountStreamrather than reading once at startup.AccountInfo.hasEntitlementstays a plainbool— 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.toJsonnow carryentitlements(andplanId), which is load-bearing rather than cosmetic: the cached account is rehydrated throughfromJson, so a field missing fromtoJsonwould be silently dropped and the gate would flip tofalseon the next app start. Covered by round-trip tests at both levels, including the nesting. - Purl-based attribution is unchanged.
LicenseAttributionand thelicense.attribution?.purl ?? license.productIdmatching 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 undertest/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), bothplanIdspellings, the cache round-trips, exact key matching, and the "trust the server" invariant.planIdis also read through anis Stringguard rather than a bare cast, so a non-string value yields''instead of throwing out ofAccountInfo.fromJsonand taking the login path down with it; tests pin that at both theLicenseand the enclosingAccountInfolevel. The 5 repository tests are the behavioural half of the tri-state: they pinnullwith nothing loaded,falsefor a loaded account lacking the key,truefor one holding it,false/[](not null) for a loaded account with no keys at all, and a return tonullafterlogout()— so the forbidden?? falseimplementation 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'sonAccountDeletedhook (in Pixtuner: an irreversible purge of the user's photos from R2) beforePOST /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 asAccountDataCleanupFailed. 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 exactlymarkForDeletion()followed bylogout(). - New: the deletion report —
MarkForDeletionResult/MarkForDeletionLicense.mark-for-deletionnow also cancels every subscription the server can cancel and reports each licence ascancelled,requiresUserAction(App Store / Google Play — Apple and Google do not let a server cancel these) orfailed(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.reasonsays why), plusalreadyMarked.UsersApiClient.markForDeletionWithReport()parses it;markForDeletion()keeps returningbool. 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 explicitsuccess:falseon a 200 is honoured as a refusal.AccountDeleteSuccessnow 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 existingonCancelAppStoreSubscriptioncallback and falls back to the platform's subscriptions page. NewshowKittyStoreSubscriptionsDialogtakes aNavigatorStatecaptured 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. NewKittySettingsCubit.deleteAccountWithReport()returns the report (deleteAccount()staysFuture<void>); newKittySettingsCubit.openStoreSubscriptions(License); new helperstoreSubscriptionsToCancel(result, licenses). New analytics:DeleteAccountStoreSubscriptionsShown,DeleteAccountStoreSubscriptionsManageClicked. - New
KittySettingsConfigcopy fields (overridable, English defaults, threaded throughresolveWithSdkConfig):storeSubscriptionsDialogTitle,storeSubscriptionsDialogContent,storeSubscriptionsDialogManage,appStoreLabel,googlePlayLabel, plus thestoreSubscriptionLabel(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,restoreAccountPromptSuffixandrestoreAccountPermanentTextnow 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 aninvoice.payment_status_updatedafter suspension, so a charge already in flight can settle after renewals have stopped. License.isGooglePlayadditionally accepts the camel-casedgooglePlayspelling the deletion report uses for its gateway, alongsidegpm/googleplay.- Additive and backward-compatible: no public signature changed;
deleteAccount()keeps itsFuture<bool>(facade, repository) andFuture<void>(cubit) contracts. New tests undertest/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.attributionwithLicenseAttribution.purlfor premium-content matching vialicense.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()andKittyAccount.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 —loginWithPlayStorereturns 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.claimPlayStoreSubscriptionsbinds a verified purchase to the signed-in account and transfers the licence off the virtualtxn_*@googleplay.internaluser. New public modelsPlayStoreLoginResult,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 authenticatedGET /api/account/vault/firebase-login(factory/account/vault/firebase-loginwire 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 nofirebase_authdependency — the app performsFirebaseAuth.signInWithCustomTokenitself. The bearer is attached and refreshed byAuthorizedHttpClient;x-project-idselects 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 asTokenOrigin.legacy, exchanges it atPOST /account/vault/login-legacyon 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: itsReauthRequiredExceptionhandler cleared all stored tokens, which would have destroyed the legacy pair on the very first failed exchange, including on theinitialize()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 explicitlogout()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 taggedTokenOrigin.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, andx-project-idis sent on the exchange only — deliberately not added todefaultHeaders, which would put it on every authenticated request. - Fix — a failed token refresh was reported to concurrent callers as a success.
AuthorizedHttpClientcompleted its refresh waiters with a hardcodedtruein afinallyblock, while the refresh itself returnedfalseon 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) onAuthTokens, plusTokenStorage.originandTokenStorage.hasLegacyPair.TokenOrigin.legacyis reachable only throughadoptLegacySession—AuthTokens.fromJsonalways yieldskitty, so no server response can put the SDK into legacy mode. Origin is local bookkeeping and is not serialized bytoJson. An install that predates this change has no stored origin and reads askitty, 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).
VaultApiClientgainsuserExist(GET /account/vault/users/exist— the pre-login existence check the mobile login/signup UX branches on) andcanLoginWithPassword(GET /account/vault/can-login-with-password; note it returns false for both "no such user" and "user exists but passwordless", so pair it withuserExistwhere that distinction matters). Both are exposed onAccountRepository(isUserExist/canLoginWithPassword, deliberately silent in analytics — they run on every login-screen step) and onKittyAccount, which additionally now surfaces the existing repository methodssendResetPasswordCode,validateResetPasswordCodeandresetPassword(resetPasswordalso logs the user in — the server mints a session, matching the legacy flow). 5 new client tests.
0.10.19 #
- Account: v2
/users/currentclient + per-license token support. NewUsersApiClient.getCurrentUserV2()callsGET /api/account/v2/users/current— same contract as v1, plus every license carriestokensPerCycle(renewable allowance per billing cycle) andsupportsTokens(plan-level flag,tokensPerCycle > 0).Licensegains both fields with safe defaults (0/false), so v1 responses and previously cached accounts keep parsing unchanged;toJsonroundtrips them through the SDK's account cache. NotesupportsTokens: falsedoes not mean a zero balance — one-time top-up tokens live inGET /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 aStateErrorwhen analytics wasn't initialized, or produced an emptysidwhen PostHog wasn't actually ready (systematic on web, whereinitPostHogWebsilently no-ops if theposthogJS global hasn't loaded yet while the SDK still reports analytics as initialized). The emptysidwent on the wire and failed the backend'sverify-codevalidation 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)sidis resolved defensively — never throws, never empty; when unavailable it is omitted fromverify-code/signup-password(the verify-code backend treatssidas 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 viaattach-sid(the same pattern App Store login already used, now shared); (3)verify-codemaps 400 →AccountErrorType.badRequestand 429 →tooManyAttempts(401 staysloginCodeNotValid); (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.isWrongCodeis narrower (exported public API). It waserrorType != 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:isOfflineandhasGenericError(any error that is neither a code rejection nor offline). Hosts that branched onisWrongCodeto mean "something failed" must switch toerrorType != null. - Offline stays delegated to the host.
hasGenericErrorrenders inline under the code input;noInternetdeliberately renders nothing inline, matching the existing login form — the auth flows already invoke the host'sonNoInternetcallback, and duplicating the message inline would double up with the host's own snackbar. - API restored —
sidis back onKittyAccount.verifyLoginCode. It was removed in 0.2.2, when sid resolution moved entirely insideAccountRepositoryon the assumption that analytics is always available. That assumption is what broke login here, so the caller-supplied override returns: an explicitsidwins over auto-resolution, and a blank/whitespace value is normalized to "absent" rather than sent as an empty string. AdditionallyKittyAccount.initialize/KittySdk.initializeaccept aSidResolver(sidResolver/accountSidResolver) — an asyncString? Function()— so hosts that already know their analytics id can supply it directly instead of depending onKittyAnalytics. Exported asSidResolver. - Failure-mode change for
signupWithPasswordwithout analytics. It previously threw a client-sideStateErrorbefore any request. It now omits the sid and reaches the server, which still requiressidforsignup-password, so the call fails with a 400ApiExceptioncarrying a clear validation message. Hosts catchingStateErroraround signup must widen toApiException. Relaxing the server-side signup schema is tracked separately; onlyverify-codeis 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 lateattach-sidis 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
onAnalyticsEventhost callback (deliberately host-side, since these fire exactly when PostHog is unavailable):LoginSidAttachedAfterAuthwhen the late attach succeeded, andLoginSidUnavailablewith areasonproperty distinguishingresolver_threw/analytics_uninitialized/analytics_timeout/analytics_blank— so a host whosesidResolverthrows is not indistinguishable from PostHog merely not being ready. - Additive and backward-compatible apart from the
isWrongCodenarrowing 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 undertest/account/).
0.10.17 #
- Settings: hide an untrusted billing period instead of showing a misleading "1 year".
KittySubscriptionPagenow derives the card's billing period via the newreliableBillingPeriod(License)helper. A web/Lago license whosedurationis 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 realdurationon renewal. Trade-off: a genuine Lago yearly license hides its period until its duration is populated. New helpershasReliableBillingPeriod(License)/reliableBillingPeriod(License)inlicense_helpers.dart(exported viakitty_sdk.dart); first unit tests added undertest/account/ui/settings/helpers/. - Additive and backward-compatible: no API/schema change; apps get the safer display with a
kitty_sdkversion bump.
0.10.16 #
- Settings: Web2Wave "manage subscription" card.
KittySubscriptionPagenow 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.
KittySettingsPagenow shows a Manage-subscription item directly on the Settings screen (new optionalonSubscriptionTap/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 fromKittyLegalInfoPageinto a shared widget (same labels, analytics events, andlaunchUrlbehaviour) so they also render as the subscription-card footer. - New helpers (exported via
kitty_sdk.dart):shouldShowUpcomingBlock(License)andtrialLengthLabel(License, {suffix}). The latter derives the trial length fromexpiresAt - 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) orcancellinglicense 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+KittySettingsBadgeThemeadded toKittySettingsTheme(ascard/badge). All fields are nullable with sensible fallbacks, so the card renders correctly with no host theming; overridecard/badgeto brand the container, divider, and the Active / Upcoming / Cancelling / Suspended / Expired badge colors. - New
KittySettingsConfigcopy fields:manageSubscriptionHeader,subscriptionActiveBadgeLabel,subscriptionUpcomingBadgeLabel,subscriptionExpiredBadgeLabel,subscriptionCancellingBadgeLabel,subscriptionSuspendedBadgeLabel,trialLabel,subscriptionSuffix(all with English defaults, threaded throughresolveWithSdkConfig), plus ahasLegalLinksgetter. The old flat-row copy fieldscurrentPlanPrefix,statusPrefix,pricePrefix,billingPeriodPrefix, andnextChargeAmountPrefixare 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_sdkversion bump; theming the card and overriding copy are optional.
0.10.15 #
- Settings:
onAccountDeletedcleanup hook onKittySettingsConfig. New optionalFuture<void> Function()? onAccountDeleted.KittySettingsCubit.deleteAccount()invokes it immediately beforeKittyAccount.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 asAccountDataCleanupFailed) 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.onSupportEmailTapwas set. The code form already fell back to opening amailto:link withKittySdkConfig.supportEmailwhen the callback was not provided, but the login form wired the tap recognizer straight to the (possibly null) callback, so with onlysupportEmailconfigured the link was dead. Both forms now share one helper:onSupportEmailTapif provided, otherwisemailto: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
KittyWebBuyNowPagerenders the checkout inside an iframe (flutter_inappwebview web implementation). Safari exposesApplePaySessionto cross-origin iframes only when the iframe carriesallow="payment", so the embedded checkout silently hid the Apple Pay button while Google Pay and card stayed visible (the checkout'ssupportsApplePay()gate sawtypeof ApplePaySession === 'undefined').InAppWebViewSettingsnow setsiframeAllow: '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
subscriptionPolicyUrlonKittySdkConfig/KittySettingsConfig(+subscriptionPolicyLabel, default "Subscription Policy"). When set,KittyLegalInfoPagerenders it next to Privacy Policy and Terms of Use (tracked asSettingsSubscriptionPolicyClicked). Required for web2app funnel compliance (refund policy visibility). - Settings: subscription page now shows renewal info.
KittySubscriptionPagerenders Price (license.price+ currency), Billing period (derived fromlicense.duration) and — for active auto-renewing subscriptions only — Next charge date (subscriptionNextChargeDate, falling back toexpiresAt) 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
currentPlanPrefixputs the value on a second line ('Current plan:\n'), butKittySettingsMenuItemhard-capped text at one line, so the plan name never showed. The menu item now takes amaxLinesparameter (default 1) and the current-plan row uses 2. - Added
formatDate,formatLicensePriceandformatBillingPeriodhelpers tolicense_helpers.dart(exported viakitty_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'sautofocusgrabbed 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(andLogOutSuccess) 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()andlogout()now force an immediate flush of queued events before tearing down. Additionally,LogOutSuccessis now tracked beforeKittyAnalytics.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_storekit0.4.4–0.4.6. 0.10.8 only handled the case whereSK2Product.purchasereturnsuserCancelled/pending(0.4.7+). On 0.4.4 the native layer instead throws aPlatformException(storekit2_purchase_cancelled/storekit2_purchase_pending), whichBuyNowHelper.buySubscriptionsilently swallowed (itson PlatformExceptionhandler only coveredstorekit_duplicate_product_object), so the bloc never leftPurchaseInProgressState. NowIOSSK2ExtendedProductDetails.buy()handles cancel/pending whether reported as a thrownPlatformExceptionor a returned result, andbuySubscriptionrethrows any otherPlatformExceptioninstead of swallowing it. Fixes the loader for the whole^0.4.0range.
0.10.8 #
- Fix: in-app buy-now loader spinning forever when the user cancels the native payment sheet (StoreKit 2).
KittyInAppBuyNowPagekept its loading overlay up indefinitely if the user dismissed the StoreKit payment dialog. Under StoreKit 2 (the default inin_app_purchase_storekit>= 0.4) the idiomaticbuyNonConsumablediscards the purchase result and StoreKit emits nothing onpurchaseStreamfor a user cancellation, so the bloc never leftPurchaseInProgressState. The SK2 purchase path now reads theSK2ProductPurchaseResultdirectly and surfacesuserCancelled/pendingas aBuyNowPurchaseNotCompletedException, which the buy-now bloc handles by clearing the loader (and trackingBuySubscriptionCanceled/BuySubscriptionPending). StoreKit 2 stays enabled; StoreKit 1 and Android behaviour are unchanged (cancellation already arrives via the purchase stream there).
0.10.7 #
TokenBalancenow exposes a per-license breakdown.GET /account/tokens/balance?projectId=...now returns an additionallicensesarray (one entry per license belonging to the authenticated user in the project, withlicenseId,balance,subscriptionBalanceandtotal). The top-level aggregate fields keep the same shape as before, so existing code continues to work.- For single-license responses (
?licenseId=...) the samelicensesarray is returned with a single entry mirroring the top-level balance. - Added
LicenseTokenBalancemodel andTokenBalance.licenses/TokenBalance.licenseBalance(licenseId)helpers (exported fromkitty_sdk.dart).
0.10.6 #
- Aggregated balance is now a single backend request.
GET /account/tokens/balanceacceptsprojectId(in addition tolicenseId) and the server returns the sum ofbalance/subscriptionBalance/totalacross the user's licenses in that project.KittyTokens.getBalance()(withoutlicenseId) makes one HTTP call instead of one per license — no more fan-out from the client. - Added
TokensRepository.getAggregateBalanceForProject({required String projectId})and the matchingTokensApiClientmethod for SDK consumers that need the aggregate from a custom repository. - Aggregated
TokenBalance.licenseIdis nownull(returned as such by the backend).
0.10.5 #
KittyTokens.getBalance()now aggregates across all of the user's licenses when called withoutlicenseId. PasslicenseId:explicitly to get the balance for a single license.TokenBalanceextended: now exposeslicenseId,subscriptionBalanceandtotal(=balance + subscriptionBalance) returned by the backend.licenseIdisnullfor aggregated balances.TokensState.tokenCountnow returnsbalance.totalinstead ofbalance.balance, so the built-in account-page UI shows the full spendable amount (top-up + subscription cycle tokens). If you relied ontokenCountto show only the top-up part, switch tostate.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/balanceand/account/tokens/operationsnow require alicenseId(UUID) query param instead ofprojectId.KittyTokens.getBalance()/getOperations()andTokensCubit.loadBalance()/loadOperations()now accept an optionallicenseId: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 underinternal-tokensand require an internal API key or a per-projectkap_bearer token. Call them from your backend instead. KittyTokens.initializeno longer takes aprojectId(licenses already carry their own project association).
0.10.2 #
- Logout cleanup: Account logout now resets PostHog and clears local auth state;
KittySettingsConfig.onLogoutlets host apps run custom settings-page logout handling. - Auto-identify control: Added
enableAutoIdentifytoKittySdk.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
KittyBuyNowConfigwithproductIds,webBuyNowUrl,pwaBuyNowUrl(Stage),domain,eulaUrl,privacyPolicyUrl,customerSupportEmail,isStaging,isAutoRestorePurchaseEnabled. Wire it throughKittySdkConfig.buyNowand access globally viaKittySdk.buyNow/KittyBuyNow.I. - Buy Now UI theming: added
KittyBuyNowThemeforKittyInAppBuyNowPageandKittyWebBuyNowPage, split into logical blocks:page,navBar,mainBlock,subscription,buttons,legal, andwebView. Replaces the previousInAppBuyNowTheme. KittyBuyNowThemeandKittyBuyNowThemeProviderare exported fromkitty_sdk.dart.
0.9.0 #
- Account UI theming:
KittyThemenow accepts a directThemeDataoverride viaKittyTheme(themeData: ...). The SDK merges it over the default SDK theme when provided. - Auth UI theming: Added
KittyAuthThemeforKittyAuthFlowandKittyBindEmailFlow. 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
KittySettingsThemeforKittySettingsFlow, split into logical blocks:page,menuItem,buttons,destructive,subscription,tokens,dialog, andbindEmail. KittyAuthUiConfig.iconis deprecated. UseKittyAuthTheme.loginHeaderWidgetorKittyAuthTheme.codeHeaderWidgetinstead.KittyAuthThemeandKittySettingsThemeare exported fromkitty_sdk.dart.
0.8.0 #
- Tokens module: New module for managing user token balances and operations.
KittyTokensConfig— configureisDebugto show/hide debug UI in settings.KittySdk.tokens— static accessor for the Tokens client withgetBalance(),addTokens(),removeTokens(), andgetOperations(). (Note:addTokens/removeTokenswere removed from the client in 0.10.3 — see above.)- Integrated into
KittyAccountPageto 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.tokensis 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 withaddLog(),addTrace(),addMetric(), and manualflush().- Automatic batching with configurable interval and batch size, gzip compression, and retry logic.
- Conditional initialization — OTEL module only starts when
KittySdkConfig.otelis provided.
0.5.5 #
- Fingerprint auto-login: On SDK init, if Flare identifies a strong fingerprint match (
IP_v4/GEO_UA) with aloginCodeinuserData, the SDK automatically exchanges it for auth tokens — seamless web-to-mobile login. onFingerprintAutoLogincallback: New optional parameter inKittySdk.initialize()— notifies the host app when fingerprint auto-login succeeds.- Safe
userDatacasts:distinctIdandloginCodefrom fingerprintuserDatanow use?.toString()instead ofas String?to prevent runtime type errors.
0.5.4 #
- KittySdkConfig: Added
productName,privacyPolicyUrl,termsOfUseUrl,shareAppText,shareAppLinkfields 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_flutterminimum to^5.15.0— fixesPostHogFeatureFlagResult not foundcompile error when resolving to older 5.x versions.
0.5.2 #
- Auto-identify on init:
KittySdk.initialize()now automatically callsflare.identify()if no previous result is cached. On a match, aliases the web PostHogdistinctIdfrom 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-keyandx-ph-hostheaders in every API request (both authenticated and unauthenticated). The backend uses these to routeidentify/aliascalls to the correct PostHog project — no shared PostHog instance needed. KittyAccountConfig.baseHeaderscentralizes common headers (content-type,x-project-id, PostHog keys) to avoid duplication across API clients.
0.5.0 #
- AccountInfo.primaryEmail: Backend now sends
primaryEmailin/users/currentresponse; SDK uses it for theemailgetter instead ofemails[0]. Fixes email not updating after bind-email flow. - Bind-email UI: Title moved to AppBar for consistent design;
embeddedmode prevents duplicate Scaffold/AppBar. - Delete account: Now performs a client-side logout instead of calling the non-existent
mark-for-deletionAPI. - Contact Support button: Upgraded to
FilledButton.tonalfor better visibility on the code page; fixedmailto: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.themeaccepts aKittyTheme(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
supportEmailtoKittySdkConfigfor global configuration — no longer needed per-form. - Resend timer default changed from 5 minutes to 30 seconds.
0.3.2 #
- Fix:
claimAppStoreSubscriptionsnow sendsx-project-idheader — licenses are correctly associated with the project instead of being created withproject_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,showSignUpTogglefrom public API.
0.3.0 #
- Account UI — Auth flow: Configurable login and 2FA code forms (
KittyAuthFlow,KittyLoginForm,KittyCodeForm) withKittyAuthUiConfigfor 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 byKittySettingsConfig. No dependency onauto_routeor external analytics/environment packages. - New dependencies:
flutter_bloc,url_launcher,share_plus.
0.2.3 #
KittyAnalytics.initialize()now callsposthog.init()via JS interop on web, so PostHog API key is only configured in Dart — no need to hardcode it inindex.html.
0.2.2 #
AccountRepositorynow auto-resolvessidfromKittyAnalytics(PostHog distinctId) forverifyLoginCodeandsignupWithPassword.- Removed
sidparameter fromKittyAccount.verifyLoginCode()public API. - Removed
canLoginWithPasswordfromAccountRepositoryandVaultApiClient.
0.2.1 #
- Fix: add web platform support — guard
Platformcalls withkIsWebto preventdart:iocrashes on web. DeviceInfoCollectornow returns browser metadata on web instead of throwingUnsupportedError.FlarePurchaseObserverandAppStorePurchaseObservergracefully no-op on web.
0.2.0 #
- Analytics module: PostHog event tracking, user identification, feature flags, session replay.
KittySdkConfig.analyticsparameter 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.