From acd4ce27d3d88ca27dca70ecb3ce731e014f2c7c Mon Sep 17 00:00:00 2001 From: Julian Raufelder Date: Wed, 24 Jun 2026 13:40:00 +0200 Subject: [PATCH 1/2] Display IAP discounts --- .../presentation/service/ProductInfo.kt | 17 +++++++-- .../ui/layout/LicenseContentViewBinder.kt | 38 +++++++++++++++---- .../res/layout/view_license_check_content.xml | 31 +++++++++++++-- presentation/src/main/res/values/strings.xml | 2 + .../presentation/service/IapBillingService.kt | 34 +++++++++++++---- .../presentation/service/ProductInfoTest.kt | 34 +++++++++++++++++ 6 files changed, 133 insertions(+), 23 deletions(-) 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..e8d59b1327 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,10 @@ class IapBillingService : Service(), PurchasesUpdatedListener { } } + private fun findPromotionalInappOffer(offerDetails: List?): ProductDetails.OneTimePurchaseOfferDetails? { + return offerDetails?.firstOrNull { it.offerId != null } + } + fun queryProductDetails(callback: (List) -> Unit) { if (!billingClient.isReady) { pendingProductDetailsCallbacks.enqueue(callback) @@ -126,13 +130,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))) + results.add(buildProductInfo(productDetails)) } } } @@ -149,7 +153,18 @@ class IapBillingService : Service(), PurchasesUpdatedListener { .build() ) ).build() - ) { it.oneTimePurchaseOfferDetails?.formattedPrice ?: "" } + ) { details -> + val baseOffer = details.oneTimePurchaseOfferDetailsList?.firstOrNull { it.offerId == null } + val promoOffer = findPromotionalInappOffer(details.oneTimePurchaseOfferDetailsList) + val price = baseOffer?.formattedPrice ?: "" + if (promoOffer != null) { + val discountPercent = promoOffer.discountDisplayInfo?.percentageDiscount + val discountEndTimeMillis = promoOffer.validTimeWindow?.endTimeMillis + ProductInfo(details.productId, price, promoOffer.formattedPrice, discountPercent, discountEndTimeMillis) + } else { + ProductInfo(details.productId, price) + } + } // Query SUBS products (must be separate — Billing Library requires same product type per query) doQuery( @@ -161,7 +176,10 @@ class IapBillingService : Service(), PurchasesUpdatedListener { .build() ) ).build() - ) { it.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: "" } + ) { details -> + val price = details.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: "" + ProductInfo(details.productId, price) + } } fun launchPurchaseFlow(activity: WeakReference, productId: String) { @@ -173,9 +191,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) - } + details.subscriptionOfferDetails?.firstOrNull()?.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..93e8cc4d59 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,38 @@ 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) + } + + @Test + fun `resolveProductPrices returns null lifetimeDiscountPrice when lifetime missing`() { + val prices = emptyList().resolveProductPrices() + + assertNull(prices.lifetimeDiscountPrice) + } } From 76a2c409c07669f23e5d75add125cf624c394e9a Mon Sep 17 00:00:00 2001 From: Julian Raufelder Date: Wed, 24 Jun 2026 16:13:07 +0200 Subject: [PATCH 2/2] Apply suggestions from review --- .../presentation/service/IapBillingService.kt | 24 ++++++++++++------- .../presentation/service/ProductInfoTest.kt | 4 ++++ 2 files changed, 19 insertions(+), 9 deletions(-) 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 e8d59b1327..9d0fe2b31b 100644 --- a/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt +++ b/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt @@ -111,6 +111,10 @@ class IapBillingService : Service(), PurchasesUpdatedListener { 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) @@ -130,13 +134,13 @@ class IapBillingService : Service(), PurchasesUpdatedListener { readyResults?.let { callback(it) } } - fun doQuery(params: QueryProductDetailsParams, buildProductInfo: (ProductDetails) -> ProductInfo) { + 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(buildProductInfo(productDetails)) + buildProductInfo(productDetails)?.let { results.add(it) } } } } @@ -156,13 +160,13 @@ class IapBillingService : Service(), PurchasesUpdatedListener { ) { details -> val baseOffer = details.oneTimePurchaseOfferDetailsList?.firstOrNull { it.offerId == null } val promoOffer = findPromotionalInappOffer(details.oneTimePurchaseOfferDetailsList) - val price = baseOffer?.formattedPrice ?: "" - if (promoOffer != null) { + if (baseOffer != null && promoOffer != null) { val discountPercent = promoOffer.discountDisplayInfo?.percentageDiscount val discountEndTimeMillis = promoOffer.validTimeWindow?.endTimeMillis - ProductInfo(details.productId, price, promoOffer.formattedPrice, discountPercent, discountEndTimeMillis) + ProductInfo(details.productId, baseOffer.formattedPrice, promoOffer.formattedPrice, discountPercent, discountEndTimeMillis) } else { - ProductInfo(details.productId, price) + val price = baseOffer?.formattedPrice ?: promoOffer?.formattedPrice + price?.let { ProductInfo(details.productId, it) } } } @@ -177,8 +181,10 @@ class IapBillingService : Service(), PurchasesUpdatedListener { ) ).build() ) { details -> - val price = details.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: "" - ProductInfo(details.productId, price) + 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) } } } @@ -191,7 +197,7 @@ 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() 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 93e8cc4d59..d64e9a292e 100644 --- a/presentation/src/test/java/org/cryptomator/presentation/service/ProductInfoTest.kt +++ b/presentation/src/test/java/org/cryptomator/presentation/service/ProductInfoTest.kt @@ -88,6 +88,8 @@ class ProductInfoTest { assertEquals("$49.99", prices.lifetimePrice) assertNull(prices.lifetimeDiscountPrice) + assertNull(prices.lifetimeDiscountPercent) + assertNull(prices.lifetimeDiscountEndTimeMillis) } @Test @@ -95,5 +97,7 @@ class ProductInfoTest { val prices = emptyList().resolveProductPrices() assertNull(prices.lifetimeDiscountPrice) + assertNull(prices.lifetimeDiscountPercent) + assertNull(prices.lifetimeDiscountEndTimeMillis) } }