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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<ProductInfo>.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
)
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
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
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(
Expand Down Expand Up @@ -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. */
Expand Down
31 changes: 27 additions & 4 deletions presentation/src/main/res/layout/view_license_check_content.xml
Original file line number Diff line number Diff line change
Expand Up @@ -341,13 +341,28 @@
android:orientation="horizontal"
android:paddingVertical="12dp">

<TextView
android:id="@+id/tvLifetimeTitle"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/screen_license_check_lifetime_title"
android:textAppearance="?attr/textAppearanceBody1" />
android:orientation="vertical">

<TextView
android:id="@+id/tvLifetimeTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/screen_license_check_lifetime_title"
android:textAppearance="?attr/textAppearanceBody1" />

<TextView
android:id="@+id/tvLifetimeDiscountSubline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceCaption"
android:textColor="?attr/colorPrimary"
android:visibility="gone" />

</LinearLayout>

<LinearLayout
android:layout_width="wrap_content"
Expand All @@ -356,6 +371,14 @@
android:gravity="center"
android:orientation="vertical">

<TextView
android:id="@+id/tvLifetimePrice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceCaption"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />

<com.google.android.material.button.MaterialButton
android:id="@+id/btnLifetime"
style="@style/Widget.Material3.Button.TonalButton"
Expand Down
2 changes: 2 additions & 0 deletions presentation/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
<string name="screen_license_check_subscription_title">Yearly Subscription</string>
<string name="screen_license_check_subscription_subtitle">yearly</string>
<string name="screen_license_check_lifetime_title">Lifetime License</string>
<string name="screen_license_check_lifetime_discount_badge_until">🔥 %1$d%% off until %2$s</string>
<string name="screen_license_check_lifetime_discount_badge_generic">🔥 Limited time sale</string>
<string name="screen_license_check_lifetime_subtitle">one-time</string>
<string name="screen_license_check_restore_purchase">Restore Purchase</string>
<string name="screen_license_check_terms">Terms of Use</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ class IapBillingService : Service(), PurchasesUpdatedListener {
}
}

private fun findPromotionalInappOffer(offerDetails: List<ProductDetails.OneTimePurchaseOfferDetails>?): ProductDetails.OneTimePurchaseOfferDetails? {
return offerDetails?.firstOrNull { it.offerId != null }
}

private fun selectSubscriptionOffer(offerDetails: List<ProductDetails.SubscriptionOfferDetails>?): ProductDetails.SubscriptionOfferDetails? {
return offerDetails?.firstOrNull()
}

fun queryProductDetails(callback: (List<ProductInfo>) -> Unit) {
if (!billingClient.isReady) {
pendingProductDetailsCallbacks.enqueue(callback)
Expand All @@ -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) }
}
}
}
Expand All @@ -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(
Expand All @@ -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<Activity>, productId: String) {
Expand All @@ -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()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Test
fun `resolveProductPrices returns null lifetimeDiscountPrice when lifetime missing`() {
val prices = emptyList<ProductInfo>().resolveProductPrices()

assertNull(prices.lifetimeDiscountPrice)
assertNull(prices.lifetimeDiscountPercent)
assertNull(prices.lifetimeDiscountEndTimeMillis)
}
}
Loading