From c520beea08748b54c730d6c832284e84abceecee Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 12:02:02 +0200 Subject: [PATCH 01/12] docs: add spec for PurchaselyWrapper + Observer purchase refactor Reactive flow architecture to decouple PurchaseManager from Purchasely SDK, with PurchaselyWrapper as central orchestrator. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...sely-wrapper-observer-purchase-refactor.md | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-10-purchasely-wrapper-observer-purchase-refactor.md diff --git a/docs/superpowers/specs/2026-04-10-purchasely-wrapper-observer-purchase-refactor.md b/docs/superpowers/specs/2026-04-10-purchasely-wrapper-observer-purchase-refactor.md new file mode 100644 index 0000000..5a8a3e6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-purchasely-wrapper-observer-purchase-refactor.md @@ -0,0 +1,301 @@ +# Purchasely Wrapper + Observer Purchase Refactor — Android + +**Date:** 2026-04-10 +**Status:** Approved +**Scope:** Android only (iOS follows after validation) + +--- + +## Problem + +Responsibilities are scattered across `ShakerApp`, `PurchaselyWrapper`, and `PurchaseManager`: + +- `ShakerApp.initPurchasely()` handles SDK initialization, event listener setup, AND the entire paywall actions interceptor (LOGIN, NAVIGATE, PURCHASE, RESTORE) +- `PurchaselyWrapper` wraps SDK calls but does not manage init or the interceptor +- `PurchaseManager` handles Google Play Billing but is coupled to `PurchaselyWrapper` (imports it, calls `synchronize()` directly) +- `PremiumManager` is called ad-hoc from multiple places after purchase/restore + +This makes the code hard to test, tightly coupled, and difficult to evolve (e.g., removing Purchasely would require touching PurchaseManager). + +## Goals + +1. `PurchaselyWrapper` becomes the single orchestrator — owns init, interceptor, and purchase flow coordination +2. `PurchaseManager` becomes a pure billing service — zero Purchasely imports, communicates via reactive flows +3. `ShakerApp` becomes trivial — just Koin init + `wrapper.initialize()` +4. Full decoupling: PurchaseManager can work without Purchasely (future-proof for SDK removal) + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Wrapper ↔ PurchaseManager coupling | Reactive flows (SharedFlow) | Total decoupling, PurchaseManager has zero Purchasely knowledge | +| Actions via flow mechanism | PURCHASE and RESTORE only | LOGIN/NAVIGATE are simple UI actions, no billing needed | +| synchronize() caller | PurchaselyWrapper observes TransactionResult | PurchaseManager stays pure billing | +| processAction callback storage | Single pending property | Only one purchase at a time from a paywall | +| Application reference | Passed via `initialize(application, config)` | Explicit, no DI magic | +| PremiumManager access | Injected into PurchaselyWrapper | Wrapper is the central point, avoids callback boilerplate | + +--- + +## Architecture + +### Reactive Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PurchaselyWrapper │ +│ │ +│ Interceptor receives PURCHASE (Observer mode) │ +│ → stores processAction in pendingProcessAction │ +│ → emits PurchaseRequest into _purchaseRequests flow │ +│ │ +│ Interceptor receives RESTORE (Observer mode) │ +│ → stores processAction in pendingProcessAction │ +│ → emits RestoreRequest into _restoreRequests flow │ +│ │ +│ Collects transactionResult: │ +│ Success → synchronize() → pendingProcessAction(false) │ +│ → premiumManager.refreshPremiumStatus() │ +│ Cancelled/Error → pendingProcessAction(false) │ +│ Idle → ignore │ +└──────────┬──────────────────────────────────┬───────────────┘ + │ PurchaseRequest │ RestoreRequest + ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ PurchaseManager │ +│ (zero Purchasely imports) │ +│ │ +│ Collects purchaseRequests: │ +│ → queryProductDetails → launchBillingFlow │ +│ │ +│ Collects restoreRequests: │ +│ → queryPurchases → acknowledgePurchase │ +│ │ +│ onPurchasesUpdated / query results: │ +│ → emits TransactionResult into _transactionResult flow │ +└─────────────────────────────────────────────────────────────┘ +``` + +### New Types + +All in `data/purchase/`: + +```kotlin +// PurchaseRequest.kt +data class PurchaseRequest( + val activity: Activity, + val productId: String, + val offerToken: String +) + +// RestoreRequest.kt +data object RestoreRequest + +// TransactionResult.kt +sealed class TransactionResult { + data object Success : TransactionResult() + data object Cancelled : TransactionResult() + data class Error(val message: String?) : TransactionResult() + data object Idle : TransactionResult() // initial state / reset after consumption +} +``` + +`Idle` resets the state after consumption to prevent stale results being re-read by new collectors. + +### PurchaseManager (refactored) + +```kotlin +class PurchaseManager( + context: Context, + purchaseRequests: SharedFlow, + restoreRequests: SharedFlow, + scope: CoroutineScope +) : PurchasesUpdatedListener { + + private val _transactionResult = MutableSharedFlow(replay = 1) + val transactionResult: SharedFlow = _transactionResult.asSharedFlow() + + // BillingClient setup (unchanged) + // init { connectBillingClient(); collectFlows(scope) } + + // Collects purchaseRequests → queryProductDetails → launchBillingFlow + // Collects restoreRequests → queryPurchases → acknowledge + + // onPurchasesUpdated → emits TransactionResult (no synchronize, no wrapper call) +} +``` + +**Removed:** `purchaselyWrapper` dependency, `onPurchaseResult` callback, `synchronize()` call. + +### PurchaselyWrapper (refactored) + +```kotlin +class PurchaselyWrapper( + private val premiumManager: PremiumManager, + private val runningModeRepo: RunningModeRepository, + private val purchaseRequests: MutableSharedFlow, + private val restoreRequests: MutableSharedFlow, + private val transactionResult: SharedFlow, + private val scope: CoroutineScope +) { + private var application: Application? = null + private var pendingProcessAction: ((Boolean) -> Unit)? = null + + fun initialize( + application: Application, + apiKey: String, + logLevel: LogLevel = LogLevel.DEBUG + ) { + this.application = application + val mode = runningModeRepo.runningMode + // Build and start SDK + // Configure event listener + // Configure paywall actions interceptor (internal) + // Start collecting transactionResult + } + + fun restart() { + close() + application?.let { initialize(it, ...) } + } + + // All existing methods remain: loadPresentation, display, getView, + // userLogin, userLogout, setUserAttribute, incrementUserAttribute, + // restoreAllProducts, synchronize, revokeDataProcessingConsent, etc. +} +``` + +**Internal interceptor logic:** + +| Action | Observer mode | Full mode | +|--------|--------------|-----------| +| PURCHASE | Store processAction, emit PurchaseRequest | proceed(true) | +| RESTORE | Store processAction, emit RestoreRequest | proceed(true) | +| LOGIN | proceed(false) | proceed(false) | +| NAVIGATE | Open URL via application.startActivity(), proceed(false) | Same | +| Other | proceed(true) | proceed(true) | + +**TransactionResult observation:** + +| Result | Actions | +|--------|---------| +| Success | synchronize() → pendingProcessAction(false) → premiumManager.refreshPremiumStatus() | +| Cancelled | pendingProcessAction(false) | +| Error | pendingProcessAction(false) | +| Idle | ignore | + +### ShakerApp (simplified) + +```kotlin +class ShakerApp : Application() { + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@ShakerApp) + modules(appModule) + } + + val wrapper: PurchaselyWrapper by inject() + wrapper.initialize( + application = this, + apiKey = BuildConfig.PURCHASELY_API_KEY, + logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN + ) + } +} +``` + +**Removed:** `initPurchasely()`, `restartPurchaselySdk()`, `getSdkModeFromStorage()`, event listener, interceptor. + +### DI Module (Koin) + +```kotlin +val appModule = module { + // Shared flows + single { MutableSharedFlow() } + single { MutableSharedFlow() } + + // Repositories + single { CocktailRepository(androidContext()) } + single { FavoritesRepository(androidContext()) } + single { OnboardingRepository(androidContext()) } + single { RunningModeRepository(androidContext()) } + + // Managers + single { PremiumManager() } + single { + PurchaseManager( + context = androidContext(), + purchaseRequests = get(), + restoreRequests = get(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + ) + } + single { + PurchaselyWrapper( + premiumManager = get(), + runningModeRepo = get(), + purchaseRequests = get(), + restoreRequests = get(), + transactionResult = get().transactionResult, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + ) + } + + // ViewModels + viewModel { HomeViewModel(get(), get(), get()) } + viewModel { params -> DetailViewModel(get(), get(), get(), get(), params.get()) } + viewModel { FavoritesViewModel(get(), get(), get(), get()) } + viewModel { SettingsViewModel(androidContext(), get(), get(), get()) } +} +``` + +### SettingsViewModel Change + +`restartPurchaselySdk` currently casts context to ShakerApp: +```kotlin +val app = context.applicationContext as? ShakerApp +app?.restartPurchaselySdk() +``` + +Becomes: +```kotlin +purchaselyWrapper.restart() +``` + +The ViewModel already has a reference to `purchaselyWrapper`. + +--- + +## Files Impacted + +| File | Action | +|------|--------| +| `data/purchase/PurchaseRequest.kt` | **New** | +| `data/purchase/RestoreRequest.kt` | **New** | +| `data/purchase/TransactionResult.kt` | **New** | +| `data/purchase/PurchaseManager.kt` | **Refactor** — remove wrapper dependency, observe flows, emit TransactionResult | +| `purchasely/PurchaselyWrapper.kt` | **Refactor** — DI constructor, absorb init + interceptor, emit requests, observe results | +| `ShakerApp.kt` | **Simplify** — Koin init + wrapper.initialize() only | +| `di/AppModule.kt` | **Modify** — new DI graph with shared flows | +| `ui/screen/settings/SettingsViewModel.kt` | **Minor** — restartPurchaselySdk → wrapper.restart() | + +### Test Files + +| File | Action | +|------|--------| +| `purchasely/PurchaselyWrapperTest.kt` | **Refactor** — test orchestration (interceptor → flow → synchronize → processAction) | +| `ui/screen/settings/SettingsViewModelTest.kt` | **Minor** — adapt restart test | +| `data/purchase/PurchaseManagerTest.kt` | **New** — test flow collection, billing, TransactionResult emission | + +### No Impact + +Screens (Compose), HomeViewModel, DetailViewModel, FavoritesViewModel, all repositories, models, FetchResult, DisplayResult. + +--- + +## Out of Scope + +- iOS implementation (follows after Android validation) +- Updating `docs/purchasely-best-practices.md` (after implementation) +- PremiumManager refactoring (stays as-is, called by wrapper) From 1cc458d7c61dbbffec56f5b0bfa15759171de82c Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 12:06:37 +0200 Subject: [PATCH 02/12] docs: add implementation plan for PurchaselyWrapper Observer purchase refactor 7 tasks with TDD, covering types, PurchaseManager, PurchaselyWrapper, DI, ShakerApp simplification, and SettingsViewModel update. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...sely-wrapper-observer-purchase-refactor.md | 1316 +++++++++++++++++ 1 file changed, 1316 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-10-purchasely-wrapper-observer-purchase-refactor.md diff --git a/docs/superpowers/plans/2026-04-10-purchasely-wrapper-observer-purchase-refactor.md b/docs/superpowers/plans/2026-04-10-purchasely-wrapper-observer-purchase-refactor.md new file mode 100644 index 0000000..3828952 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-purchasely-wrapper-observer-purchase-refactor.md @@ -0,0 +1,1316 @@ +# PurchaselyWrapper + Observer Purchase Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decouple PurchaseManager from Purchasely SDK via reactive flows, with PurchaselyWrapper as single orchestrator for init, interceptor, and purchase coordination. + +**Architecture:** PurchaselyWrapper emits PurchaseRequest/RestoreRequest via SharedFlows. PurchaseManager (zero Purchasely imports) observes these, executes native billing, and emits TransactionResult. Wrapper observes results and handles synchronize/processAction/premium refresh. + +**Tech Stack:** Kotlin, Coroutines (SharedFlow/StateFlow), MockK, Koin DI, Google Play Billing + +**Spec:** `docs/superpowers/specs/2026-04-10-purchasely-wrapper-observer-purchase-refactor.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `data/purchase/PurchaseRequest.kt` | Create | Data class for purchase trigger | +| `data/purchase/RestoreRequest.kt` | Create | Data object for restore trigger | +| `data/purchase/TransactionResult.kt` | Create | Sealed class for billing outcomes | +| `data/purchase/PurchaseManager.kt` | Refactor | Pure billing service, observes flows, emits TransactionResult | +| `purchasely/PurchaselyWrapper.kt` | Refactor | DI constructor, absorbs init + interceptor, orchestrates flows | +| `ShakerApp.kt` | Simplify | Only Koin init + wrapper.initialize() | +| `di/AppModule.kt` | Modify | New DI graph with shared flows | +| `ui/screen/settings/SettingsViewModel.kt` | Minor | restartPurchaselySdk → wrapper.restart() | +| **Tests** | | | +| `data/purchase/TransactionResultTest.kt` | Create | Sealed class behavior | +| `data/purchase/PurchaseManagerTest.kt` | Create | Flow collection + TransactionResult emission | +| `purchasely/PurchaselyWrapperTest.kt` | Rewrite | Orchestration: interceptor → flows → synchronize → processAction | +| `ui/screen/settings/SettingsViewModelTest.kt` | Minor | Adapt restart test | + +--- + +### Task 1: Create reactive flow types + +**Files:** +- Create: `android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseRequest.kt` +- Create: `android/app/src/main/java/com/purchasely/shaker/data/purchase/RestoreRequest.kt` +- Create: `android/app/src/main/java/com/purchasely/shaker/data/purchase/TransactionResult.kt` +- Create: `android/app/src/test/java/com/purchasely/shaker/data/purchase/TransactionResultTest.kt` + +- [ ] **Step 1: Create PurchaseRequest** + +```kotlin +// data/purchase/PurchaseRequest.kt +package com.purchasely.shaker.data.purchase + +import android.app.Activity + +data class PurchaseRequest( + val activity: Activity, + val productId: String, + val offerToken: String +) +``` + +- [ ] **Step 2: Create RestoreRequest** + +```kotlin +// data/purchase/RestoreRequest.kt +package com.purchasely.shaker.data.purchase + +data object RestoreRequest +``` + +- [ ] **Step 3: Create TransactionResult** + +```kotlin +// data/purchase/TransactionResult.kt +package com.purchasely.shaker.data.purchase + +sealed class TransactionResult { + data object Success : TransactionResult() + data object Cancelled : TransactionResult() + data class Error(val message: String?) : TransactionResult() + data object Idle : TransactionResult() +} +``` + +- [ ] **Step 4: Write TransactionResult test** + +```kotlin +// test: data/purchase/TransactionResultTest.kt +package com.purchasely.shaker.data.purchase + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TransactionResultTest { + + @Test + fun `Success is a singleton`() { + assertTrue(TransactionResult.Success is TransactionResult) + } + + @Test + fun `Cancelled is a singleton`() { + assertTrue(TransactionResult.Cancelled is TransactionResult) + } + + @Test + fun `Idle is a singleton`() { + assertTrue(TransactionResult.Idle is TransactionResult) + } + + @Test + fun `Error holds message`() { + val result = TransactionResult.Error("Payment failed") + assertEquals("Payment failed", result.message) + } + + @Test + fun `Error with null message`() { + val result = TransactionResult.Error(null) + assertNull(result.message) + } + + @Test + fun `exhaustive when covers all cases`() { + val results = listOf( + TransactionResult.Success, + TransactionResult.Cancelled, + TransactionResult.Error("fail"), + TransactionResult.Idle + ) + results.forEach { result -> + when (result) { + is TransactionResult.Success -> {} + is TransactionResult.Cancelled -> {} + is TransactionResult.Error -> assertEquals("fail", result.message) + is TransactionResult.Idle -> {} + } + } + } +} +``` + +- [ ] **Step 5: Run tests** + +Run: `cd android && ./gradlew testDebugUnitTest --tests "com.purchasely.shaker.data.purchase.TransactionResultTest"` +Expected: 6 tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseRequest.kt \ + android/app/src/main/java/com/purchasely/shaker/data/purchase/RestoreRequest.kt \ + android/app/src/main/java/com/purchasely/shaker/data/purchase/TransactionResult.kt \ + android/app/src/test/java/com/purchasely/shaker/data/purchase/TransactionResultTest.kt +git commit -m "feat(android): add reactive flow types for Observer purchase decoupling" +``` + +--- + +### Task 2: Refactor PurchaseManager to use flows + +**Files:** +- Modify: `android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseManager.kt` +- Create: `android/app/src/test/java/com/purchasely/shaker/data/purchase/PurchaseManagerTest.kt` + +- [ ] **Step 1: Write PurchaseManager tests** + +```kotlin +// test: data/purchase/PurchaseManagerTest.kt +package com.purchasely.shaker.data.purchase + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PurchaseManagerTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var mockBillingClient: BillingClient + private lateinit var purchaseRequests: MutableSharedFlow + private lateinit var restoreRequests: MutableSharedFlow + private lateinit var purchaseManager: PurchaseManager + + @Before + fun setUp() { + mockBillingClient = mockk(relaxed = true) + purchaseRequests = MutableSharedFlow() + restoreRequests = MutableSharedFlow() + purchaseManager = PurchaseManager( + billingClientFactory = { mockBillingClient }, + purchaseRequests = purchaseRequests, + restoreRequests = restoreRequests, + scope = testScope + ) + } + + @Test + fun `onPurchasesUpdated with OK emits Success`() = runTest { + val billingResult = mockk { + every { responseCode } returns BillingClient.BillingResponseCode.OK + } + val purchase = mockk { + every { purchaseState } returns Purchase.PurchaseState.PURCHASED + every { isAcknowledged } returns true + } + + var result: TransactionResult? = null + val job = launch(testDispatcher) { + result = purchaseManager.transactionResult.first { it !is TransactionResult.Idle } + } + + purchaseManager.onPurchasesUpdated(billingResult, listOf(purchase)) + job.join() + + assertTrue(result is TransactionResult.Success) + } + + @Test + fun `onPurchasesUpdated with USER_CANCELED emits Cancelled`() = runTest { + val billingResult = mockk { + every { responseCode } returns BillingClient.BillingResponseCode.USER_CANCELED + } + + var result: TransactionResult? = null + val job = launch(testDispatcher) { + result = purchaseManager.transactionResult.first { it !is TransactionResult.Idle } + } + + purchaseManager.onPurchasesUpdated(billingResult, null) + job.join() + + assertTrue(result is TransactionResult.Cancelled) + } + + @Test + fun `onPurchasesUpdated with error emits Error`() = runTest { + val billingResult = mockk { + every { responseCode } returns BillingClient.BillingResponseCode.ERROR + every { debugMessage } returns "Something went wrong" + } + + var result: TransactionResult? = null + val job = launch(testDispatcher) { + result = purchaseManager.transactionResult.first { it !is TransactionResult.Idle } + } + + purchaseManager.onPurchasesUpdated(billingResult, null) + job.join() + + assertTrue(result is TransactionResult.Error) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd android && ./gradlew testDebugUnitTest --tests "com.purchasely.shaker.data.purchase.PurchaseManagerTest"` +Expected: FAIL — PurchaseManager constructor doesn't match yet + +- [ ] **Step 3: Rewrite PurchaseManager** + +Replace the entire content of `data/purchase/PurchaseManager.kt`: + +```kotlin +package com.purchasely.shaker.data.purchase + +import android.app.Activity +import android.util.Log +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +class PurchaseManager( + billingClientFactory: (PurchasesUpdatedListener) -> BillingClient, + purchaseRequests: SharedFlow, + restoreRequests: SharedFlow, + scope: CoroutineScope +) : PurchasesUpdatedListener { + + private val billingClient = billingClientFactory(this) + + private val _transactionResult = MutableSharedFlow(replay = 1) + val transactionResult: SharedFlow = _transactionResult.asSharedFlow() + + init { + connectBillingClient() + scope.launch { + purchaseRequests.collect { request -> + launchPurchase(request.activity, request.productId, request.offerToken) + } + } + scope.launch { + restoreRequests.collect { + restorePurchases() + } + } + } + + private fun connectBillingClient() { + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "[Shaker] BillingClient connected") + } else { + Log.e(TAG, "[Shaker] BillingClient connection failed: ${billingResult.debugMessage}") + } + } + + override fun onBillingServiceDisconnected() { + Log.w(TAG, "[Shaker] BillingClient disconnected, reconnecting...") + connectBillingClient() + } + }) + } + + private fun launchPurchase(activity: Activity, productId: String, offerToken: String) { + val queryParams = QueryProductDetailsParams.newBuilder() + .setProductList( + listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId(productId) + .setProductType(BillingClient.ProductType.SUBS) + .build() + ) + ) + .build() + + billingClient.queryProductDetailsAsync(queryParams) { billingResult, productDetailsList -> + if (billingResult.responseCode != BillingClient.BillingResponseCode.OK || productDetailsList.isEmpty()) { + Log.e(TAG, "[Shaker] queryProductDetails failed: ${billingResult.debugMessage}") + _transactionResult.tryEmit(TransactionResult.Error(billingResult.debugMessage)) + return@queryProductDetailsAsync + } + + val productDetails = productDetailsList.first() + val flowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken) + .build() + ) + ) + .build() + + val result = billingClient.launchBillingFlow(activity, flowParams) + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + Log.e(TAG, "[Shaker] Launch billing flow failed: ${result.debugMessage}") + _transactionResult.tryEmit(TransactionResult.Error(result.debugMessage)) + } + } + } + + private fun restorePurchases() { + val subsParams = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + billingClient.queryPurchasesAsync(subsParams) { billingResult, purchases -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + val activePurchases = purchases.filter { + it.purchaseState == Purchase.PurchaseState.PURCHASED + } + activePurchases.forEach { acknowledgePurchase(it) } + Log.d(TAG, "[Shaker] Restored ${activePurchases.size} purchases") + _transactionResult.tryEmit( + if (activePurchases.isNotEmpty()) TransactionResult.Success + else TransactionResult.Cancelled + ) + } else { + Log.e(TAG, "[Shaker] Restore failed: ${billingResult.debugMessage}") + _transactionResult.tryEmit(TransactionResult.Error(billingResult.debugMessage)) + } + } + } + + override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List?) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + purchases.forEach { purchase -> + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + acknowledgePurchase(purchase) + } + } + Log.d(TAG, "[Shaker] Purchase successful") + _transactionResult.tryEmit(TransactionResult.Success) + } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { + Log.d(TAG, "[Shaker] Purchase cancelled by user") + _transactionResult.tryEmit(TransactionResult.Cancelled) + } else { + Log.e(TAG, "[Shaker] Purchase error: ${billingResult.debugMessage}") + _transactionResult.tryEmit(TransactionResult.Error(billingResult.debugMessage)) + } + } + + private fun acknowledgePurchase(purchase: Purchase) { + if (purchase.isAcknowledged) return + + val params = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + + billingClient.acknowledgePurchase(params) { billingResult -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "[Shaker] Purchase acknowledged: ${purchase.orderId}") + } else { + Log.e(TAG, "[Shaker] Acknowledge failed: ${billingResult.debugMessage}") + } + } + } + + companion object { + private const val TAG = "PurchaseManager" + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd android && ./gradlew testDebugUnitTest --tests "com.purchasely.shaker.data.purchase.PurchaseManagerTest"` +Expected: 3 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseManager.kt \ + android/app/src/test/java/com/purchasely/shaker/data/purchase/PurchaseManagerTest.kt +git commit -m "refactor(android): decouple PurchaseManager from Purchasely SDK via reactive flows" +``` + +--- + +### Task 3: Refactor PurchaselyWrapper + +**Files:** +- Modify: `android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt` +- Rewrite: `android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt` + +- [ ] **Step 1: Write new PurchaselyWrapper orchestration tests** + +Replace the entire content of `PurchaselyWrapperTest.kt`: + +```kotlin +package com.purchasely.shaker.purchasely + +import android.app.Activity +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.purchase.PurchaseRequest +import com.purchasely.shaker.data.purchase.RestoreRequest +import com.purchasely.shaker.data.purchase.TransactionResult +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.purchasely.ext.PLYPresentation +import io.purchasely.ext.PLYPresentationAction +import io.purchasely.ext.PLYPresentationActionParameters +import io.purchasely.ext.PLYPresentationInfo +import io.purchasely.ext.PLYRunningMode +import io.purchasely.models.PLYPlan +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PurchaselyWrapperTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var premiumManager: PremiumManager + private lateinit var runningModeRepo: RunningModeRepository + private lateinit var purchaseRequests: MutableSharedFlow + private lateinit var restoreRequests: MutableSharedFlow + private lateinit var transactionResult: MutableSharedFlow + private lateinit var wrapper: PurchaselyWrapper + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + premiumManager = mockk(relaxed = true) + runningModeRepo = mockk { + every { runningMode } returns PLYRunningMode.PaywallObserver + every { isObserverMode } returns true + } + purchaseRequests = MutableSharedFlow() + restoreRequests = MutableSharedFlow() + transactionResult = MutableSharedFlow() + wrapper = PurchaselyWrapper( + premiumManager = premiumManager, + runningModeRepo = runningModeRepo, + purchaseRequests = purchaseRequests, + restoreRequests = restoreRequests, + transactionResult = transactionResult, + scope = testScope + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // --- Interceptor: PURCHASE in Observer mode --- + + @Test + fun `handlePaywallAction PURCHASE in observer mode emits PurchaseRequest`() = runTest { + val mockActivity = mockk() + val mockPlan = mockk { + every { store_product_id } returns "com.test.product" + } + val mockOffer = mockk { + every { offerToken } returns "token-123" + } + val mockInfo = mockk { + every { activity } returns mockActivity + } + val mockParams = mockk { + every { plan } returns mockPlan + every { subscriptionOffer } returns mockOffer + } + + var emittedRequest: PurchaseRequest? = null + val collectJob = launch(testDispatcher) { + emittedRequest = purchaseRequests.first() + } + + wrapper.handlePaywallAction(mockInfo, PLYPresentationAction.PURCHASE, mockParams) {} + collectJob.join() + + assertNotNull(emittedRequest) + assertEquals("com.test.product", emittedRequest?.productId) + assertEquals("token-123", emittedRequest?.offerToken) + } + + @Test + fun `handlePaywallAction PURCHASE in full mode calls proceed true`() { + every { runningModeRepo.isObserverMode } returns false + var proceededWith: Boolean? = null + + wrapper.handlePaywallAction(null, PLYPresentationAction.PURCHASE, null) { proceededWith = it } + + assertEquals(true, proceededWith) + } + + // --- Interceptor: RESTORE in Observer mode --- + + @Test + fun `handlePaywallAction RESTORE in observer mode emits RestoreRequest`() = runTest { + var emittedRestore = false + val collectJob = launch(testDispatcher) { + restoreRequests.first() + emittedRestore = true + } + + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) {} + collectJob.join() + + assertTrue(emittedRestore) + } + + @Test + fun `handlePaywallAction RESTORE in full mode calls proceed true`() { + every { runningModeRepo.isObserverMode } returns false + var proceededWith: Boolean? = null + + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + + assertEquals(true, proceededWith) + } + + // --- Interceptor: LOGIN --- + + @Test + fun `handlePaywallAction LOGIN calls proceed false`() { + var proceededWith: Boolean? = null + + wrapper.handlePaywallAction(null, PLYPresentationAction.LOGIN, null) { proceededWith = it } + + assertEquals(false, proceededWith) + } + + // --- Interceptor: other actions --- + + @Test + fun `handlePaywallAction CLOSE calls proceed true`() { + var proceededWith: Boolean? = null + + wrapper.handlePaywallAction(null, PLYPresentationAction.CLOSE, null) { proceededWith = it } + + assertEquals(true, proceededWith) + } + + // --- TransactionResult observation --- + + @Test + fun `TransactionResult Success triggers synchronize and premium refresh`() = runTest { + // Set a pending processAction via a RESTORE request + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) {} + + // Emit success + transactionResult.emit(TransactionResult.Success) + testScope.testScheduler.advanceUntilIdle() + + verify { premiumManager.refreshPremiumStatus() } + } + + @Test + fun `TransactionResult Success calls pendingProcessAction with false`() = runTest { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + + transactionResult.emit(TransactionResult.Success) + testScope.testScheduler.advanceUntilIdle() + + assertEquals(false, proceededWith) + } + + @Test + fun `TransactionResult Cancelled calls pendingProcessAction with false`() = runTest { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + + transactionResult.emit(TransactionResult.Cancelled) + testScope.testScheduler.advanceUntilIdle() + + assertEquals(false, proceededWith) + } + + @Test + fun `TransactionResult Error calls pendingProcessAction with false`() = runTest { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + + transactionResult.emit(TransactionResult.Error("fail")) + testScope.testScheduler.advanceUntilIdle() + + assertEquals(false, proceededWith) + } + + @Test + fun `TransactionResult Idle is ignored`() = runTest { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + + transactionResult.emit(TransactionResult.Idle) + testScope.testScheduler.advanceUntilIdle() + + // processAction should NOT have been called + assertEquals(null, proceededWith) + } + + // --- Existing API contract (mocked wrapper) --- + + @Test + fun `loadPresentation returns FetchResult`() = runTest { + val mockedWrapper = mockk(relaxed = true) + val mockPresentation = mockk() + io.mockk.coEvery { mockedWrapper.loadPresentation("filters", null) } returns FetchResult.Success(mockPresentation) + + val result = mockedWrapper.loadPresentation("filters") + assertTrue(result is FetchResult.Success) + } + + @Test + fun `FetchResult Success exposes height`() { + val presentation = mockk { + every { height } returns 400 + } + val result = FetchResult.Success(presentation) + assertEquals(400, result.height) + } + + @Test + fun `wrapper instance can be created with dependencies`() { + assertNotNull(wrapper) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd android && ./gradlew testDebugUnitTest --tests "com.purchasely.shaker.purchasely.PurchaselyWrapperTest"` +Expected: FAIL — PurchaselyWrapper constructor doesn't match, handlePaywallAction doesn't exist + +- [ ] **Step 3: Rewrite PurchaselyWrapper** + +Replace the entire content of `purchasely/PurchaselyWrapper.kt`: + +```kotlin +package com.purchasely.shaker.purchasely + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.view.View +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.purchase.PurchaseRequest +import com.purchasely.shaker.data.purchase.RestoreRequest +import com.purchasely.shaker.data.purchase.TransactionResult +import io.purchasely.ext.EventListener +import io.purchasely.ext.LogLevel +import io.purchasely.ext.PLYPresentation +import io.purchasely.ext.PLYPresentationAction +import io.purchasely.ext.PLYPresentationActionParameters +import io.purchasely.ext.PLYPresentationInfo +import io.purchasely.ext.PLYPresentationProperties +import io.purchasely.ext.PLYPresentationType +import io.purchasely.ext.PLYProductViewResult +import io.purchasely.ext.PLYRunningMode +import io.purchasely.ext.Purchasely +import io.purchasely.ext.fetchPresentation +import io.purchasely.models.PLYError +import io.purchasely.models.PLYPlan +import io.purchasely.google.GoogleStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class PurchaselyWrapper( + private val premiumManager: PremiumManager, + private val runningModeRepo: RunningModeRepository, + private val purchaseRequests: MutableSharedFlow, + private val restoreRequests: MutableSharedFlow, + transactionResult: SharedFlow, + private val scope: CoroutineScope +) { + + private var application: Application? = null + private var apiKey: String = "" + private var logLevel: LogLevel = LogLevel.DEBUG + private var pendingProcessAction: ((Boolean) -> Unit)? = null + + init { + scope.launch { + transactionResult.collect { result -> + handleTransactionResult(result) + } + } + } + + // MARK: - SDK Initialization + + fun initialize( + application: Application, + apiKey: String, + logLevel: LogLevel = LogLevel.DEBUG + ) { + this.application = application + this.apiKey = apiKey + this.logLevel = logLevel + + val mode = runningModeRepo.runningMode + + Purchasely.Builder(application) + .apiKey(apiKey) + .logLevel(logLevel) + .readyToOpenDeeplink(true) + .runningMode(mode) + .stores(listOf(GoogleStore())) + .build() + .start { isConfigured, error -> + if (isConfigured) { + Log.d(TAG, "[Shaker] Purchasely SDK configured successfully") + premiumManager.refreshPremiumStatus() + } + error?.let { + Log.e(TAG, "[Shaker] Purchasely configuration error: ${it.message}") + } + } + + eventListener = object : EventListener { + override fun onEvent(event: io.purchasely.ext.PLYEvent) { + Log.d(TAG, "[Shaker] Event: ${event.name} | Properties: ${event.properties}") + } + } + + setPaywallActionsInterceptor { info, action, parameters, proceed -> + handlePaywallAction(info, action, parameters, proceed) + } + } + + fun restart() { + close() + val app = application ?: return + initialize(app, apiKey, logLevel) + } + + fun close() { + Purchasely.close() + } + + // MARK: - Interceptor Logic + + internal fun handlePaywallAction( + info: PLYPresentationInfo?, + action: PLYPresentationAction, + parameters: PLYPresentationActionParameters?, + processAction: (Boolean) -> Unit + ) { + when (action) { + PLYPresentationAction.LOGIN -> { + Log.d(TAG, "[Shaker] Paywall login action intercepted") + processAction(false) + } + PLYPresentationAction.NAVIGATE -> { + val url = parameters?.url + if (url != null) { + Log.d(TAG, "[Shaker] Paywall navigate action: $url") + val intent = Intent(Intent.ACTION_VIEW, url) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + application?.startActivity(intent) + } + processAction(false) + } + PLYPresentationAction.PURCHASE -> { + if (runningModeRepo.isObserverMode) { + val plan = parameters?.plan + val offer = parameters?.subscriptionOffer + val productId = plan?.store_product_id + val offerToken = offer?.offerToken + val activity = info?.activity + if (activity != null && productId != null && offerToken != null) { + pendingProcessAction = processAction + scope.launch { + purchaseRequests.emit(PurchaseRequest(activity, productId, offerToken)) + } + } else { + Log.w(TAG, "[Shaker] Observer mode purchase: missing activity, productId, or offerToken") + processAction(false) + } + } else { + processAction(true) + } + } + PLYPresentationAction.RESTORE -> { + if (runningModeRepo.isObserverMode) { + pendingProcessAction = processAction + scope.launch { + restoreRequests.emit(RestoreRequest) + } + } else { + processAction(true) + } + } + else -> processAction(true) + } + } + + // MARK: - Transaction Result Handling + + private fun handleTransactionResult(result: TransactionResult) { + when (result) { + is TransactionResult.Success -> { + synchronize() + pendingProcessAction?.invoke(false) + pendingProcessAction = null + premiumManager.refreshPremiumStatus() + Log.d(TAG, "[Shaker] Transaction success — synchronized and refreshed") + } + is TransactionResult.Cancelled -> { + pendingProcessAction?.invoke(false) + pendingProcessAction = null + Log.d(TAG, "[Shaker] Transaction cancelled") + } + is TransactionResult.Error -> { + pendingProcessAction?.invoke(false) + pendingProcessAction = null + Log.e(TAG, "[Shaker] Transaction error: ${result.message}") + } + is TransactionResult.Idle -> { /* ignore */ } + } + } + + // MARK: - Event Listener + + var eventListener: EventListener? + get() = Purchasely.eventListener + set(value) { Purchasely.eventListener = value } + + fun setPaywallActionsInterceptor( + interceptor: ( + info: PLYPresentationInfo?, + action: PLYPresentationAction, + parameters: PLYPresentationActionParameters?, + processAction: (Boolean) -> Unit + ) -> Unit + ) { + Purchasely.setPaywallActionsInterceptor(interceptor) + } + + // MARK: - Deeplinks + + fun isDeeplinkHandled(deeplink: Uri, activity: Activity?): Boolean { + @Suppress("DEPRECATION") + return Purchasely.isDeeplinkHandled(deeplink, activity) + } + + // MARK: - Presentation Loading + + suspend fun loadPresentation( + placementId: String, + contentId: String? = null + ): FetchResult { + return try { + val presentation = if (contentId != null) { + Purchasely.fetchPresentation( + properties = PLYPresentationProperties(placementId = placementId, contentId = contentId) + ) + } else { + Purchasely.fetchPresentation(placementId = placementId) + } + when (presentation.type) { + PLYPresentationType.DEACTIVATED -> FetchResult.Deactivated + PLYPresentationType.CLIENT -> FetchResult.Client(presentation) + else -> FetchResult.Success(presentation) + } + } catch (e: Exception) { + FetchResult.Error(e as? PLYError) + } + } + + // MARK: - Modal Display + + suspend fun display( + presentation: PLYPresentation, + activity: Activity + ): DisplayResult = suspendCoroutine { continuation -> + presentation.display(activity) { result: PLYProductViewResult, plan: PLYPlan? -> + when (result) { + PLYProductViewResult.PURCHASED -> continuation.resume(DisplayResult.Purchased(plan?.name)) + PLYProductViewResult.RESTORED -> continuation.resume(DisplayResult.Restored(plan?.name)) + else -> continuation.resume(DisplayResult.Cancelled) + } + } + } + + // MARK: - Embedded View + + fun getView( + presentation: PLYPresentation, + context: Context, + onResult: (DisplayResult) -> Unit + ): View? { + return presentation.buildView( + context = context, + callback = { result: PLYProductViewResult, plan: PLYPlan? -> + when (result) { + PLYProductViewResult.PURCHASED -> onResult(DisplayResult.Purchased(plan?.name)) + PLYProductViewResult.RESTORED -> onResult(DisplayResult.Restored(plan?.name)) + else -> onResult(DisplayResult.Cancelled) + } + } + ) + } + + // MARK: - User Management + + fun userLogin(userId: String, onRefresh: (Boolean) -> Unit) { + Purchasely.userLogin(userId, onRefresh) + } + + fun userLogout() { + Purchasely.userLogout() + } + + val anonymousUserId: String + get() = Purchasely.anonymousUserId + + // MARK: - User Attributes + + fun setUserAttribute(key: String, value: String) { + Purchasely.setUserAttribute(key, value) + } + + fun setUserAttribute(key: String, value: Boolean) { + Purchasely.setUserAttribute(key, value) + } + + fun setUserAttribute(key: String, value: Int) { + Purchasely.setUserAttribute(key, value) + } + + fun setUserAttribute(key: String, value: Float) { + Purchasely.setUserAttribute(key, value) + } + + fun incrementUserAttribute(key: String) { + Purchasely.incrementUserAttribute(key) + } + + // MARK: - Restore + + fun restoreAllProducts( + onSuccess: (PLYPlan?) -> Unit, + onError: (PLYError?) -> Unit + ) { + Purchasely.restoreAllProducts(onSuccess, onError) + } + + // MARK: - Observer Mode + + fun synchronize() { + Purchasely.synchronize() + } + + // MARK: - GDPR Consent + + fun revokeDataProcessingConsent(purposes: Set) { + Purchasely.revokeDataProcessingConsent(purposes) + } + + // MARK: - SDK Info + + val sdkVersion: String + get() = Purchasely.sdkVersion + + companion object { + private const val TAG = "PurchaselyWrapper" + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd android && ./gradlew testDebugUnitTest --tests "com.purchasely.shaker.purchasely.PurchaselyWrapperTest"` +Expected: 13 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt \ + android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt +git commit -m "refactor(android): PurchaselyWrapper absorbs init, interceptor, and orchestration" +``` + +--- + +### Task 4: Update DI module + +**Files:** +- Modify: `android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt` + +- [ ] **Step 1: Rewrite AppModule** + +Replace the entire content of `di/AppModule.kt`: + +```kotlin +package com.purchasely.shaker.di + +import com.android.billingclient.api.BillingClient +import com.purchasely.shaker.data.CocktailRepository +import com.purchasely.shaker.data.FavoritesRepository +import com.purchasely.shaker.data.OnboardingRepository +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.purchase.PurchaseManager +import com.purchasely.shaker.data.purchase.PurchaseRequest +import com.purchasely.shaker.data.purchase.RestoreRequest +import com.purchasely.shaker.purchasely.PurchaselyWrapper +import com.purchasely.shaker.ui.screen.home.HomeViewModel +import com.purchasely.shaker.ui.screen.detail.DetailViewModel +import com.purchasely.shaker.ui.screen.favorites.FavoritesViewModel +import com.purchasely.shaker.ui.screen.settings.SettingsViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val appModule = module { + // Shared reactive flows + single { MutableSharedFlow() } + single { MutableSharedFlow() } + + // Repositories + single { CocktailRepository(androidContext()) } + single { FavoritesRepository(androidContext()) } + single { OnboardingRepository(androidContext()) } + single { RunningModeRepository(androidContext()) } + + // Managers + single { PremiumManager() } + single { + PurchaseManager( + billingClientFactory = { listener -> + BillingClient.newBuilder(androidContext()) + .setListener(listener) + .enablePendingPurchases() + .build() + }, + purchaseRequests = get>(), + restoreRequests = get>(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + ) + } + single { + PurchaselyWrapper( + premiumManager = get(), + runningModeRepo = get(), + purchaseRequests = get>(), + restoreRequests = get>(), + transactionResult = get().transactionResult, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + ) + } + + // ViewModels + viewModel { HomeViewModel(get(), get(), get()) } + viewModel { params -> DetailViewModel(get(), get(), get(), get(), params.get()) } + viewModel { FavoritesViewModel(get(), get(), get(), get()) } + viewModel { SettingsViewModel(androidContext(), get(), get(), get()) } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +git commit -m "refactor(android): update Koin DI module for reactive flow architecture" +``` + +--- + +### Task 5: Simplify ShakerApp + +**Files:** +- Modify: `android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt` + +- [ ] **Step 1: Rewrite ShakerApp** + +Replace the entire content of `ShakerApp.kt`: + +```kotlin +package com.purchasely.shaker + +import android.app.Application +import com.purchasely.shaker.di.appModule +import com.purchasely.shaker.purchasely.PurchaselyWrapper +import io.purchasely.ext.LogLevel +import org.koin.android.ext.android.inject +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +class ShakerApp : Application() { + + override fun onCreate() { + super.onCreate() + + startKoin { + androidContext(this@ShakerApp) + modules(appModule) + } + + val purchaselyWrapper: PurchaselyWrapper by inject() + purchaselyWrapper.initialize( + application = this, + apiKey = BuildConfig.PURCHASELY_API_KEY, + logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN + ) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt +git commit -m "refactor(android): simplify ShakerApp to Koin init + wrapper.initialize()" +``` + +--- + +### Task 6: Update SettingsViewModel + fix tests + +**Files:** +- Modify: `android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt` +- Modify: `android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt` + +- [ ] **Step 1: Update SettingsViewModel — replace restartPurchaselySdk** + +In `SettingsViewModel.kt`, replace the `restartPurchaselySdk` method: + +Find: +```kotlin + private fun restartPurchaselySdk(mode: PurchaselySdkMode) { + val app = context.applicationContext as? ShakerApp + if (app == null) { + Log.e(TAG, "[Shaker] Could not restart SDK: application context is not ShakerApp") + return + } + + app.restartPurchaselySdk() + Log.d(TAG, "[Shaker] SDK restarted with mode ${mode.storageValue}") + } +``` + +Replace with: +```kotlin + private fun restartPurchaselySdk(mode: PurchaselySdkMode) { + purchaselyWrapper.restart() + Log.d(TAG, "[Shaker] SDK restarted with mode ${mode.storageValue}") + } +``` + +Also remove the `import com.purchasely.shaker.ShakerApp` import if present. + +- [ ] **Step 2: Fix SettingsViewModelTest** + +The existing test for `setSdkMode` doesn't directly test `restartPurchaselySdk` because that method is private. The test mocks `wrapper` which is already relaxed. The test should still pass because `wrapper.restart()` is a relaxed mock call. + +However, add a specific test. In `SettingsViewModelTest.kt`, add: + +```kotlin + @Test + fun `setSdkMode calls wrapper restart`() { + storedValues[PurchaselySdkMode.KEY] = PurchaselySdkMode.FULL.storageValue + val vm = createViewModel() + vm.setSdkMode(PurchaselySdkMode.PAYWALL_OBSERVER) + verify { wrapper.restart() } + } +``` + +- [ ] **Step 3: Fix PurchaselyWrapperTest — update wrapper instance test** + +In `PurchaselyWrapperTest.kt`, the test `wrapper instance can be created` already uses the new constructor. No change needed (it was rewritten in Task 3). + +- [ ] **Step 4: Run full test suite** + +Run: `cd android && ./gradlew testDebugUnitTest` +Expected: All tests PASS (previous tests for HomeViewModel, DetailViewModel, FavoritesViewModel should still pass because they mock PurchaselyWrapper with MockK relaxed) + +- [ ] **Step 5: Commit** + +```bash +git add android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt \ + android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt +git commit -m "refactor(android): SettingsViewModel uses wrapper.restart() instead of ShakerApp cast" +``` + +--- + +### Task 7: Full verification + +- [ ] **Step 1: Run complete test suite** + +Run: `cd android && ./gradlew testDebugUnitTest` +Expected: All tests PASS, 0 failures + +- [ ] **Step 2: Build debug APK** + +Run: `cd android && ./gradlew :app:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Verify no Purchasely imports in PurchaseManager** + +Run: `rg "import io.purchasely" android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseManager.kt` +Expected: No output (zero Purchasely imports) + +- [ ] **Step 4: Verify ShakerApp is minimal** + +Run: `wc -l android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt` +Expected: ~20 lines (was ~157) + +- [ ] **Step 5: Final commit if any cleanup needed** + +```bash +git status # Verify clean working tree +``` From f260d3388242babab119a5b2af7b482ad46c08f8 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 12:08:49 +0200 Subject: [PATCH 03/12] feat(android): add reactive flow types for Observer purchase decoupling Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shaker/data/purchase/PurchaseRequest.kt | 9 ++++ .../shaker/data/purchase/RestoreRequest.kt | 3 ++ .../shaker/data/purchase/TransactionResult.kt | 8 +++ .../data/purchase/TransactionResultTest.kt | 54 +++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseRequest.kt create mode 100644 android/app/src/main/java/com/purchasely/shaker/data/purchase/RestoreRequest.kt create mode 100644 android/app/src/main/java/com/purchasely/shaker/data/purchase/TransactionResult.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/data/purchase/TransactionResultTest.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseRequest.kt b/android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseRequest.kt new file mode 100644 index 0000000..c548abf --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseRequest.kt @@ -0,0 +1,9 @@ +package com.purchasely.shaker.data.purchase + +import android.app.Activity + +data class PurchaseRequest( + val activity: Activity, + val productId: String, + val offerToken: String +) diff --git a/android/app/src/main/java/com/purchasely/shaker/data/purchase/RestoreRequest.kt b/android/app/src/main/java/com/purchasely/shaker/data/purchase/RestoreRequest.kt new file mode 100644 index 0000000..84c047f --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/data/purchase/RestoreRequest.kt @@ -0,0 +1,3 @@ +package com.purchasely.shaker.data.purchase + +data object RestoreRequest diff --git a/android/app/src/main/java/com/purchasely/shaker/data/purchase/TransactionResult.kt b/android/app/src/main/java/com/purchasely/shaker/data/purchase/TransactionResult.kt new file mode 100644 index 0000000..7847ede --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/data/purchase/TransactionResult.kt @@ -0,0 +1,8 @@ +package com.purchasely.shaker.data.purchase + +sealed class TransactionResult { + data object Success : TransactionResult() + data object Cancelled : TransactionResult() + data class Error(val message: String?) : TransactionResult() + data object Idle : TransactionResult() +} diff --git a/android/app/src/test/java/com/purchasely/shaker/data/purchase/TransactionResultTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/purchase/TransactionResultTest.kt new file mode 100644 index 0000000..419719f --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/purchase/TransactionResultTest.kt @@ -0,0 +1,54 @@ +package com.purchasely.shaker.data.purchase + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TransactionResultTest { + + @Test + fun `Success is a singleton`() { + assertTrue(TransactionResult.Success is TransactionResult) + } + + @Test + fun `Cancelled is a singleton`() { + assertTrue(TransactionResult.Cancelled is TransactionResult) + } + + @Test + fun `Idle is a singleton`() { + assertTrue(TransactionResult.Idle is TransactionResult) + } + + @Test + fun `Error holds message`() { + val result = TransactionResult.Error("Payment failed") + assertEquals("Payment failed", result.message) + } + + @Test + fun `Error with null message`() { + val result = TransactionResult.Error(null) + assertNull(result.message) + } + + @Test + fun `exhaustive when covers all cases`() { + val results = listOf( + TransactionResult.Success, + TransactionResult.Cancelled, + TransactionResult.Error("fail"), + TransactionResult.Idle + ) + results.forEach { result -> + when (result) { + is TransactionResult.Success -> {} + is TransactionResult.Cancelled -> {} + is TransactionResult.Error -> assertEquals("fail", result.message) + is TransactionResult.Idle -> {} + } + } + } +} From 494e75fe78431113d2ad4b73ef4c67ffb7e28816 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 12:13:23 +0200 Subject: [PATCH 04/12] refactor(android): decouple PurchaseManager from Purchasely SDK via reactive flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PurchaseManager now takes billingClientFactory, SharedFlow, SharedFlow, and CoroutineScope — zero Purchasely imports. Results emitted via SharedFlow instead of callbacks. Temporary stubs in ShakerApp/AppModule for compilation (Tasks 4-5 will rewire). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/purchasely/shaker/ShakerApp.kt | 19 +--- .../shaker/data/purchase/PurchaseManager.kt | 82 ++++++++-------- .../com/purchasely/shaker/di/AppModule.kt | 5 +- .../data/purchase/PurchaseManagerTest.kt | 98 +++++++++++++++++++ 4 files changed, 145 insertions(+), 59 deletions(-) create mode 100644 android/app/src/test/java/com/purchasely/shaker/data/purchase/PurchaseManagerTest.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt index f0da46e..37c699d 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt @@ -4,9 +4,8 @@ import android.app.Application import android.content.Intent import android.net.Uri import android.util.Log -import com.purchasely.shaker.data.PurchaselySdkMode import com.purchasely.shaker.data.PremiumManager -import com.purchasely.shaker.data.purchase.PurchaseManager +import com.purchasely.shaker.data.PurchaselySdkMode import com.purchasely.shaker.di.appModule import com.purchasely.shaker.purchasely.PurchaselyWrapper import io.purchasely.ext.EventListener @@ -99,12 +98,8 @@ class ShakerApp : Application() { val offerToken = offer?.offerToken val activity = info?.activity if (activity != null && productId != null && offerToken != null) { - val purchaseManager: PurchaseManager by inject() - val premiumManager: PremiumManager by inject() - purchaseManager.purchase(activity, productId, offerToken) { success -> - if (success) premiumManager.refreshPremiumStatus() - proceed(false) - } + // TODO: Task 5 will update to use reactive flows + proceed(false) } else { Log.w(TAG, "[Shaker] Observer mode purchase: missing activity, productId, or offerToken") proceed(false) @@ -117,12 +112,8 @@ class ShakerApp : Application() { // in Full mode, let the SDK handle restore PLYPresentationAction.RESTORE -> { if (getSdkModeFromStorage() == PurchaselySdkMode.PAYWALL_OBSERVER) { - val purchaseManager: PurchaseManager by inject() - val premiumManager: PremiumManager by inject() - purchaseManager.restore { success -> - if (success) premiumManager.refreshPremiumStatus() - proceed(false) - } + // TODO: Task 5 will update to use reactive flows + proceed(false) } else { proceed(true) } diff --git a/android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseManager.kt b/android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseManager.kt index c651bc8..c9e1299 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseManager.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/purchase/PurchaseManager.kt @@ -2,29 +2,45 @@ package com.purchasely.shaker.data.purchase import android.app.Activity import android.util.Log +import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchasesUpdatedListener -import com.android.billingclient.api.QueryPurchasesParams -import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.QueryProductDetailsParams -import android.content.Context -import com.purchasely.shaker.purchasely.PurchaselyWrapper +import com.android.billingclient.api.QueryPurchasesParams +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch -class PurchaseManager(context: Context, private val purchaselyWrapper: PurchaselyWrapper) : PurchasesUpdatedListener { +class PurchaseManager( + billingClientFactory: (PurchasesUpdatedListener) -> BillingClient, + purchaseRequests: SharedFlow, + restoreRequests: SharedFlow, + scope: CoroutineScope +) : PurchasesUpdatedListener { - private var onPurchaseResult: ((Boolean) -> Unit)? = null + private val billingClient = billingClientFactory(this) - private val billingClient = BillingClient.newBuilder(context) - .setListener(this) - .enablePendingPurchases() - .build() + private val _transactionResult = MutableSharedFlow(replay = 1) + val transactionResult: SharedFlow = _transactionResult.asSharedFlow() init { connectBillingClient() + scope.launch { + purchaseRequests.collect { request -> + launchPurchase(request.activity, request.productId, request.offerToken) + } + } + scope.launch { + restoreRequests.collect { + restorePurchases() + } + } } private fun connectBillingClient() { @@ -44,20 +60,7 @@ class PurchaseManager(context: Context, private val purchaselyWrapper: Purchasel }) } - /** - * Launch a purchase flow using the offerToken from the Purchasely interceptor parameters. - * In Observer mode, the interceptor provides `parameters.subscriptionOffer?.offerToken`. - * We must first query ProductDetails (required by BillingClient), then launch the flow. - */ - fun purchase( - activity: Activity, - productId: String, - offerToken: String, - onResult: (Boolean) -> Unit - ) { - onPurchaseResult = onResult - - // BillingFlowParams requires ProductDetails — query it first + private fun launchPurchase(activity: Activity, productId: String, offerToken: String) { val queryParams = QueryProductDetailsParams.newBuilder() .setProductList( listOf( @@ -72,13 +75,11 @@ class PurchaseManager(context: Context, private val purchaselyWrapper: Purchasel billingClient.queryProductDetailsAsync(queryParams) { billingResult, productDetailsList -> if (billingResult.responseCode != BillingClient.BillingResponseCode.OK || productDetailsList.isEmpty()) { Log.e(TAG, "[Shaker] queryProductDetails failed: ${billingResult.debugMessage}") - onPurchaseResult?.invoke(false) - onPurchaseResult = null + _transactionResult.tryEmit(TransactionResult.Error(billingResult.debugMessage)) return@queryProductDetailsAsync } val productDetails = productDetailsList.first() - val flowParams = BillingFlowParams.newBuilder() .setProductDetailsParamsList( listOf( @@ -93,16 +94,12 @@ class PurchaseManager(context: Context, private val purchaselyWrapper: Purchasel val result = billingClient.launchBillingFlow(activity, flowParams) if (result.responseCode != BillingClient.BillingResponseCode.OK) { Log.e(TAG, "[Shaker] Launch billing flow failed: ${result.debugMessage}") - onPurchaseResult?.invoke(false) - onPurchaseResult = null + _transactionResult.tryEmit(TransactionResult.Error(result.debugMessage)) } } } - /** - * Restore purchases by querying existing subscriptions and in-app products. - */ - fun restore(onResult: (Boolean) -> Unit) { + private fun restorePurchases() { val subsParams = QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.SUBS) .build() @@ -113,13 +110,14 @@ class PurchaseManager(context: Context, private val purchaselyWrapper: Purchasel it.purchaseState == Purchase.PurchaseState.PURCHASED } activePurchases.forEach { acknowledgePurchase(it) } - Log.d(TAG, "[Shaker] Restored ${activePurchases.size} purchases") - purchaselyWrapper.synchronize() - onResult(activePurchases.isNotEmpty()) + _transactionResult.tryEmit( + if (activePurchases.isNotEmpty()) TransactionResult.Success + else TransactionResult.Cancelled + ) } else { Log.e(TAG, "[Shaker] Restore failed: ${billingResult.debugMessage}") - onResult(false) + _transactionResult.tryEmit(TransactionResult.Error(billingResult.debugMessage)) } } } @@ -131,17 +129,15 @@ class PurchaseManager(context: Context, private val purchaselyWrapper: Purchasel acknowledgePurchase(purchase) } } - Log.d(TAG, "[Shaker] Purchase successful, synchronizing with Purchasely...") - purchaselyWrapper.synchronize() - onPurchaseResult?.invoke(true) + Log.d(TAG, "[Shaker] Purchase successful") + _transactionResult.tryEmit(TransactionResult.Success) } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { Log.d(TAG, "[Shaker] Purchase cancelled by user") - onPurchaseResult?.invoke(false) + _transactionResult.tryEmit(TransactionResult.Cancelled) } else { Log.e(TAG, "[Shaker] Purchase error: ${billingResult.debugMessage}") - onPurchaseResult?.invoke(false) + _transactionResult.tryEmit(TransactionResult.Error(billingResult.debugMessage)) } - onPurchaseResult = null } private fun acknowledgePurchase(purchase: Purchase) { diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index 590efce..c798266 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -5,7 +5,7 @@ import com.purchasely.shaker.data.FavoritesRepository import com.purchasely.shaker.data.OnboardingRepository import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository -import com.purchasely.shaker.data.purchase.PurchaseManager +// import com.purchasely.shaker.data.purchase.PurchaseManager // TODO: Task 4 will re-enable import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.ui.screen.home.HomeViewModel import com.purchasely.shaker.ui.screen.detail.DetailViewModel @@ -21,7 +21,8 @@ val appModule = module { single { OnboardingRepository(androidContext()) } single { RunningModeRepository(androidContext()) } single { PremiumManager() } - single { PurchaseManager(androidContext(), get()) } + // TODO: Task 4 will update PurchaseManager wiring with reactive flows + // single { PurchaseManager(androidContext(), get()) } single { PurchaselyWrapper() } viewModel { HomeViewModel(get(), get(), get()) } viewModel { params -> DetailViewModel(get(), get(), get(), get(), params.get()) } diff --git a/android/app/src/test/java/com/purchasely/shaker/data/purchase/PurchaseManagerTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/purchase/PurchaseManagerTest.kt new file mode 100644 index 0000000..ca6991f --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/purchase/PurchaseManagerTest.kt @@ -0,0 +1,98 @@ +package com.purchasely.shaker.data.purchase + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.Purchase +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PurchaseManagerTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var mockBillingClient: BillingClient + private lateinit var purchaseRequests: MutableSharedFlow + private lateinit var restoreRequests: MutableSharedFlow + private lateinit var purchaseManager: PurchaseManager + + @Before + fun setUp() { + mockBillingClient = mockk(relaxed = true) + purchaseRequests = MutableSharedFlow() + restoreRequests = MutableSharedFlow() + purchaseManager = PurchaseManager( + billingClientFactory = { mockBillingClient }, + purchaseRequests = purchaseRequests, + restoreRequests = restoreRequests, + scope = testScope + ) + } + + @Test + fun `onPurchasesUpdated with OK emits Success`() = runTest { + val billingResult = mockk { + every { responseCode } returns BillingClient.BillingResponseCode.OK + } + val purchase = mockk { + every { purchaseState } returns Purchase.PurchaseState.PURCHASED + every { isAcknowledged } returns true + } + + var result: TransactionResult? = null + val job = launch(testDispatcher) { + result = purchaseManager.transactionResult.first { it !is TransactionResult.Idle } + } + + purchaseManager.onPurchasesUpdated(billingResult, listOf(purchase)) + job.join() + + assertTrue(result is TransactionResult.Success) + } + + @Test + fun `onPurchasesUpdated with USER_CANCELED emits Cancelled`() = runTest { + val billingResult = mockk { + every { responseCode } returns BillingClient.BillingResponseCode.USER_CANCELED + } + + var result: TransactionResult? = null + val job = launch(testDispatcher) { + result = purchaseManager.transactionResult.first { it !is TransactionResult.Idle } + } + + purchaseManager.onPurchasesUpdated(billingResult, null) + job.join() + + assertTrue(result is TransactionResult.Cancelled) + } + + @Test + fun `onPurchasesUpdated with error emits Error`() = runTest { + val billingResult = mockk { + every { responseCode } returns BillingClient.BillingResponseCode.ERROR + every { debugMessage } returns "Something went wrong" + } + + var result: TransactionResult? = null + val job = launch(testDispatcher) { + result = purchaseManager.transactionResult.first { it !is TransactionResult.Idle } + } + + purchaseManager.onPurchasesUpdated(billingResult, null) + job.join() + + assertTrue(result is TransactionResult.Error) + } +} From 85d34737e4b096211f47548ab76655db10840985 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 13:34:23 +0200 Subject: [PATCH 05/12] refactor(android): PurchaselyWrapper absorbs init, interceptor, and orchestration PurchaselyWrapper is now the single orchestrator: it owns SDK initialization (moved from ShakerApp), the paywall actions interceptor, purchase/restore request emission via SharedFlows, and TransactionResult observation with synchronize/processAction/premium refresh. AppModule and ShakerApp updated to wire the new constructor and delegate to initialize()/restart(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/purchasely/shaker/ShakerApp.kt | 113 +-------- .../com/purchasely/shaker/di/AppModule.kt | 24 +- .../shaker/purchasely/PurchaselyWrapper.kt | 172 ++++++++++++- .../purchasely/PurchaselyWrapperTest.kt | 229 ++++++++++++++++++ 4 files changed, 416 insertions(+), 122 deletions(-) create mode 100644 android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt index 37c699d..b5668c8 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt @@ -1,17 +1,10 @@ package com.purchasely.shaker import android.app.Application -import android.content.Intent -import android.net.Uri import android.util.Log -import com.purchasely.shaker.data.PremiumManager -import com.purchasely.shaker.data.PurchaselySdkMode import com.purchasely.shaker.di.appModule import com.purchasely.shaker.purchasely.PurchaselyWrapper -import io.purchasely.ext.EventListener import io.purchasely.ext.LogLevel -import io.purchasely.ext.PLYEvent -import io.purchasely.ext.PLYPresentationAction import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -32,114 +25,18 @@ class ShakerApp : Application() { } fun initPurchasely() { - // PURCHASELY: Initialize the SDK with API key, store, and running mode - // Call once in Application.onCreate(); Full mode means Purchasely owns the purchase flow + // PURCHASELY: Initialize the SDK — PurchaselyWrapper now owns init, interceptor, and orchestration // Docs: https://docs.purchasely.com/quick-start/sdk-configuration - val selectedMode = getSdkModeFromStorage() - - purchaselyWrapper.start( + purchaselyWrapper.initialize( application = this, apiKey = "6cda6b92-d63c-4444-bd55-5a164c989bd4", - logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN, - runningMode = selectedMode.runningMode, - readyToOpenDeeplink = true - ) { isConfigured, error -> - if (isConfigured) { - Log.d(TAG, "[Shaker] Purchasely SDK configured successfully (mode: ${selectedMode.label})") - val premiumManager: PremiumManager by inject() - premiumManager.refreshPremiumStatus() - } - error?.let { - Log.e(TAG, "[Shaker] Purchasely configuration error: ${it.message}") - } - } - - // PURCHASELY: Subscribe to SDK analytics events (purchases, cancellations, paywall views, etc.) - // Forward these to your own analytics pipeline or BI tool as needed - // Docs: https://docs.purchasely.com/advanced-features/events - purchaselyWrapper.eventListener = object : EventListener { - override fun onEvent(event: PLYEvent) { - Log.d(TAG, "[Shaker] Event: ${event.name} | Properties: ${event.properties}") - } - } - - // PURCHASELY: Intercept paywall button actions before the SDK handles them - // Use this to customize LOGIN, NAVIGATE, or other CTA behavior app-side - // Docs: https://docs.purchasely.com/advanced-features/customize-screens/paywall-action-interceptor - purchaselyWrapper.setPaywallActionsInterceptor { info, action, parameters, proceed -> - when (action) { - // PURCHASELY: LOGIN action — dismiss paywall and redirect user to app login flow - // Call proceed(false) to prevent the SDK's default behavior - PLYPresentationAction.LOGIN -> { - Log.d(TAG, "[Shaker] Paywall login action intercepted") - // Close the paywall and let the user navigate to Settings to log in - proceed(false) - } - // PURCHASELY: NAVIGATE action — open an external URL from a paywall CTA - // Call proceed(false) after handling it yourself to suppress SDK's default - PLYPresentationAction.NAVIGATE -> { - val url = parameters?.url - if (url != null) { - Log.d(TAG, "[Shaker] Paywall navigate action: $url") - val intent = Intent(Intent.ACTION_VIEW, url) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } - proceed(false) - } - // PURCHASELY: PURCHASE action — in Observer mode, route to native Google Play Billing; - // in Full mode, let the SDK handle the purchase flow - // Docs: https://docs.purchasely.com/advanced-features/customize-screens/paywall-action-interceptor - PLYPresentationAction.PURCHASE -> { - if (getSdkModeFromStorage() == PurchaselySdkMode.PAYWALL_OBSERVER) { - val plan = parameters?.plan - val offer = parameters?.subscriptionOffer - val productId = plan?.store_product_id - val offerToken = offer?.offerToken - val activity = info?.activity - if (activity != null && productId != null && offerToken != null) { - // TODO: Task 5 will update to use reactive flows - proceed(false) - } else { - Log.w(TAG, "[Shaker] Observer mode purchase: missing activity, productId, or offerToken") - proceed(false) - } - } else { - proceed(true) - } - } - // PURCHASELY: RESTORE action — in Observer mode, query Google Play Billing directly; - // in Full mode, let the SDK handle restore - PLYPresentationAction.RESTORE -> { - if (getSdkModeFromStorage() == PurchaselySdkMode.PAYWALL_OBSERVER) { - // TODO: Task 5 will update to use reactive flows - proceed(false) - } else { - proceed(true) - } - } - // PURCHASELY: All other actions — let the SDK handle them normally - else -> proceed(true) - } - } + logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN + ) } fun restartPurchaselySdk() { Log.d(TAG, "[Shaker] Restarting Purchasely SDK") - purchaselyWrapper.close() - initPurchasely() - } - - private fun getSdkModeFromStorage(): PurchaselySdkMode { - val prefs = getSharedPreferences(PurchaselySdkMode.PREFERENCES_NAME, MODE_PRIVATE) - val storedMode = prefs.getString(PurchaselySdkMode.KEY, null) - val resolvedMode = PurchaselySdkMode.fromStorage(storedMode) - - if (storedMode != resolvedMode.storageValue) { - prefs.edit().putString(PurchaselySdkMode.KEY, resolvedMode.storageValue).apply() - } - - return resolvedMode + purchaselyWrapper.restart() } companion object { diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index c798266..06d7ffe 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -5,14 +5,22 @@ import com.purchasely.shaker.data.FavoritesRepository import com.purchasely.shaker.data.OnboardingRepository import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.purchase.PurchaseRequest +import com.purchasely.shaker.data.purchase.RestoreRequest +import com.purchasely.shaker.data.purchase.TransactionResult // import com.purchasely.shaker.data.purchase.PurchaseManager // TODO: Task 4 will re-enable import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.ui.screen.home.HomeViewModel import com.purchasely.shaker.ui.screen.detail.DetailViewModel import com.purchasely.shaker.ui.screen.favorites.FavoritesViewModel import com.purchasely.shaker.ui.screen.settings.SettingsViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel +import org.koin.core.qualifier.named import org.koin.dsl.module val appModule = module { @@ -21,9 +29,23 @@ val appModule = module { single { OnboardingRepository(androidContext()) } single { RunningModeRepository(androidContext()) } single { PremiumManager() } + // Reactive flows for purchase orchestration + single(named("purchaseRequests")) { MutableSharedFlow() } + single(named("restoreRequests")) { MutableSharedFlow() } + single(named("transactionResult")) { MutableSharedFlow() } + single(named("appScope")) { CoroutineScope(SupervisorJob() + Dispatchers.Main) } // TODO: Task 4 will update PurchaseManager wiring with reactive flows // single { PurchaseManager(androidContext(), get()) } - single { PurchaselyWrapper() } + single { + PurchaselyWrapper( + premiumManager = get(), + runningModeRepo = get(), + purchaseRequests = get(named("purchaseRequests")), + restoreRequests = get(named("restoreRequests")), + transactionResult = get(named("transactionResult")), + scope = get(named("appScope")) + ) + } viewModel { HomeViewModel(get(), get(), get()) } viewModel { params -> DetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { FavoritesViewModel(get(), get(), get(), get()) } diff --git a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt index d13f55e..883e859 100644 --- a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt +++ b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt @@ -3,60 +3,202 @@ package com.purchasely.shaker.purchasely import android.app.Activity import android.app.Application import android.content.Context +import android.content.Intent import android.net.Uri +import android.util.Log import android.view.View +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.purchase.PurchaseRequest +import com.purchasely.shaker.data.purchase.RestoreRequest +import com.purchasely.shaker.data.purchase.TransactionResult import io.purchasely.ext.EventListener import io.purchasely.ext.LogLevel import io.purchasely.ext.PLYPresentation +import io.purchasely.ext.PLYPresentationAction +import io.purchasely.ext.PLYPresentationActionParameters +import io.purchasely.ext.PLYPresentationInfo import io.purchasely.ext.PLYPresentationProperties import io.purchasely.ext.PLYPresentationType import io.purchasely.ext.PLYProductViewResult -import io.purchasely.ext.PLYRunningMode import io.purchasely.ext.Purchasely import io.purchasely.ext.fetchPresentation import io.purchasely.models.PLYError import io.purchasely.models.PLYPlan import io.purchasely.google.GoogleStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -class PurchaselyWrapper { +class PurchaselyWrapper( + private val premiumManager: PremiumManager, + private val runningModeRepo: RunningModeRepository, + private val purchaseRequests: MutableSharedFlow, + private val restoreRequests: MutableSharedFlow, + transactionResult: SharedFlow, + private val scope: CoroutineScope +) { + + private var application: Application? = null + private var apiKey: String = "" + private var logLevel: LogLevel = LogLevel.DEBUG + private var pendingProcessAction: ((Boolean) -> Unit)? = null + + init { + scope.launch { + transactionResult.collect { result -> + handleTransactionResult(result) + } + } + } // MARK: - SDK Initialization - fun start( + fun initialize( application: Application, apiKey: String, - logLevel: LogLevel = LogLevel.DEBUG, - runningMode: PLYRunningMode = PLYRunningMode.Full, - readyToOpenDeeplink: Boolean = true, - onStarted: (Boolean, PLYError?) -> Unit + logLevel: LogLevel = LogLevel.DEBUG ) { + this.application = application + this.apiKey = apiKey + this.logLevel = logLevel + + val mode = runningModeRepo.runningMode + Purchasely.Builder(application) .apiKey(apiKey) .logLevel(logLevel) - .readyToOpenDeeplink(readyToOpenDeeplink) - .runningMode(runningMode) + .readyToOpenDeeplink(true) + .runningMode(mode) .stores(listOf(GoogleStore())) .build() .start { isConfigured, error -> - onStarted(isConfigured, error) + if (isConfigured) { + Log.d(TAG, "[Shaker] Purchasely SDK configured successfully") + premiumManager.refreshPremiumStatus() + } + error?.let { + Log.e(TAG, "[Shaker] Purchasely configuration error: ${it.message}") + } + } + + eventListener = object : EventListener { + override fun onEvent(event: io.purchasely.ext.PLYEvent) { + Log.d(TAG, "[Shaker] Event: ${event.name} | Properties: ${event.properties}") } + } + + setPaywallActionsInterceptor { info, action, parameters, proceed -> + handlePaywallAction(info, action, parameters, proceed) + } + } + + fun restart() { + close() + val app = application ?: return + initialize(app, apiKey, logLevel) } fun close() { Purchasely.close() } + // MARK: - Interceptor Logic + + internal fun handlePaywallAction( + info: PLYPresentationInfo?, + action: PLYPresentationAction, + parameters: PLYPresentationActionParameters?, + processAction: (Boolean) -> Unit + ) { + when (action) { + PLYPresentationAction.LOGIN -> { + Log.d(TAG, "[Shaker] Paywall login action intercepted") + processAction(false) + } + PLYPresentationAction.NAVIGATE -> { + val url = parameters?.url + if (url != null) { + Log.d(TAG, "[Shaker] Paywall navigate action: $url") + val intent = Intent(Intent.ACTION_VIEW, url) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + application?.startActivity(intent) + } + processAction(false) + } + PLYPresentationAction.PURCHASE -> { + if (runningModeRepo.isObserverMode) { + val plan = parameters?.plan + val offer = parameters?.subscriptionOffer + val productId = plan?.store_product_id + val offerToken = offer?.offerToken + val activity = info?.activity + if (activity != null && productId != null && offerToken != null) { + pendingProcessAction = processAction + scope.launch { + purchaseRequests.emit(PurchaseRequest(activity, productId, offerToken)) + } + } else { + Log.w(TAG, "[Shaker] Observer mode purchase: missing activity, productId, or offerToken") + processAction(false) + } + } else { + processAction(true) + } + } + PLYPresentationAction.RESTORE -> { + if (runningModeRepo.isObserverMode) { + pendingProcessAction = processAction + scope.launch { + restoreRequests.emit(RestoreRequest) + } + } else { + processAction(true) + } + } + else -> processAction(true) + } + } + + // MARK: - Transaction Result Handling + + private fun handleTransactionResult(result: TransactionResult) { + when (result) { + is TransactionResult.Success -> { + synchronize() + pendingProcessAction?.invoke(false) + pendingProcessAction = null + premiumManager.refreshPremiumStatus() + Log.d(TAG, "[Shaker] Transaction success — synchronized and refreshed") + } + is TransactionResult.Cancelled -> { + pendingProcessAction?.invoke(false) + pendingProcessAction = null + Log.d(TAG, "[Shaker] Transaction cancelled") + } + is TransactionResult.Error -> { + pendingProcessAction?.invoke(false) + pendingProcessAction = null + Log.e(TAG, "[Shaker] Transaction error: ${result.message}") + } + is TransactionResult.Idle -> { /* ignore */ } + } + } + + // MARK: - Event Listener + var eventListener: EventListener? get() = Purchasely.eventListener set(value) { Purchasely.eventListener = value } fun setPaywallActionsInterceptor( interceptor: ( - info: io.purchasely.ext.PLYPresentationInfo?, - action: io.purchasely.ext.PLYPresentationAction, - parameters: io.purchasely.ext.PLYPresentationActionParameters?, + info: PLYPresentationInfo?, + action: PLYPresentationAction, + parameters: PLYPresentationActionParameters?, processAction: (Boolean) -> Unit ) -> Unit ) { @@ -188,4 +330,8 @@ class PurchaselyWrapper { val sdkVersion: String get() = Purchasely.sdkVersion + + companion object { + private const val TAG = "PurchaselyWrapper" + } } diff --git a/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt b/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt new file mode 100644 index 0000000..513bcc1 --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt @@ -0,0 +1,229 @@ +package com.purchasely.shaker.purchasely + +import android.app.Activity +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.purchase.PurchaseRequest +import com.purchasely.shaker.data.purchase.RestoreRequest +import com.purchasely.shaker.data.purchase.TransactionResult +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.purchasely.ext.PLYPresentation +import io.purchasely.ext.PLYPresentationAction +import io.purchasely.ext.PLYPresentationActionParameters +import io.purchasely.ext.PLYPresentationInfo +import io.purchasely.ext.PLYRunningMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PurchaselyWrapperTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var premiumManager: PremiumManager + private lateinit var runningModeRepo: RunningModeRepository + private lateinit var purchaseRequests: MutableSharedFlow + private lateinit var restoreRequests: MutableSharedFlow + private lateinit var transactionResult: MutableSharedFlow + private lateinit var wrapper: PurchaselyWrapper + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + premiumManager = mockk(relaxed = true) + runningModeRepo = mockk { + every { runningMode } returns PLYRunningMode.PaywallObserver + every { isObserverMode } returns true + } + purchaseRequests = MutableSharedFlow() + restoreRequests = MutableSharedFlow() + transactionResult = MutableSharedFlow() + wrapper = PurchaselyWrapper( + premiumManager = premiumManager, + runningModeRepo = runningModeRepo, + purchaseRequests = purchaseRequests, + restoreRequests = restoreRequests, + transactionResult = transactionResult, + scope = testScope + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // --- Interceptor: PURCHASE in Observer mode --- + + @Test + fun `handlePaywallAction PURCHASE in observer mode emits PurchaseRequest`() = runTest { + val mockActivity = mockk() + val mockPlan = mockk { + every { store_product_id } returns "com.test.product" + } + val mockOffer = mockk { + every { offerToken } returns "token-123" + } + val mockInfo = mockk { + every { activity } returns mockActivity + } + val mockParams = mockk { + every { plan } returns mockPlan + every { subscriptionOffer } returns mockOffer + } + + var emittedRequest: PurchaseRequest? = null + val collectJob = launch(testDispatcher) { + emittedRequest = purchaseRequests.first() + } + + wrapper.handlePaywallAction(mockInfo, PLYPresentationAction.PURCHASE, mockParams) {} + collectJob.join() + + assertNotNull(emittedRequest) + assertEquals("com.test.product", emittedRequest?.productId) + assertEquals("token-123", emittedRequest?.offerToken) + } + + @Test + fun `handlePaywallAction PURCHASE in full mode calls proceed true`() { + every { runningModeRepo.isObserverMode } returns false + var proceededWith: Boolean? = null + + wrapper.handlePaywallAction(null, PLYPresentationAction.PURCHASE, null) { proceededWith = it } + + assertEquals(true, proceededWith) + } + + // --- Interceptor: RESTORE in Observer mode --- + + @Test + fun `handlePaywallAction RESTORE in observer mode emits RestoreRequest`() = runTest { + var emittedRestore = false + val collectJob = launch(testDispatcher) { + restoreRequests.first() + emittedRestore = true + } + + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) {} + collectJob.join() + + assertTrue(emittedRestore) + } + + @Test + fun `handlePaywallAction RESTORE in full mode calls proceed true`() { + every { runningModeRepo.isObserverMode } returns false + var proceededWith: Boolean? = null + + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + + assertEquals(true, proceededWith) + } + + // --- Interceptor: LOGIN --- + + @Test + fun `handlePaywallAction LOGIN calls proceed false`() { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.LOGIN, null) { proceededWith = it } + assertEquals(false, proceededWith) + } + + // --- Interceptor: other actions --- + + @Test + fun `handlePaywallAction CLOSE calls proceed true`() { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.CLOSE, null) { proceededWith = it } + assertEquals(true, proceededWith) + } + + // --- TransactionResult observation --- + + @Test + fun `TransactionResult Success triggers premium refresh`() = runTest { + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) {} + transactionResult.emit(TransactionResult.Success) + testScope.testScheduler.advanceUntilIdle() + verify { premiumManager.refreshPremiumStatus() } + } + + @Test + fun `TransactionResult Success calls pendingProcessAction with false`() = runTest { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + transactionResult.emit(TransactionResult.Success) + testScope.testScheduler.advanceUntilIdle() + assertEquals(false, proceededWith) + } + + @Test + fun `TransactionResult Cancelled calls pendingProcessAction with false`() = runTest { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + transactionResult.emit(TransactionResult.Cancelled) + testScope.testScheduler.advanceUntilIdle() + assertEquals(false, proceededWith) + } + + @Test + fun `TransactionResult Error calls pendingProcessAction with false`() = runTest { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + transactionResult.emit(TransactionResult.Error("fail")) + testScope.testScheduler.advanceUntilIdle() + assertEquals(false, proceededWith) + } + + @Test + fun `TransactionResult Idle is ignored`() = runTest { + var proceededWith: Boolean? = null + wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) { proceededWith = it } + transactionResult.emit(TransactionResult.Idle) + testScope.testScheduler.advanceUntilIdle() + assertEquals(null, proceededWith) + } + + // --- Existing API contract --- + + @Test + fun `loadPresentation returns FetchResult via mocked wrapper`() = runTest { + val mockedWrapper = mockk(relaxed = true) + val mockPresentation = mockk() + io.mockk.coEvery { mockedWrapper.loadPresentation("filters", null) } returns FetchResult.Success(mockPresentation) + val result = mockedWrapper.loadPresentation("filters") + assertTrue(result is FetchResult.Success) + } + + @Test + fun `FetchResult Success exposes height`() { + val presentation = mockk { + every { height } returns 400 + } + val result = FetchResult.Success(presentation) + assertEquals(400, result.height) + } + + @Test + fun `wrapper instance can be created with dependencies`() { + assertNotNull(wrapper) + } +} From 51dc979c8e92620e8d062396223d319f908deb86 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 13:48:05 +0200 Subject: [PATCH 06/12] refactor(android): SettingsViewModel uses wrapper.restart(), simplify ShakerApp - SettingsViewModel no longer casts to ShakerApp, calls wrapper.restart() directly - ShakerApp reduced to 30 lines: Koin init + wrapper.initialize() - Added test for setSdkMode calling wrapper.restart() Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/purchasely/shaker/ShakerApp.kt | 18 +- .../ui/screen/settings/SettingsViewModel.kt | 9 +- .../screen/settings/SettingsViewModelTest.kt | 342 ++++++++++++++++++ 3 files changed, 344 insertions(+), 25 deletions(-) create mode 100644 android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt index b5668c8..c8d7f2e 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt @@ -1,7 +1,6 @@ package com.purchasely.shaker import android.app.Application -import android.util.Log import com.purchasely.shaker.di.appModule import com.purchasely.shaker.purchasely.PurchaselyWrapper import io.purchasely.ext.LogLevel @@ -21,25 +20,10 @@ class ShakerApp : Application() { modules(appModule) } - initPurchasely() - } - - fun initPurchasely() { - // PURCHASELY: Initialize the SDK — PurchaselyWrapper now owns init, interceptor, and orchestration - // Docs: https://docs.purchasely.com/quick-start/sdk-configuration purchaselyWrapper.initialize( application = this, - apiKey = "6cda6b92-d63c-4444-bd55-5a164c989bd4", + apiKey = BuildConfig.PURCHASELY_API_KEY, logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN ) } - - fun restartPurchaselySdk() { - Log.d(TAG, "[Shaker] Restarting Purchasely SDK") - purchaselyWrapper.restart() - } - - companion object { - private const val TAG = "ShakerApp" - } } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt index db691ab..25966b7 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt @@ -6,7 +6,6 @@ import android.content.SharedPreferences import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.purchasely.shaker.ShakerApp import com.purchasely.shaker.data.PurchaselySdkMode import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository @@ -239,13 +238,7 @@ class SettingsViewModel( } private fun restartPurchaselySdk(mode: PurchaselySdkMode) { - val app = context.applicationContext as? ShakerApp - if (app == null) { - Log.e(TAG, "[Shaker] Could not restart SDK: application context is not ShakerApp") - return - } - - app.restartPurchaselySdk() + purchaselyWrapper.restart() Log.d(TAG, "[Shaker] SDK restarted with mode ${mode.storageValue}") } diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt new file mode 100644 index 0000000..2e15a53 --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt @@ -0,0 +1,342 @@ +package com.purchasely.shaker.ui.screen.settings + +import android.content.Context +import android.content.SharedPreferences +import com.purchasely.shaker.data.PurchaselySdkMode +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.purchasely.ext.PLYDataProcessingPurpose +import io.purchasely.models.PLYError +import io.purchasely.models.PLYPlan +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var context: Context + private lateinit var prefs: SharedPreferences + private lateinit var editor: SharedPreferences.Editor + private lateinit var premiumManager: PremiumManager + private lateinit var runningModeRepo: RunningModeRepository + private lateinit var wrapper: PurchaselyWrapper + + private val storedValues = mutableMapOf() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + storedValues.clear() + editor = mockk(relaxed = true) { + every { putString(any(), any()) } answers { + storedValues[firstArg()] = secondArg() + this@mockk + } + every { putBoolean(any(), any()) } answers { + storedValues[firstArg()] = secondArg() + this@mockk + } + every { remove(any()) } answers { + storedValues.remove(firstArg()) + this@mockk + } + } + prefs = mockk { + every { getString(any(), any()) } answers { + storedValues[firstArg()] as? String ?: secondArg() + } + every { getBoolean(any(), any()) } answers { + storedValues[firstArg()] as? Boolean ?: secondArg() + } + every { contains(any()) } answers { storedValues.containsKey(firstArg()) } + every { edit() } returns editor + } + context = mockk { + every { getSharedPreferences(any(), any()) } returns prefs + every { applicationContext } returns this + } + premiumManager = mockk { + every { isPremium } returns MutableStateFlow(false) + every { refreshPremiumStatus() } returns Unit + } + runningModeRepo = mockk(relaxed = true) { + every { isObserverMode } returns false + } + wrapper = mockk(relaxed = true) { + every { anonymousUserId } returns "anon-123" + every { sdkVersion } returns "5.7.3" + coEvery { loadPresentation(any(), any()) } returns FetchResult.Deactivated + } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = SettingsViewModel(context, premiumManager, runningModeRepo, wrapper) + + @Test + fun `initial userId is null when not stored`() { + val vm = createViewModel() + assertNull(vm.userId.value) + } + + @Test + fun `initial userId reads from SharedPreferences`() { + storedValues["user_id"] = "kevin" + val vm = createViewModel() + assertEquals("kevin", vm.userId.value) + } + + @Test + fun `login sets userId and calls wrapper`() { + val vm = createViewModel() + vm.login("kevin") + assertEquals("kevin", vm.userId.value) + verify { wrapper.userLogin("kevin", any()) } + verify { wrapper.setUserAttribute("user_id", "kevin") } + } + + @Test + fun `login persists userId to SharedPreferences`() { + val vm = createViewModel() + vm.login("kevin") + verify { editor.putString("user_id", "kevin") } + } + + @Test + fun `login with blank userId does nothing`() { + val vm = createViewModel() + vm.login("") + assertNull(vm.userId.value) + verify(exactly = 0) { wrapper.userLogin(any(), any()) } + } + + @Test + fun `login with whitespace-only userId does nothing`() { + val vm = createViewModel() + vm.login(" ") + assertNull(vm.userId.value) + verify(exactly = 0) { wrapper.userLogin(any(), any()) } + } + + @Test + fun `login refresh callback triggers premium refresh`() { + val refreshSlot = slot<(Boolean) -> Unit>() + every { wrapper.userLogin(any(), capture(refreshSlot)) } answers { + refreshSlot.captured(true) + } + val vm = createViewModel() + vm.login("kevin") + verify { premiumManager.refreshPremiumStatus() } + } + + @Test + fun `logout clears userId and calls wrapper`() { + storedValues["user_id"] = "kevin" + val vm = createViewModel() + vm.logout() + assertNull(vm.userId.value) + verify { wrapper.userLogout() } + verify { editor.remove("user_id") } + verify { premiumManager.refreshPremiumStatus() } + } + + @Test + fun `restorePurchases success updates message`() { + val successSlot = slot<(PLYPlan?) -> Unit>() + every { wrapper.restoreAllProducts(capture(successSlot), any()) } answers { + successSlot.captured(null) + } + val vm = createViewModel() + vm.restorePurchases() + assertEquals("Purchases restored successfully!", vm.restoreMessage.value) + verify { premiumManager.refreshPremiumStatus() } + } + + @Test + fun `restorePurchases error updates message`() { + val errorSlot = slot<(PLYError?) -> Unit>() + every { wrapper.restoreAllProducts(any(), capture(errorSlot)) } answers { + errorSlot.captured(mockk { every { message } returns "No purchases found" }) + } + val vm = createViewModel() + vm.restorePurchases() + assertEquals("No purchases found", vm.restoreMessage.value) + } + + @Test + fun `clearRestoreMessage resets message`() { + val vm = createViewModel() + vm.clearRestoreMessage() + assertNull(vm.restoreMessage.value) + } + + @Test + fun `setThemeMode persists and sets user attribute`() { + val vm = createViewModel() + vm.setThemeMode("dark") + assertEquals("dark", vm.themeMode.value) + verify { editor.putString("theme_mode", "dark") } + verify { wrapper.setUserAttribute("app_theme", "dark") } + } + + @Test + fun `initial themeMode defaults to system`() { + val vm = createViewModel() + assertEquals("system", vm.themeMode.value) + } + + @Test + fun `setDisplayMode persists value`() { + val vm = createViewModel() + vm.setDisplayMode("embedded") + assertEquals("embedded", vm.displayMode.value) + verify { editor.putString("display_mode", "embedded") } + } + + @Test + fun `sdkVersion delegates to wrapper`() { + val vm = createViewModel() + assertEquals("5.7.3", vm.sdkVersion) + } + + @Test + fun `refreshAnonymousId updates from wrapper`() { + val vm = createViewModel() + every { wrapper.anonymousUserId } returns "new-anon-456" + vm.refreshAnonymousId() + assertEquals("new-anon-456", vm.anonymousId.value) + } + + @Test + fun `initial anonymousId reads from wrapper`() { + val vm = createViewModel() + assertEquals("anon-123", vm.anonymousId.value) + } + + @Test + fun `setAnalyticsConsent revokes when false`() { + val vm = createViewModel() + vm.setAnalyticsConsent(false) + assertFalse(vm.analyticsConsent.value) + verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.Analytics) }) } + } + + @Test + fun `setIdentifiedAnalyticsConsent revokes when false`() { + val vm = createViewModel() + vm.setIdentifiedAnalyticsConsent(false) + assertFalse(vm.identifiedAnalyticsConsent.value) + verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.IdentifiedAnalytics) }) } + } + + @Test + fun `setPersonalizationConsent revokes when false`() { + val vm = createViewModel() + vm.setPersonalizationConsent(false) + assertFalse(vm.personalizationConsent.value) + verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.Personalization) }) } + } + + @Test + fun `setCampaignsConsent revokes when false`() { + val vm = createViewModel() + vm.setCampaignsConsent(false) + assertFalse(vm.campaignsConsent.value) + verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.Campaigns) }) } + } + + @Test + fun `setThirdPartyConsent revokes when false`() { + val vm = createViewModel() + vm.setThirdPartyConsent(false) + assertFalse(vm.thirdPartyConsent.value) + verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.ThirdPartyIntegrations) }) } + } + + @Test + fun `all consents true revokes empty set`() { + val vm = createViewModel() + // All consents default to true, verify empty set is passed + verify { wrapper.revokeDataProcessingConsent(match { it.isEmpty() }) } + } + + @Test + fun `multiple consents revoked together`() { + val vm = createViewModel() + vm.setAnalyticsConsent(false) + vm.setPersonalizationConsent(false) + verify { + wrapper.revokeDataProcessingConsent(match { + it.contains(PLYDataProcessingPurpose.Analytics) && + it.contains(PLYDataProcessingPurpose.Personalization) + }) + } + } + + @Test + fun `showOnboardingPaywall calls loadPresentation`() = runTest { + val vm = createViewModel() + vm.showOnboardingPaywall() + coVerify { wrapper.loadPresentation("onboarding", null) } + } + + @Test + fun `onPurchaseCompleted refreshes premium status`() { + val vm = createViewModel() + vm.onPurchaseCompleted() + verify { premiumManager.refreshPremiumStatus() } + } + + @Test + fun `isPremium exposes premiumManager state`() { + val vm = createViewModel() + assertFalse(vm.isPremium.value) + } + + @Test + fun `initial runningMode reads from repository`() { + val vm = createViewModel() + assertEquals("full", vm.runningMode.value) + } + + @Test + fun `initial runningMode is observer when repo says so`() { + every { runningModeRepo.isObserverMode } returns true + val vm = createViewModel() + assertEquals("observer", vm.runningMode.value) + } + + @Test + fun `setSdkMode calls wrapper restart`() { + storedValues[PurchaselySdkMode.KEY] = PurchaselySdkMode.FULL.storageValue + val vm = createViewModel() + vm.setSdkMode(PurchaselySdkMode.PAYWALL_OBSERVER) + verify { wrapper.restart() } + } +} From 38bd913cef0bf36bf9f1eff808c7aaf6f0eac2d8 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 13:59:56 +0200 Subject: [PATCH 07/12] docs: add iOS spec for PurchaselyWrapper Observer purchase refactor --- ...sely-wrapper-observer-purchase-refactor.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-10-ios-purchasely-wrapper-observer-purchase-refactor.md diff --git a/docs/superpowers/specs/2026-04-10-ios-purchasely-wrapper-observer-purchase-refactor.md b/docs/superpowers/specs/2026-04-10-ios-purchasely-wrapper-observer-purchase-refactor.md new file mode 100644 index 0000000..7ba4e8b --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-ios-purchasely-wrapper-observer-purchase-refactor.md @@ -0,0 +1,46 @@ +# Purchasely Wrapper + Observer Purchase Refactor — iOS + +**Date:** 2026-04-10 +**Status:** Approved +**Scope:** iOS only (mirrors Android refactoring) + +--- + +## Design + +Same architecture as Android spec (`2026-04-10-purchasely-wrapper-observer-purchase-refactor.md`), adapted for Swift/iOS: + +- **Combine** replaces Kotlin SharedFlow for reactive communication +- **StoreKit 2** replaces Google Play Billing (already in place) +- **Singletons** replace Koin DI (existing iOS pattern) +- `PurchaseRequest` only needs `productId` (no Activity/offerToken — StoreKit 2 API) + +### Files + +| File | Action | +|------|--------| +| `Data/purchase/PurchaseRequest.swift` | **New** — struct with productId | +| `Data/purchase/TransactionResult.swift` | **New** — enum Success/Cancelled/Error/Idle | +| `Data/PurchaselySDKMode.swift` | **New** — extracted from AppViewModel | +| `Data/PurchaseManager.swift` | **Refactor** — zero PurchaselyWrapper imports, observe Combine subjects | +| `Purchasely/PurchaselyWrapper.swift` | **Refactor** — absorb init + interceptor, emit requests, observe results | +| `Purchasely/PurchaselyWrapping.swift` | **Update** — add initialize, restart, closeDisplayedPresentation | +| `AppViewModel.swift` | **Simplify** — just wrapper.initialize() + isSDKReady | +| `Screens/Settings/SettingsViewModel.swift` | **Minor** — setSdkMode posts notification, wrapper handles restart | + +### Reactive Flow + +``` +PurchaselyWrapper PurchaseManager + │ │ + │ PURCHASE (observer) ──────────────────► │ + │ purchaseSubject.send(PurchaseRequest) │ + │ │ Product.purchase() + │ │ transaction.finish() + │ ◄──────────────────────────────────── │ + │ resultSubject.send(.success) │ + │ │ + │ synchronize() │ + │ pendingProcessAction(false) │ + │ premiumManager.refreshPremiumStatus() │ +``` From 50096357c9430ce2a595d171c46551209bc561a1 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 14:06:18 +0200 Subject: [PATCH 08/12] refactor(ios): mirror Android PurchaselyWrapper + Observer purchase decoupling - PurchaselyWrapper absorbs SDK init, interceptor, and orchestration - PurchaseManager decoupled via Combine (zero Purchasely imports) - AppViewModel simplified to just wrapper.initialize() + isSDKReady - PurchaselySDKMode extracted to its own file - New types: PurchaseRequest, TransactionResult - 97 iOS tests pass, 0 failures Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Shaker/AppViewModel.swift | 176 +---------------- ios/Shaker/Data/PurchaseManager.swift | 185 ++++++++++-------- ios/Shaker/Data/PurchaselySDKMode.swift | 48 +++++ .../Data/purchase/PurchaseRequest.swift | 5 + .../Data/purchase/TransactionResult.swift | 8 + ios/Shaker/Purchasely/PurchaselyWrapper.swift | 182 +++++++++++++++-- .../Purchasely/PurchaselyWrapping.swift | 70 +++++++ .../Data/purchase/PurchaseRequestTests.swift | 16 ++ .../purchase/TransactionResultTests.swift | 62 ++++++ .../Mocks/MockPurchaselyWrapper.swift | 109 +++++++++++ 10 files changed, 590 insertions(+), 271 deletions(-) create mode 100644 ios/Shaker/Data/PurchaselySDKMode.swift create mode 100644 ios/Shaker/Data/purchase/PurchaseRequest.swift create mode 100644 ios/Shaker/Data/purchase/TransactionResult.swift create mode 100644 ios/Shaker/Purchasely/PurchaselyWrapping.swift create mode 100644 ios/ShakerTests/Data/purchase/PurchaseRequestTests.swift create mode 100644 ios/ShakerTests/Data/purchase/TransactionResultTests.swift create mode 100644 ios/ShakerTests/Mocks/MockPurchaselyWrapper.swift diff --git a/ios/Shaker/AppViewModel.swift b/ios/Shaker/AppViewModel.swift index f41ae5d..56db06f 100644 --- a/ios/Shaker/AppViewModel.swift +++ b/ios/Shaker/AppViewModel.swift @@ -1,53 +1,6 @@ import Foundation -import UIKit import Purchasely -enum PurchaselySDKMode: String, CaseIterable, Identifiable { - case paywallObserver = "paywallObserver" - case full = "full" - - static let storageKey = "purchasely_sdk_mode" - static let defaultMode: PurchaselySDKMode = .paywallObserver - - var id: String { rawValue } - - var title: String { - switch self { - case .paywallObserver: - return "Paywall Observer" - case .full: - return "Full" - } - } - - var runningMode: PLYRunningMode { - switch self { - case .paywallObserver: - return .paywallObserver - case .full: - return .full - } - } - - static func current() -> PurchaselySDKMode { - let defaults = UserDefaults.standard - guard let rawValue = defaults.string(forKey: storageKey), - let mode = PurchaselySDKMode(rawValue: rawValue) else { - defaults.set(defaultMode.rawValue, forKey: storageKey) - return defaultMode - } - return mode - } - - func persist() { - UserDefaults.standard.set(rawValue, forKey: Self.storageKey) - } -} - -extension Notification.Name { - static let purchaselySdkModeDidChange = Notification.Name("purchaselySdkModeDidChange") -} - class AppViewModel: ObservableObject { @Published var isSDKReady = false @@ -56,35 +9,10 @@ class AppViewModel: ObservableObject { private let wrapper = PurchaselyWrapper.shared init() { - NotificationCenter.default.addObserver( - self, - selector: #selector(handleSdkModeDidChange), - name: .purchaselySdkModeDidChange, - object: nil - ) - initPurchasely() - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc private func handleSdkModeDidChange() { - DispatchQueue.main.async { [weak self] in - self?.restartPurchaselySdk() - } - } - - private func initPurchasely() { - // PURCHASELY: Initialize the SDK with your API key and configuration - // Must be called once at app launch before any other SDK call - // Docs: https://docs.purchasely.com/quick-start/sdk-configuration let apiKey = (Bundle.main.object(forInfoDictionaryKey: "PURCHASELY_API_KEY") as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let resolvedApiKey = apiKey.isEmpty ? "6cda6b92-d63c-4444-bd55-5a164c989bd4" : apiKey - let selectedMode = PurchaselySDKMode.current() let storedUserId = UserDefaults.standard.string(forKey: "user_id") - sdkError = nil #if DEBUG let sdkLogLevel: PLYLogger.PLYLogLevel = .debug @@ -92,115 +20,15 @@ class AppViewModel: ObservableObject { let sdkLogLevel: PLYLogger.PLYLogLevel = .warn #endif - wrapper.start( + wrapper.initialize( apiKey: resolvedApiKey, appUserId: storedUserId, - runningMode: selectedMode.runningMode, - storekitSettings: .storeKit2, logLevel: sdkLogLevel ) { [weak self] success, error in DispatchQueue.main.async { self?.isSDKReady = success - if success { - self?.sdkError = nil - print("[Shaker] Purchasely SDK configured successfully (mode: \(selectedMode.title))") - PremiumManager.shared.refreshPremiumStatus() - } else { - self?.sdkError = error?.localizedDescription - print("[Shaker] Purchasely configuration error: \(error?.localizedDescription ?? "unknown")") - } + self?.sdkError = success ? nil : error?.localizedDescription } } - - // PURCHASELY: Signal that the app is ready to process deeplinks - // Call after SDK init so pending deeplinks queued at launch are not dropped - // Docs: https://docs.purchasely.com/advanced-features/deeplinks - wrapper.readyToOpenDeeplink(true) - - // PURCHASELY: Register a delegate to receive SDK analytics events - // Useful for forwarding Purchasely events to your own analytics pipeline - // Docs: https://docs.purchasely.com/advanced-features/events - wrapper.setEventDelegate(self) - - // PURCHASELY: Intercept paywall actions before the SDK handles them - // Use to implement custom login flow or handle navigation links from paywalls - // Docs: https://docs.purchasely.com/advanced-features/customize-screens/paywall-action-interceptor - wrapper.setPaywallActionsInterceptor { action, parameters, info, proceed in - switch action { - case .login: - // PURCHASELY: User tapped "Sign In" on a paywall — handle login yourself, then call proceed(true) if refresh is needed - print("[Shaker] Paywall login action intercepted") - proceed(false) - case .navigate: - // PURCHASELY: Paywall contains a URL link — open it natively and suppress default SDK handling - if let url = parameters?.url { - print("[Shaker] Paywall navigate action: \(url)") - DispatchQueue.main.async { - UIApplication.shared.open(url) - } - } - proceed(false) - case .purchase: - // PURCHASELY: In Observer mode, handle purchase natively via StoreKit 2; - // in Full mode, let the SDK own the purchase flow - // Docs: https://docs.purchasely.com/advanced-features/customize-screens/paywall-action-interceptor - if PurchaselySDKMode.current() == .paywallObserver { - if let productId = parameters?.plan?.appleProductId { - if #available(iOS 15.0, *) { - Task { - do { - _ = try await PurchaseManager.shared.purchase(productId: productId) - PremiumManager.shared.refreshPremiumStatus() - } catch { - print("[Shaker] Observer purchase error: \(error)") - } - proceed(false) - } - } else { - proceed(false) - } - } else { - print("[Shaker] Observer mode purchase: missing product ID") - proceed(false) - } - } else { - proceed(true) - } - case .restore: - // PURCHASELY: In Observer mode, restore via StoreKit 2; - // in Full mode, let the SDK handle restore - if PurchaselySDKMode.current() == .paywallObserver { - if #available(iOS 15.0, *) { - Task { - do { - try await PurchaseManager.shared.restoreAllTransactions() - PremiumManager.shared.refreshPremiumStatus() - } catch { - print("[Shaker] Observer restore error: \(error)") - } - proceed(false) - } - } else { - proceed(false) - } - } else { - proceed(true) - } - default: - // PURCHASELY: All other paywall actions (close, etc.) — let the SDK handle them normally - proceed(true) - } - } - } - - private func restartPurchaselySdk() { - wrapper.closeDisplayedPresentation() - initPurchasely() - } -} - -extension AppViewModel: PLYEventDelegate { - func eventTriggered(_ event: PLYEvent, properties: [String: Any]?) { - print("[Shaker] Event: \(event.name) | Properties: \(properties ?? [:])") } } diff --git a/ios/Shaker/Data/PurchaseManager.swift b/ios/Shaker/Data/PurchaseManager.swift index b6baf28..e7df271 100644 --- a/ios/Shaker/Data/PurchaseManager.swift +++ b/ios/Shaker/Data/PurchaseManager.swift @@ -1,80 +1,124 @@ import Foundation import StoreKit -import Purchasely +import Combine @available(iOS 15.0, *) class PurchaseManager { static let shared = PurchaseManager() - private init() {} + /// Subjects for reactive communication (set by PurchaselyWrapper) + var purchaseSubject = PassthroughSubject() + var restoreSubject = PassthroughSubject() + let resultSubject = PassthroughSubject() - /// Purchase a product natively via StoreKit 2. - /// Called from the paywall interceptor when in Observer mode. - func purchase(productId: String) async throws -> Transaction { - let products = try await Product.products(for: [productId]) - guard let product = products.first else { - throw PurchaseError.productNotFound - } + /// Anonymous user ID provider — injected to avoid direct PurchaselyWrapper dependency + var anonymousUserIdProvider: (() -> String)? - // Use Purchasely anonymous user ID (lowercased) as the app account token - let userId = PurchaselyWrapper.shared.anonymousUserId.lowercased() + /// Sign promo offer — injected to avoid direct PurchaselyWrapper dependency + var signPromotionalOfferProvider: ((_ productId: String, _ offerId: String, _ success: @escaping (PLYOfferSignatureData) -> Void, _ failure: @escaping (Error) -> Void) -> Void)? - var options: Set = [] - if let uuid = UUID(uuidString: userId) { - options.insert(.appAccountToken(uuid)) + private var cancellables = Set() + + private init() { + purchaseSubject + .sink { [weak self] request in + Task { [weak self] in + await self?.handlePurchase(productId: request.productId) + } + } + .store(in: &cancellables) + + restoreSubject + .sink { [weak self] in + Task { [weak self] in + await self?.handleRestore() + } + } + .store(in: &cancellables) + } + + // MARK: - Purchase + + private func handlePurchase(productId: String) async { + do { + let products = try await Product.products(for: [productId]) + guard let product = products.first else { + resultSubject.send(.error("Product not found in the App Store")) + return + } + + var options: Set = [] + if let userId = anonymousUserIdProvider?().lowercased(), + let uuid = UUID(uuidString: userId) { + options.insert(.appAccountToken(uuid)) + } + + let result = try await product.purchase(options: options) + + switch result { + case .success(let verification): + let transaction = try checkVerified(verification) + await transaction.finish() + print("[Shaker] Observer mode: native purchase successful") + resultSubject.send(.success) + case .userCancelled: + resultSubject.send(.cancelled) + case .pending: + resultSubject.send(.error("Purchase pending approval")) + @unknown default: + resultSubject.send(.error("Unknown purchase result")) + } + } catch { + resultSubject.send(.error(error.localizedDescription)) } + } - let result = try await product.purchase(options: options) + // MARK: - Restore - switch result { - case .success(let verification): - let transaction = try checkVerified(verification) - await transaction.finish() - PurchaselyWrapper.shared.synchronize() - print("[Shaker] Observer mode: native purchase successful, synchronized") - return transaction - case .userCancelled: - throw PurchaseError.cancelled - case .pending: - throw PurchaseError.pending - @unknown default: - throw PurchaseError.unknown + private func handleRestore() async { + var restoredCount = 0 + for await result in Transaction.currentEntitlements { + if let transaction = try? checkVerified(result) { + await transaction.finish() + restoredCount += 1 + } } + print("[Shaker] Observer mode: restored \(restoredCount) transactions") + resultSubject.send(restoredCount > 0 ? .success : .cancelled) } - /// Purchase a product with a promotional offer via StoreKit 2. - /// Uses Purchasely.signPromotionalOffer() to generate the required signature. + // MARK: - Promo Offer Purchase (public for direct calls) + func purchaseWithPromoOffer( productId: String, storeOfferId: String - ) async throws -> Transaction { + ) async throws { + guard let signProvider = signPromotionalOfferProvider else { + resultSubject.send(.error("Promo offer signing not available")) + return + } + let products = try await Product.products(for: [productId]) guard let product = products.first else { - throw PurchaseError.productNotFound + resultSubject.send(.error("Product not found in the App Store")) + return } - // Sign the promotional offer via Purchasely backend - let signature = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - PurchaselyWrapper.shared.signPromotionalOffer( - storeProductId: productId, - storeOfferId: storeOfferId, - success: { signature in - continuation.resume(returning: signature) - }, - failure: { error in - continuation.resume(throwing: error) - } - ) + let signature = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + signProvider(productId, storeOfferId, { sig in + continuation.resume(returning: sig) + }, { error in + continuation.resume(throwing: error) + }) } - let userId = PurchaselyWrapper.shared.anonymousUserId.lowercased() var options: Set = [] - if let uuid = UUID(uuidString: userId) { + if let userId = anonymousUserIdProvider?().lowercased(), + let uuid = UUID(uuidString: userId) { options.insert(.appAccountToken(uuid)) } - // Add promotional offer to purchase options if let decodedSignature = Data(base64Encoded: signature.signature) { let offerOption: Product.PurchaseOption = .promotionalOffer( offerID: signature.identifier, @@ -92,30 +136,18 @@ class PurchaseManager { case .success(let verification): let transaction = try checkVerified(verification) await transaction.finish() - PurchaselyWrapper.shared.synchronize() - print("[Shaker] Observer mode: promo offer purchase successful, synchronized") - return transaction + print("[Shaker] Observer mode: promo offer purchase successful") + resultSubject.send(.success) case .userCancelled: - throw PurchaseError.cancelled + resultSubject.send(.cancelled) case .pending: - throw PurchaseError.pending + resultSubject.send(.error("Purchase pending approval")) @unknown default: - throw PurchaseError.unknown + resultSubject.send(.error("Unknown purchase result")) } } - /// Restore all completed transactions via StoreKit 2. - func restoreAllTransactions() async throws { - var restoredCount = 0 - for await result in Transaction.currentEntitlements { - if let transaction = try? checkVerified(result) { - await transaction.finish() - restoredCount += 1 - } - } - PurchaselyWrapper.shared.synchronize() - print("[Shaker] Observer mode: restored \(restoredCount) transactions, synchronized") - } + // MARK: - Verification private func checkVerified(_ result: VerificationResult) throws -> T { switch result { @@ -125,20 +157,13 @@ class PurchaseManager { return value } } +} - enum PurchaseError: LocalizedError { - case productNotFound - case cancelled - case pending - case unknown - - var errorDescription: String? { - switch self { - case .productNotFound: return "Product not found in the App Store" - case .cancelled: return "Purchase cancelled" - case .pending: return "Purchase pending approval" - case .unknown: return "Unknown purchase error" - } - } - } +/// Data structure for promo offer signatures — decouples from PLYOfferSignature +struct PLYOfferSignatureData { + let identifier: String + let keyIdentifier: String + let nonce: UUID + let signature: String + let timestamp: Int } diff --git a/ios/Shaker/Data/PurchaselySDKMode.swift b/ios/Shaker/Data/PurchaselySDKMode.swift new file mode 100644 index 0000000..de41618 --- /dev/null +++ b/ios/Shaker/Data/PurchaselySDKMode.swift @@ -0,0 +1,48 @@ +import Foundation +import Purchasely + +enum PurchaselySDKMode: String, CaseIterable, Identifiable { + case paywallObserver = "paywallObserver" + case full = "full" + + static let storageKey = "purchasely_sdk_mode" + static let defaultMode: PurchaselySDKMode = .paywallObserver + + var id: String { rawValue } + + var title: String { + switch self { + case .paywallObserver: + return "Paywall Observer" + case .full: + return "Full" + } + } + + var runningMode: PLYRunningMode { + switch self { + case .paywallObserver: + return .paywallObserver + case .full: + return .full + } + } + + static func current() -> PurchaselySDKMode { + let defaults = UserDefaults.standard + guard let rawValue = defaults.string(forKey: storageKey), + let mode = PurchaselySDKMode(rawValue: rawValue) else { + defaults.set(defaultMode.rawValue, forKey: storageKey) + return defaultMode + } + return mode + } + + func persist() { + UserDefaults.standard.set(rawValue, forKey: Self.storageKey) + } +} + +extension Notification.Name { + static let purchaselySdkModeDidChange = Notification.Name("purchaselySdkModeDidChange") +} diff --git a/ios/Shaker/Data/purchase/PurchaseRequest.swift b/ios/Shaker/Data/purchase/PurchaseRequest.swift new file mode 100644 index 0000000..f6faaa6 --- /dev/null +++ b/ios/Shaker/Data/purchase/PurchaseRequest.swift @@ -0,0 +1,5 @@ +import Foundation + +struct PurchaseRequest { + let productId: String +} diff --git a/ios/Shaker/Data/purchase/TransactionResult.swift b/ios/Shaker/Data/purchase/TransactionResult.swift new file mode 100644 index 0000000..383c9a8 --- /dev/null +++ b/ios/Shaker/Data/purchase/TransactionResult.swift @@ -0,0 +1,8 @@ +import Foundation + +enum TransactionResult { + case success + case cancelled + case error(String?) + case idle +} diff --git a/ios/Shaker/Purchasely/PurchaselyWrapper.swift b/ios/Shaker/Purchasely/PurchaselyWrapper.swift index 0d74473..a2367f2 100644 --- a/ios/Shaker/Purchasely/PurchaselyWrapper.swift +++ b/ios/Shaker/Purchasely/PurchaselyWrapper.swift @@ -1,49 +1,189 @@ import UIKit +import Combine import Purchasely -final class PurchaselyWrapper { +final class PurchaselyWrapper: PurchaselyWrapping { static let shared = PurchaselyWrapper() - private init() {} + private var apiKey: String = "" + private var logLevel: PLYLogger.PLYLogLevel = .debug + private var pendingProcessAction: ((Bool) -> Void)? + private var cancellables = Set() + + private init() { + // Observe TransactionResult from PurchaseManager + if #available(iOS 15.0, *) { + PurchaseManager.shared.resultSubject + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + self?.handleTransactionResult(result) + } + .store(in: &cancellables) + + // Wire up providers so PurchaseManager doesn't need to import Purchasely + PurchaseManager.shared.anonymousUserIdProvider = { [weak self] in + self?.anonymousUserId ?? "" + } + PurchaseManager.shared.signPromotionalOfferProvider = { [weak self] productId, offerId, success, failure in + self?.signPromotionalOffer( + storeProductId: productId, + storeOfferId: offerId, + success: { signature in + success(PLYOfferSignatureData( + identifier: signature.identifier, + keyIdentifier: signature.keyIdentifier, + nonce: signature.nonce, + signature: signature.signature, + timestamp: Int(signature.timestamp) + )) + }, + failure: failure + ) + } + } + + // Observe SDK mode changes + NotificationCenter.default.addObserver( + self, + selector: #selector(handleSdkModeDidChange), + name: .purchaselySdkModeDidChange, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } // MARK: - SDK Initialization - func start( + func initialize( apiKey: String, appUserId: String? = nil, - runningMode: PLYRunningMode = .full, - storekitSettings: StorekitSettings = .storeKit2, logLevel: PLYLogger.PLYLogLevel = .debug, - onStarted: @escaping (Bool, Error?) -> Void + onReady: @escaping (Bool, Error?) -> Void ) { + self.apiKey = apiKey + self.logLevel = logLevel + + let selectedMode = PurchaselySDKMode.current() + let storekitSettings: StorekitSettings = .storeKit2 + Purchasely.start( withAPIKey: apiKey, appUserId: appUserId, - runningMode: runningMode, + runningMode: selectedMode.runningMode, storekitSettings: storekitSettings, logLevel: logLevel ) { success, error in - onStarted(success, error) + if success { + print("[Shaker] Purchasely SDK configured successfully (mode: \(selectedMode.title))") + PremiumManager.shared.refreshPremiumStatus() + } else { + print("[Shaker] Purchasely configuration error: \(error?.localizedDescription ?? "unknown")") + } + onReady(success, error) + } + + Purchasely.readyToOpenDeeplink(true) + + Purchasely.setEventDelegate(self) + + Purchasely.setPaywallActionsInterceptor { [weak self] action, parameters, info, proceed in + self?.handlePaywallAction(action: action, parameters: parameters, info: info, processAction: proceed) } } - func readyToOpenDeeplink(_ ready: Bool) { - Purchasely.readyToOpenDeeplink(ready) + func restart() { + closeDisplayedPresentation() + let storedUserId = UserDefaults.standard.string(forKey: "user_id") + initialize(apiKey: apiKey, appUserId: storedUserId, logLevel: logLevel) { _, _ in } } - func setEventDelegate(_ delegate: PLYEventDelegate) { - Purchasely.setEventDelegate(delegate) + func closeDisplayedPresentation() { + Purchasely.closeDisplayedPresentation() } - func setPaywallActionsInterceptor( - _ interceptor: @escaping (PLYPresentationAction, PLYPresentationActionParameters?, PLYPresentationInfo?, @escaping (Bool) -> Void) -> Void + @objc private func handleSdkModeDidChange() { + DispatchQueue.main.async { [weak self] in + self?.restart() + } + } + + // MARK: - Interceptor Logic + + internal func handlePaywallAction( + action: PLYPresentationAction, + parameters: PLYPresentationActionParameters?, + info: PLYPresentationInfo?, + processAction: @escaping (Bool) -> Void ) { - Purchasely.setPaywallActionsInterceptor(interceptor) + switch action { + case .login: + print("[Shaker] Paywall login action intercepted") + processAction(false) + case .navigate: + if let url = parameters?.url { + print("[Shaker] Paywall navigate action: \(url)") + DispatchQueue.main.async { + UIApplication.shared.open(url) + } + } + processAction(false) + case .purchase: + if PurchaselySDKMode.current() == .paywallObserver { + if let productId = parameters?.plan?.appleProductId { + if #available(iOS 15.0, *) { + pendingProcessAction = processAction + PurchaseManager.shared.purchaseSubject.send(PurchaseRequest(productId: productId)) + } else { + processAction(false) + } + } else { + print("[Shaker] Observer mode purchase: missing product ID") + processAction(false) + } + } else { + processAction(true) + } + case .restore: + if PurchaselySDKMode.current() == .paywallObserver { + if #available(iOS 15.0, *) { + pendingProcessAction = processAction + PurchaseManager.shared.restoreSubject.send() + } else { + processAction(false) + } + } else { + processAction(true) + } + default: + processAction(true) + } } - func closeDisplayedPresentation() { - Purchasely.closeDisplayedPresentation() + // MARK: - Transaction Result Handling + + private func handleTransactionResult(_ result: TransactionResult) { + switch result { + case .success: + synchronize() + pendingProcessAction?(false) + pendingProcessAction = nil + PremiumManager.shared.refreshPremiumStatus() + print("[Shaker] Transaction success — synchronized and refreshed") + case .cancelled: + pendingProcessAction?(false) + pendingProcessAction = nil + print("[Shaker] Transaction cancelled") + case .error(let message): + pendingProcessAction?(false) + pendingProcessAction = nil + print("[Shaker] Transaction error: \(message ?? "unknown")") + case .idle: + break + } } // MARK: - Deeplinks @@ -188,3 +328,11 @@ final class PurchaselyWrapper { Purchasely.getSDKVersion() ?? "" } } + +// MARK: - Event Delegate + +extension PurchaselyWrapper: PLYEventDelegate { + func eventTriggered(_ event: PLYEvent, properties: [String: Any]?) { + print("[Shaker] Event: \(event.name) | Properties: \(properties ?? [:])") + } +} diff --git a/ios/Shaker/Purchasely/PurchaselyWrapping.swift b/ios/Shaker/Purchasely/PurchaselyWrapping.swift new file mode 100644 index 0000000..d805605 --- /dev/null +++ b/ios/Shaker/Purchasely/PurchaselyWrapping.swift @@ -0,0 +1,70 @@ +import UIKit +import Purchasely + +/// Protocol abstracting PurchaselyWrapper for testability. +/// ViewModels depend on this protocol rather than the concrete wrapper. +protocol PurchaselyWrapping { + + // MARK: - Presentation Loading + + @MainActor + func loadPresentation( + placementId: String, + contentId: String?, + onResult: @escaping @MainActor (DisplayResult) -> Void + ) async -> FetchResult + + // MARK: - Modal Display + + func display(presentation: PLYPresentation, from viewController: UIViewController?) + + // MARK: - Embedded View Controller + + func getController(presentation: PLYPresentation) -> PLYPresentationViewController? + + // MARK: - User Management + + func userLogin(userId: String, onRefresh: @escaping (Bool) -> Void) + func userLogout() + var anonymousUserId: String { get } + + // MARK: - User Attributes + + func setUserAttribute(_ value: String, forKey key: String) + func setUserAttribute(_ value: Bool, forKey key: String) + func setUserAttribute(_ value: Int, forKey key: String) + func setUserAttribute(_ value: Double, forKey key: String) + func incrementUserAttribute(forKey key: String) + + // MARK: - Restore + + func restoreAllProducts( + success: @escaping () -> Void, + failure: @escaping (Error) -> Void + ) + + // MARK: - Consent + + func revokeDataProcessingConsent(for purposes: Set) + + // MARK: - Lifecycle + + func restart() + func closeDisplayedPresentation() + + // MARK: - SDK Info + + var sdkVersion: String { get } +} + +// MARK: - Default parameter for contentId + +extension PurchaselyWrapping { + @MainActor + func loadPresentation( + placementId: String, + onResult: @escaping @MainActor (DisplayResult) -> Void + ) async -> FetchResult { + await loadPresentation(placementId: placementId, contentId: nil, onResult: onResult) + } +} diff --git a/ios/ShakerTests/Data/purchase/PurchaseRequestTests.swift b/ios/ShakerTests/Data/purchase/PurchaseRequestTests.swift new file mode 100644 index 0000000..9dd50f4 --- /dev/null +++ b/ios/ShakerTests/Data/purchase/PurchaseRequestTests.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import Shaker + +final class PurchaseRequestTests: XCTestCase { + + func testCreation() { + let request = PurchaseRequest(productId: "com.test.premium") + XCTAssertEqual(request.productId, "com.test.premium") + } + + func testDifferentProductIds() { + let r1 = PurchaseRequest(productId: "product.monthly") + let r2 = PurchaseRequest(productId: "product.yearly") + XCTAssertNotEqual(r1.productId, r2.productId) + } +} diff --git a/ios/ShakerTests/Data/purchase/TransactionResultTests.swift b/ios/ShakerTests/Data/purchase/TransactionResultTests.swift new file mode 100644 index 0000000..0c157f9 --- /dev/null +++ b/ios/ShakerTests/Data/purchase/TransactionResultTests.swift @@ -0,0 +1,62 @@ +import XCTest +@testable import Shaker + +final class TransactionResultTests: XCTestCase { + + func testSuccess() { + let result = TransactionResult.success + if case .success = result { + // OK + } else { + XCTFail("Expected .success") + } + } + + func testCancelled() { + let result = TransactionResult.cancelled + if case .cancelled = result { + // OK + } else { + XCTFail("Expected .cancelled") + } + } + + func testErrorWithMessage() { + let result = TransactionResult.error("Payment failed") + if case .error(let message) = result { + XCTAssertEqual(message, "Payment failed") + } else { + XCTFail("Expected .error") + } + } + + func testErrorWithNil() { + let result = TransactionResult.error(nil) + if case .error(let message) = result { + XCTAssertNil(message) + } else { + XCTFail("Expected .error") + } + } + + func testIdle() { + let result = TransactionResult.idle + if case .idle = result { + // OK + } else { + XCTFail("Expected .idle") + } + } + + func testExhaustiveSwitch() { + let results: [TransactionResult] = [.success, .cancelled, .error("fail"), .idle] + for result in results { + switch result { + case .success: break + case .cancelled: break + case .error(let msg): XCTAssertEqual(msg, "fail") + case .idle: break + } + } + } +} diff --git a/ios/ShakerTests/Mocks/MockPurchaselyWrapper.swift b/ios/ShakerTests/Mocks/MockPurchaselyWrapper.swift new file mode 100644 index 0000000..82605e9 --- /dev/null +++ b/ios/ShakerTests/Mocks/MockPurchaselyWrapper.swift @@ -0,0 +1,109 @@ +import UIKit +import Purchasely +@testable import Shaker + +/// Mock implementation of PurchaselyWrapping for unit tests. +/// Records all method calls and allows configuring return values. +final class MockPurchaselyWrapper: PurchaselyWrapping { + + // MARK: - Call tracking + + var loadPresentationCalls: [(placementId: String, contentId: String?)] = [] + var displayCalls: Int = 0 + var getControllerCalls: Int = 0 + var userLoginCalls: [String] = [] + var userLogoutCallCount = 0 + var setStringAttributeCalls: [(value: String, key: String)] = [] + var setBoolAttributeCalls: [(value: Bool, key: String)] = [] + var setIntAttributeCalls: [(value: Int, key: String)] = [] + var setDoubleAttributeCalls: [(value: Double, key: String)] = [] + var incrementAttributeCalls: [String] = [] + var restoreCallCount = 0 + var restartCallCount = 0 + var closeDisplayedPresentationCallCount = 0 + var revokeConsentCalls: [Set] = [] + + // MARK: - Configurable return values + + var loadPresentationResult: FetchResult = .deactivated + var anonymousUserIdValue = "mock-anon-123" + var sdkVersionValue = "5.7.3-mock" + + // MARK: - PurchaselyWrapping + + @MainActor + func loadPresentation( + placementId: String, + contentId: String?, + onResult: @escaping @MainActor (DisplayResult) -> Void + ) async -> FetchResult { + loadPresentationCalls.append((placementId, contentId)) + return loadPresentationResult + } + + func display(presentation: PLYPresentation, from viewController: UIViewController?) { + displayCalls += 1 + } + + func getController(presentation: PLYPresentation) -> PLYPresentationViewController? { + getControllerCalls += 1 + return nil + } + + func userLogin(userId: String, onRefresh: @escaping (Bool) -> Void) { + userLoginCalls.append(userId) + onRefresh(false) + } + + func userLogout() { + userLogoutCallCount += 1 + } + + var anonymousUserId: String { + anonymousUserIdValue + } + + func setUserAttribute(_ value: String, forKey key: String) { + setStringAttributeCalls.append((value, key)) + } + + func setUserAttribute(_ value: Bool, forKey key: String) { + setBoolAttributeCalls.append((value, key)) + } + + func setUserAttribute(_ value: Int, forKey key: String) { + setIntAttributeCalls.append((value, key)) + } + + func setUserAttribute(_ value: Double, forKey key: String) { + setDoubleAttributeCalls.append((value, key)) + } + + func incrementUserAttribute(forKey key: String) { + incrementAttributeCalls.append(key) + } + + func restoreAllProducts( + success: @escaping () -> Void, + failure: @escaping (Error) -> Void + ) { + restoreCallCount += 1 + success() + } + + func revokeDataProcessingConsent(for purposes: Set) { + revokeConsentCalls.append(purposes) + } + + func restart() { + restartCallCount += 1 + } + + func closeDisplayedPresentation() { + closeDisplayedPresentationCallCount += 1 + } + + var sdkVersion: String { + sdkVersionValue + } +} From f96053725e9a2e968baed886ae76118ae4cfd86f Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 14:07:44 +0200 Subject: [PATCH 09/12] docs: update best practices with Observer purchase flow and testability - Add section 2: Observer mode reactive purchase flow architecture - Add section 3: SDK initialization via wrapper.initialize() - Add section 11: Testability (protocol, mocking, DI patterns) - Update checklist with Observer mode and PurchaseManager decoupling rules - Renumber sections for consistency Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/purchasely-best-practices.md | 152 ++++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 19 deletions(-) diff --git a/docs/purchasely-best-practices.md b/docs/purchasely-best-practices.md index 0fe1384..4c58b14 100644 --- a/docs/purchasely-best-practices.md +++ b/docs/purchasely-best-practices.md @@ -22,7 +22,9 @@ The only exception is `PremiumManager`, which directly calls `userSubscriptions` | Category | Methods | |----------|---------| -| **Init** | `start()`, `close()`, `setPaywallActionsInterceptor()`, `setEventListener/Delegate()`, `readyToOpenDeeplink()` | +| **Init & Lifecycle** | `initialize()`, `restart()`, `close()`, `closeDisplayedPresentation()` | +| **Interceptor** | Internal paywall actions interceptor (LOGIN, NAVIGATE, PURCHASE, RESTORE) | +| **Events** | Internal event listener/delegate | | **Presentations** | `loadPresentation()`, `display()`, `getView()` (Android) / `getController()` (iOS) | | **User Attributes** | `setUserAttribute()`, `incrementUserAttribute()` | | **User Management** | `userLogin()`, `userLogout()`, `anonymousUserId` | @@ -30,11 +32,102 @@ The only exception is `PremiumManager`, which directly calls `userSubscriptions` | **Consent** | `revokeDataProcessingConsent()` | | **Info** | `sdkVersion`, `isDeeplinkHandled()` | -**Tolerated SDK type imports:** `PLYRunningMode`, `PLYDataProcessingPurpose`, `PLYPresentationAction`, `EventListener`/`PLYEventDelegate`, `PLYOfferSignature` — these are enums/types needed for configuration, not SDK call points. +**Tolerated SDK type imports:** `PLYRunningMode`, `PLYDataProcessingPurpose`, `PLYPresentationAction`, `EventListener`/`PLYEventDelegate`, `PLYOfferSignature`, `LogLevel`/`PLYLogger.PLYLogLevel` — these are enums/types needed for configuration, not SDK call points. --- -## 2. Presentation Loading: Always fetch then build/display +## 2. Observer Mode: Reactive Purchase Flow + +**Rule: In Observer mode, purchases and restores are decoupled from the SDK via reactive flows. `PurchaseManager` has zero Purchasely imports.** + +### Architecture + +``` +PurchaselyWrapper PurchaseManager + │ │ + │ PURCHASE (observer) ──────────────────► │ + │ emit PurchaseRequest │ + │ │ Native billing + │ │ (Play Billing / StoreKit 2) + │ ◄──────────────────────────────────── │ + │ TransactionResult │ + │ │ + │ synchronize() │ + │ processAction(false) │ + │ premiumManager.refreshPremiumStatus() │ +``` + +**Android (Kotlin):** +- `SharedFlow` — wrapper emits, PurchaseManager collects +- `SharedFlow` — wrapper emits, PurchaseManager collects +- `SharedFlow` — PurchaseManager emits, wrapper collects +- PurchaseManager takes a `billingClientFactory` lambda (testable, no hardcoded BillingClient) + +**iOS (Swift):** +- `PassthroughSubject` — wrapper sends, PurchaseManager sinks +- `PassthroughSubject` — wrapper sends restore trigger +- `PassthroughSubject` — PurchaseManager sends, wrapper sinks +- PurchaseManager uses injected closures for `anonymousUserId` and `signPromotionalOffer` (no wrapper reference) + +**Types:** + +```kotlin +// Android +data class PurchaseRequest(val activity: Activity, val productId: String, val offerToken: String) +data object RestoreRequest +sealed class TransactionResult { Success, Cancelled, Error(message), Idle } +``` + +```swift +// iOS +struct PurchaseRequest { let productId: String } +enum TransactionResult { case success, cancelled, error(String?), idle } +``` + +**processAction callback:** The wrapper stores a single `pendingProcessAction: ((Boolean) -> Unit)?` when emitting a purchase/restore request. When TransactionResult arrives, it invokes the callback and nullifies it. Only one purchase at a time (paywall flow is sequential). + +**Interceptor rules:** + +| Action | Observer mode | Full mode | +|--------|--------------|-----------| +| PURCHASE | Store processAction, emit PurchaseRequest | proceed(true) | +| RESTORE | Store processAction, emit RestoreRequest | proceed(true) | +| LOGIN | proceed(false) | proceed(false) | +| NAVIGATE | Open URL, proceed(false) | Open URL, proceed(false) | +| Other | proceed(true) | proceed(true) | + +**TransactionResult handling:** + +| Result | Wrapper actions | +|--------|----------------| +| Success | synchronize() → processAction(false) → premiumManager.refreshPremiumStatus() | +| Cancelled | processAction(false) | +| Error | processAction(false) | +| Idle | ignore | + +--- + +## 3. SDK Initialization + +**Rule: `PurchaselyWrapper.initialize()` is the single entry point for SDK setup. The app entry point (ShakerApp/AppViewModel) only calls `wrapper.initialize()` and handles the ready callback.** + +**Android:** `ShakerApp.onCreate()` calls `wrapper.initialize(application, apiKey, logLevel)` +**iOS:** `AppViewModel.init()` calls `wrapper.initialize(apiKey:, appUserId:, logLevel:, onReady:)` + +The wrapper internally configures: +1. SDK start with API key, running mode, StoreKit settings +2. Event listener/delegate +3. Paywall actions interceptor +4. Deeplink readiness +5. Combine/Flow subscriptions for Observer purchase flow + +**Restart:** When the SDK mode changes, `wrapper.restart()` is called: +- **Android:** `SettingsViewModel` calls `purchaselyWrapper.restart()` directly +- **iOS:** `SettingsViewModel` posts `.purchaselySdkModeDidChange` notification, wrapper observes it and calls `restart()` internally + +--- + +## 4. Presentation Loading: Always fetch then build/display **Rule: Always use `Purchasely.fetchPresentation()` followed by `presentation.buildView()` or `presentation.display()`. Never use `Purchasely.presentationView()`.** @@ -66,7 +159,7 @@ fun getView(presentation: PLYPresentation, context: Context, onResult): View? { --- -## 3. MVVM Pattern: ViewModel Owns Paywall Logic +## 5. MVVM Pattern: ViewModel Owns Paywall Logic **Rule: ViewModels decide when and what to show. Screens only provide the Activity and render the UI.** @@ -111,7 +204,7 @@ private fun prefetchPresentations() { --- -## 4. EmbeddedScreenBanner: Reusable Inline Paywall +## 6. EmbeddedScreenBanner: Reusable Inline Paywall **Rule: Use `EmbeddedScreenBanner` for any inline/embedded paywall display. The presentation must be prefetched by the ViewModel.** @@ -143,7 +236,7 @@ if (inlineResult is FetchResult.Success) { --- -## 5. User Attributes +## 7. User Attributes **Rule: Set user attributes through `PurchaselyWrapper`, always from the ViewModel layer.** @@ -159,11 +252,11 @@ purchaselyWrapper.setUserAttribute("favorite_spirit", "gin") - On preference changes (theme, user ID) - Never on every recomposition — only on actual state changes -**Typed overloads:** The wrapper provides `String`, `Boolean`, `Int`, and `Float` overloads matching the SDK. +**Typed overloads:** The wrapper provides `String`, `Boolean`, `Int`, and `Float`/`Double` overloads matching the SDK. --- -## 6. Handling Presentation Types +## 8. Handling Presentation Types Always handle all `FetchResult` variants: @@ -176,7 +269,7 @@ Always handle all `FetchResult` variants: --- -## 7. Error Handling +## 9. Error Handling - **Never crash on SDK errors.** Log and degrade gracefully. - **Never block the UI** waiting for a presentation. Use coroutines/async-await and show content immediately. @@ -185,7 +278,7 @@ Always handle all `FetchResult` variants: --- -## 8. Async: Native Async Patterns +## 10. Async: Native Async Patterns **Rule: Use the platform's native async pattern. Only use callbacks when the SDK doesn't provide an alternative.** @@ -201,11 +294,28 @@ Always handle all `FetchResult` variants: --- -## 9. Platform-Specific Notes +## 11. Testability + +**Rule: All Purchasely integration code must be testable. ViewModels use dependency injection for the wrapper.** + +**Protocol (iOS):** `PurchaselyWrapping` protocol abstracts the wrapper. ViewModels accept `PurchaselyWrapping` via init with default `PurchaselyWrapper.shared`. Tests inject `MockPurchaselyWrapper`. + +**Mocking (Android):** `PurchaselyWrapper` is injected via Koin constructor. Tests use MockK to mock it with `mockk(relaxed = true)`. + +**PurchaseManager testability:** +- **Android:** Constructor takes `billingClientFactory: (PurchasesUpdatedListener) -> BillingClient` — tests inject a mock BillingClient +- **iOS:** Uses injected closures (`anonymousUserIdProvider`, `signPromotionalOfferProvider`) instead of direct wrapper access + +**Repository testability (iOS):** `FavoritesRepository`, `OnboardingRepository` accept custom `UserDefaults` for test isolation. `CocktailRepository` accepts a `[Cocktail]` array for test data. + +--- + +## 12. Platform-Specific Notes ### Android (Kotlin / Jetpack Compose) -- `PurchaselyWrapper` is a Koin singleton (`single { PurchaselyWrapper() }`) +- `PurchaselyWrapper` is a Koin singleton with DI constructor: `PurchaselyWrapper(premiumManager, runningModeRepo, purchaseRequests, restoreRequests, transactionResult, scope)` +- `ShakerApp.onCreate()` calls `wrapper.initialize(application, apiKey, logLevel)` — nothing else - ViewModels inject it via constructor: `class HomeViewModel(..., private val purchaselyWrapper: PurchaselyWrapper)` - `EmbeddedScreenBanner` uses `koinInject()` for DI in Composables - `display()` requires an `Activity` — passed from Screen to ViewModel on-demand @@ -213,16 +323,17 @@ Always handle all `FetchResult` variants: ### iOS (SwiftUI) -- `PurchaselyWrapper` is a Swift singleton (`PurchaselyWrapper.shared`) -- ViewModels access it directly: `private let wrapper = PurchaselyWrapper.shared` +- `PurchaselyWrapper` is a Swift singleton (`PurchaselyWrapper.shared`) conforming to `PurchaselyWrapping` +- `AppViewModel.init()` calls `wrapper.initialize(apiKey:, appUserId:, logLevel:, onReady:)` — nothing else +- ViewModels accept `PurchaselyWrapping` via init with default `.shared` - Async operations use Swift `async/await` (`withCheckedContinuation` to bridge callbacks) -- `loadPresentation()` is `async` and takes an `onResult` callback for purchase/dismiss events (bound at fetch time via `fetchPresentation(for:, fetchCompletion:, completion:)`) -- `display()` is synchronous — calls `presentation.display(from: viewController)` on main thread; result comes through the `onResult` callback from `loadPresentation` +- `loadPresentation()` is `async` and takes an `onResult` callback for purchase/dismiss events +- `display()` is synchronous — calls `presentation.display(from: viewController)` on main thread - `getController()` returns `PLYPresentationViewController?` for embedding via `UIViewControllerRepresentable` - `EmbeddedScreenBanner` is a `UIViewControllerRepresentable` wrapping the presentation's controller -- Screen resolves a `UIViewController` via `ViewControllerResolver` (background helper) for modal display +- Screen resolves a `UIViewController` via `ViewControllerResolver` for modal display - `presentation.height` is in points (use as `CGFloat` directly in `.frame(height:)`) -- Prefetch is triggered from `onAppear` since `@StateObject` init doesn't have access to `@EnvironmentObject` (premium status) +- Prefetch is triggered from `onAppear` since `@StateObject` init doesn't have access to `@EnvironmentObject` --- @@ -238,6 +349,9 @@ Always handle all `FetchResult` variants: - [ ] Embedded paywalls: ViewModel prefetches, Screen uses `EmbeddedScreenBanner` with prefetched result/controller - [ ] Uses `presentation.height` (dp/points) for embedded view sizing - [ ] No crashes on SDK errors — nothing shown if fetch fails -- [ ] SDK init, interceptor, events, deeplinks go through wrapper in App class +- [ ] SDK init and interceptor are in PurchaselyWrapper.initialize() — NOT in App class +- [ ] Observer mode purchases flow through PurchaseManager via reactive subjects (not direct wrapper calls) +- [ ] PurchaseManager has zero Purchasely/SDK imports - [ ] Login/logout, restore, consent, synchronize go through wrapper in ViewModels - [ ] SDK types (PLYRunningMode, PLYDataProcessingPurpose, etc.) are tolerated as direct imports +- [ ] Tests use MockPurchaselyWrapper (iOS) or mockk (Android) — never the real SDK From 17991bc6bfb8926735ed59a11ec87cf2a7daf91a Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 14:23:31 +0200 Subject: [PATCH 10/12] docs: update INTEGRATION_GUIDE for wrapper-based architecture - All code snippets now show PurchaselyWrapper API (not direct SDK calls) - Source references updated to correct files - New section 4: Observer Mode reactive purchase flow - Renumbered sections, added architecture notes Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/INTEGRATION_GUIDE.md | 562 +++++++++++++++++++------------------- 1 file changed, 285 insertions(+), 277 deletions(-) diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 0abdfbb..daf0e4f 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -2,19 +2,22 @@ > This guide walks through every Purchasely SDK feature demonstrated in the Shaker sample app. > Code snippets are taken directly from the app — see the referenced files for full context. +> +> **Architecture note:** Shaker wraps all SDK calls in `PurchaselyWrapper`. ViewModels and Screens never import the Purchasely SDK directly. See `docs/purchasely-best-practices.md` for the full architecture rationale. ## Table of Contents 1. [SDK Initialization](#1-sdk-initialization) 2. [Displaying Paywalls](#2-displaying-paywalls) 3. [Paywall Actions Interceptor](#3-paywall-actions-interceptor) -4. [User Authentication](#4-user-authentication) -5. [Subscription Status](#5-subscription-status) -6. [User Attributes](#6-user-attributes) -7. [Events & Analytics](#7-events--analytics) -8. [Deeplinks](#8-deeplinks) -9. [GDPR & Privacy](#9-gdpr--privacy) -10. [Restore Purchases](#10-restore-purchases) +4. [Observer Mode: Native Purchase Flow](#4-observer-mode-native-purchase-flow) +5. [User Authentication](#5-user-authentication) +6. [Subscription Status](#6-subscription-status) +7. [User Attributes](#7-user-attributes) +8. [Events & Analytics](#8-events--analytics) +9. [Deeplinks](#9-deeplinks) +10. [GDPR & Privacy](#10-gdpr--privacy) +11. [Restore Purchases](#11-restore-purchases) --- @@ -22,24 +25,37 @@ **What it does:** Bootstraps the Purchasely SDK at app launch — configures the API key, running mode, store adapters, and log level. No other SDK method may be called before `start()` completes successfully. +**Architecture:** `PurchaselyWrapper.initialize()` owns the entire init: SDK start, event listener, paywall actions interceptor, and deeplink readiness. The app entry point just calls `initialize()`. + ### Android (Kotlin) Source: `android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt` ```kotlin -Purchasely.Builder(this) - .apiKey("YOUR_API_KEY") - .logLevel(LogLevel.DEBUG) +// ShakerApp.onCreate() — just Koin init + wrapper.initialize() +val purchaselyWrapper: PurchaselyWrapper by inject() +purchaselyWrapper.initialize( + application = this, + apiKey = BuildConfig.PURCHASELY_API_KEY, + logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN +) +``` + +Inside `PurchaselyWrapper.initialize()`: + +```kotlin +Purchasely.Builder(application) + .apiKey(apiKey) + .logLevel(logLevel) .readyToOpenDeeplink(true) - .runningMode(PLYRunningMode.Full) + .runningMode(runningModeRepo.runningMode) .stores(listOf(GoogleStore())) .build() .start { isConfigured, error -> - if (isConfigured) { - premiumManager.refreshPremiumStatus() - } - error?.let { Log.e(TAG, "SDK error: ${it.message}") } + if (isConfigured) premiumManager.refreshPremiumStatus() } + +// Event listener and interceptor also configured here ``` ### iOS (Swift) @@ -47,22 +63,33 @@ Purchasely.Builder(this) Source: `ios/Shaker/AppViewModel.swift` ```swift -Purchasely.start( - withAPIKey: "YOUR_API_KEY", - appUserId: nil, - runningMode: .full, - storekitSettings: .storeKit2, - logLevel: .debug +// AppViewModel.init() — just wrapper.initialize() + isSDKReady tracking +wrapper.initialize( + apiKey: resolvedApiKey, + appUserId: storedUserId, + logLevel: sdkLogLevel ) { [weak self] success, error in DispatchQueue.main.async { self?.isSDKReady = success - if success { - PremiumManager.shared.refreshPremiumStatus() - } + self?.sdkError = success ? nil : error?.localizedDescription } } +``` +Inside `PurchaselyWrapper.initialize()`: + +```swift +Purchasely.start(withAPIKey: apiKey, appUserId: appUserId, + runningMode: selectedMode.runningMode, + storekitSettings: .storeKit2, logLevel: logLevel) { success, error in + if success { PremiumManager.shared.refreshPremiumStatus() } + onReady(success, error) +} Purchasely.readyToOpenDeeplink(true) +Purchasely.setEventDelegate(self) +Purchasely.setPaywallActionsInterceptor { [weak self] action, params, info, proceed in + self?.handlePaywallAction(action: action, parameters: params, info: info, processAction: proceed) +} ``` ### Console Setup @@ -74,13 +101,14 @@ Purchasely.readyToOpenDeeplink(true) ### Common Pitfalls - `start()` is **asynchronous**. Calling `userSubscriptions()`, `fetchPresentation()`, or any other SDK method before the completion fires will produce unexpected results. -- On Android, call `start()` inside `Application.onCreate()`, not inside an Activity, so the SDK is ready before any screen is shown. +- On Android, call `initialize()` inside `Application.onCreate()`, not inside an Activity. +- The wrapper's `restart()` method re-initializes the SDK when the running mode changes (Full ↔ Observer). --- ## 2. Displaying Paywalls -**What it does:** The `fetchPresentation()` + `display()` two-step pattern fetches a paywall configured in the Purchasely Console for a given **placement**, then renders it modally. A `contentId` can be passed to personalise the paywall for a specific content item (e.g. a cocktail recipe). +**What it does:** The `loadPresentation()` + `display()` two-step pattern fetches a paywall configured in the Purchasely Console for a given **placement**, then renders it modally. A `contentId` can be passed to personalise the paywall for a specific content item (e.g. a cocktail recipe). Shaker uses four placements: @@ -91,78 +119,59 @@ Shaker uses four placements: | `favorites` | Tapping the favorites heart when not premium | | `filters` | Tapping the filter button when not premium| -### Android (Kotlin) — basic placement +### Android (Kotlin) — via PurchaselyWrapper -Source: `android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt` +Source: `android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt` ```kotlin -Purchasely.fetchPresentation("filters") { presentation, error -> - if (presentation != null && presentation.type != PLYPresentationType.DEACTIVATED) { - presentation.display(activity) { result, plan -> - when (result) { - PLYProductViewResult.PURCHASED, - PLYProductViewResult.RESTORED -> premiumManager.refreshPremiumStatus() - else -> {} - } - } - } -} +// Wrapper provides a suspend API returning type-safe FetchResult +suspend fun loadPresentation(placementId: String, contentId: String? = null): FetchResult +suspend fun display(presentation: PLYPresentation, activity: Activity): DisplayResult ``` -### Android (Kotlin) — placement with `contentId` - -Source: `android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt` +ViewModel usage: ```kotlin -val properties = PLYPresentationProperties(contentId = cocktailId) -Purchasely.fetchPresentation("recipe_detail", properties) { presentation, error -> - if (presentation != null && presentation.type != PLYPresentationType.DEACTIVATED) { - presentation.display(activity) { result, plan -> /* handle result */ } - } +// Prefetch in init +viewModelScope.launch { + _filtersPresentation.value = purchaselyWrapper.loadPresentation("filters") +} + +// Display when user taps +val presentation = pendingPresentation ?: return +val result = purchaselyWrapper.display(presentation, activity) +when (result) { + is DisplayResult.Purchased, is DisplayResult.Restored -> premiumManager.refreshPremiumStatus() + else -> {} } ``` -### iOS (Swift) — basic placement +### iOS (Swift) — via PurchaselyWrapper -Source: `ios/Shaker/Screens/Home/HomeScreen.swift` +Source: `ios/Shaker/Purchasely/PurchaselyWrapper.swift` ```swift -Purchasely.fetchPresentation( - for: "filters", - fetchCompletion: { presentation, error in - guard let presentation = presentation, presentation.type != .deactivated else { return } - DispatchQueue.main.async { - presentation.display(from: viewController) - } - }, - completion: { result, plan in - switch result { - case .purchased, .restored: - PremiumManager.shared.refreshPremiumStatus() - default: break - } - } -) +// Wrapper provides async API with onResult callback for purchase events +@MainActor +func loadPresentation(placementId: String, contentId: String? = nil, + onResult: @escaping @MainActor (DisplayResult) -> Void) async -> FetchResult +func display(presentation: PLYPresentation, from viewController: UIViewController?) ``` -### iOS (Swift) — placement with `contentId` - -Source: `ios/Shaker/Screens/Detail/DetailViewModel.swift` +ViewModel usage: ```swift -Purchasely.fetchPresentation( - for: "recipe_detail", - contentId: cocktailId, - fetchCompletion: { presentation, error in - guard let presentation = presentation, presentation.type != .deactivated else { return } - DispatchQueue.main.async { presentation.display(from: vc) } - }, - completion: { result, plan in - if result == .purchased || result == .restored { - PremiumManager.shared.refreshPremiumStatus() - } - } -) +// Prefetch +recipeFetchResult = await wrapper.loadPresentation( + placementId: "recipe_detail", contentId: cocktailId +) { result in + if case .purchased = result { PremiumManager.shared.refreshPremiumStatus() } + if case .restored = result { PremiumManager.shared.refreshPremiumStatus() } +} + +// Display +guard case .success(let presentation) = recipeFetchResult else { return } +wrapper.display(presentation: presentation, from: viewController) ``` ### Console Setup @@ -173,90 +182,166 @@ Purchasely.fetchPresentation( ### Common Pitfalls -- Always guard against `presentation.type == .deactivated` (or `PLYPresentationType.DEACTIVATED` on Android). This is the normal state when the placement has no active paywall — treat it as a no-op, not an error. -- On iOS, `presentation.display(from:)` must be called on the **main thread**. Wrap it in `DispatchQueue.main.async` if `fetchCompletion` fires on a background thread. -- On Android, pass an `Activity` (not a `Context`) to `presentation.display()`. Cast `LocalContext.current as? Activity` inside Composables. +- Always handle `FetchResult.Deactivated` (no-op) and `FetchResult.Error` (log, don't crash). +- On iOS, `presentation.display(from:)` must be called on the **main thread**. +- On Android, pass an `Activity` (not a `Context`) to `display()`. --- ## 3. Paywall Actions Interceptor -**What it does:** Intercepts specific button actions triggered from inside a paywall before they are processed. Use it to implement a custom login flow (`LOGIN` action) or to handle in-paywall navigation links (`NAVIGATE` action). Call `proceed(true)` to let the SDK handle the action, or `proceed(false)` to suppress the default behaviour. +**What it does:** Intercepts specific button actions triggered from inside a paywall before they are processed. Use it to implement a custom login flow (`LOGIN` action) or to handle in-paywall navigation links (`NAVIGATE` action). + +**Architecture:** The interceptor is configured inside `PurchaselyWrapper.initialize()` — it is **not** exposed to ViewModels. The wrapper handles all actions internally. ### Android (Kotlin) -Source: `android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt` +Source: `android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt` — `handlePaywallAction()` ```kotlin -Purchasely.setPaywallActionsInterceptor { info, action, parameters, proceed -> +internal fun handlePaywallAction(info, action, parameters, processAction) { when (action) { - PLYPresentationAction.LOGIN -> { - // Dismiss paywall; user navigates to Settings to log in - proceed(false) - } + PLYPresentationAction.LOGIN -> processAction(false) PLYPresentationAction.NAVIGATE -> { - val url = parameters?.url - if (url != null) { + parameters?.url?.let { url -> val intent = Intent(Intent.ACTION_VIEW, url) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) + application?.startActivity(intent) } - proceed(false) + processAction(false) } - else -> proceed(true) + PLYPresentationAction.PURCHASE -> { /* Observer mode flow — see section 4 */ } + PLYPresentationAction.RESTORE -> { /* Observer mode flow — see section 4 */ } + else -> processAction(true) } } ``` ### iOS (Swift) -Source: `ios/Shaker/AppViewModel.swift` +Source: `ios/Shaker/Purchasely/PurchaselyWrapper.swift` — `handlePaywallAction()` ```swift -Purchasely.setPaywallActionsInterceptor { action, parameters, info, proceed in +internal func handlePaywallAction(action, parameters, info, processAction) { switch action { - case .login: - proceed(false) + case .login: processAction(false) case .navigate: if let url = parameters?.url { DispatchQueue.main.async { UIApplication.shared.open(url) } } - proceed(false) - default: - proceed(true) + processAction(false) + case .purchase: /* Observer mode flow — see section 4 */ + case .restore: /* Observer mode flow — see section 4 */ + default: processAction(true) } } ``` -### Console Setup +### Common Pitfalls + +- Always call `processAction` (either `true` or `false`). Forgetting to call it will leave the paywall in a frozen state. +- The interceptor is global — set it once during SDK initialization, not inside individual screens. + +--- -No special console configuration is required. Add **Login** or **Navigate** buttons to your paywall in the Screen Composer and assign the corresponding action. The interceptor fires automatically when the user taps them. +## 4. Observer Mode: Native Purchase Flow + +**What it does:** In Observer mode, the app handles purchases natively (Google Play Billing / StoreKit 2) while Purchasely only observes transactions for analytics and paywall display. Shaker uses a **reactive decoupling** pattern where `PurchaseManager` has zero Purchasely SDK imports. + +### Architecture + +``` +PurchaselyWrapper PurchaseManager + │ │ + │ Interceptor receives PURCHASE │ + │ → stores processAction callback │ + │ → emits PurchaseRequest ──────────────► │ + │ │ Native billing + │ ◄──────────────────────────────────── │ + │ TransactionResult │ + │ │ + │ synchronize() │ + │ processAction(false) │ + │ premiumManager.refreshPremiumStatus() │ +``` + +### Android (Kotlin) + +Source: `android/app/src/main/java/com/purchasely/shaker/data/purchase/` + +```kotlin +// Types +data class PurchaseRequest(val activity: Activity, val productId: String, val offerToken: String) +data object RestoreRequest +sealed class TransactionResult { Success, Cancelled, Error(message), Idle } + +// PurchaseManager observes SharedFlows (zero Purchasely imports) +class PurchaseManager( + billingClientFactory: (PurchasesUpdatedListener) -> BillingClient, + purchaseRequests: SharedFlow, + restoreRequests: SharedFlow, + scope: CoroutineScope +) : PurchasesUpdatedListener { + val transactionResult: SharedFlow + // Collects requests → launches billing → emits results +} + +// PurchaselyWrapper emits requests and observes results +scope.launch { + purchaseRequests.emit(PurchaseRequest(activity, productId, offerToken)) +} +// In init block: +transactionResult.collect { result -> handleTransactionResult(result) } +``` + +### iOS (Swift) + +Source: `ios/Shaker/Data/purchase/`, `ios/Shaker/Data/PurchaseManager.swift` + +```swift +// Types +struct PurchaseRequest { let productId: String } +enum TransactionResult { case success, cancelled, error(String?), idle } + +// PurchaseManager uses Combine (zero Purchasely imports) +class PurchaseManager { + var purchaseSubject = PassthroughSubject() + var restoreSubject = PassthroughSubject() + let resultSubject = PassthroughSubject() + // Sinks requests → executes StoreKit 2 → sends results +} + +// PurchaselyWrapper sends requests and sinks results +PurchaseManager.shared.purchaseSubject.send(PurchaseRequest(productId: productId)) +// In init: +PurchaseManager.shared.resultSubject.sink { result in handleTransactionResult(result) } +``` ### Common Pitfalls -- The interceptor is a global singleton — set it once during SDK initialization, not inside individual screens. -- Always call `proceed` (either `true` or `false`). Forgetting to call it will leave the paywall in a frozen state. +- In Observer mode, `synchronize()` must be called after every successful purchase so Purchasely can track it. The wrapper handles this automatically on `TransactionResult.Success`. +- The `processAction(false)` callback must be called for **every** outcome (success, cancel, error) — otherwise the paywall freezes. +- `PurchaseManager` must never import or reference the Purchasely SDK — it uses injected closures for `anonymousUserId` and `signPromotionalOffer`. --- -## 4. User Authentication +## 5. User Authentication -**What it does:** Associates the current app user with a Purchasely user ID so that subscriptions are correctly attributed and can be restored across devices. `userLogin()` optionally triggers a subscription refresh via the `refresh` callback. `userLogout()` clears the association. +**What it does:** Associates the current app user with a Purchasely user ID so that subscriptions are correctly attributed and can be restored across devices. ### Android (Kotlin) Source: `android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt` ```kotlin -// Login -Purchasely.userLogin(userId) { refresh -> - if (refresh) { - premiumManager.refreshPremiumStatus() - } +// Login — via PurchaselyWrapper +purchaselyWrapper.userLogin(userId) { refresh -> + if (refresh) premiumManager.refreshPremiumStatus() } +purchaselyWrapper.setUserAttribute("user_id", userId) // Logout -Purchasely.userLogout() +purchaselyWrapper.userLogout() premiumManager.refreshPremiumStatus() ``` @@ -265,32 +350,29 @@ premiumManager.refreshPremiumStatus() Source: `ios/Shaker/Screens/Settings/SettingsViewModel.swift` ```swift -// Login -Purchasely.userLogin(with: userId) { refresh in - if refresh { - PremiumManager.shared.refreshPremiumStatus() - } +// Login — via PurchaselyWrapping protocol +wrapper.userLogin(userId: userId) { refresh in + if refresh { PremiumManager.shared.refreshPremiumStatus() } } +wrapper.setUserAttribute(userId, forKey: "user_id") // Logout -Purchasely.userLogout() +wrapper.userLogout() PremiumManager.shared.refreshPremiumStatus() ``` -### Console Setup - -No special console configuration is required. User IDs appear in the **Users** tab once `userLogin()` has been called at least once. - ### Common Pitfalls -- The `refresh` flag in the `userLogin` callback indicates that the SDK detected new subscription data for this user. Always call `refreshPremiumStatus()` when `refresh == true`. -- Call `userLogout()` followed by `refreshPremiumStatus()` so that the local premium state reflects the anonymous (logged-out) state immediately. +- The `refresh` flag indicates new subscription data — always call `refreshPremiumStatus()` when `refresh == true`. +- Call `userLogout()` followed by `refreshPremiumStatus()` so the local state reflects the anonymous state. --- -## 5. Subscription Status +## 6. Subscription Status -**What it does:** Queries the user's active subscriptions from Purchasely's server. Shaker uses `userSubscriptions()` to determine whether the user is premium, driving paywall gating throughout the app. +**What it does:** Queries the user's active subscriptions from Purchasely's server. Shaker uses `userSubscriptions()` to determine whether the user is premium. + +**Note:** `PremiumManager` is the only class that calls the Purchasely SDK directly — it's already an abstraction layer for subscription status. ### Android (Kotlin) @@ -304,15 +386,10 @@ Purchasely.userSubscriptions(false, object : SubscriptionsListener { } _isPremium.value = premium } - - override fun onFailure(error: Throwable) { - Log.e(TAG, "Error checking premium: ${error.message}") - } + override fun onFailure(error: Throwable) { /* log error */ } }) ``` -The first parameter (`false`) controls cache invalidation. Pass `true` to force a fresh server request. - ### iOS (Swift) Source: `ios/Shaker/Data/PremiumManager.swift` @@ -322,20 +399,13 @@ Purchasely.userSubscriptions( success: { [weak self] subscriptions in let premium = subscriptions?.contains { subscription in switch subscription.status { - case .autoRenewing, .inGracePeriod, .autoRenewingCanceled, .onHold: - return true - default: - return false + case .autoRenewing, .inGracePeriod, .autoRenewingCanceled, .onHold: return true + default: return false } } ?? false - - DispatchQueue.main.async { - self?.isPremium = premium - } + DispatchQueue.main.async { self?.isPremium = premium } }, - failure: { error in - print("Error checking premium: \(error.localizedDescription)") - } + failure: { error in /* log error */ } ) ``` @@ -351,53 +421,33 @@ Purchasely.userSubscriptions( | `deactivated` | Expired or manually revoked | No | | `revoked` | Refunded by the store | No | -### Console Setup - -Subscription statuses are driven by receipt validation — no manual setup is needed. Ensure your in-app products are configured in the **Products** section of the console and mapped to a Purchasely plan. - ### Common Pitfalls -- On Android, `subscriptionStatus` is **nullable** and `isExpired()` is a **function** (not a property). Use `?.isExpired() == false` rather than `!isExpired`. -- On iOS, do not treat `autoRenewingCanceled` as non-premium — the user still has access until the billing period ends. +- On Android, `subscriptionStatus` is **nullable** and `isExpired()` is a **function**. Use `?.isExpired() == false`. +- On iOS, do not treat `autoRenewingCanceled` as non-premium — the user still has access. --- -## 6. User Attributes +## 7. User Attributes -**What it does:** Sends typed key-value attributes to Purchasely for use in audience segmentation, A/B test targeting, and personalisation. Shaker tracks search usage, cocktail view counts, theme preference, and favourite spirit. +**What it does:** Sends typed key-value attributes to Purchasely for audience segmentation, A/B test targeting, and personalisation. -### Android (Kotlin) - -Sources: `SettingsViewModel.kt`, `HomeViewModel.kt`, `DetailViewModel.kt` +### Android (Kotlin) — via PurchaselyWrapper ```kotlin -// String attribute -Purchasely.setUserAttribute("user_id", userId) -Purchasely.setUserAttribute("app_theme", "dark") -Purchasely.setUserAttribute("favorite_spirit", "Rum") - -// Boolean attribute -Purchasely.setUserAttribute("has_used_search", true) - -// Increment a counter -Purchasely.incrementUserAttribute("cocktails_viewed") +purchaselyWrapper.setUserAttribute("app_theme", "dark") +purchaselyWrapper.setUserAttribute("has_used_search", true) +purchaselyWrapper.incrementUserAttribute("cocktails_viewed") +purchaselyWrapper.setUserAttribute("favorite_spirit", "Rum") ``` -### iOS (Swift) - -Sources: `SettingsViewModel.swift`, `HomeViewModel.swift`, `DetailViewModel.swift` +### iOS (Swift) — via PurchaselyWrapping protocol ```swift -// String attribute -Purchasely.setUserAttribute(withStringValue: userId, forKey: "user_id") -Purchasely.setUserAttribute(withStringValue: "dark", forKey: "app_theme") -Purchasely.setUserAttribute(withStringValue: "Rum", forKey: "favorite_spirit") - -// Boolean attribute -Purchasely.setUserAttribute(withBoolValue: true, forKey: "has_used_search") - -// Increment a counter -Purchasely.incrementUserAttribute(withKey: "cocktails_viewed") +wrapper.setUserAttribute("dark", forKey: "app_theme") +wrapper.setUserAttribute(true, forKey: "has_used_search") +wrapper.incrementUserAttribute(forKey: "cocktails_viewed") +wrapper.setUserAttribute("Rum", forKey: "favorite_spirit") ``` ### Attributes Tracked in Shaker @@ -410,27 +460,24 @@ Purchasely.incrementUserAttribute(withKey: "cocktails_viewed") | `cocktails_viewed` | Integer | User opens a recipe detail page | | `favorite_spirit` | String | User opens a recipe (spirit of the recipe)| -### Console Setup - -User attributes are available in **Audiences** for targeting and in the **A/B Test** configuration. No pre-registration is required — attributes are created automatically on first use. - ### Common Pitfalls -- On iOS, the method name varies by type: `withStringValue:`, `withBoolValue:`, `withIntValue:`. There is no single generic method. -- `incrementUserAttribute` increments from the last known server value — it is not a local counter. Avoid calling it multiple times for the same event in a single session. +- On iOS, the method name varies by type: `withStringValue:`, `withBoolValue:`, `withIntValue:`. The wrapper unifies this behind `setUserAttribute(_:forKey:)` overloads. +- `incrementUserAttribute` increments from the last known server value — avoid calling it multiple times for the same event. --- -## 7. Events & Analytics +## 8. Events & Analytics -**What it does:** The SDK emits named `PLYEvent` objects at key points in the subscription lifecycle (paywall shown, purchase started, purchase completed, etc.). Register a listener/delegate to log these events or forward them to your analytics stack. +**What it does:** The SDK emits named `PLYEvent` objects at key points. The event listener/delegate is configured inside `PurchaselyWrapper.initialize()`. ### Android (Kotlin) -Source: `android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt` +Source: `android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt` ```kotlin -Purchasely.eventListener = object : EventListener { +// Inside initialize() +eventListener = object : EventListener { override fun onEvent(event: PLYEvent) { Log.d(TAG, "Event: ${event.name} | Properties: ${event.properties}") } @@ -439,122 +486,91 @@ Purchasely.eventListener = object : EventListener { ### iOS (Swift) -Source: `ios/Shaker/AppViewModel.swift` +Source: `ios/Shaker/Purchasely/PurchaselyWrapper.swift` ```swift -// Register during SDK init +// Inside initialize() Purchasely.setEventDelegate(self) -// Implement PLYEventDelegate -extension AppViewModel: PLYEventDelegate { +// PurchaselyWrapper conforms to PLYEventDelegate +extension PurchaselyWrapper: PLYEventDelegate { func eventTriggered(_ event: PLYEvent, properties: [String: Any]?) { print("Event: \(event.name) | Properties: \(properties ?? [:])") } } ``` -Note: the `properties` parameter is **optional** (`[String: Any]?`). Always handle the `nil` case. - -### Console Setup - -No console configuration is required to receive events. To forward events to third-party analytics tools (Amplitude, Mixpanel, etc.) automatically, configure **Integrations** in the Purchasely Console. - ### Common Pitfalls -- On Android, assign `eventListener` **after** calling `start()` but in the same initialization block — events fired during SDK startup may otherwise be missed. +- Set the listener **after** `start()` but in the same initialization block. - On iOS, `properties` is nullable — do not force-unwrap it. --- -## 8. Deeplinks +## 9. Deeplinks -**What it does:** Allows Purchasely paywalls to be opened directly from a URL (e.g. from a push notification, web link, or QR code). `readyToOpenDeeplink(true)` signals that the app is ready to handle deeplinks. `isDeeplinkHandled(deeplink:)` processes an incoming URL and returns `true` if the SDK consumed it. +**What it does:** Allows Purchasely paywalls to be opened directly from a URL. `readyToOpenDeeplink(true)` is called inside `PurchaselyWrapper.initialize()`. ### Android (Kotlin) -Source: `android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt` +Source: `android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt` ```kotlin -// During SDK init — enable deeplink handling -Purchasely.Builder(this) - // ... +// Inside initialize() — readyToOpenDeeplink is configured in the Builder +Purchasely.Builder(application) .readyToOpenDeeplink(true) - .build() - .start { /* ... */ } -``` - -To handle an incoming intent in your Activity: + // ... -```kotlin -val uri: Uri? = intent.data -if (uri != null) { - Purchasely.isDeeplinkHandled(uri) -} +// Handle incoming intent via wrapper +fun isDeeplinkHandled(deeplink: Uri, activity: Activity?): Boolean ``` ### iOS (Swift) -Source: `ios/Shaker/ShakerApp.swift` +Source: `ios/Shaker/Purchasely/PurchaselyWrapper.swift` ```swift -// Enable deeplink readiness during SDK init (AppViewModel.swift) +// Inside initialize() Purchasely.readyToOpenDeeplink(true) -// Handle incoming URLs at the app entry point -WindowGroup { - ContentView() - .onOpenURL { url in - _ = Purchasely.isDeeplinkHandled(deeplink: url) - } -} +// Handle incoming URL +@discardableResult +func isDeeplinkHandled(deeplink: URL) -> Bool ``` -### Console Setup - -1. Go to **Campaigns** or **Push Notifications** and generate a deeplink URL pointing to a specific placement or paywall. -2. Configure your app's URL scheme (iOS: add it to `Info.plist`; Android: add an `` in `AndroidManifest.xml`). - ### Common Pitfalls -- `readyToOpenDeeplink(true)` must be called **after** `start()` fires (or at least after the SDK starts initializing). In Shaker it is called in the same `initPurchasely()` block immediately after `start()`. -- `isDeeplinkHandled` returns a `Bool` — check it if you need to apply your own fallback routing for URLs the SDK did not recognise. +- `readyToOpenDeeplink(true)` must be called **after** `start()` fires. +- `isDeeplinkHandled` returns a `Bool` — check it for fallback routing. --- -## 9. GDPR & Privacy +## 10. GDPR & Privacy -**What it does:** `revokeDataProcessingConsent(for:)` tells the SDK which data processing purposes the user has opted out of. The SDK stops the corresponding data flows immediately. Shaker exposes five toggles in Settings, one per purpose, persisted to `UserDefaults` / `SharedPreferences`. +**What it does:** `revokeDataProcessingConsent(for:)` tells the SDK which data processing purposes the user has opted out of. Shaker exposes five toggles in Settings. -### Android (Kotlin) +### Android (Kotlin) — via PurchaselyWrapper Source: `android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt` ```kotlin -private fun applyConsentPreferences() { - val revoked = mutableSetOf() - if (!analyticsConsent.value) revoked.add(PLYDataProcessingPurpose.Analytics) - if (!identifiedAnalyticsConsent.value) revoked.add(PLYDataProcessingPurpose.IdentifiedAnalytics) - if (!personalizationConsent.value) revoked.add(PLYDataProcessingPurpose.Personalization) - if (!campaignsConsent.value) revoked.add(PLYDataProcessingPurpose.Campaigns) - if (!thirdPartyConsent.value) revoked.add(PLYDataProcessingPurpose.ThirdPartyIntegrations) - Purchasely.revokeDataProcessingConsent(revoked) -} +val revoked = mutableSetOf() +if (!analyticsConsent.value) revoked.add(PLYDataProcessingPurpose.Analytics) +if (!personalizationConsent.value) revoked.add(PLYDataProcessingPurpose.Personalization) +// ... +purchaselyWrapper.revokeDataProcessingConsent(revoked) ``` -### iOS (Swift) +### iOS (Swift) — via PurchaselyWrapping protocol Source: `ios/Shaker/Screens/Settings/SettingsViewModel.swift` ```swift -private func applyConsentPreferences() { - var revoked = Set() - if !analyticsConsent { revoked.insert(.analytics) } - if !identifiedAnalyticsConsent { revoked.insert(.identifiedAnalytics) } - if !personalizationConsent { revoked.insert(.personalization) } - if !campaignsConsent { revoked.insert(.campaigns) } - if !thirdPartyConsent { revoked.insert(.thirdPartyIntegrations) } - Purchasely.revokeDataProcessingConsent(for: revoked) -} +var revoked = Set() +if !analyticsConsent { revoked.insert(.analytics) } +if !personalizationConsent { revoked.insert(.personalization) } +// ... +wrapper.revokeDataProcessingConsent(for: revoked) ``` ### Data Processing Purposes @@ -567,27 +583,23 @@ private func applyConsentPreferences() { | `Campaigns` | Win-back and retention campaigns | | `ThirdPartyIntegrations` | Data forwarding to third-party tools | -### Console Setup - -No console configuration is required. If your app is subject to GDPR or other privacy regulations, surface these toggles to users in your Settings / Privacy screen. - ### Common Pitfalls -- `applyConsentPreferences()` is called both in `init` (to restore persisted consent) and whenever a toggle changes. Do not forget the `init` call, or revoked consents will be silently reset on every app launch. -- Pass the **revoked** set (purposes the user has opted out of), not the granted set. +- Pass the **revoked** set (opted-out purposes), not the granted set. +- Call `applyConsentPreferences()` both on init and on toggle changes. --- -## 10. Restore Purchases +## 11. Restore Purchases -**What it does:** Triggers a server-side restore of all purchases associated with the current App Store / Google Play account. On success, refreshes the local premium status. Use this to handle the case where a user reinstalls the app or logs in on a new device. +**What it does:** Triggers a server-side restore of all purchases. In Full mode, goes through the wrapper. In Observer mode, the reactive flow handles it automatically via `PurchaseManager`. -### Android (Kotlin) +### Android (Kotlin) — Full mode via PurchaselyWrapper Source: `android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt` ```kotlin -Purchasely.restoreAllProducts( +purchaselyWrapper.restoreAllProducts( onSuccess = { plan -> premiumManager.refreshPremiumStatus() _restoreMessage.value = "Purchases restored successfully!" @@ -598,31 +610,27 @@ Purchasely.restoreAllProducts( ) ``` -### iOS (Swift) +### iOS (Swift) — Full mode via PurchaselyWrapping protocol Source: `ios/Shaker/Screens/Settings/SettingsViewModel.swift` ```swift -Purchasely.restoreAllProducts( +wrapper.restoreAllProducts( success: { [weak self] in PremiumManager.shared.refreshPremiumStatus() - DispatchQueue.main.async { - self?.restoreMessage = "Purchases restored successfully!" - } + DispatchQueue.main.async { self?.restoreMessage = "Purchases restored successfully!" } }, failure: { [weak self] error in - DispatchQueue.main.async { - self?.restoreMessage = error.localizedDescription - } + DispatchQueue.main.async { self?.restoreMessage = error.localizedDescription } } ) ``` -### Console Setup +### Observer mode -No special console configuration is required. Apple and Google impose their own UX requirements: on iOS, Apple requires an accessible **Restore Purchases** button, typically placed in Settings or on the paywall itself. +In Observer mode, restore from the paywall interceptor flows through the reactive architecture (Section 4): the wrapper emits `RestoreRequest`, `PurchaseManager` queries native transactions, and `TransactionResult` flows back for `synchronize()` + `processAction()`. ### Common Pitfalls -- On iOS, the `success` closure does **not** receive a `plan` parameter (unlike Android). Both platforms use separate `success`/`failure` closures — not a single completion handler. -- Always call `refreshPremiumStatus()` inside the success handler so that gated UI updates immediately without requiring a screen refresh. +- On iOS, the `success` closure does **not** receive a `plan` parameter (unlike Android). +- Always call `refreshPremiumStatus()` inside the success handler. From 169852cac54f1d7ccb67cde4dd3273b1d8fef03d Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 15:03:11 +0200 Subject: [PATCH 11/12] fix(android,ios): race guard, scope cancellation, lifecycle-aware collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cancel existing pendingProcessAction before storing new one (prevents paywall hang on rapid double-tap in Observer mode) - Wire PurchaseManager into Koin DI with BillingClient factory (Observer mode purchase flow now functional end-to-end) - Extract startTransactionCollection() for proper cancel/restart on close()/initialize() — prevents duplicate collectors after SDK restart - Migrate all 5 Android Screens from collectAsState() to collectAsStateWithLifecycle() — stops Flow collection when UI is in STOPPED state (background), resumes on STARTED - Update tolerated SDK types list and best-practices docs - Update INTEGRATION_GUIDE with scope restart pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/purchasely/shaker/di/AppModule.kt | 22 +++++++++++---- .../shaker/purchasely/PurchaselyWrapper.kt | 19 +++++++++++-- .../shaker/ui/screen/detail/DetailScreen.kt | 8 +++--- .../ui/screen/favorites/FavoritesScreen.kt | 6 ++-- .../shaker/ui/screen/home/FilterSheet.kt | 8 +++--- .../shaker/ui/screen/home/HomeScreen.kt | 12 ++++---- .../ui/screen/settings/SettingsScreen.kt | 28 +++++++++---------- docs/INTEGRATION_GUIDE.md | 7 +++-- docs/purchasely-best-practices.md | 19 ++++++------- ios/Shaker/Purchasely/PurchaselyWrapper.swift | 2 ++ 10 files changed, 80 insertions(+), 51 deletions(-) diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index 06d7ffe..d4293dd 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -1,14 +1,14 @@ package com.purchasely.shaker.di +import com.android.billingclient.api.BillingClient import com.purchasely.shaker.data.CocktailRepository import com.purchasely.shaker.data.FavoritesRepository import com.purchasely.shaker.data.OnboardingRepository import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.purchase.PurchaseManager import com.purchasely.shaker.data.purchase.PurchaseRequest import com.purchasely.shaker.data.purchase.RestoreRequest -import com.purchasely.shaker.data.purchase.TransactionResult -// import com.purchasely.shaker.data.purchase.PurchaseManager // TODO: Task 4 will re-enable import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.ui.screen.home.HomeViewModel import com.purchasely.shaker.ui.screen.detail.DetailViewModel @@ -32,17 +32,27 @@ val appModule = module { // Reactive flows for purchase orchestration single(named("purchaseRequests")) { MutableSharedFlow() } single(named("restoreRequests")) { MutableSharedFlow() } - single(named("transactionResult")) { MutableSharedFlow() } single(named("appScope")) { CoroutineScope(SupervisorJob() + Dispatchers.Main) } - // TODO: Task 4 will update PurchaseManager wiring with reactive flows - // single { PurchaseManager(androidContext(), get()) } + single { + PurchaseManager( + billingClientFactory = { listener -> + BillingClient.newBuilder(androidContext()) + .setListener(listener) + .enablePendingPurchases() + .build() + }, + purchaseRequests = get(named("purchaseRequests")), + restoreRequests = get(named("restoreRequests")), + scope = get(named("appScope")) + ) + } single { PurchaselyWrapper( premiumManager = get(), runningModeRepo = get(), purchaseRequests = get(named("purchaseRequests")), restoreRequests = get(named("restoreRequests")), - transactionResult = get(named("transactionResult")), + transactionResult = get().transactionResult, scope = get(named("appScope")) ) } diff --git a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt index 883e859..6b9f299 100644 --- a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt +++ b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt @@ -27,6 +27,7 @@ import io.purchasely.models.PLYError import io.purchasely.models.PLYPlan import io.purchasely.google.GoogleStore import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @@ -38,7 +39,7 @@ class PurchaselyWrapper( private val runningModeRepo: RunningModeRepository, private val purchaseRequests: MutableSharedFlow, private val restoreRequests: MutableSharedFlow, - transactionResult: SharedFlow, + private val transactionResult: SharedFlow, private val scope: CoroutineScope ) { @@ -46,9 +47,15 @@ class PurchaselyWrapper( private var apiKey: String = "" private var logLevel: LogLevel = LogLevel.DEBUG private var pendingProcessAction: ((Boolean) -> Unit)? = null + private var collectionJob: Job? = null init { - scope.launch { + startTransactionCollection() + } + + private fun startTransactionCollection() { + collectionJob?.cancel() + collectionJob = scope.launch { transactionResult.collect { result -> handleTransactionResult(result) } @@ -94,6 +101,8 @@ class PurchaselyWrapper( setPaywallActionsInterceptor { info, action, parameters, proceed -> handlePaywallAction(info, action, parameters, proceed) } + + startTransactionCollection() } fun restart() { @@ -103,6 +112,10 @@ class PurchaselyWrapper( } fun close() { + collectionJob?.cancel() + collectionJob = null + pendingProcessAction?.invoke(false) + pendingProcessAction = null Purchasely.close() } @@ -137,6 +150,7 @@ class PurchaselyWrapper( val offerToken = offer?.offerToken val activity = info?.activity if (activity != null && productId != null && offerToken != null) { + pendingProcessAction?.invoke(false) pendingProcessAction = processAction scope.launch { purchaseRequests.emit(PurchaseRequest(activity, productId, offerToken)) @@ -151,6 +165,7 @@ class PurchaselyWrapper( } PLYPresentationAction.RESTORE -> { if (runningModeRepo.isObserverMode) { + pendingProcessAction?.invoke(false) pendingProcessAction = processAction scope.launch { restoreRequests.emit(RestoreRequest) diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt index 8197770..6fb09e3 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt @@ -33,7 +33,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -55,9 +55,9 @@ fun DetailScreen( onBack: () -> Unit, viewModel: DetailViewModel = koinViewModel { parametersOf(cocktailId) } ) { - val cocktail by viewModel.cocktail.collectAsState() - val isPremium by viewModel.isPremium.collectAsState() - val favoriteIds by viewModel.favoriteIds.collectAsState() + val cocktail by viewModel.cocktail.collectAsStateWithLifecycle() + val isPremium by viewModel.isPremium.collectAsStateWithLifecycle() + val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() val isFavorite = favoriteIds.contains(cocktailId) val context = LocalContext.current diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt index 01f332c..dac36b8 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,8 +45,8 @@ fun FavoritesScreen( onCocktailClick: (String) -> Unit, viewModel: FavoritesViewModel = koinViewModel() ) { - val favoriteIds by viewModel.favoriteIds.collectAsState() - val isPremium by viewModel.isPremium.collectAsState() + val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() + val isPremium by viewModel.isPremium.collectAsStateWithLifecycle() val favorites = viewModel.getFavoriteCocktails() val context = LocalContext.current diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/FilterSheet.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/FilterSheet.kt index da26b09..8acdc9f 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/FilterSheet.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/FilterSheet.kt @@ -17,7 +17,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,9 +29,9 @@ fun FilterSheet( viewModel: HomeViewModel, onDismiss: () -> Unit ) { - val selectedSpirits by viewModel.selectedSpirits.collectAsState() - val selectedCategories by viewModel.selectedCategories.collectAsState() - val selectedDifficulty by viewModel.selectedDifficulty.collectAsState() + val selectedSpirits by viewModel.selectedSpirits.collectAsStateWithLifecycle() + val selectedCategories by viewModel.selectedCategories.collectAsStateWithLifecycle() + val selectedDifficulty by viewModel.selectedDifficulty.collectAsStateWithLifecycle() ModalBottomSheet( onDismissRequest = onDismiss, diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt index fed3484..6fa409a 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt @@ -38,7 +38,7 @@ import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -60,11 +60,11 @@ fun HomeScreen( onCocktailClick: (String) -> Unit, viewModel: HomeViewModel = koinViewModel() ) { - val cocktails by viewModel.cocktails.collectAsState() - val searchQuery by viewModel.searchQuery.collectAsState() - val isPremium by viewModel.isPremium.collectAsState() - val isFiltersLoading by viewModel.isFiltersLoading.collectAsState() - val inlinePresentation by viewModel.inlinePresentation.collectAsState() + val cocktails by viewModel.cocktails.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val isPremium by viewModel.isPremium.collectAsStateWithLifecycle() + val isFiltersLoading by viewModel.isFiltersLoading.collectAsStateWithLifecycle() + val inlinePresentation by viewModel.inlinePresentation.collectAsStateWithLifecycle() val context = LocalContext.current var showFilterSheet by remember { mutableStateOf(false) } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt index badc9c4..4a19fa0 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt @@ -32,7 +32,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -51,20 +51,20 @@ import org.koin.androidx.compose.koinViewModel fun SettingsScreen( viewModel: SettingsViewModel = koinViewModel() ) { - val userId by viewModel.userId.collectAsState() - val isPremium by viewModel.isPremium.collectAsState() - val restoreMessage by viewModel.restoreMessage.collectAsState() - val themeMode by viewModel.themeMode.collectAsState() - val sdkMode by viewModel.sdkMode.collectAsState() + val userId by viewModel.userId.collectAsStateWithLifecycle() + val isPremium by viewModel.isPremium.collectAsStateWithLifecycle() + val restoreMessage by viewModel.restoreMessage.collectAsStateWithLifecycle() + val themeMode by viewModel.themeMode.collectAsStateWithLifecycle() + val sdkMode by viewModel.sdkMode.collectAsStateWithLifecycle() - val analyticsConsent by viewModel.analyticsConsent.collectAsState() - val identifiedAnalyticsConsent by viewModel.identifiedAnalyticsConsent.collectAsState() - val personalizationConsent by viewModel.personalizationConsent.collectAsState() - val campaignsConsent by viewModel.campaignsConsent.collectAsState() - val thirdPartyConsent by viewModel.thirdPartyConsent.collectAsState() - val runningMode by viewModel.runningMode.collectAsState() - val anonymousId by viewModel.anonymousId.collectAsState() - val displayMode by viewModel.displayMode.collectAsState() + val analyticsConsent by viewModel.analyticsConsent.collectAsStateWithLifecycle() + val identifiedAnalyticsConsent by viewModel.identifiedAnalyticsConsent.collectAsStateWithLifecycle() + val personalizationConsent by viewModel.personalizationConsent.collectAsStateWithLifecycle() + val campaignsConsent by viewModel.campaignsConsent.collectAsStateWithLifecycle() + val thirdPartyConsent by viewModel.thirdPartyConsent.collectAsStateWithLifecycle() + val runningMode by viewModel.runningMode.collectAsStateWithLifecycle() + val anonymousId by viewModel.anonymousId.collectAsStateWithLifecycle() + val displayMode by viewModel.displayMode.collectAsStateWithLifecycle() val clipboardManager: ClipboardManager = LocalClipboardManager.current val context = LocalContext.current var loginInput by remember { mutableStateOf("") } diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index daf0e4f..0b3bf5e 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -290,8 +290,11 @@ class PurchaseManager( scope.launch { purchaseRequests.emit(PurchaseRequest(activity, productId, offerToken)) } -// In init block: -transactionResult.collect { result -> handleTransactionResult(result) } +// Collection starts in init{} and restarts on initialize()/restart(): +private fun startTransactionCollection() { + collectionJob?.cancel() + collectionJob = scope.launch { transactionResult.collect { handleTransactionResult(it) } } +} ``` ### iOS (Swift) diff --git a/docs/purchasely-best-practices.md b/docs/purchasely-best-practices.md index 4c58b14..c70b531 100644 --- a/docs/purchasely-best-practices.md +++ b/docs/purchasely-best-practices.md @@ -32,7 +32,7 @@ The only exception is `PremiumManager`, which directly calls `userSubscriptions` | **Consent** | `revokeDataProcessingConsent()` | | **Info** | `sdkVersion`, `isDeeplinkHandled()` | -**Tolerated SDK type imports:** `PLYRunningMode`, `PLYDataProcessingPurpose`, `PLYPresentationAction`, `EventListener`/`PLYEventDelegate`, `PLYOfferSignature`, `LogLevel`/`PLYLogger.PLYLogLevel` — these are enums/types needed for configuration, not SDK call points. +**Tolerated SDK type imports:** `PLYRunningMode`, `PLYDataProcessingPurpose`, `PLYPresentationAction`, `PLYPresentationInfo`, `PLYPresentationActionParameters`, `PLYPresentation`, `PLYPresentationViewController`, `EventListener`/`PLYEventDelegate`, `PLYOfferSignature`, `LogLevel`/`PLYLogger.PLYLogLevel` — these are enums/types needed for configuration, interceptor logic, and presentation handling. They are not SDK call points. --- @@ -84,7 +84,7 @@ struct PurchaseRequest { let productId: String } enum TransactionResult { case success, cancelled, error(String?), idle } ``` -**processAction callback:** The wrapper stores a single `pendingProcessAction: ((Boolean) -> Unit)?` when emitting a purchase/restore request. When TransactionResult arrives, it invokes the callback and nullifies it. Only one purchase at a time (paywall flow is sequential). +**processAction callback:** The wrapper stores a single `pendingProcessAction: ((Boolean) -> Unit)?` when emitting a purchase/restore request. When TransactionResult arrives, it invokes the callback and nullifies it. **Race guard:** Before storing a new `pendingProcessAction`, the wrapper cancels any existing one by calling `pendingProcessAction?.invoke(false)` — this prevents a second interceptor action from silently overwriting and orphaning the first callback. **Interceptor rules:** @@ -122,7 +122,7 @@ The wrapper internally configures: 5. Combine/Flow subscriptions for Observer purchase flow **Restart:** When the SDK mode changes, `wrapper.restart()` is called: -- **Android:** `SettingsViewModel` calls `purchaselyWrapper.restart()` directly +- **Android:** `SettingsViewModel` calls `purchaselyWrapper.restart()` directly. `restart()` → `close()` → `initialize()`. `close()` cancels the transaction result collection job, clears any pending process action, then stops the SDK. `initialize()` restarts the collection. - **iOS:** `SettingsViewModel` posts `.purchaselySdkModeDidChange` notification, wrapper observes it and calls `restart()` internally --- @@ -147,14 +147,12 @@ suspend fun loadPresentation(placementId: String): FetchResult { } // For modal display -suspend fun display(presentation: PLYPresentation, activity: Activity): DisplayResult { - // Uses presentation.display(activity) { result, plan -> } internally -} +suspend fun display(presentation: PLYPresentation, activity: Activity): DisplayResult +func display(presentation: PLYPresentation, from viewController: UIViewController?) // iOS // For inline/embedded display -fun getView(presentation: PLYPresentation, context: Context, onResult): View? { - // Uses presentation.buildView(context, callback) internally -} +fun getView(presentation: PLYPresentation, context: Context, onResult): View? // Android +func getController(presentation: PLYPresentation) -> PLYPresentationViewController? // iOS ``` --- @@ -210,7 +208,7 @@ private fun prefetchPresentations() { ```kotlin // In Screen — only render when prefetch succeeded -val inlineResult by viewModel.inlinePresentation.collectAsState() +val inlineResult by viewModel.inlinePresentation.collectAsStateWithLifecycle() if (inlineResult is FetchResult.Success) { val heightModifier = if (inlineResult.height > 0) { Modifier.height(inlineResult.height.dp) @@ -354,4 +352,5 @@ Always handle all `FetchResult` variants: - [ ] PurchaseManager has zero Purchasely/SDK imports - [ ] Login/logout, restore, consent, synchronize go through wrapper in ViewModels - [ ] SDK types (PLYRunningMode, PLYDataProcessingPurpose, etc.) are tolerated as direct imports +- [ ] Android Screens use `collectAsStateWithLifecycle()` (not `collectAsState()`) for lifecycle-aware collection - [ ] Tests use MockPurchaselyWrapper (iOS) or mockk (Android) — never the real SDK diff --git a/ios/Shaker/Purchasely/PurchaselyWrapper.swift b/ios/Shaker/Purchasely/PurchaselyWrapper.swift index a2367f2..c0d8b6b 100644 --- a/ios/Shaker/Purchasely/PurchaselyWrapper.swift +++ b/ios/Shaker/Purchasely/PurchaselyWrapper.swift @@ -135,6 +135,7 @@ final class PurchaselyWrapper: PurchaselyWrapping { if PurchaselySDKMode.current() == .paywallObserver { if let productId = parameters?.plan?.appleProductId { if #available(iOS 15.0, *) { + pendingProcessAction?(false) pendingProcessAction = processAction PurchaseManager.shared.purchaseSubject.send(PurchaseRequest(productId: productId)) } else { @@ -150,6 +151,7 @@ final class PurchaselyWrapper: PurchaselyWrapping { case .restore: if PurchaselySDKMode.current() == .paywallObserver { if #available(iOS 15.0, *) { + pendingProcessAction?(false) pendingProcessAction = processAction PurchaseManager.shared.restoreSubject.send() } else { From c21c8fa129c30e661d94a9569a0e3e8cc6153f34 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 15:07:34 +0200 Subject: [PATCH 12/12] refactor: extract PurchaselyWrapper + comprehensive test suite + docs - Extracted all direct Purchasely SDK calls into a Koin-injectable `PurchaselyWrapper` (Android) - Cleaned `HomeScreen` and `HomeViewModel` by removing `io.purchasely` imports - Added `EmbeddedScreenBanner` reusable Composable for inline paywalls - Created `FetchResult` and `DisplayResult` sealed classes for type-safe SDK outcomes - Added a comprehensive unit test suite for both Android (JUnit/MockK) and iOS (XCTest) covering: - ViewModels (Home, Detail, Favorites, Settings) - Repositories (Favorites, Onboarding, RunningMode) - Domain models and result types - Added `docs/purchasely-best-practices.md` defining architecture and integration standards - Updated `CLAUDE.md` to reflect new reference architecture and project structure - Configured Xcode project and Gradle for unit testing dependencies (JUnit, MockK, Turbine) Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/build.gradle.kts | 10 + .../java/com/purchasely/shaker/ShakerApp.kt | 1 + .../com/purchasely/shaker/di/AppModule.kt | 11 +- .../java/com/purchasely/shaker/TestHelpers.kt | 23 + .../shaker/data/FavoritesRepositoryTest.kt | 138 +++ .../shaker/data/OnboardingRepositoryTest.kt | 67 ++ .../shaker/data/PurchaselySdkModeTest.kt | 72 ++ .../shaker/data/RunningModeRepositoryTest.kt | 81 ++ .../shaker/domain/model/CocktailTest.kt | 131 +++ .../shaker/purchasely/DisplayResultTest.kt | 72 ++ .../ui/screen/detail/DetailViewModelTest.kt | 160 +++ .../favorites/FavoritesViewModelTest.kt | 117 +++ .../ui/screen/home/HomeViewModelTest.kt | 278 +++++ android/gradle/libs.versions.toml | 9 + .../2026-04-09-purchasely-wrapper-refactor.md | 949 ++++++++++++++++++ ios/Podfile | 4 + ios/Shaker/Data/CocktailRepository.swift | 5 + ios/Shaker/Data/FavoritesRepository.swift | 14 +- ios/Shaker/Data/OnboardingRepository.swift | 16 +- .../Screens/Detail/DetailViewModel.swift | 10 +- ios/Shaker/Screens/Home/HomeViewModel.swift | 9 +- .../Screens/Settings/SettingsViewModel.swift | 33 +- .../Data/FavoritesRepositoryTests.swift | 83 ++ .../Data/OnboardingRepositoryTests.swift | 51 + ios/ShakerTests/Mocks/TestHelpers.swift | 22 + ios/ShakerTests/Model/CocktailTests.swift | 90 ++ .../Purchasely/DisplayResultTests.swift | 69 ++ .../Purchasely/PurchaselyWrapperTests.swift | 72 ++ .../Screens/DetailViewModelTests.swift | 91 ++ .../Screens/HomeViewModelTests.swift | 261 +++++ .../Screens/SettingsViewModelTests.swift | 247 +++++ ios/project.yml | 18 +- 32 files changed, 3183 insertions(+), 31 deletions(-) create mode 100644 android/app/src/test/java/com/purchasely/shaker/TestHelpers.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/data/PurchaselySdkModeTest.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/domain/model/CocktailTest.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/purchasely/DisplayResultTest.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt create mode 100644 docs/superpowers/plans/2026-04-09-purchasely-wrapper-refactor.md create mode 100644 ios/ShakerTests/Data/FavoritesRepositoryTests.swift create mode 100644 ios/ShakerTests/Data/OnboardingRepositoryTests.swift create mode 100644 ios/ShakerTests/Mocks/TestHelpers.swift create mode 100644 ios/ShakerTests/Model/CocktailTests.swift create mode 100644 ios/ShakerTests/Purchasely/DisplayResultTests.swift create mode 100644 ios/ShakerTests/Purchasely/PurchaselyWrapperTests.swift create mode 100644 ios/ShakerTests/Screens/DetailViewModelTests.swift create mode 100644 ios/ShakerTests/Screens/HomeViewModelTests.swift create mode 100644 ios/ShakerTests/Screens/SettingsViewModelTests.swift diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e1fb7c7..f16e665 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -59,6 +59,10 @@ android { jvmTarget = "11" } + testOptions { + unitTests.isReturnDefaultValues = true + } + buildFeatures { compose = true buildConfig = true @@ -98,4 +102,10 @@ dependencies { // Google Play Billing (for PaywallObserver mode) implementation(libs.google.billing) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) } diff --git a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt index c8d7f2e..86ea2cf 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt @@ -25,5 +25,6 @@ class ShakerApp : Application() { apiKey = BuildConfig.PURCHASELY_API_KEY, logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN ) + } } diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index d4293dd..abf1bf8 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -1,6 +1,7 @@ package com.purchasely.shaker.di import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.PendingPurchasesParams import com.purchasely.shaker.data.CocktailRepository import com.purchasely.shaker.data.FavoritesRepository import com.purchasely.shaker.data.OnboardingRepository @@ -38,11 +39,15 @@ val appModule = module { billingClientFactory = { listener -> BillingClient.newBuilder(androidContext()) .setListener(listener) - .enablePendingPurchases() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) .build() }, - purchaseRequests = get(named("purchaseRequests")), - restoreRequests = get(named("restoreRequests")), + purchaseRequests = get>(named("purchaseRequests")), + restoreRequests = get>(named("restoreRequests")), scope = get(named("appScope")) ) } diff --git a/android/app/src/test/java/com/purchasely/shaker/TestHelpers.kt b/android/app/src/test/java/com/purchasely/shaker/TestHelpers.kt new file mode 100644 index 0000000..be796da --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/TestHelpers.kt @@ -0,0 +1,23 @@ +package com.purchasely.shaker + +import com.purchasely.shaker.domain.model.Cocktail +import com.purchasely.shaker.domain.model.Ingredient + +fun testCocktail( + id: String = "1", + name: String = "Mojito", + spirit: String = "Rum", + category: String = "Classic", + difficulty: String = "Easy" +) = Cocktail( + id = id, + name = name, + image = "$id.jpg", + description = "A test cocktail", + category = category, + spirit = spirit, + difficulty = difficulty, + tags = listOf("test"), + ingredients = listOf(Ingredient("Ingredient", "60ml")), + instructions = listOf("Mix and serve") +) diff --git a/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt new file mode 100644 index 0000000..a62b514 --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt @@ -0,0 +1,138 @@ +package com.purchasely.shaker.data + +import android.content.Context +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class FavoritesRepositoryTest { + + private lateinit var prefs: SharedPreferences + private lateinit var editor: SharedPreferences.Editor + private lateinit var context: Context + private var storedSet: MutableSet = mutableSetOf() + + @Before + fun setUp() { + storedSet = mutableSetOf() + editor = mockk(relaxed = true) { + every { putStringSet(any(), any()) } answers { + storedSet = (secondArg() as Set).toMutableSet() + this@mockk + } + } + prefs = mockk { + every { getStringSet(any(), any()) } answers { storedSet.toSet() } + every { edit() } returns editor + } + context = mockk { + every { getSharedPreferences(any(), any()) } returns prefs + } + } + + private fun createRepository(): FavoritesRepository = FavoritesRepository(context) + + @Test + fun `initial state is empty when no stored favorites`() { + val repo = createRepository() + assertTrue(repo.favoriteIds.value.isEmpty()) + } + + @Test + fun `initial state loads stored favorites`() { + storedSet = mutableSetOf("cocktail1", "cocktail2") + val repo = createRepository() + assertEquals(setOf("cocktail1", "cocktail2"), repo.favoriteIds.value) + } + + @Test + fun `addFavorite adds cocktail id`() { + val repo = createRepository() + repo.addFavorite("cocktail1") + assertTrue(repo.favoriteIds.value.contains("cocktail1")) + } + + @Test + fun `addFavorite persists to SharedPreferences`() { + val repo = createRepository() + repo.addFavorite("cocktail1") + verify { editor.putStringSet(any(), match { it.contains("cocktail1") }) } + } + + @Test + fun `removeFavorite removes cocktail id`() { + storedSet = mutableSetOf("cocktail1") + val repo = createRepository() + repo.removeFavorite("cocktail1") + assertFalse(repo.favoriteIds.value.contains("cocktail1")) + } + + @Test + fun `removeFavorite persists to SharedPreferences`() { + storedSet = mutableSetOf("cocktail1") + val repo = createRepository() + repo.removeFavorite("cocktail1") + verify { editor.putStringSet(any(), match { !it.contains("cocktail1") }) } + } + + @Test + fun `toggleFavorite adds when not present`() { + val repo = createRepository() + repo.toggleFavorite("cocktail1") + assertTrue(repo.favoriteIds.value.contains("cocktail1")) + } + + @Test + fun `toggleFavorite removes when already present`() { + storedSet = mutableSetOf("cocktail1") + val repo = createRepository() + repo.toggleFavorite("cocktail1") + assertFalse(repo.favoriteIds.value.contains("cocktail1")) + } + + @Test + fun `isFavorite returns true for existing favorite`() { + storedSet = mutableSetOf("cocktail1") + val repo = createRepository() + assertTrue(repo.isFavorite("cocktail1")) + } + + @Test + fun `isFavorite returns false for non-favorite`() { + val repo = createRepository() + assertFalse(repo.isFavorite("cocktail1")) + } + + @Test + fun `multiple add and remove operations`() { + val repo = createRepository() + repo.addFavorite("a") + repo.addFavorite("b") + repo.addFavorite("c") + assertEquals(setOf("a", "b", "c"), repo.favoriteIds.value) + + repo.removeFavorite("b") + assertEquals(setOf("a", "c"), repo.favoriteIds.value) + } + + @Test + fun `addFavorite is idempotent`() { + val repo = createRepository() + repo.addFavorite("cocktail1") + repo.addFavorite("cocktail1") + assertEquals(1, repo.favoriteIds.value.size) + } + + @Test + fun `removeFavorite on non-existing id is no-op`() { + val repo = createRepository() + repo.removeFavorite("nonexistent") + assertTrue(repo.favoriteIds.value.isEmpty()) + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt new file mode 100644 index 0000000..96a689e --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt @@ -0,0 +1,67 @@ +package com.purchasely.shaker.data + +import android.content.Context +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class OnboardingRepositoryTest { + + private lateinit var prefs: SharedPreferences + private lateinit var editor: SharedPreferences.Editor + private lateinit var context: Context + private var storedBoolean: Boolean = false + + @Before + fun setUp() { + storedBoolean = false + editor = mockk(relaxed = true) { + every { putBoolean(any(), any()) } answers { + storedBoolean = secondArg() + this@mockk + } + } + prefs = mockk { + every { getBoolean(any(), any()) } answers { storedBoolean } + every { edit() } returns editor + } + context = mockk { + every { getSharedPreferences(any(), any()) } returns prefs + } + } + + @Test + fun `initial state is false`() { + val repo = OnboardingRepository(context) + assertFalse(repo.isOnboardingCompleted) + } + + @Test + fun `setting to true persists`() { + val repo = OnboardingRepository(context) + repo.isOnboardingCompleted = true + assertTrue(repo.isOnboardingCompleted) + verify { editor.putBoolean("onboarding_completed", true) } + } + + @Test + fun `setting to false persists`() { + storedBoolean = true + val repo = OnboardingRepository(context) + repo.isOnboardingCompleted = false + assertFalse(repo.isOnboardingCompleted) + verify { editor.putBoolean("onboarding_completed", false) } + } + + @Test + fun `reads stored value on creation`() { + storedBoolean = true + val repo = OnboardingRepository(context) + assertTrue(repo.isOnboardingCompleted) + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/data/PurchaselySdkModeTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/PurchaselySdkModeTest.kt new file mode 100644 index 0000000..473368d --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/PurchaselySdkModeTest.kt @@ -0,0 +1,72 @@ +package com.purchasely.shaker.data + +import io.purchasely.ext.PLYRunningMode +import org.junit.Assert.assertEquals +import org.junit.Test + +class PurchaselySdkModeTest { + + @Test + fun `fromStorage returns PAYWALL_OBSERVER for paywallObserver`() { + val mode = PurchaselySdkMode.fromStorage("paywallObserver") + assertEquals(PurchaselySdkMode.PAYWALL_OBSERVER, mode) + } + + @Test + fun `fromStorage returns FULL for full`() { + val mode = PurchaselySdkMode.fromStorage("full") + assertEquals(PurchaselySdkMode.FULL, mode) + } + + @Test + fun `fromStorage returns DEFAULT for null`() { + val mode = PurchaselySdkMode.fromStorage(null) + assertEquals(PurchaselySdkMode.DEFAULT, mode) + } + + @Test + fun `fromStorage returns DEFAULT for unknown value`() { + val mode = PurchaselySdkMode.fromStorage("unknown") + assertEquals(PurchaselySdkMode.DEFAULT, mode) + } + + @Test + fun `DEFAULT is PAYWALL_OBSERVER`() { + assertEquals(PurchaselySdkMode.PAYWALL_OBSERVER, PurchaselySdkMode.DEFAULT) + } + + @Test + fun `storageValue matches expected strings`() { + assertEquals("paywallObserver", PurchaselySdkMode.PAYWALL_OBSERVER.storageValue) + assertEquals("full", PurchaselySdkMode.FULL.storageValue) + } + + @Test + fun `label matches expected strings`() { + assertEquals("Paywall Observer", PurchaselySdkMode.PAYWALL_OBSERVER.label) + assertEquals("Full", PurchaselySdkMode.FULL.label) + } + + @Test + fun `runningMode maps correctly`() { + assertEquals(PLYRunningMode.PaywallObserver, PurchaselySdkMode.PAYWALL_OBSERVER.runningMode) + assertEquals(PLYRunningMode.Full, PurchaselySdkMode.FULL.runningMode) + } + + @Test + fun `PREFERENCES_NAME constant`() { + assertEquals("shaker_settings", PurchaselySdkMode.PREFERENCES_NAME) + } + + @Test + fun `KEY constant`() { + assertEquals("purchasely_sdk_mode", PurchaselySdkMode.KEY) + } + + @Test + fun `roundtrip fromStorage with storageValue`() { + PurchaselySdkMode.values().forEach { mode -> + assertEquals(mode, PurchaselySdkMode.fromStorage(mode.storageValue)) + } + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt new file mode 100644 index 0000000..dc00e25 --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt @@ -0,0 +1,81 @@ +package com.purchasely.shaker.data + +import android.content.Context +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.purchasely.ext.PLYRunningMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class RunningModeRepositoryTest { + + private lateinit var prefs: SharedPreferences + private lateinit var editor: SharedPreferences.Editor + private lateinit var context: Context + private var storedString: String? = "full" + + @Before + fun setUp() { + storedString = "full" + editor = mockk(relaxed = true) { + every { putString(any(), any()) } answers { + storedString = secondArg() + this@mockk + } + } + prefs = mockk { + every { getString(any(), any()) } answers { storedString ?: secondArg() } + every { edit() } returns editor + } + context = mockk { + every { getSharedPreferences(any(), any()) } returns prefs + } + } + + @Test + fun `default mode is Full`() { + val repo = RunningModeRepository(context) + assertEquals(PLYRunningMode.Full, repo.runningMode) + } + + @Test + fun `isObserverMode is false when Full`() { + val repo = RunningModeRepository(context) + assertFalse(repo.isObserverMode) + } + + @Test + fun `setting to PaywallObserver persists observer string`() { + val repo = RunningModeRepository(context) + repo.runningMode = PLYRunningMode.PaywallObserver + verify { editor.putString("running_mode", "observer") } + } + + @Test + fun `reading PaywallObserver from storage`() { + storedString = "observer" + val repo = RunningModeRepository(context) + assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) + assertTrue(repo.isObserverMode) + } + + @Test + fun `setting to Full persists full string`() { + storedString = "observer" + val repo = RunningModeRepository(context) + repo.runningMode = PLYRunningMode.Full + verify { editor.putString("running_mode", "full") } + } + + @Test + fun `unknown stored value defaults to Full`() { + storedString = "unknown" + val repo = RunningModeRepository(context) + assertEquals(PLYRunningMode.Full, repo.runningMode) + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/domain/model/CocktailTest.kt b/android/app/src/test/java/com/purchasely/shaker/domain/model/CocktailTest.kt new file mode 100644 index 0000000..7b10d8d --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/domain/model/CocktailTest.kt @@ -0,0 +1,131 @@ +package com.purchasely.shaker.domain.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CocktailTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `deserialize single cocktail from JSON`() { + val jsonString = """ + { + "id": "mojito", + "name": "Mojito", + "image": "mojito.jpg", + "description": "A refreshing Cuban cocktail", + "category": "Classic", + "spirit": "Rum", + "difficulty": "Easy", + "tags": ["refreshing", "summer"], + "ingredients": [ + {"name": "White Rum", "amount": "60ml"}, + {"name": "Lime Juice", "amount": "30ml"} + ], + "instructions": ["Muddle mint", "Add rum and lime", "Top with soda"] + } + """.trimIndent() + + val cocktail = json.decodeFromString(jsonString) + + assertEquals("mojito", cocktail.id) + assertEquals("Mojito", cocktail.name) + assertEquals("mojito.jpg", cocktail.image) + assertEquals("A refreshing Cuban cocktail", cocktail.description) + assertEquals("Classic", cocktail.category) + assertEquals("Rum", cocktail.spirit) + assertEquals("Easy", cocktail.difficulty) + assertEquals(listOf("refreshing", "summer"), cocktail.tags) + assertEquals(2, cocktail.ingredients.size) + assertEquals("White Rum", cocktail.ingredients[0].name) + assertEquals("60ml", cocktail.ingredients[0].amount) + assertEquals(3, cocktail.instructions.size) + } + + @Test + fun `deserialize CocktailsData with multiple cocktails`() { + val jsonString = """ + { + "cocktails": [ + { + "id": "1", + "name": "Mojito", + "image": "mojito.jpg", + "description": "Desc", + "category": "Classic", + "spirit": "Rum", + "difficulty": "Easy", + "tags": [], + "ingredients": [], + "instructions": [] + }, + { + "id": "2", + "name": "Margarita", + "image": "margarita.jpg", + "description": "Desc", + "category": "Classic", + "spirit": "Tequila", + "difficulty": "Medium", + "tags": [], + "ingredients": [], + "instructions": [] + } + ] + } + """.trimIndent() + + val data = json.decodeFromString(jsonString) + + assertEquals(2, data.cocktails.size) + assertEquals("Mojito", data.cocktails[0].name) + assertEquals("Margarita", data.cocktails[1].name) + } + + @Test + fun `cocktail data class equality`() { + val c1 = Cocktail("1", "Mojito", "img", "desc", "Classic", "Rum", "Easy", emptyList(), emptyList(), emptyList()) + val c2 = Cocktail("1", "Mojito", "img", "desc", "Classic", "Rum", "Easy", emptyList(), emptyList(), emptyList()) + assertEquals(c1, c2) + } + + @Test + fun `ingredient data class equality`() { + val i1 = Ingredient("Rum", "60ml") + val i2 = Ingredient("Rum", "60ml") + assertEquals(i1, i2) + } + + @Test + fun `deserialize ignores unknown keys`() { + val jsonString = """ + { + "id": "1", + "name": "Test", + "image": "test.jpg", + "description": "Desc", + "category": "Cat", + "spirit": "Gin", + "difficulty": "Hard", + "tags": [], + "ingredients": [], + "instructions": [], + "unknownField": "should be ignored" + } + """.trimIndent() + + val cocktail = json.decodeFromString(jsonString) + assertEquals("Test", cocktail.name) + } + + @Test + fun `cocktail with empty collections`() { + val cocktail = Cocktail("1", "Empty", "img", "desc", "Cat", "Gin", "Easy", emptyList(), emptyList(), emptyList()) + assertTrue(cocktail.tags.isEmpty()) + assertTrue(cocktail.ingredients.isEmpty()) + assertTrue(cocktail.instructions.isEmpty()) + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/purchasely/DisplayResultTest.kt b/android/app/src/test/java/com/purchasely/shaker/purchasely/DisplayResultTest.kt new file mode 100644 index 0000000..6afa54b --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/purchasely/DisplayResultTest.kt @@ -0,0 +1,72 @@ +package com.purchasely.shaker.purchasely + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DisplayResultTest { + + @Test + fun `Purchased holds plan name`() { + val result = DisplayResult.Purchased("Premium Monthly") + assertTrue(result is DisplayResult.Purchased) + assertEquals("Premium Monthly", result.planName) + } + + @Test + fun `Purchased with null plan name`() { + val result = DisplayResult.Purchased(null) + assertNull(result.planName) + } + + @Test + fun `Restored holds plan name`() { + val result = DisplayResult.Restored("Premium Yearly") + assertTrue(result is DisplayResult.Restored) + assertEquals("Premium Yearly", result.planName) + } + + @Test + fun `Restored with null plan name`() { + val result = DisplayResult.Restored(null) + assertNull(result.planName) + } + + @Test + fun `Cancelled is a singleton object`() { + val result = DisplayResult.Cancelled + assertTrue(result is DisplayResult.Cancelled) + } + + @Test + fun `sealed class exhaustive when`() { + val results = listOf( + DisplayResult.Purchased("Plan A"), + DisplayResult.Restored("Plan B"), + DisplayResult.Cancelled + ) + + results.forEach { result -> + when (result) { + is DisplayResult.Purchased -> assertEquals("Plan A", result.planName) + is DisplayResult.Restored -> assertEquals("Plan B", result.planName) + is DisplayResult.Cancelled -> {} // OK + } + } + } + + @Test + fun `Purchased data class equality`() { + val a = DisplayResult.Purchased("Plan") + val b = DisplayResult.Purchased("Plan") + assertEquals(a, b) + } + + @Test + fun `Restored data class equality`() { + val a = DisplayResult.Restored("Plan") + val b = DisplayResult.Restored("Plan") + assertEquals(a, b) + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt new file mode 100644 index 0000000..64deb2f --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt @@ -0,0 +1,160 @@ +package com.purchasely.shaker.ui.screen.detail + +import com.purchasely.shaker.data.CocktailRepository +import com.purchasely.shaker.data.FavoritesRepository +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper +import com.purchasely.shaker.testCocktail +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DetailViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private val mojito = testCocktail("mojito", "Mojito", "Rum", "Classic", "Easy") + + private lateinit var repository: CocktailRepository + private lateinit var premiumManager: PremiumManager + private lateinit var favoritesRepository: FavoritesRepository + private lateinit var wrapper: PurchaselyWrapper + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + repository = mockk { + every { loadCocktails() } returns listOf(mojito) + every { getCocktail("mojito") } returns mojito + every { getCocktail("nonexistent") } returns null + } + premiumManager = mockk { + every { isPremium } returns MutableStateFlow(false) + every { refreshPremiumStatus() } returns Unit + } + favoritesRepository = mockk(relaxed = true) { + every { favoriteIds } returns MutableStateFlow(emptySet()) + every { isFavorite(any()) } returns false + } + wrapper = mockk(relaxed = true) { + coEvery { loadPresentation(any(), any()) } returns FetchResult.Deactivated + } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(cocktailId: String = "mojito") = + DetailViewModel(repository, premiumManager, favoritesRepository, wrapper, cocktailId) + + @Test + fun `loads cocktail by id on init`() { + val vm = createViewModel() + assertNotNull(vm.cocktail.value) + assertEquals("Mojito", vm.cocktail.value?.name) + } + + @Test + fun `null cocktail when id not found`() { + val vm = createViewModel("nonexistent") + assertNull(vm.cocktail.value) + } + + @Test + fun `init tracks cocktails_viewed attribute`() { + createViewModel() + verify { wrapper.incrementUserAttribute("cocktails_viewed") } + } + + @Test + fun `init tracks favorite_spirit attribute`() { + createViewModel() + verify { wrapper.setUserAttribute("favorite_spirit", "Rum") } + } + + @Test + fun `init does not track spirit when cocktail not found`() { + createViewModel("nonexistent") + verify(exactly = 0) { wrapper.setUserAttribute("favorite_spirit", any()) } + } + + @Test + fun `isFavorite delegates to favoritesRepository`() { + every { favoritesRepository.isFavorite("mojito") } returns true + val vm = createViewModel() + assertTrue(vm.isFavorite()) + } + + @Test + fun `isFavorite returns false when not favorited`() { + val vm = createViewModel() + assertFalse(vm.isFavorite()) + } + + @Test + fun `toggleFavorite delegates to favoritesRepository`() { + val vm = createViewModel() + vm.toggleFavorite() + verify { favoritesRepository.toggleFavorite("mojito") } + } + + @Test + fun `showRecipePaywall calls loadPresentation with recipe_detail placement`() = runTest { + val vm = createViewModel() + vm.showRecipePaywall() + coVerify { wrapper.loadPresentation("recipe_detail", "mojito") } + } + + @Test + fun `showFavoritesPaywall calls loadPresentation with favorites placement`() = runTest { + val vm = createViewModel() + vm.showFavoritesPaywall() + coVerify { wrapper.loadPresentation("favorites", null) } + } + + @Test + fun `onPaywallDismissed refreshes premium status`() { + val vm = createViewModel() + vm.onPaywallDismissed() + verify { premiumManager.refreshPremiumStatus() } + } + + @Test + fun `isPremium exposes premiumManager state`() { + val premiumFlow = MutableStateFlow(false) + every { premiumManager.isPremium } returns premiumFlow + val vm = createViewModel() + assertFalse(vm.isPremium.value) + premiumFlow.value = true + assertTrue(vm.isPremium.value) + } + + @Test + fun `favoriteIds exposes favoritesRepository state`() { + val favFlow = MutableStateFlow(setOf("mojito")) + every { favoritesRepository.favoriteIds } returns favFlow + val vm = createViewModel() + assertTrue(vm.favoriteIds.value.contains("mojito")) + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt new file mode 100644 index 0000000..be9da03 --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt @@ -0,0 +1,117 @@ +package com.purchasely.shaker.ui.screen.favorites + +import com.purchasely.shaker.data.CocktailRepository +import com.purchasely.shaker.data.FavoritesRepository +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper +import com.purchasely.shaker.testCocktail +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FavoritesViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private val testCocktails = listOf( + testCocktail("1", "Mojito", "Rum"), + testCocktail("2", "Margarita", "Tequila"), + testCocktail("3", "Negroni", "Gin") + ) + + private lateinit var cocktailRepository: CocktailRepository + private lateinit var favoritesRepository: FavoritesRepository + private lateinit var premiumManager: PremiumManager + private lateinit var wrapper: PurchaselyWrapper + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + cocktailRepository = mockk { + every { loadCocktails() } returns testCocktails + } + favoritesRepository = mockk(relaxed = true) { + every { favoriteIds } returns MutableStateFlow(setOf("1", "3")) + } + premiumManager = mockk { + every { isPremium } returns MutableStateFlow(false) + every { refreshPremiumStatus() } returns Unit + } + wrapper = mockk(relaxed = true) { + coEvery { loadPresentation(any(), any()) } returns FetchResult.Deactivated + } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = + FavoritesViewModel(cocktailRepository, favoritesRepository, premiumManager, wrapper) + + @Test + fun `getFavoriteCocktails returns matching cocktails`() { + val vm = createViewModel() + val favorites = vm.getFavoriteCocktails() + assertEquals(2, favorites.size) + assertEquals("Mojito", favorites[0].name) + assertEquals("Negroni", favorites[1].name) + } + + @Test + fun `getFavoriteCocktails returns empty when no favorites`() { + every { favoritesRepository.favoriteIds } returns MutableStateFlow(emptySet()) + val vm = createViewModel() + assertTrue(vm.getFavoriteCocktails().isEmpty()) + } + + @Test + fun `removeFavorite delegates to repository`() { + val vm = createViewModel() + vm.removeFavorite("1") + verify { favoritesRepository.removeFavorite("1") } + } + + @Test + fun `showFavoritesPaywall calls loadPresentation`() = runTest { + val vm = createViewModel() + vm.showFavoritesPaywall() + coVerify { wrapper.loadPresentation("favorites", null) } + } + + @Test + fun `onPaywallDismissed refreshes premium status`() { + val vm = createViewModel() + vm.onPaywallDismissed() + verify { premiumManager.refreshPremiumStatus() } + } + + @Test + fun `isPremium exposes premiumManager state`() { + val vm = createViewModel() + assertEquals(false, vm.isPremium.value) + } + + @Test + fun `favoriteIds exposes favoritesRepository state`() { + val vm = createViewModel() + assertEquals(setOf("1", "3"), vm.favoriteIds.value) + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt new file mode 100644 index 0000000..03376fe --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt @@ -0,0 +1,278 @@ +package com.purchasely.shaker.ui.screen.home + +import com.purchasely.shaker.data.CocktailRepository +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper +import com.purchasely.shaker.testCocktail +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private val testCocktails = listOf( + testCocktail("1", "Mojito", "Rum", "Classic", "Easy"), + testCocktail("2", "Margarita", "Tequila", "Classic", "Medium"), + testCocktail("3", "Negroni", "Gin", "Bitter", "Easy"), + testCocktail("4", "Old Fashioned", "Whiskey", "Classic", "Hard"), + testCocktail("5", "Daiquiri", "Rum", "Tropical", "Easy") + ) + + private lateinit var repository: CocktailRepository + private lateinit var premiumManager: PremiumManager + private lateinit var wrapper: PurchaselyWrapper + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + repository = mockk { + every { loadCocktails() } returns testCocktails + every { getSpirits() } returns listOf("Gin", "Rum", "Tequila", "Whiskey") + every { getCategories() } returns listOf("Bitter", "Classic", "Tropical") + every { getDifficulties() } returns listOf("Easy", "Medium", "Hard") + } + premiumManager = mockk { + every { isPremium } returns MutableStateFlow(false) + every { refreshPremiumStatus() } returns Unit + } + wrapper = mockk(relaxed = true) { + coEvery { loadPresentation(any(), any()) } returns FetchResult.Deactivated + } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = HomeViewModel(repository, premiumManager, wrapper) + + @Test + fun `initial cocktails are loaded from repository`() { + val vm = createViewModel() + assertEquals(testCocktails, vm.cocktails.value) + } + + @Test + fun `availableSpirits delegates to repository`() { + val vm = createViewModel() + assertEquals(listOf("Gin", "Rum", "Tequila", "Whiskey"), vm.availableSpirits) + } + + @Test + fun `availableCategories delegates to repository`() { + val vm = createViewModel() + assertEquals(listOf("Bitter", "Classic", "Tropical"), vm.availableCategories) + } + + @Test + fun `availableDifficulties delegates to repository`() { + val vm = createViewModel() + assertEquals(listOf("Easy", "Medium", "Hard"), vm.availableDifficulties) + } + + @Test + fun `search filters cocktails by name`() { + val vm = createViewModel() + vm.onSearchQueryChanged("Mojito") + assertEquals(1, vm.cocktails.value.size) + assertEquals("Mojito", vm.cocktails.value[0].name) + } + + @Test + fun `search is case insensitive`() { + val vm = createViewModel() + vm.onSearchQueryChanged("mojito") + assertEquals(1, vm.cocktails.value.size) + } + + @Test + fun `empty search returns all cocktails`() { + val vm = createViewModel() + vm.onSearchQueryChanged("Mojito") + vm.onSearchQueryChanged("") + assertEquals(5, vm.cocktails.value.size) + } + + @Test + fun `search sets has_used_search user attribute`() { + val vm = createViewModel() + vm.onSearchQueryChanged("test") + verify { wrapper.setUserAttribute("has_used_search", true) } + } + + @Test + fun `blank search does not set user attribute`() { + val vm = createViewModel() + vm.onSearchQueryChanged(" ") + verify(exactly = 0) { wrapper.setUserAttribute("has_used_search", true) } + } + + @Test + fun `toggleSpirit filters by spirit`() { + val vm = createViewModel() + vm.toggleSpirit("Rum") + assertEquals(2, vm.cocktails.value.size) + assertTrue(vm.cocktails.value.all { it.spirit == "Rum" }) + } + + @Test + fun `toggleSpirit twice removes filter`() { + val vm = createViewModel() + vm.toggleSpirit("Rum") + vm.toggleSpirit("Rum") + assertEquals(5, vm.cocktails.value.size) + } + + @Test + fun `multiple spirits filter with OR logic`() { + val vm = createViewModel() + vm.toggleSpirit("Rum") + vm.toggleSpirit("Gin") + assertEquals(3, vm.cocktails.value.size) + } + + @Test + fun `toggleCategory filters by category`() { + val vm = createViewModel() + vm.toggleCategory("Classic") + assertEquals(3, vm.cocktails.value.size) + assertTrue(vm.cocktails.value.all { it.category == "Classic" }) + } + + @Test + fun `toggleCategory twice removes filter`() { + val vm = createViewModel() + vm.toggleCategory("Classic") + vm.toggleCategory("Classic") + assertEquals(5, vm.cocktails.value.size) + } + + @Test + fun `selectDifficulty filters by difficulty`() { + val vm = createViewModel() + vm.selectDifficulty("Easy") + assertEquals(3, vm.cocktails.value.size) + assertTrue(vm.cocktails.value.all { it.difficulty == "Easy" }) + } + + @Test + fun `selectDifficulty same value toggles off`() { + val vm = createViewModel() + vm.selectDifficulty("Easy") + vm.selectDifficulty("Easy") + assertEquals(5, vm.cocktails.value.size) + } + + @Test + fun `combined spirit and category filters`() { + val vm = createViewModel() + vm.toggleSpirit("Rum") + vm.toggleCategory("Classic") + assertEquals(1, vm.cocktails.value.size) + assertEquals("Mojito", vm.cocktails.value[0].name) + } + + @Test + fun `combined search and spirit filter`() { + val vm = createViewModel() + vm.toggleSpirit("Rum") + vm.onSearchQueryChanged("Daiquiri") + assertEquals(1, vm.cocktails.value.size) + assertEquals("Daiquiri", vm.cocktails.value[0].name) + } + + @Test + fun `clearFilters resets all filters`() { + val vm = createViewModel() + vm.toggleSpirit("Rum") + vm.toggleCategory("Classic") + vm.selectDifficulty("Easy") + vm.clearFilters() + assertEquals(5, vm.cocktails.value.size) + assertTrue(vm.selectedSpirits.value.isEmpty()) + assertTrue(vm.selectedCategories.value.isEmpty()) + assertEquals(null, vm.selectedDifficulty.value) + } + + @Test + fun `hasActiveFilters is false initially`() { + val vm = createViewModel() + assertFalse(vm.hasActiveFilters) + } + + @Test + fun `hasActiveFilters is true with spirit filter`() { + val vm = createViewModel() + vm.toggleSpirit("Rum") + assertTrue(vm.hasActiveFilters) + } + + @Test + fun `hasActiveFilters is true with category filter`() { + val vm = createViewModel() + vm.toggleCategory("Classic") + assertTrue(vm.hasActiveFilters) + } + + @Test + fun `hasActiveFilters is true with difficulty filter`() { + val vm = createViewModel() + vm.selectDifficulty("Easy") + assertTrue(vm.hasActiveFilters) + } + + @Test + fun `init prefetches presentations when not premium`() { + createViewModel() + coVerify { wrapper.loadPresentation("filters", null) } + coVerify { wrapper.loadPresentation("inline", null) } + } + + @Test + fun `init does not prefetch presentations when premium`() { + every { premiumManager.isPremium } returns MutableStateFlow(true) + createViewModel() + coVerify(exactly = 0) { wrapper.loadPresentation(any(), any()) } + } + + @Test + fun `onPaywallDismissed refreshes premium status`() { + val vm = createViewModel() + vm.onPaywallDismissed() + verify { premiumManager.refreshPremiumStatus() } + } + + @Test + fun `onFilterClick does nothing when premium`() { + every { premiumManager.isPremium } returns MutableStateFlow(true) + val vm = createViewModel() + vm.onFilterClick() + // No exception, no paywall display signal + } + + @Test + fun `filter returns no results when none match`() { + val vm = createViewModel() + vm.onSearchQueryChanged("NonExistentCocktail") + assertTrue(vm.cocktails.value.isEmpty()) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 39788aa..ee62800 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -11,6 +11,10 @@ kotlinx-serialization = "1.7.3" coil = "3.0.4" purchasely = "5.7.3" billing = "7.1.1" +junit = "4.13.2" +mockk = "1.13.13" +coroutines-test = "1.9.0" +turbine = "1.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -39,6 +43,11 @@ purchasely-google = { group = "io.purchasely", name = "google-play", version.ref google-billing = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines-test" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/docs/superpowers/plans/2026-04-09-purchasely-wrapper-refactor.md b/docs/superpowers/plans/2026-04-09-purchasely-wrapper-refactor.md new file mode 100644 index 0000000..be5b37e --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-purchasely-wrapper-refactor.md @@ -0,0 +1,949 @@ +# PurchaselyWrapper Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract all direct Purchasely SDK calls from UI layers into a `PurchaselyWrapper` class, starting with HomeScreen/HomeViewModel. + +**Architecture:** Create a `purchasely` package under `com.purchasely.shaker` containing a Koin-injectable `PurchaselyWrapper` class with suspend functions for async SDK operations. ViewModels call the wrapper; Screens have zero `io.purchasely` imports. An `EmbeddedScreenBanner` composable in the same package handles inline paywall rendering. + +**Tech Stack:** Kotlin, Jetpack Compose, Koin DI, Coroutines + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `purchasely/PurchaselyWrapper.kt` | Wraps all Purchasely SDK calls (fetch, display, buildView, attributes) | +| Create | `purchasely/FetchResult.kt` | Sealed class for fetch outcomes | +| Create | `purchasely/DisplayResult.kt` | Sealed class for display/purchase outcomes | +| Create | `purchasely/EmbeddedScreenBanner.kt` | Reusable Composable for inline paywall views | +| Modify | `di/AppModule.kt` | Register PurchaselyWrapper in Koin | +| Modify | `ui/screen/home/HomeViewModel.kt` | Inject PurchaselyWrapper, move paywall logic here | +| Modify | `ui/screen/home/HomeScreen.kt` | Remove all `io.purchasely` imports, use ViewModel + EmbeddedScreenBanner | +| Create | `docs/purchasely-best-practices.md` (project root) | Best practices document | +| Modify | `CLAUDE.md` (project root) | Reference best practices doc, note demo project purpose | + +All paths relative to `android/app/src/main/java/com/purchasely/shaker/` unless noted. + +--- + +### Task 1: Create FetchResult sealed class + +**Files:** +- Create: `android/app/src/main/java/com/purchasely/shaker/purchasely/FetchResult.kt` + +- [ ] **Step 1: Create FetchResult.kt** + +```kotlin +package com.purchasely.shaker.purchasely + +import io.purchasely.ext.PLYPresentation +import io.purchasely.models.PLYError + +sealed class FetchResult { + data class Success(val presentation: PLYPresentation) : FetchResult() + data class Client(val presentation: PLYPresentation) : FetchResult() + data object Deactivated : FetchResult() + data class Error(val error: PLYError?) : FetchResult() +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd /Users/kevin/Purchasely/Shaker/android && ./gradlew :app:compileDebugKotlin 2>&1 | tail -5` +Expected: BUILD SUCCESSFUL + +--- + +### Task 2: Create DisplayResult sealed class + +**Files:** +- Create: `android/app/src/main/java/com/purchasely/shaker/purchasely/DisplayResult.kt` + +- [ ] **Step 1: Create DisplayResult.kt** + +```kotlin +package com.purchasely.shaker.purchasely + +sealed class DisplayResult { + data class Purchased(val planName: String?) : DisplayResult() + data class Restored(val planName: String?) : DisplayResult() + data object Cancelled : DisplayResult() +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd /Users/kevin/Purchasely/Shaker/android && ./gradlew :app:compileDebugKotlin 2>&1 | tail -5` +Expected: BUILD SUCCESSFUL + +--- + +### Task 3: Create PurchaselyWrapper + +**Files:** +- Create: `android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt` + +- [ ] **Step 1: Create PurchaselyWrapper.kt** + +```kotlin +package com.purchasely.shaker.purchasely + +import android.app.Activity +import android.content.Context +import android.view.View +import io.purchasely.ext.PLYPresentation +import io.purchasely.ext.PLYPresentationProperties +import io.purchasely.ext.PLYPresentationType +import io.purchasely.ext.PLYProductViewResult +import io.purchasely.ext.Purchasely +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class PurchaselyWrapper { + + suspend fun loadPresentation( + placementId: String, + contentId: String? = null + ): FetchResult = suspendCoroutine { continuation -> + val callback = { presentation: PLYPresentation?, error: io.purchasely.models.PLYError? -> + when { + presentation == null -> continuation.resume(FetchResult.Error(error)) + presentation.type == PLYPresentationType.DEACTIVATED -> continuation.resume(FetchResult.Deactivated) + presentation.type == PLYPresentationType.CLIENT -> continuation.resume(FetchResult.Client(presentation)) + else -> continuation.resume(FetchResult.Success(presentation)) + } + } + if (contentId != null) { + Purchasely.fetchPresentation( + properties = PLYPresentationProperties(placementId = placementId, contentId = contentId), + callback + ) + } else { + Purchasely.fetchPresentation(placementId, callback) + } + } + + suspend fun display( + presentation: PLYPresentation, + activity: Activity + ): DisplayResult = suspendCoroutine { continuation -> + presentation.display(activity) { result, plan -> + when (result) { + PLYProductViewResult.PURCHASED -> continuation.resume(DisplayResult.Purchased(plan?.name)) + PLYProductViewResult.RESTORED -> continuation.resume(DisplayResult.Restored(plan?.name)) + else -> continuation.resume(DisplayResult.Cancelled) + } + } + } + + fun getView( + presentation: PLYPresentation, + context: Context, + onClose: () -> Unit, + onResult: (DisplayResult) -> Unit + ): View { + return presentation.buildView(context, onClose) { result, plan -> + when (result) { + PLYProductViewResult.PURCHASED -> onResult(DisplayResult.Purchased(plan?.name)) + PLYProductViewResult.RESTORED -> onResult(DisplayResult.Restored(plan?.name)) + else -> onResult(DisplayResult.Cancelled) + } + } + } + + fun setUserAttribute(key: String, value: Any) { + Purchasely.setUserAttribute(key, value) + } + + fun incrementUserAttribute(key: String) { + Purchasely.incrementUserAttribute(key) + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd /Users/kevin/Purchasely/Shaker/android && ./gradlew :app:compileDebugKotlin 2>&1 | tail -5` +Expected: BUILD SUCCESSFUL + +--- + +### Task 4: Create EmbeddedScreenBanner composable + +**Files:** +- Create: `android/app/src/main/java/com/purchasely/shaker/purchasely/EmbeddedScreenBanner.kt` + +- [ ] **Step 1: Create EmbeddedScreenBanner.kt** + +```kotlin +package com.purchasely.shaker.purchasely + +import android.util.Log +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import org.koin.compose.koinInject + +@Composable +fun EmbeddedScreenBanner( + placementId: String, + onResult: (DisplayResult) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val wrapper: PurchaselyWrapper = koinInject() + var view by remember { mutableStateOf(null) } + + LaunchedEffect(placementId) { + when (val result = wrapper.loadPresentation(placementId)) { + is FetchResult.Success -> { + view = wrapper.getView( + presentation = result.presentation, + context = context, + onClose = { view = null }, + onResult = onResult + ) + } + is FetchResult.Client -> { + Log.d("EmbeddedScreenBanner", "[Shaker] CLIENT presentation for $placementId") + } + else -> { + Log.d("EmbeddedScreenBanner", "[Shaker] Presentation not available for $placementId") + } + } + } + + view?.let { androidView -> + AndroidView( + factory = { androidView }, + modifier = modifier + ) + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd /Users/kevin/Purchasely/Shaker/android && ./gradlew :app:compileDebugKotlin 2>&1 | tail -5` +Expected: BUILD SUCCESSFUL + +--- + +### Task 5: Register PurchaselyWrapper in Koin + +**Files:** +- Modify: `android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt` + +- [ ] **Step 1: Add PurchaselyWrapper to Koin module** + +Add import and singleton registration: + +```kotlin +// Add import +import com.purchasely.shaker.purchasely.PurchaselyWrapper + +// In the module block, add: +single { PurchaselyWrapper() } +``` + +The full updated `appModule` block becomes: +```kotlin +val appModule = module { + single { CocktailRepository(androidContext()) } + single { FavoritesRepository(androidContext()) } + single { OnboardingRepository(androidContext()) } + single { RunningModeRepository(androidContext()) } + single { PremiumManager() } + single { PurchaseManager(androidContext()) } + single { PurchaselyWrapper() } + viewModel { HomeViewModel(get(), get(), get()) } + viewModel { params -> DetailViewModel(get(), get(), get(), params.get()) } + viewModel { FavoritesViewModel(get(), get(), get()) } + viewModel { SettingsViewModel(androidContext(), get(), get()) } +} +``` + +Note: `HomeViewModel` now takes 3 params (added `PurchaselyWrapper`). + +- [ ] **Step 2: Verify compilation** + +Run: `cd /Users/kevin/Purchasely/Shaker/android && ./gradlew :app:compileDebugKotlin 2>&1 | tail -5` +Expected: compilation error (HomeViewModel constructor not updated yet — expected) + +--- + +### Task 6: Refactor HomeViewModel + +**Files:** +- Modify: `android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt` + +- [ ] **Step 1: Rewrite HomeViewModel.kt** + +Replace the full file content: + +```kotlin +package com.purchasely.shaker.ui.screen.home + +import android.app.Activity +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.purchasely.shaker.data.CocktailRepository +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.model.Cocktail +import com.purchasely.shaker.purchasely.DisplayResult +import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper +import io.purchasely.ext.PLYPresentation +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class HomeViewModel( + private val repository: CocktailRepository, + private val premiumManager: PremiumManager, + private val purchaselyWrapper: PurchaselyWrapper +) : ViewModel() { + + private val _cocktails = MutableStateFlow>(emptyList()) + val cocktails: StateFlow> = _cocktails.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + val isPremium: StateFlow = premiumManager.isPremium + + // Filter state + private val _selectedSpirits = MutableStateFlow>(emptySet()) + val selectedSpirits: StateFlow> = _selectedSpirits.asStateFlow() + + private val _selectedCategories = MutableStateFlow>(emptySet()) + val selectedCategories: StateFlow> = _selectedCategories.asStateFlow() + + private val _selectedDifficulty = MutableStateFlow(null) + val selectedDifficulty: StateFlow = _selectedDifficulty.asStateFlow() + + val availableSpirits: List get() = repository.getSpirits() + val availableCategories: List get() = repository.getCategories() + val availableDifficulties: List get() = repository.getDifficulties() + + val hasActiveFilters: Boolean + get() = _selectedSpirits.value.isNotEmpty() || + _selectedCategories.value.isNotEmpty() || + _selectedDifficulty.value != null + + // Paywall: ViewModel fetches, then signals Screen to display (Screen provides Activity) + private var pendingFiltersPresentation: PLYPresentation? = null + + private val _requestPaywallDisplay = MutableSharedFlow() + val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() + + init { + _cocktails.value = repository.loadCocktails() + } + + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + if (query.isNotBlank()) { + purchaselyWrapper.setUserAttribute("has_used_search", true) + } + applyFilters() + } + + fun onFilterClick() { + if (isPremium.value) { + // Premium user: show filter sheet directly (handled by Screen via return value or state) + return + } + viewModelScope.launch { + when (val result = purchaselyWrapper.loadPresentation("filters")) { + is FetchResult.Success -> { + pendingFiltersPresentation = result.presentation + _requestPaywallDisplay.emit(Unit) + } + is FetchResult.Client -> { + Log.d("HomeViewModel", "[Shaker] CLIENT presentation for filters — build custom UI here") + } + else -> {} + } + } + } + + suspend fun displayPendingPaywall(activity: Activity) { + val presentation = pendingFiltersPresentation ?: return + pendingFiltersPresentation = null + val result = purchaselyWrapper.display(presentation, activity) + when (result) { + is DisplayResult.Purchased, is DisplayResult.Restored -> { + Log.d("HomeViewModel", "[Shaker] Purchased/Restored from filters: ${(result as? DisplayResult.Purchased)?.planName ?: (result as? DisplayResult.Restored)?.planName}") + onPaywallDismissed() + } + else -> {} + } + } + + fun toggleSpirit(spirit: String) { + val current = _selectedSpirits.value.toMutableSet() + if (current.contains(spirit)) current.remove(spirit) else current.add(spirit) + _selectedSpirits.value = current + applyFilters() + } + + fun toggleCategory(category: String) { + val current = _selectedCategories.value.toMutableSet() + if (current.contains(category)) current.remove(category) else current.add(category) + _selectedCategories.value = current + applyFilters() + } + + fun selectDifficulty(difficulty: String?) { + _selectedDifficulty.value = if (_selectedDifficulty.value == difficulty) null else difficulty + applyFilters() + } + + fun clearFilters() { + _selectedSpirits.value = emptySet() + _selectedCategories.value = emptySet() + _selectedDifficulty.value = null + applyFilters() + } + + fun onPaywallDismissed() { + premiumManager.refreshPremiumStatus() + } + + private fun applyFilters() { + val query = _searchQuery.value + val spirits = _selectedSpirits.value + val categories = _selectedCategories.value + val difficulty = _selectedDifficulty.value + + _cocktails.value = repository.loadCocktails().filter { cocktail -> + val matchesQuery = query.isBlank() || cocktail.name.contains(query, ignoreCase = true) + val matchesSpirit = spirits.isEmpty() || spirits.contains(cocktail.spirit) + val matchesCategory = categories.isEmpty() || categories.contains(cocktail.category) + val matchesDifficulty = difficulty == null || cocktail.difficulty == difficulty + matchesQuery && matchesSpirit && matchesCategory && matchesDifficulty + } + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd /Users/kevin/Purchasely/Shaker/android && ./gradlew :app:compileDebugKotlin 2>&1 | tail -5` +Expected: compilation error (HomeScreen still uses old APIs — expected) + +--- + +### Task 7: Refactor HomeScreen + +**Files:** +- Modify: `android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt` + +- [ ] **Step 1: Rewrite HomeScreen.kt** + +Replace the full file content. Key changes: +- Zero `io.purchasely` imports +- Filter icon click calls `viewModel.onFilterClick()` for non-premium, or toggles sheet for premium +- `LaunchedEffect` collects `requestPaywallDisplay` to call `viewModel.displayPendingPaywall(activity)` +- `EmbeddedPaywallBanner` replaced by `EmbeddedScreenBanner` from the `purchasely` package +- Removed `EmbeddedPaywallBanner` private composable + +```kotlin +package com.purchasely.shaker.ui.screen.home + +import android.app.Activity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Alignment +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.purchasely.shaker.domain.model.Cocktail +import com.purchasely.shaker.purchasely.EmbeddedScreenBanner +import com.purchasely.shaker.ui.components.CocktailImage +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + onCocktailClick: (String) -> Unit, + viewModel: HomeViewModel = koinViewModel() +) { + val cocktails by viewModel.cocktails.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val isPremium by viewModel.isPremium.collectAsState() + val context = LocalContext.current + var showFilterSheet by remember { mutableStateOf(false) } + + // Collect paywall display requests from ViewModel + LaunchedEffect(Unit) { + viewModel.requestPaywallDisplay.collect { + val activity = context as? Activity ?: return@collect + viewModel.displayPendingPaywall(activity) + } + } + + Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) { + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = searchQuery, + onQueryChange = viewModel::onSearchQueryChanged, + onSearch = {}, + expanded = false, + onExpandedChange = {}, + placeholder = { Text("Search cocktails...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + trailingIcon = { + IconButton(onClick = { + if (isPremium) { + showFilterSheet = true + } else { + viewModel.onFilterClick() + } + }) { + if (viewModel.hasActiveFilters) { + BadgedBox(badge = { Badge() }) { + Icon(Icons.Default.Tune, contentDescription = "Filters") + } + } else { + Icon(Icons.Default.Tune, contentDescription = "Filters") + } + } + } + ) + }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) {} + + if (cocktails.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "No cocktails found", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Try a different search or filter.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + if (!isPremium) { + item(span = { GridItemSpan(2) }) { + EmbeddedScreenBanner( + placementId = "inline", + onResult = { viewModel.onPaywallDismissed() }, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .padding(vertical = 8.dp) + ) + } + } + items(cocktails, key = { it.id }) { cocktail -> + CocktailCard(cocktail = cocktail, onClick = { onCocktailClick(cocktail.id) }) + } + } + } + } + + if (showFilterSheet) { + FilterSheet( + viewModel = viewModel, + onDismiss = { showFilterSheet = false } + ) + } +} + +@Composable +private fun CocktailCard(cocktail: Cocktail, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column { + CocktailImage( + cocktail = cocktail, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.75f) + .clip(MaterialTheme.shapes.medium) + ) + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = cocktail.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = cocktail.category.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} +``` + +- [ ] **Step 2: Verify full compilation** + +Run: `cd /Users/kevin/Purchasely/Shaker/android && ./gradlew :app:compileDebugKotlin 2>&1 | tail -5` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Verify zero `io.purchasely` imports in HomeScreen** + +Run: `rg 'import io\.purchasely' android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt` +Expected: no output (0 matches) + +- [ ] **Step 4: Commit** + +```bash +git add android/app/src/main/java/com/purchasely/shaker/purchasely/ \ + android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt \ + android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt \ + android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt +git commit -m "refactor(android): extract PurchaselyWrapper, clean HomeScreen/HomeViewModel" +``` + +--- + +### Task 8: Create best practices document + +**Files:** +- Create: `docs/purchasely-best-practices.md` (at Shaker project root) + +- [ ] **Step 1: Write the best practices document** + +Create `docs/purchasely-best-practices.md` with comprehensive guidelines covering: +- Architecture: always use a wrapper, never call SDK directly from UI +- Fetching: always use `fetchPresentation()` then `buildView()`/`display()`, never `presentationView()` +- MVVM: ViewModel owns paywall logic, Screen provides Activity when needed +- Embedded vs modal patterns +- User attributes best practices +- Error handling patterns +- Platform-specific notes (Android section now, iOS section placeholder) + +Full content: + +```markdown +# Purchasely SDK — Best Practices + +This document defines the integration standards for the Purchasely SDK in Shaker. +All changes to the Purchasely integration must follow and update this document. + +--- + +## 1. Architecture: PurchaselyWrapper + +**Rule: Never call the Purchasely SDK directly from Screens or ViewModels.** + +All SDK calls go through `PurchaselyWrapper`, a Koin-injectable class in the `purchasely` package. + +**Why:** +- Single point of control for all SDK interactions +- Screens have zero `io.purchasely` imports — clean separation of concerns +- Easier to test (mock the wrapper, not the SDK) +- Consistent error handling and result mapping +- Type-safe result types (`FetchResult`, `DisplayResult`) instead of raw SDK enums + +**Wrapper responsibilities:** +- `loadPresentation()` — fetch a presentation (suspend) +- `display()` — show a modal paywall (suspend) +- `getView()` — build an inline view for embedding +- `setUserAttribute()` / `incrementUserAttribute()` — user targeting + +--- + +## 2. Presentation Loading: Always fetch then build/display + +**Rule: Always use `Purchasely.fetchPresentation()` followed by `presentation.buildView()` or `presentation.display()`. Never use `Purchasely.presentationView()`.** + +**Why:** +- `fetchPresentation` + `buildView`/`display` gives full control over the presentation lifecycle +- You can inspect `presentation.type` before deciding what to do (NORMAL, CLIENT, DEACTIVATED) +- You can handle errors from the fetch step separately from display errors +- `presentationView()` is a convenience shortcut that hides these steps — unsuitable for production code + +**Pattern:** +```kotlin +// In PurchaselyWrapper +suspend fun loadPresentation(placementId: String): FetchResult { + // Uses Purchasely.fetchPresentation() internally + // Maps result to FetchResult sealed class +} + +// For modal display +suspend fun display(presentation: PLYPresentation, activity: Activity): DisplayResult { + // Uses presentation.display(activity) internally +} + +// For inline/embedded display +fun getView(presentation: PLYPresentation, context: Context, ...): View { + // Uses presentation.buildView(context, onClose) internally +} +``` + +--- + +## 3. MVVM Pattern: ViewModel Owns Paywall Logic + +**Rule: ViewModels decide when and what to show. Screens only provide the Activity and render the UI.** + +**Modal paywall flow:** +1. User action triggers ViewModel method (e.g., `onFilterClick()`) +2. ViewModel checks premium status +3. If not premium, ViewModel calls `wrapper.loadPresentation()` (suspend, in viewModelScope) +4. On success, ViewModel stores the presentation and emits an event via `SharedFlow` +5. Screen collects the event, gets the Activity, calls `viewModel.displayPendingPaywall(activity)` +6. ViewModel calls `wrapper.display(presentation, activity)` and handles the result + +**Why the Activity passes through the ViewModel:** +- The SDK requires an `Activity` to display modal paywalls +- The ViewModel cannot hold an Activity reference (lifecycle leak) +- The Screen provides the Activity on-demand when the ViewModel signals readiness +- This is a standard Android MVVM compromise for SDK interactions + +**Embedded paywall flow:** +1. Use the `EmbeddedScreenBanner` composable from the `purchasely` package +2. Pass `placementId` and an `onResult` callback +3. The composable handles fetch + buildView internally via `LaunchedEffect` +4. The `onResult` callback notifies the ViewModel of purchase results + +--- + +## 4. EmbeddedScreenBanner: Reusable Inline Paywall + +**Rule: Use `EmbeddedScreenBanner` for any inline/embedded paywall display.** + +```kotlin +EmbeddedScreenBanner( + placementId = "inline", + onResult = { viewModel.onPaywallDismissed() }, + modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp) +) +``` + +**Behavior:** +- Fetches the presentation via `PurchaselyWrapper.loadPresentation()` +- Builds the view via `PurchaselyWrapper.getView()` +- Renders via `AndroidView` +- Handles CLIENT and DEACTIVATED types gracefully (logs, no crash) +- `onClose` hides the banner; `onResult` forwards purchase events + +--- + +## 5. User Attributes + +**Rule: Set user attributes through `PurchaselyWrapper`, always from the ViewModel layer.** + +```kotlin +// In ViewModel +purchaselyWrapper.setUserAttribute("has_used_search", true) +purchaselyWrapper.incrementUserAttribute("cocktails_viewed") +purchaselyWrapper.setUserAttribute("favorite_spirit", "gin") +``` + +**When to set attributes:** +- On meaningful user actions (search, view detail, add favorite) +- On preference changes (theme, user ID) +- Never on every recomposition — only on actual state changes + +--- + +## 6. Handling Presentation Types + +Always handle all `FetchResult` variants: + +| Type | Action | +|------|--------| +| `Success` | Display or build view normally | +| `Client` | App must build its own paywall UI using plan data from the presentation | +| `Deactivated` | Do nothing — placement is disabled in the Purchasely console | +| `Error` | Log the error, fail gracefully (no crash, no empty screen) | + +--- + +## 7. Error Handling + +- **Never crash on SDK errors.** Log and degrade gracefully. +- **Never block the UI** waiting for a presentation. Use coroutines (suspend) and show content immediately. +- **Embedded views:** If fetch fails, the banner simply doesn't appear. +- **Modal paywalls:** If fetch fails, the user action is silently ignored (with a log). + +--- + +## 8. Platform-Specific Notes + +### Android (Kotlin / Jetpack Compose) + +- `PurchaselyWrapper` is a Koin singleton (`single { PurchaselyWrapper() }`) +- ViewModels inject it via constructor: `class HomeViewModel(..., private val purchaselyWrapper: PurchaselyWrapper)` +- `EmbeddedScreenBanner` uses `koinInject()` for DI in Composables +- Async operations use Kotlin coroutines (`suspend` functions with `suspendCoroutine`) +- `display()` requires an `Activity` — passed from Screen to ViewModel on-demand + +### iOS (SwiftUI) + +_To be defined — will follow the same architectural principles adapted to SwiftUI/Combine patterns._ + +--- + +## Checklist for New Purchasely Integrations + +- [ ] All SDK calls go through `PurchaselyWrapper` +- [ ] Screen has zero `io.purchasely` imports +- [ ] Uses `loadPresentation()` + `display()`/`getView()`, never `presentationView()` +- [ ] Handles all `FetchResult` variants (Success, Client, Deactivated, Error) +- [ ] User attributes set from ViewModel, not Screen +- [ ] Modal paywalls: ViewModel fetches, Screen provides Activity +- [ ] Embedded paywalls: uses `EmbeddedScreenBanner` +- [ ] No crashes on SDK errors +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/purchasely-best-practices.md +git commit -m "docs: add Purchasely SDK best practices guide" +``` + +--- + +### Task 9: Update CLAUDE.md + +**Files:** +- Modify: `CLAUDE.md` (Shaker project root) + +- [ ] **Step 1: Update About section** + +Change the first paragraph of the About section to emphasize demo/reference purpose: + +From: +``` +Shaker is a cocktail discovery app demonstrating a production-quality Purchasely SDK integration. +``` + +To: +``` +Shaker is a **reference demo application** showcasing the best way to integrate the Purchasely SDK on iOS and Android. It serves as the canonical example for SDK integration patterns — all code must follow the best practices defined in `docs/purchasely-best-practices.md`. +``` + +- [ ] **Step 2: Add best practices reference to Conventions section** + +Add at the end of the Conventions section: + +```markdown +- **Purchasely best practices**: All SDK integration changes must follow `docs/purchasely-best-practices.md`. Update the doc when patterns change. +``` + +- [ ] **Step 3: Add PurchaselyWrapper to Key Components** + +Add to the Key Components section: + +```markdown +- **PurchaselyWrapper**: Koin singleton wrapping all Purchasely SDK calls. ViewModels use this exclusively — Screens never import `io.purchasely`. See `docs/purchasely-best-practices.md`. +- **EmbeddedScreenBanner**: Reusable Composable for inline paywall display. Uses PurchaselyWrapper internally. +``` + +- [ ] **Step 4: Update Repository Structure** + +Add `purchasely/` to the Android directory tree: + +``` +│ │ ├── data/ # CocktailRepository, FavoritesRepository, PremiumManager, OnboardingRepository +│ │ ├── di/ # Koin DI modules +│ │ ├── domain/model/ # Data models (Cocktail, Ingredient) +│ │ ├── purchasely/ # PurchaselyWrapper, EmbeddedScreenBanner, result types +│ │ └── ui/ # Compose UI (screens, navigation, theme, components) +``` + +- [ ] **Step 5: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: update CLAUDE.md with PurchaselyWrapper architecture and best practices ref" +``` diff --git a/ios/Podfile b/ios/Podfile index c8e8510..ab9af44 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -4,6 +4,10 @@ target 'Shaker' do use_frameworks! pod 'Purchasely' + + target 'ShakerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/ios/Shaker/Data/CocktailRepository.swift b/ios/Shaker/Data/CocktailRepository.swift index c941bc0..5a9393d 100644 --- a/ios/Shaker/Data/CocktailRepository.swift +++ b/ios/Shaker/Data/CocktailRepository.swift @@ -10,6 +10,11 @@ class CocktailRepository { loadCocktails() } + /// For testing: create a repository with pre-loaded cocktails + init(cocktails: [Cocktail]) { + self.cocktails = cocktails + } + @discardableResult func loadCocktails() -> [Cocktail] { guard cocktails.isEmpty else { return cocktails } diff --git a/ios/Shaker/Data/FavoritesRepository.swift b/ios/Shaker/Data/FavoritesRepository.swift index c618538..305f79e 100644 --- a/ios/Shaker/Data/FavoritesRepository.swift +++ b/ios/Shaker/Data/FavoritesRepository.swift @@ -6,10 +6,20 @@ class FavoritesRepository: ObservableObject { @Published var favoriteIds: Set = [] - private let defaults = UserDefaults.standard - private let key = "favorite_cocktail_ids" + private let defaults: UserDefaults + private let key: String private init() { + self.defaults = .standard + self.key = "favorite_cocktail_ids" + let saved = defaults.stringArray(forKey: key) ?? [] + favoriteIds = Set(saved) + } + + /// For testing: create a repository with a custom UserDefaults suite + init(defaults: UserDefaults, key: String = "favorite_cocktail_ids") { + self.defaults = defaults + self.key = key let saved = defaults.stringArray(forKey: key) ?? [] favoriteIds = Set(saved) } diff --git a/ios/Shaker/Data/OnboardingRepository.swift b/ios/Shaker/Data/OnboardingRepository.swift index ce2452c..92b023e 100644 --- a/ios/Shaker/Data/OnboardingRepository.swift +++ b/ios/Shaker/Data/OnboardingRepository.swift @@ -6,13 +6,23 @@ class OnboardingRepository: ObservableObject { @Published var isOnboardingCompleted: Bool { didSet { - UserDefaults.standard.set(isOnboardingCompleted, forKey: key) + defaults.set(isOnboardingCompleted, forKey: key) } } - private let key = "onboarding_completed" + private let defaults: UserDefaults + private let key: String private init() { - isOnboardingCompleted = UserDefaults.standard.bool(forKey: key) + self.defaults = .standard + self.key = "onboarding_completed" + isOnboardingCompleted = defaults.bool(forKey: key) + } + + /// For testing: create a repository with a custom UserDefaults suite + init(defaults: UserDefaults, key: String = "onboarding_completed") { + self.defaults = defaults + self.key = key + isOnboardingCompleted = defaults.bool(forKey: key) } } diff --git a/ios/Shaker/Screens/Detail/DetailViewModel.swift b/ios/Shaker/Screens/Detail/DetailViewModel.swift index cd5f04f..9f17acb 100644 --- a/ios/Shaker/Screens/Detail/DetailViewModel.swift +++ b/ios/Shaker/Screens/Detail/DetailViewModel.swift @@ -7,10 +7,14 @@ class DetailViewModel: ObservableObject { @Published var recipeFetchResult: FetchResult? @Published var favoritesFetchResult: FetchResult? - private let repository = CocktailRepository.shared - private let wrapper = PurchaselyWrapper.shared + private let repository: CocktailRepository + private let wrapper: PurchaselyWrapping - init(cocktailId: String) { + init(cocktailId: String, + repository: CocktailRepository = .shared, + wrapper: PurchaselyWrapping = PurchaselyWrapper.shared) { + self.repository = repository + self.wrapper = wrapper cocktail = repository.cocktail(byId: cocktailId) trackCocktailViewed() } diff --git a/ios/Shaker/Screens/Home/HomeViewModel.swift b/ios/Shaker/Screens/Home/HomeViewModel.swift index bba840a..c73ef31 100644 --- a/ios/Shaker/Screens/Home/HomeViewModel.swift +++ b/ios/Shaker/Screens/Home/HomeViewModel.swift @@ -17,8 +17,8 @@ class HomeViewModel: ObservableObject { @Published var filtersPresentation: FetchResult? @Published var isFiltersLoading = false - private let repository = CocktailRepository.shared - private let wrapper = PurchaselyWrapper.shared + private let repository: CocktailRepository + private let wrapper: PurchaselyWrapping private var cancellables = Set() var availableSpirits: [String] { repository.spirits() } @@ -40,7 +40,10 @@ class HomeViewModel: ObservableObject { inlinePresentation?.height ?? 0 } - init() { + init(repository: CocktailRepository = .shared, + wrapper: PurchaselyWrapping = PurchaselyWrapper.shared) { + self.repository = repository + self.wrapper = wrapper let allCocktails = repository.allCocktails() Publishers.CombineLatest4( diff --git a/ios/Shaker/Screens/Settings/SettingsViewModel.swift b/ios/Shaker/Screens/Settings/SettingsViewModel.swift index 4c40c59..cdbefbb 100644 --- a/ios/Shaker/Screens/Settings/SettingsViewModel.swift +++ b/ios/Shaker/Screens/Settings/SettingsViewModel.swift @@ -22,7 +22,8 @@ class SettingsViewModel: ObservableObject { // Prefetched onboarding presentation @Published var onboardingFetchResult: FetchResult? - private let wrapper = PurchaselyWrapper.shared + private let wrapper: PurchaselyWrapping + private let defaults: UserDefaults private let userIdKey = "user_id" private let themeKey = "theme_mode" @@ -35,20 +36,22 @@ class SettingsViewModel: ObservableObject { var sdkVersion: String { wrapper.sdkVersion } - init() { - userId = UserDefaults.standard.string(forKey: userIdKey) - themeMode = UserDefaults.standard.string(forKey: themeKey) ?? "system" + init(wrapper: PurchaselyWrapping = PurchaselyWrapper.shared, + defaults: UserDefaults = .standard) { + self.wrapper = wrapper + self.defaults = defaults + userId = defaults.string(forKey: userIdKey) + themeMode = defaults.string(forKey: themeKey) ?? "system" sdkMode = PurchaselySDKMode.current() sdkModeRestartMessage = nil - let defaults = UserDefaults.standard analyticsConsent = defaults.object(forKey: consentAnalyticsKey) == nil ? true : defaults.bool(forKey: consentAnalyticsKey) identifiedAnalyticsConsent = defaults.object(forKey: consentIdentifiedAnalyticsKey) == nil ? true : defaults.bool(forKey: consentIdentifiedAnalyticsKey) personalizationConsent = defaults.object(forKey: consentPersonalizationKey) == nil ? true : defaults.bool(forKey: consentPersonalizationKey) campaignsConsent = defaults.object(forKey: consentCampaignsKey) == nil ? true : defaults.bool(forKey: consentCampaignsKey) thirdPartyConsent = defaults.object(forKey: consentThirdPartyKey) == nil ? true : defaults.bool(forKey: consentThirdPartyKey) runningMode = RunningModeRepository.shared.isObserverMode ? "observer" : "full" - displayMode = UserDefaults.standard.string(forKey: displayModeKey) ?? "fullscreen" + displayMode = defaults.string(forKey: displayModeKey) ?? "fullscreen" applyConsentPreferences() anonymousId = wrapper.anonymousUserId @@ -83,14 +86,14 @@ class SettingsViewModel: ObservableObject { } self.userId = userId - UserDefaults.standard.set(userId, forKey: userIdKey) + defaults.set(userId, forKey: userIdKey) wrapper.setUserAttribute(userId, forKey: "user_id") } func logout() { wrapper.userLogout() userId = nil - UserDefaults.standard.removeObject(forKey: userIdKey) + defaults.removeObject(forKey: userIdKey) PremiumManager.shared.refreshPremiumStatus() print("[Shaker] Logged out") } @@ -120,7 +123,7 @@ class SettingsViewModel: ObservableObject { func setThemeMode(_ mode: String) { themeMode = mode - UserDefaults.standard.set(mode, forKey: themeKey) + defaults.set(mode, forKey: themeKey) wrapper.setUserAttribute(mode, forKey: "app_theme") } @@ -141,31 +144,31 @@ class SettingsViewModel: ObservableObject { func setAnalyticsConsent(_ enabled: Bool) { analyticsConsent = enabled - UserDefaults.standard.set(enabled, forKey: consentAnalyticsKey) + defaults.set(enabled, forKey: consentAnalyticsKey) applyConsentPreferences() } func setIdentifiedAnalyticsConsent(_ enabled: Bool) { identifiedAnalyticsConsent = enabled - UserDefaults.standard.set(enabled, forKey: consentIdentifiedAnalyticsKey) + defaults.set(enabled, forKey: consentIdentifiedAnalyticsKey) applyConsentPreferences() } func setPersonalizationConsent(_ enabled: Bool) { personalizationConsent = enabled - UserDefaults.standard.set(enabled, forKey: consentPersonalizationKey) + defaults.set(enabled, forKey: consentPersonalizationKey) applyConsentPreferences() } func setCampaignsConsent(_ enabled: Bool) { campaignsConsent = enabled - UserDefaults.standard.set(enabled, forKey: consentCampaignsKey) + defaults.set(enabled, forKey: consentCampaignsKey) applyConsentPreferences() } func setThirdPartyConsent(_ enabled: Bool) { thirdPartyConsent = enabled - UserDefaults.standard.set(enabled, forKey: consentThirdPartyKey) + defaults.set(enabled, forKey: consentThirdPartyKey) applyConsentPreferences() } @@ -177,7 +180,7 @@ class SettingsViewModel: ObservableObject { func setDisplayMode(_ mode: String) { displayMode = mode - UserDefaults.standard.set(mode, forKey: displayModeKey) + defaults.set(mode, forKey: displayModeKey) print("[Shaker] Display mode changed to: \(mode)") } diff --git a/ios/ShakerTests/Data/FavoritesRepositoryTests.swift b/ios/ShakerTests/Data/FavoritesRepositoryTests.swift new file mode 100644 index 0000000..324b132 --- /dev/null +++ b/ios/ShakerTests/Data/FavoritesRepositoryTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import Shaker + +final class FavoritesRepositoryTests: XCTestCase { + + private var defaults: UserDefaults! + private var repo: FavoritesRepository! + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: "FavoritesRepositoryTests")! + defaults.removePersistentDomain(forName: "FavoritesRepositoryTests") + repo = FavoritesRepository(defaults: defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: "FavoritesRepositoryTests") + super.tearDown() + } + + func testInitialStateIsEmpty() { + XCTAssertTrue(repo.favoriteIds.isEmpty) + } + + func testAddFavorite() { + repo.addFavorite("cocktail1") + XCTAssertTrue(repo.favoriteIds.contains("cocktail1")) + } + + func testRemoveFavorite() { + repo.addFavorite("cocktail1") + repo.removeFavorite("cocktail1") + XCTAssertFalse(repo.favoriteIds.contains("cocktail1")) + } + + func testToggleFavoriteAddsWhenNotPresent() { + repo.toggleFavorite("cocktail1") + XCTAssertTrue(repo.favoriteIds.contains("cocktail1")) + } + + func testToggleFavoriteRemovesWhenPresent() { + repo.addFavorite("cocktail1") + repo.toggleFavorite("cocktail1") + XCTAssertFalse(repo.favoriteIds.contains("cocktail1")) + } + + func testIsFavorite() { + repo.addFavorite("cocktail1") + XCTAssertTrue(repo.isFavorite("cocktail1")) + XCTAssertFalse(repo.isFavorite("cocktail2")) + } + + func testMultipleOperations() { + repo.addFavorite("a") + repo.addFavorite("b") + repo.addFavorite("c") + XCTAssertEqual(repo.favoriteIds, Set(["a", "b", "c"])) + + repo.removeFavorite("b") + XCTAssertEqual(repo.favoriteIds, Set(["a", "c"])) + } + + func testAddFavoriteIsIdempotent() { + repo.addFavorite("cocktail1") + repo.addFavorite("cocktail1") + XCTAssertEqual(repo.favoriteIds.count, 1) + } + + func testRemoveNonExistentIsNoOp() { + repo.removeFavorite("nonexistent") + XCTAssertTrue(repo.favoriteIds.isEmpty) + } + + func testPersistence() { + repo.addFavorite("cocktail1") + repo.addFavorite("cocktail2") + + // Create a new repo with the same UserDefaults + let newRepo = FavoritesRepository(defaults: defaults) + XCTAssertTrue(newRepo.favoriteIds.contains("cocktail1")) + XCTAssertTrue(newRepo.favoriteIds.contains("cocktail2")) + } +} diff --git a/ios/ShakerTests/Data/OnboardingRepositoryTests.swift b/ios/ShakerTests/Data/OnboardingRepositoryTests.swift new file mode 100644 index 0000000..b09b049 --- /dev/null +++ b/ios/ShakerTests/Data/OnboardingRepositoryTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import Shaker + +final class OnboardingRepositoryTests: XCTestCase { + + private var defaults: UserDefaults! + private var repo: OnboardingRepository! + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: "OnboardingRepositoryTests")! + defaults.removePersistentDomain(forName: "OnboardingRepositoryTests") + repo = OnboardingRepository(defaults: defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: "OnboardingRepositoryTests") + super.tearDown() + } + + func testInitialStateIsFalse() { + XCTAssertFalse(repo.isOnboardingCompleted) + } + + func testSetToTrue() { + repo.isOnboardingCompleted = true + XCTAssertTrue(repo.isOnboardingCompleted) + } + + func testSetToFalse() { + repo.isOnboardingCompleted = true + repo.isOnboardingCompleted = false + XCTAssertFalse(repo.isOnboardingCompleted) + } + + func testPersistence() { + repo.isOnboardingCompleted = true + + // Create a new repo with the same UserDefaults + let newRepo = OnboardingRepository(defaults: defaults) + XCTAssertTrue(newRepo.isOnboardingCompleted) + } + + func testPersistsFalse() { + repo.isOnboardingCompleted = true + repo.isOnboardingCompleted = false + + let newRepo = OnboardingRepository(defaults: defaults) + XCTAssertFalse(newRepo.isOnboardingCompleted) + } +} diff --git a/ios/ShakerTests/Mocks/TestHelpers.swift b/ios/ShakerTests/Mocks/TestHelpers.swift new file mode 100644 index 0000000..55462e1 --- /dev/null +++ b/ios/ShakerTests/Mocks/TestHelpers.swift @@ -0,0 +1,22 @@ +@testable import Shaker + +func testCocktail( + id: String = "1", + name: String = "Mojito", + spirit: String = "Rum", + category: String = "Classic", + difficulty: String = "Easy" +) -> Cocktail { + Cocktail( + id: id, + name: name, + image: "\(id).jpg", + description: "A test cocktail", + category: category, + spirit: spirit, + difficulty: difficulty, + tags: ["test"], + ingredients: [Ingredient(name: "Ingredient", amount: "60ml")], + instructions: ["Mix and serve"] + ) +} diff --git a/ios/ShakerTests/Model/CocktailTests.swift b/ios/ShakerTests/Model/CocktailTests.swift new file mode 100644 index 0000000..cf45490 --- /dev/null +++ b/ios/ShakerTests/Model/CocktailTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import Shaker + +final class CocktailTests: XCTestCase { + + func testDecodeSingleCocktail() throws { + let json = """ + { + "id": "mojito", + "name": "Mojito", + "image": "mojito.jpg", + "description": "A refreshing Cuban cocktail", + "category": "Classic", + "spirit": "Rum", + "difficulty": "Easy", + "tags": ["refreshing", "summer"], + "ingredients": [ + {"name": "White Rum", "amount": "60ml"}, + {"name": "Lime Juice", "amount": "30ml"} + ], + "instructions": ["Muddle mint", "Add rum and lime", "Top with soda"] + } + """.data(using: .utf8)! + + let cocktail = try JSONDecoder().decode(Cocktail.self, from: json) + + XCTAssertEqual(cocktail.id, "mojito") + XCTAssertEqual(cocktail.name, "Mojito") + XCTAssertEqual(cocktail.image, "mojito.jpg") + XCTAssertEqual(cocktail.description, "A refreshing Cuban cocktail") + XCTAssertEqual(cocktail.category, "Classic") + XCTAssertEqual(cocktail.spirit, "Rum") + XCTAssertEqual(cocktail.difficulty, "Easy") + XCTAssertEqual(cocktail.tags, ["refreshing", "summer"]) + XCTAssertEqual(cocktail.ingredients.count, 2) + XCTAssertEqual(cocktail.ingredients[0].name, "White Rum") + XCTAssertEqual(cocktail.ingredients[0].amount, "60ml") + XCTAssertEqual(cocktail.instructions.count, 3) + } + + func testDecodeCocktailsData() throws { + let json = """ + { + "cocktails": [ + { + "id": "1", "name": "Mojito", "image": "mojito.jpg", + "description": "Desc", "category": "Classic", "spirit": "Rum", + "difficulty": "Easy", "tags": [], "ingredients": [], "instructions": [] + }, + { + "id": "2", "name": "Margarita", "image": "margarita.jpg", + "description": "Desc", "category": "Classic", "spirit": "Tequila", + "difficulty": "Medium", "tags": [], "ingredients": [], "instructions": [] + } + ] + } + """.data(using: .utf8)! + + let data = try JSONDecoder().decode(CocktailsData.self, from: json) + XCTAssertEqual(data.cocktails.count, 2) + XCTAssertEqual(data.cocktails[0].name, "Mojito") + XCTAssertEqual(data.cocktails[1].name, "Margarita") + } + + func testCocktailWithEmptyCollections() { + let cocktail = testCocktail() + XCTAssertFalse(cocktail.tags.isEmpty) // testCocktail has ["test"] + XCTAssertEqual(cocktail.ingredients.count, 1) + XCTAssertEqual(cocktail.instructions.count, 1) + } + + func testIngredientIdentifiable() { + let ingredient = Ingredient(name: "Rum", amount: "60ml") + XCTAssertEqual(ingredient.id, "Rum") + } + + func testCocktailIdentifiable() { + let cocktail = testCocktail(id: "mojito") + XCTAssertEqual(cocktail.id, "mojito") + } + + func testEncodeDecode() throws { + let original = testCocktail(id: "test", name: "Test Cocktail") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Cocktail.self, from: data) + XCTAssertEqual(original.id, decoded.id) + XCTAssertEqual(original.name, decoded.name) + XCTAssertEqual(original.spirit, decoded.spirit) + } +} diff --git a/ios/ShakerTests/Purchasely/DisplayResultTests.swift b/ios/ShakerTests/Purchasely/DisplayResultTests.swift new file mode 100644 index 0000000..2f5d4a7 --- /dev/null +++ b/ios/ShakerTests/Purchasely/DisplayResultTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import Shaker + +final class DisplayResultTests: XCTestCase { + + func testPurchasedWithPlanName() { + let result = DisplayResult.purchased(planName: "Premium Monthly") + if case .purchased(let planName) = result { + XCTAssertEqual(planName, "Premium Monthly") + } else { + XCTFail("Expected .purchased") + } + } + + func testPurchasedWithNilPlanName() { + let result = DisplayResult.purchased(planName: nil) + if case .purchased(let planName) = result { + XCTAssertNil(planName) + } else { + XCTFail("Expected .purchased") + } + } + + func testRestoredWithPlanName() { + let result = DisplayResult.restored(planName: "Premium Yearly") + if case .restored(let planName) = result { + XCTAssertEqual(planName, "Premium Yearly") + } else { + XCTFail("Expected .restored") + } + } + + func testRestoredWithNilPlanName() { + let result = DisplayResult.restored(planName: nil) + if case .restored(let planName) = result { + XCTAssertNil(planName) + } else { + XCTFail("Expected .restored") + } + } + + func testCancelled() { + let result = DisplayResult.cancelled + if case .cancelled = result { + // OK + } else { + XCTFail("Expected .cancelled") + } + } + + func testExhaustiveSwitch() { + let results: [DisplayResult] = [ + .purchased(planName: "A"), + .restored(planName: "B"), + .cancelled + ] + + for result in results { + switch result { + case .purchased(let name): + XCTAssertEqual(name, "A") + case .restored(let name): + XCTAssertEqual(name, "B") + case .cancelled: + break + } + } + } +} diff --git a/ios/ShakerTests/Purchasely/PurchaselyWrapperTests.swift b/ios/ShakerTests/Purchasely/PurchaselyWrapperTests.swift new file mode 100644 index 0000000..35a75b6 --- /dev/null +++ b/ios/ShakerTests/Purchasely/PurchaselyWrapperTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import Shaker + +final class PurchaselyWrapperTests: XCTestCase { + + func testSharedInstanceExists() { + let wrapper = PurchaselyWrapper.shared + XCTAssertNotNil(wrapper) + } + + func testConformsToProtocol() { + let wrapper: PurchaselyWrapping = PurchaselyWrapper.shared + XCTAssertNotNil(wrapper) + } + + func testSdkVersionReturnsString() { + let version = PurchaselyWrapper.shared.sdkVersion + XCTAssertFalse(version.isEmpty) + } + + func testAnonymousUserIdReturnsString() { + // May be empty if SDK is not initialized, but should not crash + let _ = PurchaselyWrapper.shared.anonymousUserId + } + + // MARK: - Mock protocol conformance + + func testMockImplementsAllMethods() { + let mock = MockPurchaselyWrapper() + + // User management + mock.userLogin(userId: "test") { _ in } + mock.userLogout() + _ = mock.anonymousUserId + + // Attributes + mock.setUserAttribute("value", forKey: "key") + mock.setUserAttribute(true, forKey: "key") + mock.setUserAttribute(42, forKey: "key") + mock.setUserAttribute(3.14, forKey: "key") + mock.incrementUserAttribute(forKey: "key") + + // Restore + mock.restoreAllProducts(success: {}, failure: { _ in }) + + // Consent + mock.revokeDataProcessingConsent(for: []) + + // Info + _ = mock.sdkVersion + + // Verify tracking + XCTAssertEqual(mock.userLoginCalls.count, 1) + XCTAssertEqual(mock.userLogoutCallCount, 1) + XCTAssertEqual(mock.setStringAttributeCalls.count, 1) + XCTAssertEqual(mock.setBoolAttributeCalls.count, 1) + XCTAssertEqual(mock.setIntAttributeCalls.count, 1) + XCTAssertEqual(mock.setDoubleAttributeCalls.count, 1) + XCTAssertEqual(mock.incrementAttributeCalls.count, 1) + XCTAssertEqual(mock.restoreCallCount, 1) + XCTAssertEqual(mock.revokeConsentCalls.count, 1) + } + + func testMockConfigurableReturnValues() { + let mock = MockPurchaselyWrapper() + mock.anonymousUserIdValue = "custom-id" + mock.sdkVersionValue = "1.0.0" + + XCTAssertEqual(mock.anonymousUserId, "custom-id") + XCTAssertEqual(mock.sdkVersion, "1.0.0") + } +} diff --git a/ios/ShakerTests/Screens/DetailViewModelTests.swift b/ios/ShakerTests/Screens/DetailViewModelTests.swift new file mode 100644 index 0000000..49c0f25 --- /dev/null +++ b/ios/ShakerTests/Screens/DetailViewModelTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import Shaker + +final class DetailViewModelTests: XCTestCase { + + private var mockWrapper: MockPurchaselyWrapper! + + private let mojito = testCocktail(id: "mojito", name: "Mojito", spirit: "Rum") + private let margarita = testCocktail(id: "margarita", name: "Margarita", spirit: "Tequila") + + override func setUp() { + super.setUp() + mockWrapper = MockPurchaselyWrapper() + } + + private func createViewModel(cocktailId: String = "mojito") -> DetailViewModel { + let repo = CocktailRepository(cocktails: [mojito, margarita]) + return DetailViewModel(cocktailId: cocktailId, repository: repo, wrapper: mockWrapper) + } + + // MARK: - Cocktail loading + + func testLoadsCocktailById() { + let vm = createViewModel() + XCTAssertEqual(vm.cocktail?.name, "Mojito") + } + + func testNilCocktailWhenNotFound() { + let vm = createViewModel(cocktailId: "nonexistent") + XCTAssertNil(vm.cocktail) + } + + // MARK: - User attribute tracking + + func testTracksCocktailsViewedOnInit() { + _ = createViewModel() + XCTAssertTrue(mockWrapper.incrementAttributeCalls.contains("cocktails_viewed")) + } + + func testTracksFavoriteSpiritOnInit() { + _ = createViewModel() + XCTAssertTrue(mockWrapper.setStringAttributeCalls.contains(where: { + $0.key == "favorite_spirit" && $0.value == "Rum" + })) + } + + func testDoesNotTrackSpiritWhenCocktailNotFound() { + _ = createViewModel(cocktailId: "nonexistent") + XCTAssertFalse(mockWrapper.setStringAttributeCalls.contains(where: { + $0.key == "favorite_spirit" + })) + } + + func testTracksDifferentSpirits() { + let repo = CocktailRepository(cocktails: [mojito, margarita]) + _ = DetailViewModel(cocktailId: "margarita", repository: repo, wrapper: mockWrapper) + XCTAssertTrue(mockWrapper.setStringAttributeCalls.contains(where: { + $0.key == "favorite_spirit" && $0.value == "Tequila" + })) + } + + // MARK: - Prefetch presentations + + func testPrefetchRecipePresentation() { + let vm = createViewModel() + vm.prefetchRecipePresentation(contentId: "mojito") + + let expectation = expectation(description: "Prefetch recipe") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertTrue(self.mockWrapper.loadPresentationCalls.contains(where: { + $0.placementId == "recipe_detail" && $0.contentId == "mojito" + })) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testPrefetchFavoritesPresentation() { + let vm = createViewModel() + vm.prefetchFavoritesPresentation() + + let expectation = expectation(description: "Prefetch favorites") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertTrue(self.mockWrapper.loadPresentationCalls.contains(where: { + $0.placementId == "favorites" + })) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/ios/ShakerTests/Screens/HomeViewModelTests.swift b/ios/ShakerTests/Screens/HomeViewModelTests.swift new file mode 100644 index 0000000..c3813a1 --- /dev/null +++ b/ios/ShakerTests/Screens/HomeViewModelTests.swift @@ -0,0 +1,261 @@ +import XCTest +import Combine +@testable import Shaker + +final class HomeViewModelTests: XCTestCase { + + private var mockWrapper: MockPurchaselyWrapper! + private var cancellables: Set! + + private let testCocktails = [ + testCocktail(id: "1", name: "Mojito", spirit: "Rum", category: "Classic", difficulty: "Easy"), + testCocktail(id: "2", name: "Margarita", spirit: "Tequila", category: "Classic", difficulty: "Medium"), + testCocktail(id: "3", name: "Negroni", spirit: "Gin", category: "Bitter", difficulty: "Easy"), + testCocktail(id: "4", name: "Old Fashioned", spirit: "Whiskey", category: "Classic", difficulty: "Hard"), + testCocktail(id: "5", name: "Daiquiri", spirit: "Rum", category: "Tropical", difficulty: "Easy") + ] + + override func setUp() { + super.setUp() + mockWrapper = MockPurchaselyWrapper() + cancellables = [] + } + + override func tearDown() { + cancellables = nil + super.tearDown() + } + + private func createViewModel() -> HomeViewModel { + let repo = CocktailRepository(cocktails: testCocktails) + return HomeViewModel(repository: repo, wrapper: mockWrapper) + } + + // MARK: - Initial state + + func testInitialCocktailsLoaded() { + let vm = createViewModel() + XCTAssertEqual(vm.cocktails.count, 5) + } + + func testAvailableSpirits() { + let vm = createViewModel() + let spirits = vm.availableSpirits + XCTAssertTrue(spirits.contains("Rum")) + XCTAssertTrue(spirits.contains("Gin")) + XCTAssertTrue(spirits.contains("Tequila")) + XCTAssertTrue(spirits.contains("Whiskey")) + } + + func testAvailableCategories() { + let vm = createViewModel() + let categories = vm.availableCategories + XCTAssertTrue(categories.contains("Classic")) + XCTAssertTrue(categories.contains("Bitter")) + XCTAssertTrue(categories.contains("Tropical")) + } + + func testAvailableDifficulties() { + let vm = createViewModel() + let difficulties = vm.availableDifficulties + XCTAssertTrue(difficulties.contains("Easy")) + XCTAssertTrue(difficulties.contains("Medium")) + XCTAssertTrue(difficulties.contains("Hard")) + } + + func testHasActiveFiltersInitiallyFalse() { + let vm = createViewModel() + XCTAssertFalse(vm.hasActiveFilters) + } + + // MARK: - Spirit filter + + func testToggleSpiritAdds() { + let vm = createViewModel() + vm.toggleSpirit("Rum") + XCTAssertTrue(vm.selectedSpirits.contains("Rum")) + XCTAssertTrue(vm.hasActiveFilters) + } + + func testToggleSpiritRemoves() { + let vm = createViewModel() + vm.toggleSpirit("Rum") + vm.toggleSpirit("Rum") + XCTAssertFalse(vm.selectedSpirits.contains("Rum")) + XCTAssertFalse(vm.hasActiveFilters) + } + + // MARK: - Category filter + + func testToggleCategoryAdds() { + let vm = createViewModel() + vm.toggleCategory("Classic") + XCTAssertTrue(vm.selectedCategories.contains("Classic")) + XCTAssertTrue(vm.hasActiveFilters) + } + + func testToggleCategoryRemoves() { + let vm = createViewModel() + vm.toggleCategory("Classic") + vm.toggleCategory("Classic") + XCTAssertFalse(vm.selectedCategories.contains("Classic")) + } + + // MARK: - Difficulty filter + + func testSelectDifficulty() { + let vm = createViewModel() + vm.selectDifficulty("Easy") + XCTAssertEqual(vm.selectedDifficulty, "Easy") + XCTAssertTrue(vm.hasActiveFilters) + } + + func testSelectDifficultyTogglesOff() { + let vm = createViewModel() + vm.selectDifficulty("Easy") + vm.selectDifficulty("Easy") + XCTAssertNil(vm.selectedDifficulty) + } + + // MARK: - Clear filters + + func testClearFilters() { + let vm = createViewModel() + vm.toggleSpirit("Rum") + vm.toggleCategory("Classic") + vm.selectDifficulty("Easy") + vm.clearFilters() + XCTAssertTrue(vm.selectedSpirits.isEmpty) + XCTAssertTrue(vm.selectedCategories.isEmpty) + XCTAssertNil(vm.selectedDifficulty) + XCTAssertFalse(vm.hasActiveFilters) + } + + // MARK: - Filtering logic (via Combine pipeline) + + func testSpiritFilterUpdatesResults() { + let vm = createViewModel() + let expectation = expectation(description: "Cocktails filtered") + + vm.toggleSpirit("Rum") + + // Wait for Combine pipeline (debounce on searchQuery, immediate on spirits) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + let filtered = vm.cocktails + XCTAssertEqual(filtered.count, 2) + XCTAssertTrue(filtered.allSatisfy { $0.spirit == "Rum" }) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func testCategoryFilterUpdatesResults() { + let vm = createViewModel() + let expectation = expectation(description: "Cocktails filtered by category") + + vm.toggleCategory("Classic") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + let filtered = vm.cocktails + XCTAssertEqual(filtered.count, 3) + XCTAssertTrue(filtered.allSatisfy { $0.category == "Classic" }) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func testDifficultyFilterUpdatesResults() { + let vm = createViewModel() + let expectation = expectation(description: "Cocktails filtered by difficulty") + + vm.selectDifficulty("Easy") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + let filtered = vm.cocktails + XCTAssertEqual(filtered.count, 3) + XCTAssertTrue(filtered.allSatisfy { $0.difficulty == "Easy" }) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func testSearchFilterUpdatesResults() { + let vm = createViewModel() + let expectation = expectation(description: "Cocktails filtered by search") + + vm.searchQuery = "Mojito" + + // 200ms debounce + buffer + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + let filtered = vm.cocktails + XCTAssertEqual(filtered.count, 1) + XCTAssertEqual(filtered.first?.name, "Mojito") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func testSearchSetsUserAttribute() { + let vm = createViewModel() + let expectation = expectation(description: "User attribute set") + + vm.searchQuery = "test" + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + XCTAssertTrue(self.mockWrapper.setBoolAttributeCalls.contains(where: { + $0.key == "has_used_search" && $0.value == true + })) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func testCombinedSpiritAndCategoryFilter() { + let vm = createViewModel() + let expectation = expectation(description: "Combined filter") + + vm.toggleSpirit("Rum") + vm.toggleCategory("Classic") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + let filtered = vm.cocktails + XCTAssertEqual(filtered.count, 1) + XCTAssertEqual(filtered.first?.name, "Mojito") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Prefetch + + func testPrefetchCallsWrapper() { + let vm = createViewModel() + vm.prefetchPresentations(isPremium: false) + + let expectation = expectation(description: "Prefetch completed") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertTrue(self.mockWrapper.loadPresentationCalls.contains(where: { $0.placementId == "filters" })) + XCTAssertTrue(self.mockWrapper.loadPresentationCalls.contains(where: { $0.placementId == "inline" })) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testPrefetchSkipsWhenPremium() { + let vm = createViewModel() + vm.prefetchPresentations(isPremium: true) + + let expectation = expectation(description: "No prefetch") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertTrue(self.mockWrapper.loadPresentationCalls.isEmpty) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/ios/ShakerTests/Screens/SettingsViewModelTests.swift b/ios/ShakerTests/Screens/SettingsViewModelTests.swift new file mode 100644 index 0000000..9f89d9b --- /dev/null +++ b/ios/ShakerTests/Screens/SettingsViewModelTests.swift @@ -0,0 +1,247 @@ +import XCTest +import Purchasely +@testable import Shaker + +final class SettingsViewModelTests: XCTestCase { + + private var mockWrapper: MockPurchaselyWrapper! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + mockWrapper = MockPurchaselyWrapper() + defaults = UserDefaults(suiteName: "SettingsViewModelTests")! + defaults.removePersistentDomain(forName: "SettingsViewModelTests") + } + + override func tearDown() { + defaults.removePersistentDomain(forName: "SettingsViewModelTests") + super.tearDown() + } + + private func createViewModel() -> SettingsViewModel { + SettingsViewModel(wrapper: mockWrapper, defaults: defaults) + } + + // MARK: - Initial state + + func testInitialUserIdIsNil() { + let vm = createViewModel() + XCTAssertNil(vm.userId) + } + + func testInitialUserIdReadsFromDefaults() { + defaults.set("kevin", forKey: "user_id") + let vm = createViewModel() + XCTAssertEqual(vm.userId, "kevin") + } + + func testInitialThemeModeIsSystem() { + let vm = createViewModel() + XCTAssertEqual(vm.themeMode, "system") + } + + func testInitialAnonymousIdFromWrapper() { + let vm = createViewModel() + XCTAssertEqual(vm.anonymousId, "mock-anon-123") + } + + func testSdkVersionFromWrapper() { + let vm = createViewModel() + XCTAssertEqual(vm.sdkVersion, "5.7.3-mock") + } + + func testInitialConsentsAreTrue() { + let vm = createViewModel() + XCTAssertTrue(vm.analyticsConsent) + XCTAssertTrue(vm.identifiedAnalyticsConsent) + XCTAssertTrue(vm.personalizationConsent) + XCTAssertTrue(vm.campaignsConsent) + XCTAssertTrue(vm.thirdPartyConsent) + } + + func testInitialDisplayModeIsFullscreen() { + let vm = createViewModel() + XCTAssertEqual(vm.displayMode, "fullscreen") + } + + // MARK: - Login + + func testLoginSetsUserIdAndCallsWrapper() { + let vm = createViewModel() + vm.login(userId: "kevin") + XCTAssertEqual(vm.userId, "kevin") + XCTAssertEqual(mockWrapper.userLoginCalls.count, 1) + XCTAssertEqual(mockWrapper.userLoginCalls.first, "kevin") + } + + func testLoginSetsUserAttribute() { + let vm = createViewModel() + vm.login(userId: "kevin") + XCTAssertTrue(mockWrapper.setStringAttributeCalls.contains(where: { + $0.key == "user_id" && $0.value == "kevin" + })) + } + + func testLoginPersistsToDefaults() { + let vm = createViewModel() + vm.login(userId: "kevin") + XCTAssertEqual(defaults.string(forKey: "user_id"), "kevin") + } + + func testLoginWithEmptyStringDoesNothing() { + let vm = createViewModel() + vm.login(userId: "") + XCTAssertNil(vm.userId) + XCTAssertTrue(mockWrapper.userLoginCalls.isEmpty) + } + + // MARK: - Logout + + func testLogoutClearsUserId() { + let vm = createViewModel() + vm.login(userId: "kevin") + vm.logout() + XCTAssertNil(vm.userId) + XCTAssertEqual(mockWrapper.userLogoutCallCount, 1) + } + + func testLogoutRemovesFromDefaults() { + defaults.set("kevin", forKey: "user_id") + let vm = createViewModel() + vm.logout() + XCTAssertNil(defaults.string(forKey: "user_id")) + } + + // MARK: - Restore + + func testRestorePurchasesCallsWrapper() { + let vm = createViewModel() + vm.restorePurchases() + XCTAssertEqual(mockWrapper.restoreCallCount, 1) + } + + func testRestoreSuccessUpdatesMessage() { + let vm = createViewModel() + vm.restorePurchases() + + let expectation = expectation(description: "Restore message updated") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(vm.restoreMessage, "Purchases restored successfully!") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testClearRestoreMessage() { + let vm = createViewModel() + vm.restorePurchases() + vm.clearRestoreMessage() + XCTAssertNil(vm.restoreMessage) + } + + // MARK: - Theme + + func testSetThemeMode() { + let vm = createViewModel() + vm.setThemeMode("dark") + XCTAssertEqual(vm.themeMode, "dark") + XCTAssertEqual(defaults.string(forKey: "theme_mode"), "dark") + } + + func testSetThemeSetsUserAttribute() { + let vm = createViewModel() + vm.setThemeMode("dark") + XCTAssertTrue(mockWrapper.setStringAttributeCalls.contains(where: { + $0.key == "app_theme" && $0.value == "dark" + })) + } + + // MARK: - Display mode + + func testSetDisplayMode() { + let vm = createViewModel() + vm.setDisplayMode("embedded") + XCTAssertEqual(vm.displayMode, "embedded") + XCTAssertEqual(defaults.string(forKey: "display_mode"), "embedded") + } + + // MARK: - Anonymous ID + + func testRefreshAnonymousId() { + let vm = createViewModel() + mockWrapper.anonymousUserIdValue = "new-anon-456" + vm.refreshAnonymousId() + XCTAssertEqual(vm.anonymousId, "new-anon-456") + } + + // MARK: - Consent + + func testSetAnalyticsConsentFalse() { + let vm = createViewModel() + vm.setAnalyticsConsent(false) + XCTAssertFalse(vm.analyticsConsent) + XCTAssertTrue(mockWrapper.revokeConsentCalls.last?.contains(.analytics) ?? false) + } + + func testSetIdentifiedAnalyticsConsentFalse() { + let vm = createViewModel() + vm.setIdentifiedAnalyticsConsent(false) + XCTAssertFalse(vm.identifiedAnalyticsConsent) + XCTAssertTrue(mockWrapper.revokeConsentCalls.last?.contains(.identifiedAnalytics) ?? false) + } + + func testSetPersonalizationConsentFalse() { + let vm = createViewModel() + vm.setPersonalizationConsent(false) + XCTAssertFalse(vm.personalizationConsent) + XCTAssertTrue(mockWrapper.revokeConsentCalls.last?.contains(.personalization) ?? false) + } + + func testSetCampaignsConsentFalse() { + let vm = createViewModel() + vm.setCampaignsConsent(false) + XCTAssertFalse(vm.campaignsConsent) + XCTAssertTrue(mockWrapper.revokeConsentCalls.last?.contains(.campaigns) ?? false) + } + + func testSetThirdPartyConsentFalse() { + let vm = createViewModel() + vm.setThirdPartyConsent(false) + XCTAssertFalse(vm.thirdPartyConsent) + XCTAssertTrue(mockWrapper.revokeConsentCalls.last?.contains(.thirdPartyIntegrations) ?? false) + } + + func testAllConsentsTrue_revokesEmptySet() { + let vm = createViewModel() + // Init calls applyConsentPreferences with all true + XCTAssertTrue(mockWrapper.revokeConsentCalls.last?.isEmpty ?? false) + } + + func testMultipleConsentsRevoked() { + let vm = createViewModel() + vm.setAnalyticsConsent(false) + vm.setPersonalizationConsent(false) + + let lastRevoked = mockWrapper.revokeConsentCalls.last + XCTAssertNotNil(lastRevoked) + XCTAssertTrue(lastRevoked?.contains(.analytics) ?? false) + XCTAssertTrue(lastRevoked?.contains(.personalization) ?? false) + } + + // MARK: - Prefetch onboarding + + func testPrefetchOnboardingPresentation() { + let vm = createViewModel() + vm.prefetchOnboardingPresentation() + + let expectation = expectation(description: "Prefetch onboarding") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertTrue(self.mockWrapper.loadPresentationCalls.contains(where: { + $0.placementId == "onboarding" + })) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/ios/project.yml b/ios/project.yml index d0907cb..c663ef4 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -36,5 +36,19 @@ targets: Release: SWIFT_OPTIMIZATION_LEVEL: -O scheme: - testTargets: [] - gatherCoverageData: false + testTargets: + - ShakerTests + gatherCoverageData: true + ShakerTests: + type: bundle.unit-test + platform: iOS + sources: + - path: ShakerTests + excludes: + - "**/.DS_Store" + dependencies: + - target: Shaker + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.purchasely.shaker.tests + GENERATE_INFOPLIST_FILE: "YES"