Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions purchasely/references/android/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Null fallback blocks actions

When activity is null or the presentation view is detached, proceed() cannot display the SDK dialog, so alert.onDismiss() is never triggered. The alert remains pending and blocks the shared action queue, leaving the paywall unresponsive—the exact freeze this example is meant to prevent. Use alert.onDismiss() for this fallback instead. The same unsafe fallback also appears in common-patterns.md, migration-v6.md, and common-issues.md.

Greptile automatically discovered a related ticket stating that a null activity or detached view prevents proceed() from completing the alert, which informed this comment.

Source Used: Linear — Android v6: onAlert without proceed() blocks the actions queue and locks the paywall

Prompt To Fix With AI
This is a comment left during a code review.
Path: purchasely/references/android/api-reference.md
Line: 408

Comment:
**Null fallback blocks actions**

When `activity` is null or the presentation view is detached, `proceed()` cannot display the SDK dialog, so `alert.onDismiss()` is never triggered. The alert remains pending and blocks the shared action queue, leaving the paywall unresponsive—the exact freeze this example is meant to prevent. Use `alert.onDismiss()` for this fallback instead. The same unsafe fallback also appears in `common-patterns.md`, `migration-v6.md`, and `common-issues.md`.

Greptile automatically discovered a related ticket stating that a null activity or detached view prevents `proceed()` from completing the alert, which informed this comment.

**Source Used:** Linear — [Android v6: onAlert without proceed() blocks the actions queue and locks the paywall](https://linear.app/purchasely/issue/MOB-462/android-v6-onalert-without-proceed-blocks-the-actions-queue-and-locks)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

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.
Expand Down
23 changes: 23 additions & 0 deletions purchasely/references/android/common-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions purchasely/references/android/migration-v6.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions purchasely/references/concepts/paywall-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 17 additions & 1 deletion purchasely/references/troubleshooting/common-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<flow_id>` post-purchase branches |
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading