diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c99a32..bb098a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Added + +- `references/android/api-reference.md`: a **UI Handler — Alerts** section. An `onAlert` implementation that displays its own dialogs must end every branch with `proceed()` or `alert.onDismiss()`; the paywall action that raised the alert stays open until then, so a branch calling neither leaves the Screen displayed and unresponsive, close button included. Covers the `PLYAlertMessage` base-class helpers (`onDismiss()`, `getTitleContent()`, `getContentMessage()`, `getButtonContent()`) and the order/exclusivity rules. +- `references/android/migration-v6.md`: a `PLYUIHandler` section and a verification-checklist item — the signature is unchanged, but early v5 releases did not wait for the alert dismissal, so a missing call is a latent v5 bug the migration surfaces. +- `references/android/common-patterns.md`: a **Custom alert dialogs (`PLYUIHandler`)** pattern dismissing from `setOnDismissListener`. +- `references/troubleshooting/common-issues.md`: §2 "UI Frozen / Paywall Stuck" now lists the custom `PLYUIHandler` as Cause A alongside the unresolved interceptor (Cause B), plus a row in the symptom → likely cause table. +- `references/concepts/paywall-actions.md`: an Android anti-pattern for an `onAlert` that dismisses neither way. +- `skills/purchasely-review/SKILL.md`: check 3.3 flags an `onAlert` branch that can skip both calls, both calls made for the same alert, and `onDismiss()` called before the custom dialog closes. Skipped on iOS and the cross-platform bridges — the contract is Android-specific. +- `skills/purchasely-debug/SKILL.md`: "UI Frozen After Paywall Action" names the UI handler as the second Android cause and adds a step to audit it, plus a symptom-table row. +- `skills/purchasely-migrate/SKILL.md`: a **UI handler alerts** step in the Android workflow. +- `skills/purchasely-sdk-expert/SKILL.md`: a routing-index row for `PLYUIHandler` / custom alert dialog questions. + ## [2.1.1] — 2026-09-02 A marketplace install of the Claude Code plugin reported `failed to load` since `2.0.0`. The plugin's own manifest declared a hooks file that Claude Code already loads by convention, so the `SessionStart` hook never reached the session. diff --git a/purchasely/references/android/api-reference.md b/purchasely/references/android/api-reference.md index 46751e1..9f43cf7 100644 --- a/purchasely/references/android/api-reference.md +++ b/purchasely/references/android/api-reference.md @@ -375,6 +375,55 @@ Purchasely.removeAllActionInterceptors(); > `OpenPresentation.presentationId` is the **target presentation** the action wants to open (`PLYPresentationAction.OpenPresentation(presentationId = …)` in source) — it is a parameter on the action, not a renamed field. It is distinct from `PLYPresentation.screenId` (the loaded presentation's own id, renamed from `id` in v6). The "don't use `presentationId`" guidance above applies to the loaded `PLYPresentation`, not to this action parameter. +## UI Handler — Alerts + +In Full mode the SDK displays its own `AlertDialog` at the end of a paywall action (purchase success, purchase error, restore result, plan change). `PLYUIHandler.onAlert` lets the app display its own dialog instead. + +```kotlin +Purchasely.uiHandler = object : PLYUIHandler { + override fun onAlert( + alert: PLYAlertMessage, + purchaselyView: View, + activity: Activity?, + proceed: () -> Unit + ) { … } +} +// Java: Purchasely.setUIHandler(new CustomUIHandler()); +``` + +**Every branch of `onAlert` must end with exactly one of `proceed()` or `alert.onDismiss()`.** + +| Call | Effect | +|------|--------| +| `proceed()` | The SDK displays its own dialog and dismisses the alert when the user taps its button. | +| `alert.onDismiss()` | Dismisses the alert with no SDK dialog. Use it when you display your own. | + +The alert is the last step of the paywall action that produced it, and the SDK keeps that action open until the alert is dismissed — only then does it resume the Screen (closing it after a successful purchase, accepting taps again after an error). A branch that calls neither leaves the action pending: **the Screen stays displayed and stops reacting to taps, close button included**, and later actions are never processed. Early v5 releases did not wait for the dismissal, so a missing call went unnoticed. + +`onDismiss()` is declared on the `PLYAlertMessage` base class, so it is available on every alert type without a `when` branch. The base class also exposes the strings the SDK would have displayed — `getTitleContent()`, `getContentMessage()`, `getButtonContent()` — so a custom dialog can reuse them. + +```kotlin +Purchasely.uiHandler = object : PLYUIHandler { + override fun onAlert(alert: PLYAlertMessage, purchaselyView: View, activity: Activity?, proceed: () -> Unit) { + val context = activity ?: return proceed() // no activity: let the SDK display the alert + when (alert) { + is PLYAlertMessage.InAppSuccess, + is PLYAlertMessage.InAppSuccessUnauthentified -> + // dismiss once your dialog is closed, so the SDK resumes the Screen + showMyDialog(context, alert.getTitleContent(), alert.getContentMessage()) { alert.onDismiss() } + else -> proceed() + } + } +} +``` + +Two rules: + +- Call `alert.onDismiss()` **after** your dialog is dismissed, not before — on a success alert the SDK resumes the flow and closes the Screen. +- Never call both `proceed()` and `alert.onDismiss()` for the same alert, or the SDK dialog is displayed on top of yours. + +`PLYAlertMessage` is a sealed class; the error is carried by the types that have one (`PLYAlertMessage.InAppError`, `PLYAlertMessage.InAppRestorationError`, …). Inside a `when` branch read it with `alert.error`; its localized message is also what `alert.getContentMessage()` returns. + ## Deeplinks and Campaigns The SDK **auto-intercepts** its own deeplinks (zero code): it reads the foreground activity's intent on create and resume. Manual calls still work and are deduped. diff --git a/purchasely/references/android/common-patterns.md b/purchasely/references/android/common-patterns.md index 0f7443a..a3c3767 100644 --- a/purchasely/references/android/common-patterns.md +++ b/purchasely/references/android/common-patterns.md @@ -243,6 +243,29 @@ presentation.display(activity) { outcome -> } ``` +## Custom alert dialogs (`PLYUIHandler`) + +Every branch must end with `proceed()` or `alert.onDismiss()` — the paywall action that raised the alert stays open until one of them is called. + +```kotlin +Purchasely.uiHandler = object : PLYUIHandler { + override fun onAlert(alert: PLYAlertMessage, purchaselyView: View, activity: Activity?, proceed: () -> Unit) { + val context = activity ?: return proceed() // no activity: let the SDK display the alert + when (alert) { + is PLYAlertMessage.InAppSuccess, + is PLYAlertMessage.InAppSuccessUnauthentified -> + MaterialAlertDialogBuilder(context) + .setTitle(alert.getTitleContent()) + .setMessage(alert.getContentMessage()) + .setPositiveButton(alert.getButtonContent()) { d, _ -> d.dismiss() } + .setOnDismissListener { alert.onDismiss() } // after the dialog closes + .show() + else -> proceed() // SDK dialog, dismissal handled for you + } + } +} +``` + ## Cleanup on restart ```kotlin diff --git a/purchasely/references/android/migration-v6.md b/purchasely/references/android/migration-v6.md index a4966fa..c679fa4 100644 --- a/purchasely/references/android/migration-v6.md +++ b/purchasely/references/android/migration-v6.md @@ -329,6 +329,25 @@ Purchasely.removeActionInterceptor(PLYPresentationAction.Purchase.class); // Jav Purchasely.removeAllActionInterceptors(); ``` +## `PLYUIHandler` — custom alert dialogs must dismiss the alert + +The signature of `PLYUIHandler.onAlert` is unchanged, but the contract is now enforced: an implementation that displays its own dialogs must end **every** branch with `proceed()` (the SDK displays its dialog and dismisses the alert) or `alert.onDismiss()` (the alert is dismissed with no SDK dialog). + +In v6 every paywall action goes through a single queue, and the action that raised the alert — a purchase, a restore, a plan change — stays open until the alert is dismissed. A branch that calls neither leaves it pending: the Screen remains displayed and stops reacting to taps, close button included. Early v5 releases did not wait for that dismissal, so the missing call went unnoticed. + +```kotlin +Purchasely.uiHandler = object : PLYUIHandler { + override fun onAlert(alert: PLYAlertMessage, purchaselyView: View, activity: Activity?, proceed: () -> Unit) { + val context = activity ?: return proceed() // no activity: let the SDK display the alert + showMyDialog(context, alert.getTitleContent(), alert.getContentMessage()) { + alert.onDismiss() // dismisses the alert once your dialog is closed, no SDK dialog + } + } +} +``` + +`onDismiss()` is declared on the `PLYAlertMessage` base class, so it covers every alert type without a `when` branch. Call it **after** your dialog is closed, not before: on a success alert the SDK resumes the flow and closes the Screen. Never call both `proceed()` and `onDismiss()` for the same alert. See [api-reference.md](api-reference.md) § UI Handler — Alerts. + ## Observer-mode bridge: callback -> suspend ```kotlin @@ -468,6 +487,7 @@ Mechanical, in order: 8. **Deeplinks**: redundant `handleDeeplink(intent.data)` removed (auto-intercepted) unless on `singleTask`/`singleTop` without `setIntent(intent)`. 9. **Offers**: `intro*` / `INTRO_*` / `TRIAL_*` -> `offer*` / `OFFER_*`. 10. **Removed UI**: `subscriptionsFragment()`, `purchaseHistory()`, `isPastSubscriber()` replaced. +11. **UI handler**: if `PLYUIHandler.onAlert` displays custom dialogs, every branch ends with `proceed()` or `alert.onDismiss()` — otherwise the Screen stays displayed and unresponsive. Build and test: diff --git a/purchasely/references/concepts/paywall-actions.md b/purchasely/references/concepts/paywall-actions.md index 0565a83..23659ce 100644 --- a/purchasely/references/concepts/paywall-actions.md +++ b/purchasely/references/concepts/paywall-actions.md @@ -212,6 +212,7 @@ Key points: - ❌ Resolving twice (e.g. once in the happy path, once in `finally`). - ❌ Doing heavy synchronous work in the interceptor — the paywall is waiting on you. - ❌ Trying to "stay on the paywall after purchase" by holding the interceptor open or skipping the result — instead, configure the button with no second action (Observer mode) or add an explicit `open_screen` / `open_placement` step. +- ❌ **Android only** — displaying a custom dialog from `PLYUIHandler.onAlert` without calling `proceed()` or `alert.onDismiss()`. The alert is the last step of the action that raised it, so the action stays open and the Screen freezes exactly as it does for an unresolved interceptor. See [../android/api-reference.md](../android/api-reference.md) § UI Handler — Alerts. ## See also diff --git a/purchasely/references/troubleshooting/common-issues.md b/purchasely/references/troubleshooting/common-issues.md index f87e0da..94a9187 100644 --- a/purchasely/references/troubleshooting/common-issues.md +++ b/purchasely/references/troubleshooting/common-issues.md @@ -78,6 +78,7 @@ If any of those three is missing, you have a defined symptom — see the table b | No `RECEIPT_VALIDATED` event | Receipt failed server-side validation | Check `[Purchasely] Receipt status: …` — `failed` / `error` → check StoreKit config, sandbox account, server clock | | `IN_APP_PURCHASED` but no `IN_APP_RENEWED` | Receipt validated but no active subscription state | Dashboard → Subscribers → look up the transaction; check store product config | | `PRESENTATION_CLOSED` never fires after a successful purchase | Dismiss API not called, or called before the action was acknowledged | Verify the order: the action MUST be acknowledged before dismissal. Native iOS/Android use `closeAllScreens()`; Flutter v6 uses `presentation.close()`; React Native v6 and Cordova v6 use `request.close()` | +| Android: Screen stays displayed and ignores every tap after a purchase / restore / error dialog | A custom `PLYUIHandler.onAlert` displayed its own dialog without calling `proceed()` or `alert.onDismiss()`, so the paywall action never completed | See §2, Cause A. Check every branch of `onAlert`, including the early returns | | `pendingSuccessfulPurchase=false` after a real purchase | The flag was never set (transaction handler didn't run, or wrong mode) | Check interceptor `.purchase` case took the Observer branch | | Follow-up `fetchPresentation` returns `type=deactivated` or `error=…` | The chained placement is missing / typo / deactivated on the dashboard | Dashboard → Placements → check the exact vendor ID. Common gotcha: typo in the placement_id string | | Follow-up placement returns a presentation, but renders "the previous paywall again" | The Flow hosting the original placement chains a post-purchase step that points to the wrong paywall | The event's `flow_id` and `displayed_presentation` reveal the chained step. Dashboard → Flows → inspect `` post-purchase branches | @@ -215,7 +216,22 @@ Purchasely.apiKey("KEY").storekitSettings(.storeKit2).start { error in **Symptoms:** Paywall buttons stop responding, spinner never dismisses, app appears frozen. -**Cause:** the action was not acknowledged in all code paths of the interceptor — a returned `PLYInterceptResult` (`success` / `failed` / `notHandled`) on native iOS/Android v6 and Flutter v6, a returned `'success' / 'failed' / 'notHandled'` string on React Native v6, or a returned/resolved `Purchasely.InterceptResult` on Cordova v6. +**Cause A (Android, custom `PLYUIHandler`):** an `onAlert` branch displayed the app's own dialog and called neither `proceed()` nor `alert.onDismiss()`. The alert is the last step of the paywall action that raised it (purchase, restore, plan change); the SDK keeps that action open until the alert is dismissed, so the Screen stays displayed and stops reacting to taps, close button included. Early v5 releases did not wait for the dismissal, so apps that migrate to v6 with an existing handler surface this for the first time. + +**Solution:** end every `onAlert` branch with exactly one of `proceed()` (SDK displays its dialog and dismisses the alert) or `alert.onDismiss()` (dismiss with no SDK dialog), the latter from the dismiss callback of your own dialog — after it closes, never before. + +```kotlin +Purchasely.uiHandler = object : PLYUIHandler { + override fun onAlert(alert: PLYAlertMessage, purchaselyView: View, activity: Activity?, proceed: () -> Unit) { + val context = activity ?: return proceed() // no activity: let the SDK display the alert + showMyDialog(context, alert.getTitleContent(), alert.getContentMessage()) { alert.onDismiss() } + } +} +``` + +`onDismiss()` is on the `PLYAlertMessage` base class, so it covers every alert type without a `when` branch. Never call both `proceed()` and `onDismiss()` for the same alert — the SDK dialog would appear on top of yours. + +**Cause B (all platforms):** the action was not acknowledged in all code paths of the interceptor — a returned `PLYInterceptResult` (`success` / `failed` / `notHandled`) on native iOS/Android v6 and Flutter v6, a returned `'success' / 'failed' / 'notHandled'` string on React Native v6, or a returned/resolved `Purchasely.InterceptResult` on Cordova v6. **Solution:** Ensure every branch resolves exactly once. Native iOS/Android v6, Flutter v6, React Native v6, and Cordova v6 all return or resolve a result. diff --git a/purchasely/skills/purchasely-debug/SKILL.md b/purchasely/skills/purchasely-debug/SKILL.md index 161ce5c..e349715 100644 --- a/purchasely/skills/purchasely-debug/SKILL.md +++ b/purchasely/skills/purchasely-debug/SKILL.md @@ -127,11 +127,14 @@ The cause differs by platform: - **React Native (v6):** the per-action handler passed to `Purchasely.interceptAction(kind, handler)` did **not return a string result** on some path (or the `async` handler never resolved). There is no `onProcessAction` in React Native v6 — the handler's returned string (`'success'` / `'failed'` / `'notHandled'`) is the signal. A handler that throws or falls through without returning will freeze the paywall. - **Cordova (v6):** the per-action handler passed to `Purchasely.interceptAction(kind, handler)` did **not return/resolve a `Purchasely.InterceptResult`** on some path (or the returned `Promise` never resolved). There is no `onProcessAction` in Cordova v6 — the handler's returned/resolved result (`Purchasely.InterceptResult.success` / `.failed` / `.notHandled`) is the signal. A handler that throws or returns a `Promise` that never settles will freeze the paywall. +- **Android — second cause, a custom `PLYUIHandler`:** even with a correct interceptor, an `onAlert` branch that displays the app's own dialog and calls neither `proceed()` nor `alert.onDismiss()` freezes the Screen the same way. The alert is the last step of the action that raised it (purchase, restore, plan change) and the SDK keeps that action open until the alert is dismissed. Check this **first** when the freeze happens right after a dialog would have appeared, and when the app migrated from v5 with an existing handler — early v5 releases did not wait for the dismissal, so the missing call was silent there. + 1. **Find the interceptor** -- native v6 / Flutter v6 / React Native v6 / Cordova v6: search for `Purchasely.interceptAction`. Older native code may still reference the removed `setPaywallActionsInterceptor` — that won't compile against v6. Older Cordova code may still reference the removed `setPaywallActionInterceptor` / `onProcessAction`. 2. **Audit every code path** -- native v6: every branch (success, failure, cancellation, timeout) MUST return a `PLYInterceptResult`. Flutter v6: every branch MUST return an `InterceptResult`. React Native v6: every branch MUST return a string (`'success'` / `'failed'` / `'notHandled'`). Cordova v6: every branch MUST return or resolve a `Purchasely.InterceptResult`. A missing return / unresolved promise freezes the paywall. 3. **Check async operations** -- if the handler makes an API call (login, server validation), verify it always resolves. Native v6 `async` handlers must reach a `return`; the completion-based form must always invoke the completion. Flutter v6 / React Native v6 `async` handlers must reach a `return`. Cordova v6 handlers returning a `Promise` must always `resolve(...)` (never leave it pending). Look for missing error handlers, timeouts, or network failures that skip it. 4. **Check try/catch blocks** -- native v6: a caught exception must still `return .failed` (or `.notHandled`). Flutter v6: a caught exception must still `return InterceptResult.failed` (or `.notHandled`). React Native v6: a caught exception must still `return 'failed'` (or `'notHandled'`). Cordova v6: a caught exception must still resolve `Purchasely.InterceptResult.failed` (or `.notHandled`). -5. **Fix**: ensure every exit path produces a result. Native v6: wrap in `do/catch` (Swift) / `try/finally` (Kotlin) and return `.failed` on error. Flutter v6: wrap in `try/catch` and `return InterceptResult.failed` on error. React Native v6: wrap in `try/catch` and `return 'failed'` on error. Cordova v6: wrap in `try/catch` (or a `Promise` `.catch(...)`) and resolve `Purchasely.InterceptResult.failed` on error. +5. **Audit the UI handler too (Android)** -- search for `Purchasely.uiHandler` / `setUIHandler(`. If `onAlert` is overridden, every branch (each `when` arm, the `else`, early returns, the null-`activity` path, `catch` blocks) must end with exactly one of `proceed()` or `alert.onDismiss()`. Fix by calling `alert.onDismiss()` from the dismiss callback of the custom dialog — after it closes, not before (on a success alert the SDK resumes the flow and closes the Screen) — and never alongside `proceed()`, which would stack the SDK dialog on top. `onDismiss()` is on the `PLYAlertMessage` base class, so a single call covers every alert type. See `../../references/android/api-reference.md` § UI Handler — Alerts. +6. **Fix**: ensure every exit path produces a result. Native v6: wrap in `do/catch` (Swift) / `try/finally` (Kotlin) and return `.failed` on error. Flutter v6: wrap in `try/catch` and `return InterceptResult.failed` on error. React Native v6: wrap in `try/catch` and `return 'failed'` on error. Cordova v6: wrap in `try/catch` (or a `Promise` `.catch(...)`) and resolve `Purchasely.InterceptResult.failed` on error. ### Purchases Not Working @@ -201,6 +204,7 @@ When you identify one of these patterns, apply the known fix immediately: | Purchase succeeds but status not updated | Observer mode purchase not going through the interceptor at all (or the interceptor never returns `SUCCESS`) | Returning `SUCCESS`/`success` from the `purchase`/`restore` interceptor already triggers synchronization automatically — verify the interceptor is registered and actually resolves with `SUCCESS`. Only add a manual `Purchasely.synchronize()` call for purchases made **outside** the interceptor (a custom sell screen, BYOS) | | Observer purchase works but paywall freezes | The interceptor never signalled completion after the native purchase finished | Native v6: the `.purchase` handler must `return PLYInterceptResult.SUCCESS` (or `.FAILED`) for every outcome (success, cancel, error) -- a hung/unawaited billing call leaves it unsignalled. Flutter v6: the `PresentationActionKind.purchase` handler must `return InterceptResult.success` (or `.failed`) for every outcome. React Native v6: the `'purchase'` handler must `return 'success'` (or `'failed'`) for every outcome. Cordova v6: return or resolve `Purchasely.InterceptResult.success` (or `.failed`) for every outcome. In decoupled (reactive) architectures, make sure the billing result is mapped back to a returned result / completion for every branch | | Paywall loads but buttons do nothing | `PLYUIDelegate` / `UIDelegate` not set or not retained | Set the delegate and store a strong reference to the delegate object | +| Android: Screen ignores every tap (close button included) right after a purchase / restore / error dialog | A custom `PLYUIHandler.onAlert` displayed the app's own dialog and called neither `proceed()` nor `alert.onDismiss()`, so the paywall action that raised the alert never completed | End every `onAlert` branch with exactly one of `proceed()` or `alert.onDismiss()`; call `onDismiss()` from the custom dialog's dismiss callback, after it closes. Common after a v5 → v6 migration — early v5 releases did not wait for the dismissal | | Crash on paywall display (Android) | Application context passed instead of Activity context | Pass the current Activity, not `applicationContext` | | App freezes after closing a flow paywall (touches don't register) | The X button fires `.close` (back navigation) instead of `.closeAll` (full exit); `PLYWindow` stays alive waiting for a next step that never comes | Fix the paywall in Purchasely Console: change X button action from `close` to `closeAll`. Fallback: map `.close` → `closeAllScreens()` in interceptor. See `../../references/troubleshooting/common-issues.md` §11 | | Paywall doesn't dismiss after Observer-mode purchase | Observer mode does **not** auto-close (the implicit `close_all` is Full-only), so the app must dismiss itself | Native iOS/Android v6: inside the `.purchase` handler, `return PLYInterceptResult.SUCCESS` (auto-triggers synchronization), then call `Purchasely.closeAllScreens()` from your billing-result handler **after** the interceptor has resolved (do not call it inside the interceptor closure before returning — that races the SDK). Flutter v6: `return InterceptResult.success`, then dismiss with `presentation.close()`. React Native v6: `return 'success'`, then dismiss with `request.close()`. Cordova v6: resolve `Purchasely.InterceptResult.success`, then dismiss with `request.close()` on the held presentation request after the handler resolves (`closePresentation()` is kept as a deprecated alias). | diff --git a/purchasely/skills/purchasely-migrate/SKILL.md b/purchasely/skills/purchasely-migrate/SKILL.md index 48138fb..945c593 100644 --- a/purchasely/skills/purchasely-migrate/SKILL.md +++ b/purchasely/skills/purchasely-migrate/SKILL.md @@ -98,9 +98,10 @@ Incorporate corrections before editing files. 9. **Presentation API.** Replace `fetchPresentation(...)` / `PLYPresentationProperties` with `PLYPresentation { placementId(...) ; screenId(...) ; contentId(...) ; onPresented{…}; onCloseRequested{}; onDismissed{outcome->} }.preload { loaded, error -> }` (or `.preload()` in a coroutine, or the atomic `display(context, presentation, callback)`). **Do not put `flowId`/`productId`/`planId` on the builder — they are not exposed in v6;** display a Flow via its deeplink `app_scheme://ply/flows/FLOW_ID`. Update imports `io.purchasely.ext.*` → `io.purchasely.ext.presentation.*`. Rename `PLYPresentation.id` → `screenId` (keep `screenId`; do not rename Android code to `presentationId`) and `onClose` → `onCloseRequested`. `display(context)` is non-suspend and returns a `PLYPresentationSession` you can `.await()`. Callbacks now deliver one `PLYPresentationOutcome` (`purchaseResult`/`plan`/`closeReason`/`error`); `PLYProductViewResult` → `PLYPurchaseResult`. Note on `presentation.close()`: it delegates to `Purchasely.closeAllScreens()` — there is no instance-scoped close on Android, unlike iOS. **Default dismiss handler — no rename needed on Android:** `Purchasely.setDefaultPresentationDismissHandler { outcome -> … }` is, and always was, the correct Android name; `setDefaultPresentationResultHandler` never existed there (only iOS renamed *from* that name in v6 — see the iOS workflow below). Since `6.0.1` its `handler` parameter is nullable — pass `null` to unregister it. 10. **Embedded UI.** Replace `presentationView(...)` with `loaded.buildView(context) { outcome -> }` or `loaded.getFragment { outcome -> }`. For Jetpack Compose there is **no SDK composable** — wrap the view: `AndroidView(factory = { loaded.buildView(it) { outcome -> } })`. Do not reference `io.purchasely:presentation-compose` or a `PLYPresentationView` composable; `PLYPresentationView` is the Android `View` type that `buildView()` returns. 11. **Observer mode.** If the app used `processAction(Boolean)` to gate the SDK on the host purchase flow, port that to a `pendingResult: ((PLYInterceptResult) -> Unit)?` field + a `suspendCancellableCoroutine` bridge inside the new `suspend` interceptor (see `../../references/android/migration-v6.md` → "Observer-mode bridge"). On billing success resolve directly with `SUCCESS`; resolve `NOT_HANDLED`/`FAILED` otherwise. **Do not call `Purchasely.synchronize()` inside the interceptor** — returning `SUCCESS` from a `purchase`/`restore` interceptor already triggers synchronization automatically (a v5 habit worth dropping during the migration, not just carrying it forward as-is). Manual `Purchasely.synchronize(onSuccess = { … }, onError = { … })` remains legitimate only for purchases made **outside** the interceptor (a custom sell screen, BYOS). Clear `pendingResult` in `close()`/`restart()` before `removeAllActionInterceptors()` to avoid leaking suspended coroutines. -12. **Other renames/removals.** Deeplinks: `readyToOpenDeeplink` → `allowDeeplink`, `isDeeplinkHandled(uri, activity)` → `handleDeeplink(uri, activity)`; v6 also auto-intercepts deeplinks (the manual call may become unnecessary, but watch the `singleTask`/`singleTop` + `setIntent()` pitfall). User-attribute mutations now return `Deferred` (`.await()` when you need the result). Replace removed APIs: `subscriptionsFragment()` and the subscription/cancellation UI (build your own from `userSubscriptions`/`userSubscriptionsHistory`), `purchaseHistory()` → `userSubscriptionsHistory()`, `isPastSubscriber()` → derive from history, and all `intro*`/`INTRO_*`/`TRIAL_*` → `offer*`/`OFFER_*`. -13. Update tests to the v6 API and run unit tests. -14. Run the final Android assemble command before reporting completion. +12. **UI handler alerts.** If the project registers a `PLYUIHandler` (`Purchasely.uiHandler = …` / `setUIHandler(...)`) whose `onAlert` displays the app's own dialogs, make every branch end with exactly one of `proceed()` or `alert.onDismiss()` — including the `else`/default arm, early returns and the null-`activity` path. The signature did not change, but in v6 the paywall action that raised the alert stays open until the alert is dismissed, so a branch that calls neither leaves the Screen displayed and unresponsive (close button included); early v5 releases did not wait for the dismissal, so this is a latent v5 bug the migration surfaces. Call `alert.onDismiss()` from the dismiss callback of the custom dialog, never before it closes, and never alongside `proceed()`. `onDismiss()`, `getTitleContent()`, `getContentMessage()` and `getButtonContent()` are on the `PLYAlertMessage` base class — no `when` branch needed. See `../../references/android/migration-v6.md` → "`PLYUIHandler` — custom alert dialogs must dismiss the alert". +13. **Other renames/removals.** Deeplinks: `readyToOpenDeeplink` → `allowDeeplink`, `isDeeplinkHandled(uri, activity)` → `handleDeeplink(uri, activity)`; v6 also auto-intercepts deeplinks (the manual call may become unnecessary, but watch the `singleTask`/`singleTop` + `setIntent()` pitfall). User-attribute mutations now return `Deferred` (`.await()` when you need the result). Replace removed APIs: `subscriptionsFragment()` and the subscription/cancellation UI (build your own from `userSubscriptions`/`userSubscriptionsHistory`), `purchaseHistory()` → `userSubscriptionsHistory()`, `isPastSubscriber()` → derive from history, and all `intro*`/`INTRO_*`/`TRIAL_*` → `offer*`/`OFFER_*`. +14. Update tests to the v6 API and run unit tests. +15. Run the final Android assemble command before reporting completion. ## Mandatory Workflow — iOS (Swift & Objective-C) diff --git a/purchasely/skills/purchasely-review/SKILL.md b/purchasely/skills/purchasely-review/SKILL.md index 8cbf919..d4cc2ee 100644 --- a/purchasely/skills/purchasely-review/SKILL.md +++ b/purchasely/skills/purchasely-review/SKILL.md @@ -95,7 +95,7 @@ Search the entire codebase using these patterns to build a map of all Purchasely - Flutter v6: `PurchaselyBuilder.apiKey(` / `.runningMode(` / `RunningMode.full` / `.storekitVersion(` / `.start()` - React Native v6: `Purchasely.builder(` / `.runningMode('full')` / `.storekitVersion('storeKit2')` / `.stores([` / `.start()` - Cordova v6: `Purchasely.builder(` / `.runningMode(` / `.start(` / options-object `Purchasely.start({` (v5 positional `Purchasely.start(apiKey, stores, ...)` is removed) -- `PLYAlertMessage` / `PLYUIHandler` +- `PLYAlertMessage` / `PLYUIHandler` / `Purchasely.uiHandler` / `setUIHandler(` / `onAlert(` — on Android also grep the body of `onAlert` for `proceed(` and `onDismiss(` (see 3.3) - `apiKey` / `PLY_API_KEY` **Paywall patterns:** @@ -179,6 +179,7 @@ For each item below, search the code, analyze the context, and report one of: - [ ] **CLOSE action handled** — The close action must dismiss the paywall. v6 (native + Flutter + React Native + Cordova, kind `close` / `PresentationActionKind.close` / `'close'` / `Purchasely.PresentationAction.close`): return `notHandled` to let the SDK close, or `success` if the app closes it itself (Flutter: via `presentation.close()`; React Native: via `request.close()`; Cordova: via `request.close()`). FAIL if missing (users cannot close the paywall). - [ ] **No missing intercept result** — every branch (early return, error catch, switch default) MUST return or resolve exactly one result. This is the #1 most common stuck-paywall bug. FAIL if any code path can skip the result. - [ ] **No double-completion** — Returning/resolving once and then mutating state as if the handler were still pending is a logic error. WARNING if there's a risk of double signalling. +- [ ] **Custom `PLYUIHandler.onAlert` dismisses the alert (Android)** — if `Purchasely.uiHandler` / `setUIHandler(...)` is registered and `onAlert` displays the app's own dialog, **every** branch — `when` arms, `else`, early returns, the null-`activity` path, error catches — must end with exactly one of `proceed()` (SDK displays its dialog and dismisses the alert) or `alert.onDismiss()` (dismiss with no SDK dialog). The alert is the last step of the paywall action that raised it, so a branch calling neither leaves that action pending and the Screen stays displayed and unresponsive, close button included. FAIL if any branch can skip both; FAIL if both are called for the same alert (the SDK dialog stacks on top of the custom one); WARNING if `alert.onDismiss()` is called *before* the custom dialog is dismissed rather than from its dismiss callback (on a success alert the SDK resumes the flow and closes the Screen). SKIP on iOS and on the cross-platform bridges — the dismissal contract is Android-specific. See `../../references/android/api-reference.md` § UI Handler — Alerts. - [ ] **No stale rc-era Android import** — `import io.purchasely.ext.interceptAction` (and `removeActionInterceptor`) was only required before rc.3, when these were top-level extension functions; since rc.3 they are member functions of `Purchasely` and need no import at all. WARNING if the import is still present in the code — it's harmless dead code, not a bug; delete it. - [ ] **No attempt to override post-purchase flow from the interceptor** — If the app holds the interceptor open, skips `proceed`, or calls `Purchasely.close()` manually to "stay on the paywall" / "show a custom thank-you screen" after a purchase, that's the wrong layer. The Composer button supports a **second action** (`purchase + open_screen` / `purchase + open_placement` / `purchase + deeplink`) and the default is *close in Full mode, stay open in Observer mode*. WARNING — recommend wiring the second action in the Console (or BYOS if the next screen is custom). See `../../references/concepts/paywall-actions.md` § Chaining multiple actions. diff --git a/purchasely/skills/purchasely-sdk-expert/SKILL.md b/purchasely/skills/purchasely-sdk-expert/SKILL.md index dfcb079..9cc31c1 100644 --- a/purchasely/skills/purchasely-sdk-expert/SKILL.md +++ b/purchasely/skills/purchasely-sdk-expert/SKILL.md @@ -42,6 +42,7 @@ Read the matching file before you answer. Paths are relative to this skill (`../ | Which Screen a Placement serves, audience priority, A/B override, no Screen at all | `concepts/screen-resolution.md` | | `NORMAL` / `FALLBACK` / `DEACTIVATED` / `CLIENT`, blank paywall | `concepts/presentation-types.md` | | Button action, interceptor, frozen paywall | `concepts/paywall-actions.md` | +| `PLYUIHandler`, custom alert dialog, `onAlert`, `proceed()` vs `alert.onDismiss()`, Android Screen unresponsive after a dialog | `android/api-reference.md` § UI Handler — Alerts | | Flow, Transition, Quiz, `PLYPresentationOutcome` | `concepts/flows.md` | | Promotional offer, offer code, developer determined offer, offer eligibility | `concepts/promotional-offers.md` | | `setDynamicOffering`, runtime plan or offer override | `concepts/dynamic-offerings.md` |