diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2e7397d..09e1c2a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -2,7 +2,6 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) } @@ -60,10 +59,6 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } - testOptions { unitTests.isReturnDefaultValues = true } @@ -118,5 +113,7 @@ dependencies { androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.espresso.core) debugImplementation(libs.compose.ui.test.manifest) } diff --git a/android/app/src/main/java/com/purchasely/shaker/data/PurchaselySdkMode.kt b/android/app/src/main/java/com/purchasely/shaker/data/PurchaselySdkMode.kt index bf6817b..5bc896d 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/PurchaselySdkMode.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/PurchaselySdkMode.kt @@ -10,7 +10,7 @@ enum class PurchaselySdkMode( PAYWALL_OBSERVER( storageValue = "paywallObserver", label = "Paywall Observer", - runningMode = PLYRunningMode.PaywallObserver + runningMode = PLYRunningMode.Observer ), FULL( storageValue = "full", diff --git a/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt index ec46f87..1ca76c8 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt @@ -23,7 +23,7 @@ class RunningModeRepository(private val store: KeyValueStore) { } val isObserverMode: Boolean - get() = runningMode == PLYRunningMode.PaywallObserver + get() = runningMode == PLYRunningMode.Observer companion object { private const val KEY_RUNNING_MODE = "running_mode" 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 c9e1299..2875777 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 @@ -72,7 +72,8 @@ class PurchaseManager( ) .build() - billingClient.queryProductDetailsAsync(queryParams) { billingResult, productDetailsList -> + billingClient.queryProductDetailsAsync(queryParams) { billingResult, queryResult -> + val productDetailsList = queryResult.productDetailsList if (billingResult.responseCode != BillingClient.BillingResponseCode.OK || productDetailsList.isEmpty()) { Log.e(TAG, "[Shaker] queryProductDetails failed: ${billingResult.debugMessage}") _transactionResult.tryEmit(TransactionResult.Error(billingResult.debugMessage)) diff --git a/android/app/src/main/java/com/purchasely/shaker/purchasely/PresentationHandle.kt b/android/app/src/main/java/com/purchasely/shaker/purchasely/PresentationHandle.kt index f06e2e7..b3ef322 100644 --- a/android/app/src/main/java/com/purchasely/shaker/purchasely/PresentationHandle.kt +++ b/android/app/src/main/java/com/purchasely/shaker/purchasely/PresentationHandle.kt @@ -1,6 +1,6 @@ package com.purchasely.shaker.purchasely -import io.purchasely.ext.PLYPresentation +import io.purchasely.ext.presentation.PLYPresentation @JvmInline value class PresentationHandle internal constructor( 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 f29f609..6892d5e 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 @@ -14,17 +14,18 @@ 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.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.SubscriptionsListener import io.purchasely.ext.PLYDataProcessingPurpose -import io.purchasely.ext.PLYProductViewResult +import io.purchasely.ext.PLYInterceptResult +import io.purchasely.ext.PLYInterceptorInfo import io.purchasely.ext.Purchasely -import io.purchasely.ext.fetchPresentation -import io.purchasely.models.PLYPlan +import io.purchasely.ext.SubscriptionsListener +import io.purchasely.ext.interceptAction +import io.purchasely.ext.presentation.PLYPresentation +import io.purchasely.ext.presentation.PLYPresentationAction +import io.purchasely.ext.presentation.PLYPresentationOutcome +import io.purchasely.ext.presentation.PLYPresentationType +import io.purchasely.ext.presentation.PLYPurchaseResult +import io.purchasely.ext.presentation.preload import io.purchasely.google.GoogleStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -48,7 +49,7 @@ class PurchaselyWrapper( private var apiKey: String = "" private var logLevel: LogLevel = LogLevel.DEBUG private var onConfiguredCallback: (() -> Unit)? = null - private var pendingProcessAction: ((Boolean) -> Unit)? = null + private var pendingResult: ((PLYInterceptResult) -> Unit)? = null private var collectionJob: Job? = null // PURCHASELY: Flag set when a successful purchase is reported by PurchaseManager @@ -85,22 +86,25 @@ class PurchaselyWrapper( val mode = runningModeRepo.runningMode - Purchasely.Builder(application) - .apiKey(apiKey) - .logLevel(logLevel) - .readyToOpenDeeplink(true) - .runningMode(mode) - .stores(listOf(GoogleStore())) - .build() - .start { isConfigured, error -> - if (isConfigured) { + // PURCHASELY (v6): use the Kotlin DSL entrypoint. `Purchasely { ... }` configures + // and starts the SDK in one call — no .build()/.start() chain. For Java callers, + // fall back to the fluent Purchasely.Builder(...).build().start { ... }. + Purchasely { + context(application) + apiKey(apiKey) + logLevel(logLevel) + allowDeeplink(true) + runningMode(mode) + stores(listOf(GoogleStore())) + onInitialized { error -> + if (error == null) { Log.d(TAG, "[Shaker] Purchasely SDK configured successfully") onConfigured?.invoke() - } - error?.let { - Log.e(TAG, "[Shaker] Purchasely configuration error: ${it.message}") + } else { + Log.e(TAG, "[Shaker] Purchasely configuration error: ${error.message}") } } + } eventListener = object : EventListener { override fun onEvent(event: io.purchasely.ext.PLYEvent) { @@ -108,9 +112,7 @@ class PurchaselyWrapper( } } - setPaywallActionsInterceptor { info, action, parameters, proceed -> - handlePaywallAction(info, action, parameters, proceed) - } + registerActionInterceptors() startTransactionCollection() } @@ -124,67 +126,95 @@ class PurchaselyWrapper( fun close() { collectionJob?.cancel() collectionJob = null - pendingProcessAction?.invoke(false) - pendingProcessAction = null + pendingResult?.invoke(PLYInterceptResult.NOT_HANDLED) + pendingResult = null + Purchasely.removeAllActionInterceptors() 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) + private fun registerActionInterceptors() { + Purchasely.interceptAction { _, _ -> + Log.d(TAG, "[Shaker] Paywall login action intercepted") + PLYInterceptResult.SUCCESS + } + + Purchasely.interceptAction { _, navigate -> + val url = navigate.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) } - 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?.invoke(false) - 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) + PLYInterceptResult.SUCCESS + } + + Purchasely.interceptAction { info, purchase -> + handlePurchase(info, purchase) + } + + Purchasely.interceptAction { _, _ -> + handleRestore() + } + } + + internal suspend fun handlePurchase( + info: PLYInterceptorInfo?, + purchase: PLYPresentationAction.Purchase + ): PLYInterceptResult { + if (!runningModeRepo.isObserverMode) { + return PLYInterceptResult.NOT_HANDLED + } + + val plan = purchase.plan + val offer = purchase.subscriptionOffer + val productId = plan.store_product_id + val offerToken = offer?.offerToken + val activity = info?.activity + + return if (activity != null && productId != null && offerToken != null) { + awaitPendingResult { resultCallback -> + pendingResult = resultCallback + scope.launch { + purchaseRequests.emit(PurchaseRequest(activity, productId, offerToken)) } } - PLYPresentationAction.RESTORE -> { - if (runningModeRepo.isObserverMode) { - pendingProcessAction?.invoke(false) - pendingProcessAction = processAction - scope.launch { - restoreRequests.emit(RestoreRequest) - } - } else { - processAction(true) - } + } else { + Log.w(TAG, "[Shaker] Observer mode purchase: missing activity, productId, or offerToken") + PLYInterceptResult.NOT_HANDLED + } + } + + internal suspend fun handleRestore(): PLYInterceptResult { + if (!runningModeRepo.isObserverMode) { + return PLYInterceptResult.NOT_HANDLED + } + return awaitPendingResult { resultCallback -> + pendingResult = resultCallback + scope.launch { + restoreRequests.emit(RestoreRequest) } - else -> processAction(true) + } + } + + /** + * Bridges the legacy "processAction" callback style to v6's suspend interceptor: + * the interceptor lambda suspends until the pending result callback is invoked + * from [handleTransactionResult] with the outcome reported by the host app. + */ + private suspend fun awaitPendingResult( + register: ((PLYInterceptResult) -> Unit) -> Unit + ): PLYInterceptResult = suspendCancellableCoroutine { continuation -> + // Cancel any previously-pending continuation before installing a new one. + pendingResult?.invoke(PLYInterceptResult.NOT_HANDLED) + val callback: (PLYInterceptResult) -> Unit = { result -> + if (continuation.isActive) continuation.resume(result) + } + register(callback) + continuation.invokeOnCancellation { + if (pendingResult === callback) pendingResult = null } } @@ -194,8 +224,8 @@ class PurchaselyWrapper( when (result) { is TransactionResult.Success -> { synchronize() - pendingProcessAction?.invoke(false) - pendingProcessAction = null + pendingResult?.invoke(PLYInterceptResult.SUCCESS) + pendingResult = null // PURCHASELY: Defer onTransactionCompleted to the success_payment chain. // closeAllScreens() forces the paywall to dismiss; display()'s callback // then sees pendingSuccessfulPurchase=true and opens "success_payment". @@ -204,13 +234,13 @@ class PurchaselyWrapper( Log.d(TAG, "[Shaker] Transaction success — synchronized; awaiting success_payment dismissal") } is TransactionResult.Cancelled -> { - pendingProcessAction?.invoke(false) - pendingProcessAction = null + pendingResult?.invoke(PLYInterceptResult.NOT_HANDLED) + pendingResult = null Log.d(TAG, "[Shaker] Transaction cancelled") } is TransactionResult.Error -> { - pendingProcessAction?.invoke(false) - pendingProcessAction = null + pendingResult?.invoke(PLYInterceptResult.FAILED) + pendingResult = null Log.e(TAG, "[Shaker] Transaction error: ${result.message}") } is TransactionResult.Idle -> { /* ignore */ } @@ -223,22 +253,10 @@ class PurchaselyWrapper( 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) + return Purchasely.handleDeeplink(deeplink, activity) } // MARK: - Presentation Loading @@ -248,13 +266,13 @@ class PurchaselyWrapper( contentId: String? = null ): FetchResult { return try { - val presentation = if (contentId != null) { - Purchasely.fetchPresentation( - properties = PLYPresentationProperties(placementId = placementId, contentId = contentId) - ) - } else { - Purchasely.fetchPresentation(placementId = placementId) + val prepared = PLYPresentation { + placementId(placementId) + if (contentId != null) contentId(contentId) } + val presentation = prepared.preload() + ?: return FetchResult.Error("Presentation preload returned null") + val handle = PresentationHandle(presentation) when (presentation.type) { PLYPresentationType.DEACTIVATED -> FetchResult.Deactivated @@ -273,12 +291,8 @@ class PurchaselyWrapper( activity: Activity ): DisplayResult { val initial: DisplayResult = suspendCancellableCoroutine { continuation -> - handle.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) - } + handle.presentation.display(activity) { outcome: PLYPresentationOutcome -> + if (continuation.isActive) continuation.resume(outcome.toDisplayResult()) } } @@ -303,8 +317,8 @@ class PurchaselyWrapper( is FetchResult.Success -> { // Display the success_payment screen and wait for it to close suspendCancellableCoroutine { continuation -> - fetchResult.handle.presentation.display(activity) { _, _ -> - continuation.resume(Unit) + fetchResult.handle.presentation.display(activity) { _ -> + if (continuation.isActive) continuation.resume(Unit) } } // PURCHASELY: After the success_payment screen closes, refresh subscriptions @@ -327,18 +341,18 @@ class PurchaselyWrapper( context: Context, onResult: (DisplayResult) -> Unit ): View? { - return handle.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) - } - } - ) + return handle.presentation.buildView(context) { outcome -> + onResult(outcome.toDisplayResult()) + } } + private fun PLYPresentationOutcome.toDisplayResult(): DisplayResult = + when (purchaseResult) { + PLYPurchaseResult.PURCHASED -> DisplayResult.Purchased(plan?.name) + PLYPurchaseResult.RESTORED -> DisplayResult.Restored(plan?.name) + else -> DisplayResult.Cancelled + } + // MARK: - User Management fun userLogin(userId: String, onRefresh: (Boolean) -> Unit) { 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 index 473368d..0627c19 100644 --- a/android/app/src/test/java/com/purchasely/shaker/data/PurchaselySdkModeTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/data/PurchaselySdkModeTest.kt @@ -49,7 +49,7 @@ class PurchaselySdkModeTest { @Test fun `runningMode maps correctly`() { - assertEquals(PLYRunningMode.PaywallObserver, PurchaselySdkMode.PAYWALL_OBSERVER.runningMode) + assertEquals(PLYRunningMode.Observer, PurchaselySdkMode.PAYWALL_OBSERVER.runningMode) assertEquals(PLYRunningMode.Full, PurchaselySdkMode.FULL.runningMode) } 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 index a92fff7..f58ffc6 100644 --- a/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt @@ -20,7 +20,7 @@ class RunningModeRepositoryTest { @Test fun `default mode is PaywallObserver (PurchaselySdkMode DEFAULT)`() { val repo = RunningModeRepository(store) - assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) + assertEquals(PLYRunningMode.Observer, repo.runningMode) } @Test @@ -32,7 +32,7 @@ class RunningModeRepositoryTest { @Test fun `setting to PaywallObserver persists paywallObserver string`() { val repo = RunningModeRepository(store) - repo.runningMode = PLYRunningMode.PaywallObserver + repo.runningMode = PLYRunningMode.Observer assertEquals("paywallObserver", store.getString("running_mode")) } @@ -40,7 +40,7 @@ class RunningModeRepositoryTest { fun `reading paywallObserver from storage`() { store.putString("running_mode", "paywallObserver") val repo = RunningModeRepository(store) - assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) + assertEquals(PLYRunningMode.Observer, repo.runningMode) assertTrue(repo.isObserverMode) } @@ -48,7 +48,7 @@ class RunningModeRepositoryTest { fun `legacy observer value migrates to PaywallObserver`() { store.putString("running_mode", "observer") val repo = RunningModeRepository(store) - assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) + assertEquals(PLYRunningMode.Observer, repo.runningMode) assertTrue(repo.isObserverMode) } @@ -72,6 +72,6 @@ class RunningModeRepositoryTest { fun `unknown stored value defaults to PaywallObserver`() { store.putString("running_mode", "unknown") val repo = RunningModeRepository(store) - assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) + assertEquals(PLYRunningMode.Observer, repo.runningMode) } } 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 index c4e8538..932e85a 100644 --- a/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt @@ -8,17 +8,20 @@ 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.PLYInterceptResult +import io.purchasely.ext.PLYInterceptorInfo import io.purchasely.ext.PLYRunningMode +import io.purchasely.ext.presentation.PLYPresentation +import io.purchasely.ext.presentation.PLYPresentationAction +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel 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 @@ -34,7 +37,10 @@ import org.junit.Test class PurchaselyWrapperTest { private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) + + // Wrapper gets a standalone scope so its long-lived collectionJob does not + // keep runTest's scope busy after each test. + private lateinit var wrapperScope: CoroutineScope private lateinit var onTransactionCompletedCallback: (() -> Unit) private lateinit var runningModeRepo: RunningModeRepository @@ -46,9 +52,10 @@ class PurchaselyWrapperTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) + wrapperScope = CoroutineScope(testDispatcher + Job()) onTransactionCompletedCallback = mockk(relaxed = true) runningModeRepo = mockk { - every { runningMode } returns PLYRunningMode.PaywallObserver + every { runningMode } returns PLYRunningMode.Observer every { isObserverMode } returns true } purchaseRequests = MutableSharedFlow() @@ -59,7 +66,7 @@ class PurchaselyWrapperTest { purchaseRequests = purchaseRequests, restoreRequests = restoreRequests, transactionResult = transactionResult, - scope = testScope + scope = wrapperScope ).also { it.onTransactionCompleted = onTransactionCompletedCallback } @@ -67,26 +74,28 @@ class PurchaselyWrapperTest { @After fun tearDown() { + wrapperScope.cancel() Dispatchers.resetMain() } // --- Interceptor: PURCHASE in Observer mode --- @Test - fun `handlePaywallAction PURCHASE in observer mode emits PurchaseRequest`() = runTest { + fun `handlePurchase in observer mode emits PurchaseRequest`() = runTest(testDispatcher) { val mockActivity = mockk() val mockPlan = mockk { every { store_product_id } returns "com.test.product" } - val mockOffer = mockk { + val mockOffer = mockk { every { offerToken } returns "token-123" } - val mockInfo = mockk { + val mockInfo = mockk { every { activity } returns mockActivity } - val mockParams = mockk { + val purchase = mockk { every { plan } returns mockPlan every { subscriptionOffer } returns mockOffer + every { offer } returns null } var emittedRequest: PurchaseRequest? = null @@ -94,115 +103,94 @@ class PurchaselyWrapperTest { emittedRequest = purchaseRequests.first() } - wrapper.handlePaywallAction(mockInfo, PLYPresentationAction.PURCHASE, mockParams) {} + // handlePurchase suspends until pendingResult is resolved. Run it in a + // child coroutine so the test can keep observing the emitted request. + val interceptJob = async(testDispatcher) { + wrapper.handlePurchase(mockInfo, purchase) + } collectJob.join() assertNotNull(emittedRequest) assertEquals("com.test.product", emittedRequest?.productId) assertEquals("token-123", emittedRequest?.offerToken) + + // Clean up: resolve the suspending interceptor so the async coroutine completes. + transactionResult.emit(TransactionResult.Cancelled) + interceptJob.await() } @Test - fun `handlePaywallAction PURCHASE in full mode calls proceed true`() { + fun `handlePurchase in full mode returns NOT_HANDLED`() = runTest(testDispatcher) { every { runningModeRepo.isObserverMode } returns false - var proceededWith: Boolean? = null - - wrapper.handlePaywallAction(null, PLYPresentationAction.PURCHASE, null) { proceededWith = it } - - assertEquals(true, proceededWith) + val purchase = mockk(relaxed = true) + val result = wrapper.handlePurchase(null, purchase) + assertEquals(PLYInterceptResult.NOT_HANDLED, result) } // --- Interceptor: RESTORE in Observer mode --- @Test - fun `handlePaywallAction RESTORE in observer mode emits RestoreRequest`() = runTest { + fun `handleRestore in observer mode emits RestoreRequest`() = runTest(testDispatcher) { var emittedRestore = false val collectJob = launch(testDispatcher) { restoreRequests.first() emittedRestore = true } - wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) {} + val interceptJob = async(testDispatcher) { + wrapper.handleRestore() + } 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) + transactionResult.emit(TransactionResult.Cancelled) + interceptJob.await() } - // --- 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) + fun `handleRestore in full mode returns NOT_HANDLED`() = runTest(testDispatcher) { + every { runningModeRepo.isObserverMode } returns false + val result = wrapper.handleRestore() + assertEquals(PLYInterceptResult.NOT_HANDLED, result) } // --- TransactionResult observation --- @Test - fun `TransactionResult Success defers onTransactionCompleted to success_payment chain`() = runTest { - wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) {} + fun `TransactionResult Success defers onTransactionCompleted to success_payment chain`() = runTest(testDispatcher) { + val subscriber = launch(testDispatcher) { restoreRequests.first() } + val interceptJob = wrapperScope.async { wrapper.handleRestore() } + subscriber.join() + transactionResult.emit(TransactionResult.Success) - testScope.testScheduler.advanceUntilIdle() + val result = interceptJob.await() + // PURCHASELY: onTransactionCompleted is no longer invoked synchronously at // TransactionResult.Success — it is deferred to after the success_payment // screen closes (chained by display() once pendingSuccessfulPurchase is consumed). verify(exactly = 0) { onTransactionCompletedCallback.invoke() } + assertEquals(PLYInterceptResult.SUCCESS, result) } @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) - } + fun `TransactionResult Cancelled resolves pendingResult with NOT_HANDLED`() = runTest(testDispatcher) { + val subscriber = launch(testDispatcher) { restoreRequests.first() } + val interceptJob = wrapperScope.async { wrapper.handleRestore() } + subscriber.join() - @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) + assertEquals(PLYInterceptResult.NOT_HANDLED, interceptJob.await()) } @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) - } + fun `TransactionResult Error resolves pendingResult with FAILED`() = runTest(testDispatcher) { + val subscriber = launch(testDispatcher) { restoreRequests.first() } + val interceptJob = wrapperScope.async { wrapper.handleRestore() } + subscriber.join() - @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) + transactionResult.emit(TransactionResult.Error("fail")) + assertEquals(PLYInterceptResult.FAILED, interceptJob.await()) } // --- Existing API contract --- diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 132ad8d..86acfea 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 07c50fb..aa94146 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -9,13 +9,15 @@ lifecycle = "2.8.7" koin = "4.0.0" kotlinx-serialization = "1.7.3" coil = "3.0.4" -purchasely = "5.7.4" -billing = "7.1.1" +purchasely = "6.0.0" +billing = "8.3.0" junit = "4.13.2" mockk = "1.13.13" coroutines-test = "1.9.0" turbine = "1.2.0" -androidx-test-ext-junit = "1.2.1" +androidx-test-ext-junit = "1.3.0" +androidx-test-runner = "1.7.0" +androidx-test-espresso = "3.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -42,7 +44,7 @@ coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref purchasely-core = { group = "io.purchasely", name = "core", version.ref = "purchasely" } purchasely-google = { group = "io.purchasely", name = "google-play", version.ref = "purchasely" } -google-billing = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" } +google-billing = { group = "com.android.billingclient", name = "billing", version.ref = "billing" } junit = { group = "junit", name = "junit", version.ref = "junit" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } @@ -51,9 +53,10 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } +androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-test-espresso" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 37f853b..c61a118 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 0a06b0c..e6ecd23 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -9,6 +9,7 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + mavenLocal() google() mavenCentral() } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2a200b9..2d5868d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,7 @@ plugins { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + mavenLocal() // PURCHASELY v6.0.0 — remove once published to Maven Central google() mavenCentral() }