diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt b/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt index 0ef8e3b093..10b244efd8 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt @@ -2,7 +2,10 @@ package org.cryptomator.presentation.service data class ProductInfo( val productId: String, - val formattedPrice: String + val price: String, + val discountPrice: String? = null, + val discountPercent: Int? = null, + val discountEndTimeMillis: Long? = null ) { companion object { const val PRODUCT_FULL_VERSION = "full_version" @@ -12,14 +15,20 @@ data class ProductInfo( data class ProductPrices( val subscriptionPrice: String?, - val lifetimePrice: String? + val lifetimePrice: String?, + val lifetimeDiscountPrice: String?, + val lifetimeDiscountPercent: Int?, + val lifetimeDiscountEndTimeMillis: Long? ) fun List.resolveProductPrices(): ProductPrices { val subscription = find { it.productId == ProductInfo.PRODUCT_YEARLY_SUBSCRIPTION } val lifetime = find { it.productId == ProductInfo.PRODUCT_FULL_VERSION } return ProductPrices( - subscriptionPrice = subscription?.formattedPrice, - lifetimePrice = lifetime?.formattedPrice + subscriptionPrice = subscription?.price, + lifetimePrice = lifetime?.price, + lifetimeDiscountPrice = lifetime?.discountPrice, + lifetimeDiscountPercent = lifetime?.discountPercent, + lifetimeDiscountEndTimeMillis = lifetime?.discountEndTimeMillis ) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt index 17c79dc42e..498c20883d 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt @@ -1,6 +1,7 @@ package org.cryptomator.presentation.ui.layout import android.content.Intent +import android.graphics.Paint import android.net.Uri import android.view.View import org.cryptomator.presentation.CryptomatorApp @@ -8,11 +9,14 @@ import org.cryptomator.presentation.R import org.cryptomator.presentation.databinding.ViewLicenseCheckContentBinding import org.cryptomator.presentation.licensing.LicenseEnforcer import org.cryptomator.presentation.service.ProductInfo +import org.cryptomator.presentation.service.ProductPrices import org.cryptomator.presentation.service.RestoreOutcome import org.cryptomator.presentation.service.resolveProductPrices import org.cryptomator.presentation.service.toDialogFragment import org.cryptomator.presentation.ui.activity.BaseActivity import java.lang.ref.WeakReference +import java.text.DateFormat +import java.util.Date /** Shared visibility-toggling logic for the license check content included layout. */ class LicenseContentViewBinder( @@ -110,24 +114,42 @@ class LicenseContentViewBinder( fun loadAndBindPrices(app: CryptomatorApp) { app.queryProductDetails { products -> val prices = products.resolveProductPrices() - binding.root.post { - bindProductPrices(prices.subscriptionPrice, prices.lifetimePrice) - } + binding.root.post { bindProductPrices(prices) } } } /** Updates subscription and lifetime button text and enabled state from resolved prices. */ - fun bindProductPrices(subscriptionPrice: String?, lifetimePrice: String?) { - if (!subscriptionPrice.isNullOrEmpty()) { - binding.btnSubscription.text = subscriptionPrice + fun bindProductPrices(prices: ProductPrices) { + if (!prices.subscriptionPrice.isNullOrEmpty()) { + binding.btnSubscription.text = prices.subscriptionPrice binding.rowSubscription.isEnabled = true binding.btnSubscription.isEnabled = true } - if (!lifetimePrice.isNullOrEmpty()) { - binding.btnLifetime.text = lifetimePrice + if (!prices.lifetimePrice.isNullOrEmpty()) { + binding.btnLifetime.text = prices.lifetimeDiscountPrice ?: prices.lifetimePrice binding.rowLifetime.isEnabled = true binding.btnLifetime.isEnabled = true } + if (prices.lifetimeDiscountPrice != null) { + binding.tvLifetimePrice.text = prices.lifetimePrice + binding.tvLifetimePrice.paintFlags = binding.tvLifetimePrice.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + binding.tvLifetimePrice.visibility = View.VISIBLE + binding.tvLifetimeDiscountSubline.text = lifetimeDiscountSubline(prices) + binding.tvLifetimeDiscountSubline.visibility = View.VISIBLE + } else { + binding.tvLifetimePrice.visibility = View.GONE + binding.tvLifetimeDiscountSubline.visibility = View.GONE + } + } + + private fun lifetimeDiscountSubline(prices: ProductPrices): String { + val percent = prices.lifetimeDiscountPercent + val endTimeMillis = prices.lifetimeDiscountEndTimeMillis + if (percent == null || endTimeMillis == null) { + return context.getString(R.string.screen_license_check_lifetime_discount_badge_generic) + } + val date = DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(endTimeMillis)) + return context.getString(R.string.screen_license_check_lifetime_discount_badge_until, percent, date) } /** Refreshes purchase/trial visibility and the header info text from the current license state. */ diff --git a/presentation/src/main/res/layout/view_license_check_content.xml b/presentation/src/main/res/layout/view_license_check_content.xml index 4826777afc..4905b7e5df 100644 --- a/presentation/src/main/res/layout/view_license_check_content.xml +++ b/presentation/src/main/res/layout/view_license_check_content.xml @@ -341,13 +341,28 @@ android:orientation="horizontal" android:paddingVertical="12dp"> - + android:orientation="vertical"> + + + + + + + + Yearly Subscription yearly Lifetime License + 🔥 %1$d%% off until %2$s + 🔥 Limited time sale one-time Restore Purchase Terms of Use diff --git a/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt b/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt index 6bc3e045eb..9d0fe2b31b 100644 --- a/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt +++ b/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt @@ -107,6 +107,14 @@ class IapBillingService : Service(), PurchasesUpdatedListener { } } + private fun findPromotionalInappOffer(offerDetails: List?): ProductDetails.OneTimePurchaseOfferDetails? { + return offerDetails?.firstOrNull { it.offerId != null } + } + + private fun selectSubscriptionOffer(offerDetails: List?): ProductDetails.SubscriptionOfferDetails? { + return offerDetails?.firstOrNull() + } + fun queryProductDetails(callback: (List) -> Unit) { if (!billingClient.isReady) { pendingProductDetailsCallbacks.enqueue(callback) @@ -126,13 +134,13 @@ class IapBillingService : Service(), PurchasesUpdatedListener { readyResults?.let { callback(it) } } - fun doQuery(params: QueryProductDetailsParams, getPrice: (ProductDetails) -> String) { + fun doQuery(params: QueryProductDetailsParams, buildProductInfo: (ProductDetails) -> ProductInfo?) { billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsResult -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { synchronized(lock) { for (productDetails in productDetailsResult.productDetailsList) { productDetailsMap[productDetails.productId] = productDetails - results.add(ProductInfo(productDetails.productId, getPrice(productDetails))) + buildProductInfo(productDetails)?.let { results.add(it) } } } } @@ -149,7 +157,18 @@ class IapBillingService : Service(), PurchasesUpdatedListener { .build() ) ).build() - ) { it.oneTimePurchaseOfferDetails?.formattedPrice ?: "" } + ) { details -> + val baseOffer = details.oneTimePurchaseOfferDetailsList?.firstOrNull { it.offerId == null } + val promoOffer = findPromotionalInappOffer(details.oneTimePurchaseOfferDetailsList) + if (baseOffer != null && promoOffer != null) { + val discountPercent = promoOffer.discountDisplayInfo?.percentageDiscount + val discountEndTimeMillis = promoOffer.validTimeWindow?.endTimeMillis + ProductInfo(details.productId, baseOffer.formattedPrice, promoOffer.formattedPrice, discountPercent, discountEndTimeMillis) + } else { + val price = baseOffer?.formattedPrice ?: promoOffer?.formattedPrice + price?.let { ProductInfo(details.productId, it) } + } + } // Query SUBS products (must be separate — Billing Library requires same product type per query) doQuery( @@ -161,7 +180,12 @@ class IapBillingService : Service(), PurchasesUpdatedListener { .build() ) ).build() - ) { it.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: "" } + ) { details -> + val offer = selectSubscriptionOffer(details.subscriptionOfferDetails) + val recurringPhase = offer?.pricingPhases?.pricingPhaseList?.lastOrNull { it.recurrenceMode == ProductDetails.RecurrenceMode.INFINITE_RECURRING } + ?: offer?.pricingPhases?.pricingPhaseList?.lastOrNull() + recurringPhase?.formattedPrice?.let { ProductInfo(details.productId, it) } + } } fun launchPurchaseFlow(activity: WeakReference, productId: String) { @@ -173,9 +197,11 @@ class IapBillingService : Service(), PurchasesUpdatedListener { } val paramsBuilder = ProductDetailsParams.newBuilder().setProductDetails(details) if (details.productType == BillingClient.ProductType.SUBS) { - details.subscriptionOfferDetails?.firstOrNull()?.offerToken?.let { - paramsBuilder.setOfferToken(it) - } + selectSubscriptionOffer(details.subscriptionOfferDetails)?.offerToken?.let { paramsBuilder.setOfferToken(it) } + } else if (details.productType == BillingClient.ProductType.INAPP) { + val promoOffer = findPromotionalInappOffer(details.oneTimePurchaseOfferDetailsList) + ?: details.oneTimePurchaseOfferDetailsList?.firstOrNull() + promoOffer?.offerToken?.let { paramsBuilder.setOfferToken(it) } } val billingFlowParams = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(paramsBuilder.build())) diff --git a/presentation/src/test/java/org/cryptomator/presentation/service/ProductInfoTest.kt b/presentation/src/test/java/org/cryptomator/presentation/service/ProductInfoTest.kt index 601ef092af..d64e9a292e 100644 --- a/presentation/src/test/java/org/cryptomator/presentation/service/ProductInfoTest.kt +++ b/presentation/src/test/java/org/cryptomator/presentation/service/ProductInfoTest.kt @@ -62,4 +62,42 @@ class ProductInfoTest { assertNull(prices.subscriptionPrice) assertNull(prices.lifetimePrice) } + + @Test + fun `resolveProductPrices returns lifetime discount details when lifetime has discount`() { + val products = listOf( + ProductInfo(ProductInfo.PRODUCT_YEARLY_SUBSCRIPTION, "$9.99/yr"), + ProductInfo(ProductInfo.PRODUCT_FULL_VERSION, "$49.99", "$24.99", 50, 1_700_000_000_000) + ) + + val prices = products.resolveProductPrices() + + assertEquals("$49.99", prices.lifetimePrice) + assertEquals("$24.99", prices.lifetimeDiscountPrice) + assertEquals(50, prices.lifetimeDiscountPercent) + assertEquals(1_700_000_000_000, prices.lifetimeDiscountEndTimeMillis) + } + + @Test + fun `resolveProductPrices returns null lifetimeDiscountPrice when no discount`() { + val products = listOf( + ProductInfo(ProductInfo.PRODUCT_FULL_VERSION, "$49.99") + ) + + val prices = products.resolveProductPrices() + + assertEquals("$49.99", prices.lifetimePrice) + assertNull(prices.lifetimeDiscountPrice) + assertNull(prices.lifetimeDiscountPercent) + assertNull(prices.lifetimeDiscountEndTimeMillis) + } + + @Test + fun `resolveProductPrices returns null lifetimeDiscountPrice when lifetime missing`() { + val prices = emptyList().resolveProductPrices() + + assertNull(prices.lifetimeDiscountPrice) + assertNull(prices.lifetimeDiscountPercent) + assertNull(prices.lifetimeDiscountEndTimeMillis) + } }