Added Payment support (Google + Rust).

This commit is contained in:
Neil Marietta
2025-05-06 16:04:22 +02:00
parent e781be65ea
commit a32dc0c486
74 changed files with 4901 additions and 7 deletions
+4
View File
@@ -235,6 +235,10 @@ dependencies {
implementation(project(":shared:core:humanverification:presentation"))
implementation(project(":shared:core:auth:dagger"))
implementation(project(":shared:core:auth:presentation"))
implementation(project(":shared:core:payment:dagger"))
implementation(project(":shared:core:payment:presentation"))
implementation(project(":shared:core:payment-google:dagger"))
implementation(project(":shared:core:payment-google:presentation"))
implementation(libs.bundles.appLibs)
implementation(libs.java.jna) { artifact { type = "aar" } }
@@ -390,7 +390,7 @@ fun Home(
drawerState = drawerState,
navigationActions = buildSidebarActions(navController, launcherActions).copy(
onReportBug = { navController.navigate(Destination.Screen.ApplicationLogs.route) },
onSubscription = { showFeatureMissingSnackbar() }
onSubscription = { launcherActions.onSubscription() }
)
)
}
@@ -46,6 +46,8 @@ import me.proton.android.core.auth.presentation.AuthOrchestrator
import me.proton.android.core.auth.presentation.login.LoginInput
import me.proton.android.core.auth.presentation.onAddAccountResult
import me.proton.android.core.auth.presentation.onLoginResult
import me.proton.android.core.payment.presentation.PaymentOrchestrator
import me.proton.android.core.payment.presentation.onUpgradeResult
import me.proton.core.domain.entity.UserId
import javax.inject.Inject
@@ -53,6 +55,7 @@ import javax.inject.Inject
@SuppressWarnings("NotImplementedDeclaration", "UnusedPrivateMember")
class LauncherViewModel @Inject constructor(
private val authOrchestrator: AuthOrchestrator,
private val paymentOrchestrator: PaymentOrchestrator,
private val setPrimaryAccount: SetPrimaryAccount,
private val userSessionRepository: UserSessionRepository,
private val notificationsPermissionOrchestrator: NotificationsPermissionOrchestrator
@@ -77,6 +80,7 @@ class LauncherViewModel @Inject constructor(
override fun onCleared() {
authOrchestrator.unregister()
notificationsPermissionOrchestrator.unregister()
paymentOrchestrator.unregister()
super.onCleared()
}
@@ -91,6 +95,10 @@ class LauncherViewModel @Inject constructor(
}
notificationsPermissionOrchestrator.register(context)
with(paymentOrchestrator) {
register(context)
onUpgradeResult { }
}
}
fun submit(action: Action) {
@@ -125,8 +133,8 @@ class LauncherViewModel @Inject constructor(
TODO("ET - Not yet implemented")
}
private fun onOpenSubscription() {
TODO("ET - Not yet implemented")
private fun onOpenSubscription() = viewModelScope.launch {
paymentOrchestrator.startSubscriptionWorkflow()
}
private fun onOpenSecurityKeys() {
@@ -33,6 +33,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import me.proton.android.core.auth.presentation.AuthOrchestrator
import me.proton.android.core.payment.presentation.PaymentOrchestrator
import org.junit.Rule
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -44,6 +45,7 @@ class LauncherViewModelTest {
val mainDispatcherRule = MainDispatcherRule()
private val authOrchestrator = mockk<AuthOrchestrator>()
private val paymentOrchestrator = mockk<PaymentOrchestrator>()
private val setPrimaryAccount = mockk<SetPrimaryAccount>()
private val userSessionRepository = mockk<UserSessionRepository>()
private val notificationsPermissionOrchestrator = mockk<NotificationsPermissionOrchestrator>(relaxUnitFun = true)
@@ -59,6 +61,7 @@ class LauncherViewModelTest {
// When
viewModel = LauncherViewModel(
authOrchestrator,
paymentOrchestrator,
setPrimaryAccount,
userSessionRepository,
notificationsPermissionOrchestrator
@@ -87,6 +90,7 @@ class LauncherViewModelTest {
// When
viewModel = LauncherViewModel(
authOrchestrator,
paymentOrchestrator,
setPrimaryAccount,
userSessionRepository,
notificationsPermissionOrchestrator
+6 -1
View File
@@ -7,6 +7,7 @@ sentry-gradle-plugin = "4.14.1"
accompanist-webview = "0.30.1"
accompanist-permissions = "0.37.2"
desugar-jdk-libs = "2.1.5"
android-billing = "7.1.1"
androidx-activity = "1.10.1"
androidx-annotation = "1.9.1"
androidx-appcompat = "1.7.0"
@@ -58,6 +59,7 @@ ksp-symbol-processing-api = "2.0.21-1.0.27"
kotlin = "2.0.21"
kotlin-compile-testing = "0.6.0"
kotlinx-coroutines = "1.8.0"
kotlinx-datetime = "0.6.2"
kotlinx-immutable-collections = "0.3.5"
kotlinx-serialization = "1.6.3"
lottie = "6.1.0"
@@ -93,6 +95,7 @@ paparazzi = { module = "app.cash.paparazzi:paparazzi-gradle-plugin", version.ref
# Standard dependencies
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist-webview" }
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permissions" }
android-billing = { module = "com.android.billingclient:billing-ktx", version.ref = "android-billing" }
android-tools-desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar-jdk-libs" }
android-material = { module = "com.google.android.material:material", version.ref = "material" }
androidx-activity-ktx = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
@@ -172,12 +175,13 @@ kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version
kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-immutableCollections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable-collections" }
ksp-symbolProcessingApi = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp-symbol-processing-api" }
kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoet-ksp" }
leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" }
lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
kotlinx-immutableCollections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable-collections" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
@@ -235,6 +239,7 @@ proton-core-userRecovery = { module = "me.proton.core:user-recovery", version.re
proton-core-userRecoveryTest = { module = "me.proton.core:user-recovery-test", version.ref = "proton-core" }
proton-core-userSettings = { module = "me.proton.core:user-settings", version.ref = "proton-core" }
proton-core-utilKotlin = { module = "me.proton.core:util-kotlin", version.ref = "proton-core" }
proton-core-utilAndroidDateTime = { module = "me.proton.core:util-android-datetime", version.ref = "proton-core" }
proton-core-utilAndroidDagger = { module = "me.proton.core:util-android-dagger", version.ref = "proton-core" }
proton-core-utilAndroidSentry = { module = "me.proton.core:util-android-sentry", version.ref = "proton-core" }
proton-core-utilAndroidStrictMode = { module = "me.proton.core:util-android-strict-mode", version.ref = "proton-core" }
@@ -19,18 +19,20 @@
package ch.protonmail.android.mailsession.domain.repository
import arrow.core.Either
import ch.protonmail.android.mailsession.domain.wrapper.MailUserSessionWrapper
import ch.protonmail.android.mailsession.domain.model.Account
import ch.protonmail.android.mailsession.domain.model.AccountState
import ch.protonmail.android.mailsession.domain.model.ForkedSessionId
import ch.protonmail.android.mailsession.domain.model.SessionError
import ch.protonmail.android.mailsession.domain.wrapper.MailUserSessionWrapper
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.transform
import me.proton.core.domain.entity.UserId
import me.proton.core.network.domain.session.SessionId
import uniffi.proton_mail_uniffi.MailUserSession
interface UserSessionRepository {
@@ -61,3 +63,9 @@ fun UserSessionRepository.onAccountState(state: AccountState, initialState: Bool
.filter { it.state == state }
.drop(if (initialState) 0 else 1)
.distinctUntilChanged()
suspend fun UserSessionRepository.getPrimarySession(): MailUserSession? {
val primaryUserId = observePrimaryUserId().firstOrNull() ?: return null
val userSession = getUserSession(primaryUserId) ?: return null
return userSession.getRustUserSession()
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Proton AG
* This file is part of Proton AG and Proton Mail.
*
* Proton Mail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Mail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.design.compose.component
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun ExpandableHeader(
header: @Composable ColumnScope.() -> Unit,
content: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
expanded: Boolean = true,
enter: EnterTransition = fadeIn() + expandVertically(),
exit: ExitTransition = fadeOut() + shrinkVertically()
) {
Column(modifier = modifier) {
header()
AnimatedVisibility(
visible = expanded,
enter = enter,
exit = exit
) {
Column(content = content)
}
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
private fun ExpandableHeaderPreview() {
ExpandableHeader(
expanded = false,
header = {
Text("Header")
},
content = {
Text("Content1")
Text("Content2")
}
)
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
private fun ExpandableHeaderExpandedPreview() {
ExpandableHeader(
expanded = true,
header = {
Text("Header")
},
content = {
Text("Content1")
Text("Content2")
}
)
}
@@ -0,0 +1,136 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package ch.protonmail.android.design.compose.component
import android.content.res.Configuration
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CardElevation
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import ch.protonmail.android.design.compose.R
import ch.protonmail.android.design.compose.theme.ProtonDimens.BorderSize
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
@Composable
fun ExposedDropdownMenuCard(
list: List<String>,
modifier: Modifier = Modifier,
onSelectedIndexChanged: (Int) -> Unit = {},
shape: Shape = CardDefaults.shape,
colors: CardColors = CardDefaults.cardColors(
containerColor = ProtonTheme.colors.backgroundSecondary
),
elevation: CardElevation = CardDefaults.cardElevation(),
border: BorderStroke? = BorderStroke(
width = BorderSize.Default,
color = ProtonTheme.colors.borderNorm
),
expandedInitially: Boolean = false,
item: @Composable (String) -> Unit
) {
var expanded by remember { mutableStateOf(expandedInitially) }
var selected by remember { mutableIntStateOf(0) }
ExposedDropdownMenuBox(
modifier = modifier,
expanded = expanded,
onExpandedChange = {}
) {
Card(
onClick = { expanded = !expanded },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
shape = shape,
colors = colors,
elevation = elevation,
border = border
) {
ListItem(
headlineContent = { item(list[selected]) },
trailingContent = { TrailingIcon(expanded) },
colors = ListItemDefaults.colors(
containerColor = colors.containerColor
)
)
}
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
containerColor = ProtonTheme.colors.backgroundNorm
) {
list.forEachIndexed { index, item ->
DropdownMenuItem(
text = { item(item) },
onClick = {
expanded = false
selected = index
onSelectedIndexChanged(index)
}
)
}
}
}
}
@Composable
internal fun TrailingIcon(expanded: Boolean) {
Icon(
painter = when (expanded) {
false -> painterResource(R.drawable.ic_proton_chevron_down)
true -> painterResource(R.drawable.ic_proton_chevron_up)
},
contentDescription = null
)
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = false)
private fun ExposedDropdownMenuCardPreview() {
ProtonTheme {
ExposedDropdownMenuCard(
list = listOf("Pay monthly", "Pay annually"),
modifier = Modifier.padding(Spacing.Medium)
) {
Text(text = it)
}
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.design.compose.component.appbar
import android.content.res.Configuration
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import ch.protonmail.android.design.compose.R
import ch.protonmail.android.design.compose.theme.ProtonTheme
@Composable
fun ProtonNavigationIcon(
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
painter: Painter = painterResource(id = R.drawable.ic_proton_close),
contentDescription: String? = stringResource(id = R.string.presentation_close)
) {
IconButton(
modifier = modifier,
onClick = onClick
) {
Icon(
tint = ProtonTheme.colors.iconNorm,
painter = painter,
contentDescription = contentDescription
)
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = false)
private fun ProtonNavigationIconPreview() {
ProtonTheme { ProtonNavigationIcon() }
}
@@ -30,9 +30,9 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
* @see [hiltViewModel].
*/
@Composable
inline fun <reified VM : ViewModel> hiltViewModelOrNull(): VM? {
inline fun <reified VM : ViewModel> hiltViewModelOrNull(key: String? = null): VM? {
return when (LocalViewModelStoreOwner.current) {
is HasDefaultViewModelProviderFactory -> hiltViewModel()
is HasDefaultViewModelProviderFactory -> hiltViewModel(key = key)
else -> null
}
}
+7
View File
@@ -31,6 +31,13 @@ include(":shared:core:account-recovery:presentation")
include(":shared:core:humanverification:dagger")
include(":shared:core:humanverification:domain")
include(":shared:core:humanverification:presentation")
include(":shared:core:payment:dagger")
include(":shared:core:payment:data")
include(":shared:core:payment:domain")
include(":shared:core:payment:presentation")
include(":shared:core:payment-google:dagger")
include(":shared:core:payment-google:data")
include(":shared:core:payment-google:presentation")
includeBuild("build-plugin")
include(":app")
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
kotlin("android")
kotlin("kapt")
id("dagger.hilt.android.plugin")
id("app-config-plugin")
}
android {
namespace = "me.proton.android.core.payment.google.dagger"
compileSdk = AppConfiguration.compileSdk.get()
defaultConfig {
minSdk = AppConfiguration.minSdk.get()
lint.targetSdk = AppConfiguration.targetSdk.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}
dependencies {
kapt(libs.bundles.app.annotationProcessors)
api(project(":shared:core:payment:domain"))
api(project(":shared:core:payment:presentation"))
implementation(project(":shared:core:payment-google:data"))
implementation(project(":shared:core:payment-google:presentation"))
implementation(libs.dagger.hilt.android)
}
@@ -0,0 +1,48 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.google.dagger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.proton.android.core.payment.domain.PaymentManager
import me.proton.android.core.payment.domain.PurchaseListener
import me.proton.android.core.payment.google.data.PaymentManagerImpl
import me.proton.android.core.payment.google.data.PurchaseStoreListener
import me.proton.android.core.payment.google.presentation.PurchaseOrchestratorImpl
import me.proton.android.core.payment.presentation.PurchaseOrchestrator
@InstallIn(SingletonComponent::class)
@Module
object CorePaymentGoogleModule {
@Module
@InstallIn(SingletonComponent::class)
interface BindsModule {
@Binds
fun bindPaymentManager(impl: PaymentManagerImpl): PaymentManager
@Binds
fun bindPurchaseOrchestrator(impl: PurchaseOrchestratorImpl): PurchaseOrchestrator
@Binds
fun providePurchaseStoreListener(purchaseStoreListener: PurchaseStoreListener): PurchaseListener
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
kotlin("android")
id("app-config-plugin")
}
android {
namespace = "me.proton.android.core.payment.google.data"
compileSdk = AppConfiguration.compileSdk.get()
defaultConfig {
minSdk = AppConfiguration.minSdk.get()
lint.targetSdk = AppConfiguration.targetSdk.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}
dependencies {
implementation(libs.android.billing)
implementation(libs.dagger.hilt.android)
implementation(libs.proton.core.utilKotlin)
implementation(project(":shared:core:payment:domain"))
implementation(libs.kotlinx.datetime)
implementation(libs.javax.inject)
}
@@ -0,0 +1,85 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.google.data
import android.content.Context
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.PendingPurchasesParams
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
import me.proton.android.core.payment.domain.PaymentManager
import me.proton.android.core.payment.domain.model.ProductDetail
import me.proton.android.core.payment.domain.model.Purchase
import me.proton.android.core.payment.google.data.extension.getOrThrow
import me.proton.android.core.payment.google.data.extension.getProductDetails
import me.proton.android.core.payment.google.data.extension.getProductPurchasesAsync
import me.proton.android.core.payment.google.data.extension.withConnection
import me.proton.android.core.payment.google.data.model.toProductHeader
import me.proton.android.core.payment.google.data.model.toProductPrice
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PaymentManagerImpl @Inject constructor(
@ApplicationContext val context: Context,
private val purchaseStoreListener: PurchaseStoreListener
) : PaymentManager {
// Return a new instance (multiple simultaneous instances are supported).
private fun newClient() = BillingClient
.newBuilder(context)
.setListener(purchaseStoreListener)
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
.build()
override suspend fun getStoreProducts(ids: List<String>): List<ProductDetail> = newClient().withConnection {
getProductDetails(ids).getOrThrow()?.map { detail ->
val phases = detail.subscriptionOfferDetails?.getOrNull(0)?.pricingPhases?.pricingPhaseList ?: emptyList()
val current = phases.getOrNull(0)
val renew = phases.getOrNull(1)
ProductDetail(
productId = detail.productId,
planName = detail.name, // Comes from Proton BE.
header = detail.toProductHeader(requireNotNull(current)),
price = current.toProductPrice(detail.productId),
renew = renew?.toProductPrice(detail.productId) ?: current.toProductPrice(detail.productId),
entitlements = emptyList()
)
}.orEmpty()
}
override suspend fun getStorePurchases(): List<Purchase> = newClient().withConnection {
purchaseStoreListener.purchases
.onSubscription { getProductPurchasesAsync(purchaseStoreListener) }
.first()
}
override fun observeStorePurchases(): Flow<List<Purchase>> = callbackFlow {
val job = launch { purchaseStoreListener.purchases.collect { send(it) } }
newClient().withConnection {
getProductPurchasesAsync(purchaseStoreListener)
job.join()
}
awaitClose { job.cancel() }
}
}
@@ -0,0 +1,65 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.google.data
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PurchasesResponseListener
import com.android.billingclient.api.PurchasesUpdatedListener
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import me.proton.android.core.payment.domain.LogTag
import me.proton.android.core.payment.domain.PurchaseListener
import me.proton.android.core.payment.domain.model.Purchase
import me.proton.android.core.payment.google.data.model.toPurchase
import me.proton.core.util.kotlin.CoreLogger
import me.proton.core.util.kotlin.CoroutineScopeProvider
import javax.inject.Inject
import javax.inject.Singleton
import com.android.billingclient.api.Purchase as BillingClientPurchase
@Singleton
class PurchaseStoreListener @Inject constructor(
private val scopeProvider: CoroutineScopeProvider
) :
PurchaseListener,
PurchasesUpdatedListener,
PurchasesResponseListener {
private val mutablePurchases = MutableSharedFlow<List<Purchase>>()
val purchases: SharedFlow<List<Purchase>> = mutablePurchases.asSharedFlow()
override fun onPurchasesUpdated(result: BillingResult, list: List<BillingClientPurchase>?) {
CoreLogger.d(LogTag.DEFAULT, "onPurchasesUpdated -> $list")
list?.let { onPurchaseListUpdated(list.map { it.toPurchase() }) }
}
override fun onQueryPurchasesResponse(result: BillingResult, list: List<BillingClientPurchase>) {
CoreLogger.d(LogTag.DEFAULT, "onQueryPurchasesResponse -> $list")
onPurchaseListUpdated(list.map { it.toPurchase() })
}
override fun onPurchaseListUpdated(list: List<Purchase>) {
CoreLogger.d(LogTag.DEFAULT, "onPurchaseListUpdated -> $list")
scopeProvider.GlobalIOSupervisedScope.launch {
mutablePurchases.emit(list)
}
}
}
@@ -0,0 +1,118 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.google.data.extension
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClient.ConnectionState
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetailsResult
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryProductDetailsParams.Product
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.queryProductDetails
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
import me.proton.android.core.payment.domain.LogTag
import me.proton.android.core.payment.domain.PaymentException
import me.proton.android.core.payment.google.data.PurchaseStoreListener
import me.proton.core.util.kotlin.CoreLogger
/**
* Calls the specified suspending [block] receiving a connected or closed [BillingClient], suspending while connection
* is establishing, and return the result [R].
*
* Note: This implementation take care of calling [BillingClient.startConnection] and [BillingClient.endConnection].
*
* Example of usage:
*
* ```
* newBillingClient().withConnection { connectedClient ->
* connectedClient.queryPurchasesAsync(...)
* }
* ```
* @throws PaymentException on any billing setup error.
* @see [BillingClient]
*/
suspend fun <R> BillingClient.withConnection(retry: Int = Int.MAX_VALUE, block: suspend BillingClient.() -> R): R {
var remainingRetry = retry
val mutableConnectionState = MutableStateFlow<Int?>(null)
val billingClientStateListener = object : BillingClientStateListener {
override fun onBillingServiceDisconnected() {
CoreLogger.d(LogTag.STORE, "BillingClient: onBillingServiceDisconnected: remainingRetry: $remainingRetry")
if (remainingRetry > 0) {
remainingRetry -= 1
startConnection(this)
mutableConnectionState.tryEmit(ConnectionState.CONNECTING)
} else {
endConnection()
mutableConnectionState.tryEmit(ConnectionState.CLOSED)
}
}
override fun onBillingSetupFinished(result: BillingResult) {
CoreLogger.d(LogTag.STORE, "BillingClient: onBillingSetupFinished: $result")
result.checkOrThrow()
mutableConnectionState.tryEmit(connectionState)
}
}
try {
CoreLogger.d(LogTag.STORE, "BillingClient: startConnection...")
startConnection(billingClientStateListener)
mutableConnectionState.firstOrNull {
when (it) {
ConnectionState.DISCONNECTED -> false
ConnectionState.CONNECTING -> false
ConnectionState.CONNECTED -> true
ConnectionState.CLOSED -> true
else -> false
}
}
CoreLogger.d(LogTag.STORE, "BillingClient: invoking block...")
return withContext(Dispatchers.IO) { block.invoke(this@withConnection) }
} finally {
endConnection()
CoreLogger.d(LogTag.STORE, "BillingClient: endConnection.")
}
}
suspend fun BillingClient.getProductDetails(ids: List<String>): ProductDetailsResult {
val productList = ids.map { id ->
Product.newBuilder().setProductId(id).setProductType(BillingClient.ProductType.SUBS).build()
}
val params = QueryProductDetailsParams.newBuilder()
params.setProductList(productList)
return queryProductDetails(params.build())
}
fun BillingClient.getProductPurchasesAsync(listener: PurchaseStoreListener) {
val params = QueryPurchasesParams.newBuilder()
params.setProductType(BillingClient.ProductType.SUBS)
return queryPurchasesAsync(params.build(), listener)
}
fun BillingResult.checkOrThrow() = getExceptionOrNull()?.let { throw it }
fun BillingResult.getExceptionOrNull() = when {
responseCode == BillingResponseCode.OK -> null
else -> PaymentException(responseCode, debugMessage)
}
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.google.data.extension
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetailsResult
fun ProductDetailsResult.getOrThrow(): List<ProductDetails>? {
billingResult.checkOrThrow()
return productDetailsList
}
inline fun <R> ProductDetailsResult.fold(
onSuccess: (value: ProductDetailsResult) -> R,
onFailure: (exception: Throwable) -> R
): R {
return when (val exception = billingResult.getExceptionOrNull()) {
null -> onSuccess(this)
else -> onFailure(exception)
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.google.data.model
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetails.PricingPhase
import kotlinx.datetime.DateTimePeriod
import me.proton.android.core.payment.domain.model.ProductHeader
import me.proton.android.core.payment.domain.model.ProductPrice
fun ProductDetails.toProductHeader(phase: PricingPhase) = ProductHeader(
title = title,
description = description, // Comes from Proton BE.
priceText = phase.formattedPrice,
cycleText = phase.billingPeriod,
starred = false // Comes from Proton BE.
)
@Suppress("MagicNumber")
fun PricingPhase.toProductPrice(productId: String) = ProductPrice(
productId = productId,
customerId = null, // Comes from Proton BE.
amount = priceAmountMicros.toInt(),
currency = priceCurrencyCode,
formatted = formattedPrice,
cycle = DateTimePeriod.parse(billingPeriod).let { it.months + it.years * 12 }
)
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.google.data.model
import me.proton.android.core.payment.domain.model.PurchaseStatus
import com.android.billingclient.api.Purchase as BillingClientPurchase
fun BillingClientPurchase.toPurchase() = me.proton.android.core.payment.domain.model.Purchase(
productType = "google",
productId = products.first(),
orderId = orderId ?: "unknown",
clientId = packageName,
customerId = accountIdentifiers?.obfuscatedAccountId ?: "unknown",
receipt = purchaseToken,
autoRenewing = isAutoRenewing,
status = when {
isAcknowledged -> PurchaseStatus.Acknowledged
else -> PurchaseStatus.map[purchaseState] ?: PurchaseStatus.Unspecified
}
)
@@ -0,0 +1,66 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
kotlin("android")
kotlin("kapt")
id("org.jetbrains.kotlin.plugin.compose")
id("dagger.hilt.android.plugin")
id("app-config-plugin")
}
android {
namespace = "me.proton.android.core.payment.google.presentation"
compileSdk = AppConfiguration.compileSdk.get()
defaultConfig {
minSdk = AppConfiguration.minSdk.get()
lint.targetSdk = AppConfiguration.targetSdk.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.android.billing)
implementation(libs.androidx.compose.runtime)
implementation(libs.dagger.hilt.android)
implementation(libs.proton.core.utilKotlin)
implementation(project(":shared:core:payment:domain"))
implementation(project(":shared:core:payment:presentation"))
implementation(project(":shared:core:payment-google:data"))
kapt(libs.bundles.app.annotationProcessors)
testImplementation(kotlin("test"))
testImplementation(libs.bundles.test)
}
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.google.presentation
import android.app.Activity
import android.content.Context
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetailsResult
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryProductDetailsParams.Product
import com.android.billingclient.api.queryProductDetails
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import me.proton.android.core.payment.domain.LogTag
import me.proton.android.core.payment.google.data.PurchaseStoreListener
import me.proton.android.core.payment.google.data.extension.checkOrThrow
import me.proton.android.core.payment.google.data.extension.withConnection
import me.proton.android.core.payment.presentation.PurchaseOrchestrator
import me.proton.core.util.kotlin.CoreLogger
import me.proton.core.util.kotlin.CoroutineScopeProvider
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PurchaseOrchestratorImpl @Inject constructor(
@ApplicationContext val context: Context,
private val purchaseStoreListener: PurchaseStoreListener,
private val scopeProvider: CoroutineScopeProvider
) : PurchaseOrchestrator {
// Return a new instance (multiple simultaneous instances are supported).
private fun newClient() = BillingClient
.newBuilder(context)
.setListener(purchaseStoreListener)
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
.build()
private suspend fun getProductDetails(id: String): ProductDetailsResult {
val productList = listOf(
Product.newBuilder().setProductId(id).setProductType(BillingClient.ProductType.SUBS).build()
)
val params = QueryProductDetailsParams.newBuilder()
params.setProductList(productList)
return newClient().withConnection {
queryProductDetails(params.build())
}
}
private suspend fun launchBillingFlow(
caller: Activity,
productId: String,
accountId: String
): BillingResult {
val productDetailResult = getProductDetails(productId)
val productDetail = requireNotNull(productDetailResult.productDetailsList).first()
val offerDetail = requireNotNull(productDetail.subscriptionOfferDetails).first()
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
ProductDetailsParams.newBuilder()
.setProductDetails(productDetail)
.setOfferToken(offerDetail.offerToken)
.build()
)
)
.setObfuscatedAccountId(accountId)
.build()
return newClient().withConnection {
launchBillingFlow(caller, params)
}
}
override fun startPurchaseWorkflow(
caller: Activity,
productId: String,
accountId: String
) {
scopeProvider.GlobalIOSupervisedScope.launch {
val result = launchBillingFlow(caller, productId, accountId)
CoreLogger.d(LogTag.STORE, "launchBillingFlow result: $result")
result.checkOrThrow()
}
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
kotlin("android")
kotlin("kapt")
id("dagger.hilt.android.plugin")
id("app-config-plugin")
}
android {
namespace = "me.proton.android.core.payment.dagger"
compileSdk = AppConfiguration.compileSdk.get()
defaultConfig {
minSdk = AppConfiguration.minSdk.get()
lint.targetSdk = AppConfiguration.targetSdk.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}
dependencies {
kapt(libs.bundles.app.annotationProcessors)
api(project(":shared:core:payment:domain"))
api(project(":shared:core:payment:data"))
api(project(":shared:core:payment:presentation"))
implementation(libs.dagger.hilt.android)
}
@@ -0,0 +1,51 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.dagger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.proton.android.core.payment.data.IconResourceManagerRust
import me.proton.android.core.payment.data.SubscriptionManagerRust
import me.proton.android.core.payment.domain.IconResourceManager
import me.proton.android.core.payment.domain.SubscriptionManager
import me.proton.android.core.payment.presentation.component.PurchaseButtonProcessor
import me.proton.android.core.payment.presentation.component.PurchaseButtonProcessorNative
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
interface CorePaymentModule {
@Binds
fun bindPurchaseButtonProcessor(processor: PurchaseButtonProcessorNative): PurchaseButtonProcessor
}
@Module
@InstallIn(SingletonComponent::class)
interface CorePaymentRustModule {
@Binds
@Singleton
fun provideIconResourceManager(manager: IconResourceManagerRust): IconResourceManager
@Binds
@Singleton
fun provideSubscriptionManager(manager: SubscriptionManagerRust): SubscriptionManager
}
+64
View File
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Proton AG
* This file is part of Proton AG and Proton Mail.
*
* Proton Mail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Mail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
kotlin("android")
kotlin("kapt")
id("dagger.hilt.android.plugin")
id("app-config-plugin")
}
android {
namespace = "me.proton.android.core.payment.rust.data"
compileSdk = AppConfiguration.compileSdk.get()
defaultConfig {
minSdk = AppConfiguration.minSdk.get()
lint.targetSdk = AppConfiguration.targetSdk.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}
dependencies {
compileOnly(libs.proton.rust.core)
implementation(libs.dagger.hilt.android)
implementation(libs.proton.core.user.domain)
implementation(libs.proton.core.presentation) // formatCentsPriceDefaultLocale
implementation(project(":mail-session:domain"))
implementation(project(":shared:core:payment:domain"))
implementation(project(":shared:core:payment:presentation"))
kapt(libs.bundles.app.annotationProcessors)
testImplementation(kotlin("test"))
testImplementation(libs.bundles.test)
testImplementation(libs.proton.rust.core)
}
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.data
import ch.protonmail.android.mailsession.domain.repository.UserSessionRepository
import ch.protonmail.android.mailsession.domain.repository.getPrimarySession
import me.proton.android.core.payment.domain.IconResourceManager
import uniffi.proton_mail_uniffi.MailUserSessionGetPaymentsResourcesIconsResult
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class IconResourceManagerRust @Inject constructor(
private val sessionRepository: UserSessionRepository
) : IconResourceManager {
override suspend fun getBitmap(iconName: String): ByteArray? {
val session = sessionRepository.getPrimarySession() ?: return null
return when (val result = session.getPaymentsResourcesIcons(iconName)) {
is MailUserSessionGetPaymentsResourcesIconsResult.Error -> null
is MailUserSessionGetPaymentsResourcesIconsResult.Ok -> result.v1
}
}
}
@@ -0,0 +1,149 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.data
import android.content.Context
import ch.protonmail.android.mailsession.domain.repository.UserSessionRepository
import ch.protonmail.android.mailsession.domain.repository.getPrimarySession
import dagger.hilt.android.qualifiers.ApplicationContext
import me.proton.android.core.payment.data.model.toProductDetail
import me.proton.android.core.payment.data.model.toSubscriptionDetail
import me.proton.android.core.payment.domain.PaymentException
import me.proton.android.core.payment.domain.PaymentException.Companion.ErrorCode.DEVELOPER_ERROR
import me.proton.android.core.payment.domain.SubscriptionManager
import me.proton.android.core.payment.domain.model.ProductDetail
import me.proton.android.core.payment.domain.model.Purchase
import me.proton.android.core.payment.domain.model.SubscriptionDetail
import me.proton.android.core.payment.presentation.extension.getErrorMessage
import uniffi.proton_mail_uniffi.GetPaymentsPlansOptions
import uniffi.proton_mail_uniffi.GoogleRecurringReceiptDetails
import uniffi.proton_mail_uniffi.MailUserSession
import uniffi.proton_mail_uniffi.MailUserSessionGetPaymentsPlansResult
import uniffi.proton_mail_uniffi.MailUserSessionGetPaymentsSubscriptionResult
import uniffi.proton_mail_uniffi.MailUserSessionPostPaymentsSubscriptionResult
import uniffi.proton_mail_uniffi.MailUserSessionPostPaymentsTokensResult
import uniffi.proton_mail_uniffi.NewSubscription
import uniffi.proton_mail_uniffi.NewSubscriptionValues
import uniffi.proton_mail_uniffi.PaymentReceipt
import uniffi.proton_mail_uniffi.PaymentToken
import uniffi.proton_mail_uniffi.UserSessionError
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@Suppress("MagicNumber")
class SubscriptionManagerRust @Inject constructor(
@ApplicationContext private val context: Context,
private val sessionRepository: UserSessionRepository
) : SubscriptionManager {
private fun UserSessionError.throwException(): Nothing {
throw PaymentException(DEVELOPER_ERROR, getErrorMessage(context))
}
private suspend fun getProtonToken(
product: ProductDetail,
purchase: Purchase,
session: MailUserSession
): PaymentToken = when (
val result = session.postPaymentsTokens(
amount = product.price.amount.toULong(),
currency = product.price.currency,
payment = PaymentReceipt.Google(
details = GoogleRecurringReceiptDetails(
orderId = purchase.orderId,
productId = purchase.productId,
customerId = purchase.customerId,
packageName = purchase.clientId,
token = purchase.receipt
)
)
)
) {
is MailUserSessionPostPaymentsTokensResult.Error -> result.v1.throwException()
is MailUserSessionPostPaymentsTokensResult.Ok -> result.v1
}
private suspend fun createOrUpdateSubscription(
session: MailUserSession,
product: ProductDetail,
paymentToken: PaymentToken
) = when (
val result = session.postPaymentsSubscription(
subscription = NewSubscription(
cycle = product.price.cycle.toUByte(),
currency = product.price.currency,
currencyId = null,
plans = mapOf(product.planName to 1),
planIds = null,
codes = null,
couponCode = null,
giftCode = null
),
newValues = NewSubscriptionValues(
amount = product.price.amount.toULong(),
payments = null,
paymentToken = paymentToken.token
)
)
) {
is MailUserSessionPostPaymentsSubscriptionResult.Error -> result.v1.throwException()
is MailUserSessionPostPaymentsSubscriptionResult.Ok -> Unit
}
override suspend fun getCurrent(): List<SubscriptionDetail> {
val session = sessionRepository.getPrimarySession() ?: return emptyList()
when (val result = session.getPaymentsSubscription()) {
is MailUserSessionGetPaymentsSubscriptionResult.Error -> {
result.v1.throwException()
}
is MailUserSessionGetPaymentsSubscriptionResult.Ok -> {
val subscriptions = result.v1.current
return subscriptions.map { it.toSubscriptionDetail() }
}
}
}
override suspend fun getAvailable(): List<ProductDetail> {
val session = sessionRepository.getPrimarySession() ?: return emptyList()
val options = GetPaymentsPlansOptions(
vendor = "google",
currency = null,
state = null,
timestamp = null,
fallback = null
)
when (val result = session.getPaymentsPlans(options)) {
is MailUserSessionGetPaymentsPlansResult.Error -> {
result.v1.throwException()
}
is MailUserSessionGetPaymentsPlansResult.Ok -> {
val plans = result.v1.plans
return plans.map { plan -> plan.instances.map { instance -> plan.toProductDetail(instance) } }.flatten()
}
}
}
override suspend fun subscribe(product: ProductDetail, purchase: Purchase) {
val session = checkNotNull(sessionRepository.getPrimarySession())
val protonToken = getProtonToken(product, purchase, session)
createOrUpdateSubscription(session, product, protonToken)
}
}
@@ -0,0 +1,119 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package me.proton.android.core.payment.data.model
import me.proton.android.core.payment.domain.model.ProductDetail
import me.proton.android.core.payment.domain.model.ProductEntitlement
import me.proton.android.core.payment.domain.model.ProductHeader
import me.proton.android.core.payment.domain.model.ProductPrice
import me.proton.android.core.payment.domain.model.SubscriptionDetail
import me.proton.core.presentation.utils.formatCentsPriceDefaultLocale
import uniffi.proton_mail_uniffi.Plan
import uniffi.proton_mail_uniffi.PlanDecoration
import uniffi.proton_mail_uniffi.PlanEntitlement
import uniffi.proton_mail_uniffi.PlanInstance
import uniffi.proton_mail_uniffi.PlanVendorName
import uniffi.proton_mail_uniffi.Subscription
private fun getFormattedPrice(amount: ULong?, currency: String?) = when {
amount == null -> ""
currency == null -> ""
else -> amount.toDouble().formatCentsPriceDefaultLocale(currency)
}
fun Subscription.toSubscriptionDetail() = SubscriptionDetail(
name = name ?: "free",
header = ProductHeader(
title = title,
description = description,
priceText = getFormattedPrice(amount, currency),
cycleText = cycleDescription ?: "",
starred = false
),
entitlements = entitlements.map { it.toProductEntitlement() },
price = amount?.let {
ProductPrice(
productId = "unknown",
customerId = "unknown",
cycle = requireNotNull(cycle).toInt(),
amount = requireNotNull(amount).toInt(),
currency = requireNotNull(currency),
formatted = getFormattedPrice(amount, currency)
)
},
renew = renewAmount?.let {
ProductPrice(
productId = "unknown",
customerId = "unknown",
cycle = requireNotNull(cycle).toInt(),
amount = requireNotNull(renewAmount).toInt(),
currency = requireNotNull(currency),
formatted = getFormattedPrice(renewAmount, currency)
)
},
periodEnd = periodEnd?.toLong(),
managedBy = external?.toInt()
)
fun Plan.toProductDetail(instance: PlanInstance) = ProductDetail(
productId = requireNotNull(instance.vendors[PlanVendorName.GOOGLE]?.productId),
planName = requireNotNull(name),
header = toProductHeader(instance),
price = instance.toProductPrice(),
renew = instance.toProductPrice(),
entitlements = entitlements.map { it.toProductEntitlement() }
)
fun Plan.toProductHeader(instance: PlanInstance) = ProductHeader(
title = title,
description = description,
priceText = instance.price.first().let { getFormattedPrice(it.current, it.currency) },
cycleText = instance.description,
starred = decorations.any { it as? PlanDecoration.Starred != null }
)
fun PlanInstance.toProductPrice() = ProductPrice(
productId = requireNotNull(vendors[PlanVendorName.GOOGLE]?.productId),
customerId = requireNotNull(vendors[PlanVendorName.GOOGLE]?.customerId),
cycle = cycle.toInt(),
amount = price.first().current.toInt(),
currency = price.first().currency,
formatted = price.first().let { getFormattedPrice(it.current, it.currency) }
)
fun PlanEntitlement.toProductEntitlement() = when (this) {
is PlanEntitlement.Description -> toProductEntitlement()
is PlanEntitlement.Progress -> toProductEntitlement()
}
fun PlanEntitlement.Description.toProductEntitlement() = ProductEntitlement.Description(
iconName = iconName,
text = text,
hint = hint
)
fun PlanEntitlement.Progress.toProductEntitlement() = ProductEntitlement.Progress(
startText = title ?: "",
iconName = iconName,
endText = text,
min = min.toInt(),
max = max.toInt(),
current = current.toInt()
)
@@ -0,0 +1,47 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
kotlin("android")
id("app-config-plugin")
}
android {
namespace = "me.proton.android.core.payment.domain"
compileSdk = AppConfiguration.compileSdk.get()
defaultConfig {
minSdk = AppConfiguration.minSdk.get()
lint.targetSdk = AppConfiguration.targetSdk.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}
dependencies {
implementation(libs.javax.inject)
implementation(libs.kotlin.coroutines.core)
}
@@ -0,0 +1,26 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain
import javax.inject.Singleton
@Singleton
interface IconResourceManager {
suspend fun getBitmap(iconName: String): ByteArray?
}
@@ -0,0 +1,24 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain
object LogTag {
const val DEFAULT = "core.payment.default"
const val STORE = "core.payment.store.default"
}
@@ -0,0 +1,37 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain
data class PaymentException(
val errorCode: Int?,
override val message: String?
) : Exception("errorCode: $errorCode message: $message") {
companion object {
object ErrorCode {
const val OK: Int = 0
const val USER_CANCELED: Int = 1
const val SERVICE_UNAVAILABLE: Int = 2
const val BILLING_UNAVAILABLE: Int = 3
const val ITEM_UNAVAILABLE: Int = 4
const val DEVELOPER_ERROR: Int = 5
const val NETWORK_ERROR: Int = 12
}
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain
import kotlinx.coroutines.flow.Flow
import me.proton.android.core.payment.domain.model.ProductDetail
import me.proton.android.core.payment.domain.model.Purchase
/**
* Get [ProductDetail] from App Store (e.g. Google Play Store).
*/
interface PaymentManager {
/**
* Get corresponding [ProductDetail] from Store.
*
* @throws PaymentException
*/
suspend fun getStoreProducts(ids: List<String>): List<ProductDetail>
/**
* Get existing [Purchase] from Store.
*
* @throws PaymentException
*/
suspend fun getStorePurchases(): List<Purchase>
/**
* Observe [Purchase] from Store.
*
* @throws PaymentException
*/
fun observeStorePurchases(): Flow<List<Purchase>>
}
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain
import me.proton.android.core.payment.domain.model.Purchase
interface PurchaseListener {
fun onPurchaseListUpdated(list: List<Purchase>)
}
@@ -0,0 +1,48 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain
import me.proton.android.core.payment.domain.model.ProductDetail
import me.proton.android.core.payment.domain.model.Purchase
import me.proton.android.core.payment.domain.model.SubscriptionDetail
/**
* Get [ProductDetail] from Proton Payment BE.
*/
interface SubscriptionManager {
/**
* Get all current [SubscriptionDetail] from BE.
*/
suspend fun getCurrent(): List<SubscriptionDetail>
/**
* Get all available [ProductDetail] from BE.
*/
suspend fun getAvailable(): List<ProductDetail>
/**
* Subscribe to [product] using [purchase] on BE.
*
* Note: Create Proton Token and subscribe.
*
* @throws PaymentException
*/
suspend fun subscribe(product: ProductDetail, purchase: Purchase)
}
@@ -0,0 +1,62 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain.model
data class ProductDetail(
val productId: String,
val planName: String,
val header: ProductHeader,
val price: ProductPrice,
val renew: ProductPrice,
val entitlements: List<ProductEntitlement>
)
data class ProductHeader(
val title: String,
val description: String,
val priceText: String,
val cycleText: String,
val starred: Boolean
)
data class ProductPrice(
val productId: String,
val customerId: String?,
val cycle: Int,
val amount: Int,
val currency: String,
val formatted: String?
)
sealed interface ProductEntitlement {
data class Description(
val iconName: String?,
val text: String,
val hint: String?
) : ProductEntitlement
data class Progress(
val startText: String,
val iconName: String?,
val endText: String,
val min: Int,
val max: Int,
val current: Int
) : ProductEntitlement
}
@@ -0,0 +1,40 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain.model
data class Purchase(
val productType: String,
val productId: String,
val orderId: String,
val clientId: String, // packageName
val customerId: String,
val receipt: String,
val autoRenewing: Boolean,
val status: PurchaseStatus
)
enum class PurchaseStatus(val value: Int) {
Unspecified(0),
Purchased(1),
Pending(2),
Acknowledged(3);
companion object {
val map = entries.associateBy { it.value }
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain.model
data class SubscriptionDetail(
val name: String,
val header: ProductHeader,
val entitlements: List<ProductEntitlement>,
val price: ProductPrice?,
val renew: ProductPrice?,
val periodEnd: Long?,
val managedBy: Int?
)
@@ -0,0 +1,66 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain.usecase
import me.proton.android.core.payment.domain.PaymentManager
import me.proton.android.core.payment.domain.SubscriptionManager
import me.proton.android.core.payment.domain.model.ProductDetail
import me.proton.android.core.payment.domain.model.PurchaseStatus
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GetAvailableUpgrades @Inject constructor(
private val paymentManager: PaymentManager,
private val subscriptionManager: SubscriptionManager
) {
suspend operator fun invoke(): List<ProductDetail> {
val upgrades = subscriptionManager.getAvailable()
if (upgrades.isEmpty()) return upgrades
val ids = upgrades.map { it.productId }
val storePurchases = paymentManager.getStorePurchases().associateBy { it.productId }
val storeProducts = paymentManager.getStoreProducts(ids).associateBy { it.productId }
val acknowledged = storePurchases.filterValues { it.status == PurchaseStatus.Acknowledged }
val available = upgrades
// Remove any acknowledged Purchase.
.filter { !acknowledged.containsKey(it.productId) }
// Remove any unavailable product in Store.
.filter { storeProducts.containsKey(it.productId) }
return available.map {
val store = storeProducts.getValue(it.productId)
it.copy(
header = it.header.copy(
priceText = store.header.priceText
),
price = it.price.copy(
amount = store.price.amount,
currency = store.price.currency,
formatted = store.price.formatted
),
renew = it.price.copy(
amount = store.renew.amount,
currency = store.renew.currency,
formatted = store.renew.formatted
)
)
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.domain.usecase
import me.proton.android.core.payment.domain.PaymentManager
import me.proton.android.core.payment.domain.SubscriptionManager
import me.proton.android.core.payment.domain.model.SubscriptionDetail
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GetCurrentSubscriptions @Inject constructor(
private val paymentManager: PaymentManager,
private val subscriptionManager: SubscriptionManager
) {
suspend operator fun invoke(): List<SubscriptionDetail> {
val subscriptions = subscriptionManager.getCurrent()
val ids = subscriptions.mapNotNull { null } // Use it.productId. Currently not existing.
if (ids.isEmpty()) return subscriptions
val storeProducts = paymentManager.getStoreProducts(ids).associateBy { it.productId }
return subscriptions.map {
val store = storeProducts[it.name]
it.copy(
header = it.header.copy(
priceText = store?.price?.formatted ?: store?.header?.priceText ?: it.header.priceText,
cycleText = store?.header?.cycleText ?: it.header.cycleText
)
)
}
}
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Proton AG
* This file is part of Proton AG and Proton Mail.
*
* Proton Mail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Mail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
id("com.android.library")
kotlin("android")
kotlin("kapt")
id("org.jetbrains.kotlin.plugin.compose")
id("dagger.hilt.android.plugin")
id("app.cash.paparazzi")
id("app-config-plugin")
}
android {
namespace = "me.proton.android.core.payment.presentation"
compileSdk = AppConfiguration.compileSdk.get()
defaultConfig {
minSdk = AppConfiguration.minSdk.get()
lint.targetSdk = AppConfiguration.targetSdk.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
buildFeatures {
compose = true
}
}
dependencies {
compileOnly(libs.proton.rust.core)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.coil.compose)
implementation(libs.dagger.hilt.android)
implementation(libs.kotlinx.datetime)
implementation(libs.proton.core.presentation)
implementation(libs.proton.core.presentationCompose)
implementation(libs.proton.core.utilAndroidDateTime)
implementation(libs.proton.core.utilKotlin)
implementation(project(":design-system"))
implementation(project(":presentation-compose"))
implementation(project(":shared:core:payment:domain"))
kapt(libs.bundles.app.annotationProcessors)
testImplementation(kotlin("test"))
testImplementation(libs.bundles.test)
testImplementation(libs.proton.rust.core)
}
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2025 Proton AG
~ This file is part of Proton AG and ProtonCore.
~
~ ProtonCore is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ ProtonCore is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".SubscriptionActivity"
android:windowSoftInputMode="adjustResize" />
</application>
</manifest>
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
object StartSubscription : ActivityResultContract<Unit, Boolean>() {
override fun createIntent(context: Context, input: Unit) = Intent(context, SubscriptionActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean = when (resultCode) {
Activity.RESULT_OK -> true
else -> false
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation
import android.content.Context
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.key.Keyer
import coil.request.Options
import me.proton.android.core.payment.domain.IconResourceManager
import me.proton.android.core.payment.presentation.extension.use
import okio.buffer
import okio.source
data class IconResource(
val name: String
)
class IconResourceKeyer : Keyer<IconResource> {
override fun key(data: IconResource, options: Options): String = data.name
}
@OptIn(ExperimentalCoilApi::class)
class IconResourceFetcher(
private val context: Context,
private val iconResource: IconResource,
private val iconResourceManager: IconResourceManager
) : Fetcher {
private val diskCache = DiskCache.Builder()
.directory(context.cacheDir.resolve("icon-cache"))
.maxSizePercent(0.02)
.build()
private fun readCache(snapshot: DiskCache.Snapshot): SourceResult {
val inputStream = snapshot.data.toFile().inputStream()
return SourceResult(
source = ImageSource(source = inputStream.source().buffer(), context = context),
dataSource = DataSource.DISK,
mimeType = null
)
}
private suspend fun readResource(editor: DiskCache.Editor): SourceResult {
val array = requireNotNull(iconResourceManager.getBitmap(iconResource.name))
editor.data.toFile().writeBytes(array)
return SourceResult(
source = ImageSource(source = array.inputStream().source().buffer(), context = context),
dataSource = DataSource.NETWORK,
mimeType = null
)
}
override suspend fun fetch(): FetchResult? {
return diskCache.openSnapshot(iconResource.name)?.use { readCache(it) }
?: diskCache.openEditor(iconResource.name)?.use { readResource(it) }
}
class Factory(
private val context: Context,
private val manager: IconResourceManager
) : Fetcher.Factory<IconResource> {
override fun create(
data: IconResource,
options: Options,
imageLoader: ImageLoader
): Fetcher = IconResourceFetcher(context, data, manager)
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.ActivityResultLauncher
import javax.inject.Inject
class PaymentOrchestrator @Inject constructor() {
private var subscriptionWorkflowLauncher: ActivityResultLauncher<Unit>? = null
private var onUpgradeResultListener: ((result: Boolean) -> Unit)? = {}
private fun registerUpgradeResult(caller: ActivityResultCaller): ActivityResultLauncher<Unit> =
caller.registerForActivityResult(StartSubscription) {
onUpgradeResultListener?.invoke(it)
}
private fun <T> checkRegistered(launcher: ActivityResultLauncher<T>?) =
checkNotNull(launcher) { "You must call register(context) before starting workflow!" }
fun setOnUpgradeResult(block: (result: Boolean) -> Unit) {
onUpgradeResultListener = block
}
/**
* Register all needed workflow for internal usage.
*
* Note: This function have to be called [ComponentActivity.onCreate]] before [ComponentActivity.onResume].
*/
fun register(caller: ActivityResultCaller) {
subscriptionWorkflowLauncher = registerUpgradeResult(caller)
}
/**
* Unregister all workflow activity launcher and listener.
*/
fun unregister() {
subscriptionWorkflowLauncher?.unregister()
subscriptionWorkflowLauncher = null
onUpgradeResultListener = null
}
/**
* Start the Subscription Workflow.
*/
fun startSubscriptionWorkflow() {
checkRegistered(subscriptionWorkflowLauncher).launch(Unit)
}
}
fun PaymentOrchestrator.onUpgradeResult(block: (result: Boolean) -> Unit): PaymentOrchestrator {
setOnUpgradeResult { block(it) }
return this
}
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation
import android.app.Activity
interface PurchaseOrchestrator {
/**
* Start Purchase Workflow (e.g. BillingClient.launchBillingFlow).
*/
fun startPurchaseWorkflow(
caller: Activity,
productId: String,
accountId: String
)
}
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation
import android.app.Activity
import android.os.Bundle
import androidx.activity.compose.setContent
import ch.protonmail.android.design.compose.theme.ProtonTheme
import coil.Coil
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import me.proton.android.core.payment.domain.IconResourceManager
import me.proton.android.core.payment.presentation.screen.SubscriptionScreen
import me.proton.core.presentation.ui.ProtonActivity
import me.proton.core.presentation.utils.addOnBackPressedCallback
import me.proton.core.presentation.utils.errorToast
import javax.inject.Inject
@AndroidEntryPoint
class SubscriptionActivity : ProtonActivity() {
@Inject
lateinit var iconResourceManager: IconResourceManager
private var success = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Coil.setImageLoader(
ImageLoader.Builder(applicationContext)
.components {
add(IconResourceKeyer())
add(IconResourceFetcher.Factory(applicationContext, iconResourceManager))
}
.crossfade(true)
.build()
)
addOnBackPressedCallback { onClose() }
setContent {
ProtonTheme {
SubscriptionScreen(
onClose = { onClose() },
onSuccess = { success = true },
onErrorMessage = { onError(it) }
)
}
}
}
private fun onError(message: String?) {
errorToast(message ?: getString(R.string.presentation_error_general))
}
private fun onSuccess() {
setResult(Activity.RESULT_OK)
finish()
}
private fun onClose() {
if (success) onSuccess() else {
setResult(Activity.RESULT_CANCELED)
finish()
}
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import ch.protonmail.android.design.compose.theme.ProtonDimens.IconSize
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import coil.compose.rememberAsyncImagePainter
import me.proton.android.core.payment.domain.model.ProductEntitlement
import me.proton.android.core.payment.presentation.IconResource
import me.proton.android.core.payment.presentation.model.Subscription
import me.proton.core.presentation.R
@Composable
fun EntitlementList(entitlements: List<ProductEntitlement>) {
Column(modifier = Modifier.padding(top = Spacing.Medium)) {
entitlements.forEach { EntitlementItem(it) }
}
}
@Composable
fun EntitlementItem(item: ProductEntitlement, modifier: Modifier = Modifier) {
when (item) {
is ProductEntitlement.Description -> DescriptionItem(item, modifier)
is ProductEntitlement.Progress -> ProgressItem(item, modifier)
}
}
@Composable
internal fun DescriptionItem(item: ProductEntitlement.Description, modifier: Modifier = Modifier) {
val defaultIcon = painterResource(R.drawable.ic_proton_checkmark)
Row(modifier = modifier) {
Icon(
modifier = Modifier.size(IconSize.Medium),
painter = rememberAsyncImagePainter(
model = item.iconName?.let { IconResource(it) },
error = defaultIcon,
fallback = defaultIcon,
placeholder = defaultIcon
),
contentDescription = null,
tint = ProtonTheme.colors.iconAccent
)
Text(
modifier = Modifier.align(Alignment.CenterVertically).padding(start = Spacing.MediumLight),
text = item.text,
style = ProtonTheme.typography.labelMedium,
color = ProtonTheme.colors.textWeak,
textAlign = TextAlign.Start,
maxLines = 2
)
}
}
@Composable
internal fun ProgressItem(item: ProductEntitlement.Progress, modifier: Modifier = Modifier) {
val level1 = ProtonTheme.colors.notificationSuccess
val level2 = ProtonTheme.colors.notificationWarning
val level3 = ProtonTheme.colors.notificationError
val progress = remember { (item.current - item.min) / item.max.toFloat() }
val color = remember {
when (progress) {
in 0.0f..0.5f -> level1
in 0.5f..0.8f -> level2
else -> level3
}
}
Column(modifier = modifier) {
Row {
Text(
modifier = Modifier.padding(end = Spacing.MediumLight).weight(1f),
text = item.startText,
style = ProtonTheme.typography.labelMedium,
color = ProtonTheme.colors.textWeak,
textAlign = TextAlign.Start,
maxLines = 1
)
Text(
modifier = Modifier.padding(start = Spacing.MediumLight),
text = item.endText,
style = ProtonTheme.typography.labelMedium,
color = ProtonTheme.colors.textWeak,
textAlign = TextAlign.End,
maxLines = 1
)
}
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.MediumLight),
progress = { progress },
drawStopIndicator = {},
color = color
)
}
}
@Composable
@PreviewLightDark
internal fun EntitlementListSubscriptionCardPreview() {
ProtonTheme {
SubscriptionCard(
subscription = Subscription.test,
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@@ -0,0 +1,210 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CardElevation
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import ch.protonmail.android.design.compose.component.ExpandableHeader
import ch.protonmail.android.design.compose.theme.ProtonDimens.BorderSize
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import ch.protonmail.android.design.compose.theme.bodySmallHint
import me.proton.android.core.payment.presentation.model.Product
import me.proton.core.presentation.R
@Composable
fun ProductCard(
product: Product,
modifier: Modifier = Modifier,
expandable: Boolean = true,
expandedInitially: Boolean = true,
shape: Shape = CardDefaults.shape,
colors: CardColors = CardDefaults.cardColors(
containerColor = ProtonTheme.colors.backgroundSecondary
),
colorsExpanded: CardColors = CardDefaults.outlinedCardColors(
containerColor = ProtonTheme.colors.backgroundNorm
),
elevation: CardElevation = CardDefaults.cardElevation(),
border: BorderStroke? = BorderStroke(
width = BorderSize.Default,
color = ProtonTheme.colors.borderNorm
),
borderExpanded: BorderStroke? = BorderStroke(
width = BorderSize.Default,
color = ProtonTheme.colors.interactionBrandDefaultNorm
),
content: @Composable ColumnScope.() -> Unit
) {
var expanded by remember { mutableStateOf(expandedInitially) }
Card(
onClick = { if (expandable) { expanded = !expanded } },
modifier = modifier,
shape = shape,
colors = if (expanded) colorsExpanded else colors,
elevation = elevation,
border = if (expanded) borderExpanded else border
) {
val icon = when {
!expandable -> null
!expanded -> painterResource(R.drawable.ic_proton_chevron_down)
else -> painterResource(R.drawable.ic_proton_chevron_up)
}
ExpandableHeader(
modifier = Modifier.padding(Spacing.Medium),
expanded = expanded,
header = {
ProductHeader(product.header, icon = icon)
},
content = {
EntitlementList(product.entitlements)
content()
}
)
}
}
@Composable
fun ProductCardContent(
product: Product,
modifier: Modifier = Modifier,
button: @Composable ColumnScope.() -> Unit
) {
Column(modifier = modifier.fillMaxWidth()) {
button()
if (product.renewalText != null) {
Text(
modifier = Modifier.fillMaxWidth().padding(top = Spacing.Medium),
text = product.renewalText,
style = ProtonTheme.typography.bodySmallHint,
textAlign = TextAlign.Center
)
}
}
}
@Composable
@PreviewLightDark
internal fun ProductCardPreview() {
ProtonTheme {
ProductCard(
product = Product.test,
modifier = Modifier.padding(Spacing.Medium),
expandable = true,
expandedInitially = false
) {
ProductCardContent(Product.test) {
PurchaseButton(
product = Product.test,
modifier = Modifier.padding(top = Spacing.Medium).fillMaxWidth()
)
}
}
}
}
@Composable
@PreviewLightDark
internal fun PurchaseCardExpandedPreview() {
ProtonTheme {
ProductCard(
product = Product.test,
modifier = Modifier.padding(Spacing.Medium),
expandable = false,
expandedInitially = true
) {
ProductCardContent(Product.test) {
PurchaseButton(
product = Product.test,
modifier = Modifier.padding(top = Spacing.Medium).fillMaxWidth()
)
}
}
}
}
@Composable
@PreviewLightDark
internal fun PurchaseCardDarkPreview() {
ProtonTheme {
ProductCard(
product = Product.test,
modifier = Modifier.padding(Spacing.Medium),
expandable = true,
expandedInitially = false
) {
ProductCardContent(Product.test) {
PurchaseButton(
product = Product.test,
modifier = Modifier.padding(top = Spacing.Medium).fillMaxWidth()
)
}
}
}
}
@Composable
@PreviewLightDark
internal fun PurchaseCardExpandedDarkPreview() {
ProtonTheme {
ProductCard(
product = Product.test,
modifier = Modifier.padding(Spacing.Medium),
expandable = false,
expandedInitially = true
) {
ProductCardContent(Product.test) {
PurchaseButton(
product = Product.test,
modifier = Modifier.padding(top = Spacing.Medium).fillMaxWidth()
)
}
}
}
}
@Composable
@PreviewLightDark
internal fun PurchaseCardContentPreview() {
ProtonTheme {
ProductCardContent(Product.test) {
PurchaseButton(
product = Product.test,
modifier = Modifier.padding(Spacing.Medium).fillMaxWidth()
)
}
}
}
@@ -0,0 +1,119 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import ch.protonmail.android.design.compose.theme.ProtonDimens.IconSize
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import me.proton.android.core.payment.domain.model.ProductHeader
import me.proton.android.core.payment.presentation.model.Subscription
import me.proton.core.presentation.R
@Composable
fun ProductHeader(
header: ProductHeader,
modifier: Modifier = Modifier,
icon: Painter? = null
) {
Row(modifier = modifier) {
Column(modifier = Modifier.weight(1f)) {
Row {
Text(
modifier = Modifier.padding(end = Spacing.Small),
text = header.title,
style = ProtonTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (header.starred) {
Icon(
modifier = Modifier.size(IconSize.Small).align(Alignment.CenterVertically),
painter = painterResource(R.drawable.ic_proton_star_filled),
tint = ProtonTheme.colors.iconAccent,
contentDescription = null
)
}
}
Text(
modifier = Modifier.padding(top = Spacing.Medium, end = Spacing.Medium),
text = header.description,
style = ProtonTheme.typography.bodyMedium,
color = ProtonTheme.colors.textHint
)
}
Column {
Text(
text = header.priceText,
style = ProtonTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
modifier = Modifier.align(Alignment.End),
text = header.cycleText,
style = ProtonTheme.typography.labelMedium,
color = ProtonTheme.colors.textHint
)
if (icon != null) {
Icon(
modifier = Modifier.padding(top = Spacing.Large).size(IconSize.Medium).align(Alignment.End),
painter = icon,
tint = ProtonTheme.colors.iconAccent,
contentDescription = null
)
}
}
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = false)
private fun ProductHeaderPreview() {
ProtonTheme {
ProductHeader(
header = Subscription.test.header,
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = false)
private fun PlanHeaderIconPreview() {
ProtonTheme {
ProductHeader(
header = Subscription.test.header,
modifier = Modifier.padding(Spacing.Medium),
icon = painterResource(R.drawable.ic_proton_chevron_down)
)
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import me.proton.android.core.payment.presentation.model.Product
@Composable
fun ProductItem(
product: Product,
modifier: Modifier = Modifier,
onSuccess: (Product) -> Unit = {},
onErrorMessage: (String) -> Unit = {},
expandable: Boolean = true,
expandedInitially: Boolean = true
) {
ProductCard(
product = product,
modifier = modifier,
expandable = expandable,
expandedInitially = expandedInitially
) {
ProductCardContent(product) {
PurchaseButton(
product = product,
modifier = Modifier.padding(top = Spacing.Medium).fillMaxWidth(),
onSuccess = onSuccess,
onErrorMessage = onErrorMessage
)
}
}
}
@Composable
@PreviewLightDark
internal fun ProductItemPreview() {
ProtonTheme {
ProductItem(
product = Product.test,
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@@ -0,0 +1,175 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("UseComposableActions")
package me.proton.android.core.payment.presentation.component
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ch.protonmail.android.design.compose.component.ExposedDropdownMenuCard
import ch.protonmail.android.design.compose.component.ProtonCenteredProgress
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import ch.protonmail.android.design.compose.viewmodel.hiltViewModelOrNull
import me.proton.android.core.payment.presentation.R
import me.proton.android.core.payment.presentation.model.Product
@Composable
fun ProductList(
modifier: Modifier = Modifier,
onSuccess: (Product) -> Unit = {},
onErrorMessage: (String) -> Unit = {},
viewModel: ProductListViewModel? = hiltViewModelOrNull()
) {
val state = viewModel?.state?.collectAsStateWithLifecycle()?.value
ProductList(
modifier = modifier,
onSuccess = onSuccess,
onErrorMessage = onErrorMessage,
onRetryClicked = { viewModel?.perform(ProductListAction.Load()) },
state = state ?: ProductListState.Loading
)
}
@Composable
fun ProductList(
modifier: Modifier = Modifier,
onSuccess: (Product) -> Unit = {},
onErrorMessage: (String) -> Unit = {},
onRetryClicked: () -> Unit = {},
state: ProductListState
) {
when (state) {
is ProductListState.Data -> ProductList(
modifier = modifier,
onSuccess = onSuccess,
onErrorMessage = onErrorMessage,
list = state.list
)
is ProductListState.Error -> ProtonErrorRetryLayout(
modifier = modifier,
description = state.message,
onClick = { onRetryClicked() }
)
is ProductListState.Loading -> ProtonCenteredProgress(
modifier = modifier
)
}
}
@Composable
fun ProductList(
list: List<Product>,
modifier: Modifier = Modifier,
onSuccess: (Product) -> Unit = {},
onErrorMessage: (String) -> Unit = {}
) {
val cycles = list.rememberCyclesStringResource()
val cycleKeys = remember(list) { cycles.keys.toTypedArray() }
val cycleValues = remember(list) { cycles.values.toTypedArray() }
var cycleIndex by remember { mutableIntStateOf(0) }
val cycle = remember(cycleIndex) { cycleKeys.getOrNull(cycleIndex) }
Column(modifier = modifier) {
if (cycles.size > 1) {
ExposedDropdownMenuCard(
list = cycleValues.toList(),
modifier = Modifier.padding(bottom = Spacing.Medium),
onSelectedIndexChanged = { cycleIndex = it }
) {
Text(text = it)
}
}
Column {
list.filter { it.cycle == cycle }.forEachIndexed { index, item ->
ProductItem(
product = item,
modifier = Modifier.padding(bottom = Spacing.Medium),
onSuccess = onSuccess,
onErrorMessage = onErrorMessage,
expandedInitially = index == 0
)
}
}
}
}
@Composable
@Suppress("MagicNumber")
fun List<Product>.rememberCyclesStringResource() = remember(this) {
fold(mutableSetOf<Int>()) { acc, product ->
acc.apply { add(product.cycle) }
}.sorted()
}.associateWith {
when (it) {
1 -> stringResource(R.string.payment_pay_monthly)
12 -> stringResource(R.string.payment_pay_annually)
24 -> stringResource(R.string.payment_pay_biennially)
else -> pluralStringResource(R.plurals.payment_pay_other, it)
}
}
@Composable
@PreviewLightDark
internal fun ProductListPreview() {
ProtonTheme {
ProductList(
list = listOf(Product.test, Product.test_mail2022_1, Product.test_pass2022_1),
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@Composable
@PreviewLightDark
internal fun ProductListNoDropdownPreview() {
ProtonTheme {
ProductList(
list = listOf(Product.test_mail2022_1, Product.test_pass2022_1),
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = false)
internal fun ProductListErrorPreview() {
ProtonTheme {
ProductList(
state = ProductListState.Error("An error occurs. Please retry."),
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
sealed interface ProductListOperation
sealed interface ProductListAction : ProductListOperation {
data class Load(val unused: Long = System.currentTimeMillis()) : ProductListAction
}
@@ -0,0 +1,26 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import me.proton.android.core.payment.presentation.model.Product
sealed interface ProductListState {
data object Loading : ProductListState
data class Data(val list: List<Product>) : ProductListState
data class Error(val message: String) : ProductListState
}
@@ -0,0 +1,93 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import android.content.Context
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import me.proton.android.core.payment.domain.PaymentException
import me.proton.android.core.payment.domain.model.ProductDetail
import me.proton.android.core.payment.domain.usecase.GetAvailableUpgrades
import me.proton.android.core.payment.presentation.R
import me.proton.android.core.payment.presentation.model.Product
import me.proton.core.compose.viewmodel.BaseViewModel
import javax.inject.Inject
@HiltViewModel
class ProductListViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val getAvailableUpgrades: GetAvailableUpgrades
) : BaseViewModel<ProductListAction, ProductListState>(
initialAction = ProductListAction.Load(),
initialState = ProductListState.Loading
) {
private val res = context.resources
override fun onAction(action: ProductListAction): Flow<ProductListState> = flow {
when (action) {
is ProductListAction.Load -> emitAll(onLoad())
}
}
private fun onLoad() = flow {
emit(ProductListState.Loading)
val details = getAvailableUpgrades()
val list = details.map {
Product(
planName = it.planName,
productId = it.productId,
accountId = requireNotNull(it.price.customerId),
cycle = it.price.cycle,
header = it.header,
entitlements = it.entitlements,
renewalText = it.getRenewalText()
)
}
emit(ProductListState.Data(list))
}.catch {
when ((it as? PaymentException)?.errorCode) {
PaymentException.Companion.ErrorCode.BILLING_UNAVAILABLE -> {
emit(ProductListState.Data(emptyList()))
}
else -> {
emit(ProductListState.Error(it.message ?: "Error in onLoad"))
}
}
}
override suspend fun FlowCollector<ProductListState>.onError(throwable: Throwable) {
emit(ProductListState.Error(throwable.message ?: "Unknown error"))
}
@Suppress("MagicNumber")
private fun ProductDetail.getRenewalText(): String? = when {
price.amount == renew.amount -> null
else -> when (price.cycle) {
1 -> res.getString(R.string.payment_welcome_offer_renew_monthly, renew.formatted)
12 -> res.getString(R.string.payment_welcome_offer_renew_annually, renew.formatted)
24 -> res.getString(R.string.payment_welcome_offer_renew_biennially, renew.formatted)
else -> res.getQuantityString(R.plurals.payment_welcome_offer_renew_other, price.cycle, renew.formatted)
}
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import ch.protonmail.android.design.compose.component.ProtonSolidButton
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing.Medium
import ch.protonmail.android.design.compose.theme.ProtonTheme
import me.proton.android.core.payment.presentation.R
@Composable
fun ProtonErrorRetryLayout(
modifier: Modifier = Modifier,
title: String = stringResource(R.string.payment_error_title),
description: String = stringResource(R.string.payment_error_description),
image: Painter = painterResource(R.drawable.ic_proton_bug),
buttonText: String = stringResource(R.string.presentation_retry),
onClick: () -> Unit = {}
) {
Row(modifier = modifier) {
Column(
modifier = Modifier.fillMaxWidth().align(Alignment.CenterVertically)
) {
Image(
modifier = Modifier.align(CenterHorizontally).padding(Medium),
painter = image,
contentDescription = null
)
Text(
modifier = Modifier.align(CenterHorizontally).padding(Medium),
text = title,
style = ProtonTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
Text(
modifier = Modifier.align(CenterHorizontally).padding(Medium),
text = description,
style = ProtonTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
ProtonSolidButton(
modifier = Modifier.align(CenterHorizontally).padding(Medium),
onClick = { onClick() }
) {
Text(
modifier = Modifier.align(CenterHorizontally),
text = buttonText
)
}
}
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = false)
private fun ProtonErrorRetryLayoutPreview() {
ProtonTheme {
ProtonErrorRetryLayout()
}
}
@@ -0,0 +1,155 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ch.protonmail.android.design.compose.component.ProtonSolidButton
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import ch.protonmail.android.design.compose.viewmodel.hiltViewModelOrNull
import me.proton.android.core.payment.presentation.R
import me.proton.android.core.payment.presentation.component.PurchaseButtonState.Error
import me.proton.android.core.payment.presentation.component.PurchaseButtonState.Idle
import me.proton.android.core.payment.presentation.component.PurchaseButtonState.Loading
import me.proton.android.core.payment.presentation.component.PurchaseButtonState.Pending
import me.proton.android.core.payment.presentation.component.PurchaseButtonState.Success
import me.proton.android.core.payment.presentation.model.Product
@Composable
fun PurchaseButton(
product: Product,
modifier: Modifier = Modifier,
onSuccess: (Product) -> Unit = {},
onErrorMessage: (String) -> Unit = {},
viewModel: PurchaseButtonViewModel? = hiltViewModelOrNull(product.productId)
) {
val state = viewModel?.state?.collectAsStateWithLifecycle()?.value
LaunchedEffect(state) {
when (state) {
is Success -> onSuccess(state.product)
is Error -> onErrorMessage(state.message)
else -> Unit
}
}
LaunchedEffect(product) {
viewModel?.perform(PurchaseButtonAction.Load(product))
}
PurchaseButton(
product = product,
state = state ?: Idle,
modifier = modifier,
onClick = { viewModel?.perform(PurchaseButtonAction.Purchase(product)) }
)
}
@Composable
fun PurchaseButton(
product: Product,
state: PurchaseButtonState,
modifier: Modifier = Modifier,
onClick: () -> Unit = {}
) {
val text = when (state) {
is Success -> stringResource(R.string.payment_purchase_button_subscribed, product.header.title)
else -> stringResource(R.string.payment_purchase_button_get, product.header.title)
}
PurchaseButton(
text = text,
modifier = modifier,
onClick = onClick,
isLoading = state is Pending,
isEnabled = state !is Loading && state !is Success && (state as? Error)?.enabled != false,
message = (state as? Error)?.message
)
}
@Composable
fun PurchaseButton(
text: String,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
isLoading: Boolean = false,
isEnabled: Boolean = true,
message: String? = null
) {
ProtonSolidButton(
onClick = onClick,
loading = isLoading,
enabled = isEnabled,
modifier = modifier
) {
Column {
Text(text = text)
message?.let {
Text(
text = it,
style = ProtonTheme.typography.labelSmall,
maxLines = 3,
modifier = Modifier.padding(top = Spacing.MediumLight)
)
}
}
}
}
@Composable
@PreviewLightDark
internal fun PurchaseButtonPreview() {
ProtonTheme {
PurchaseButton(
product = Product.test_mail2022_1,
state = Idle,
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@Composable
@PreviewLightDark
internal fun PurchaseButtonErrorPreview() {
ProtonTheme {
PurchaseButton(
product = Product.test_mail2022_1,
state = Error("Payment processor temporarily unavailable. Please try again in a few minutes."),
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@Composable
@PreviewLightDark
internal fun PurchaseButtonSuccessPreview() {
ProtonTheme {
PurchaseButton(
product = Product.test_mail2022_1,
state = Success(Product.test_mail2022_1),
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import me.proton.android.core.payment.presentation.model.Product
sealed interface PurchaseButtonOperation
sealed interface PurchaseButtonAction : PurchaseButtonOperation {
data object Init : PurchaseButtonAction
data class Load(val product: Product, val unused: Long = System.currentTimeMillis()) : PurchaseButtonAction
data class Purchase(val product: Product, val unused: Long = System.currentTimeMillis()) : PurchaseButtonAction
}
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import kotlinx.coroutines.flow.Flow
interface PurchaseButtonProcessor {
fun onAction(action: PurchaseButtonAction): Flow<PurchaseButtonState>
}
@@ -0,0 +1,116 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.flow.transformLatest
import me.proton.android.core.payment.domain.LogTag
import me.proton.android.core.payment.domain.PaymentManager
import me.proton.android.core.payment.domain.SubscriptionManager
import me.proton.android.core.payment.domain.model.ProductDetail
import me.proton.android.core.payment.domain.model.Purchase
import me.proton.android.core.payment.domain.model.PurchaseStatus
import me.proton.android.core.payment.presentation.PurchaseOrchestrator
import me.proton.android.core.payment.presentation.R
import me.proton.android.core.payment.presentation.model.Product
import me.proton.core.presentation.app.ActivityProvider
import me.proton.core.util.kotlin.CoreLogger
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@OptIn(FlowPreview::class)
class PurchaseButtonProcessorNative @Inject constructor(
@ApplicationContext private val context: Context,
private val paymentManager: PaymentManager,
private val subscriptionManager: SubscriptionManager,
private val purchaseOrchestrator: PurchaseOrchestrator,
private val activityProvider: ActivityProvider
) : PurchaseButtonProcessor {
private val res = context.resources
private val productNotFound = res.getString(R.string.payment_purchase_button_product_not_found)
override fun onAction(action: PurchaseButtonAction): Flow<PurchaseButtonState> = flow {
when (action) {
is PurchaseButtonAction.Init -> emit(PurchaseButtonState.Idle)
is PurchaseButtonAction.Load -> emitAll(onLoad(action.product))
is PurchaseButtonAction.Purchase -> emitAll(onPurchase(action.product))
}
}
private fun onLoad(product: Product) = flow {
val storeProducts = paymentManager.getStoreProducts(listOf(product.productId))
val storeProduct = storeProducts.firstOrNull()
when {
storeProduct == null -> emit(PurchaseButtonState.Error(productNotFound, enabled = false))
else -> emitAll(observePurchases(product, storeProduct))
}
}.catch {
emit(PurchaseButtonState.Error(it.message ?: "Error on onLoad"))
}
private fun observePurchases(product: Product, detail: ProductDetail) =
paymentManager.observeStorePurchases().transformLatest { list ->
val purchase = list.firstOrNull { it.productId == product.productId }
when {
purchase != null -> when (purchase.status) {
PurchaseStatus.Unspecified,
PurchaseStatus.Purchased,
PurchaseStatus.Pending -> emitAll(onPending(product, detail, purchase))
PurchaseStatus.Acknowledged -> emitAll(onSuccess(product))
}
else -> emit(PurchaseButtonState.Idle)
}
}.timeout(10.seconds).retry()
private fun onPurchase(product: Product) = flow {
val activity = requireNotNull(activityProvider.lastResumed)
purchaseOrchestrator.startPurchaseWorkflow(activity, product.productId, product.accountId)
emitAll(onLoad(product))
}.catch {
emit(PurchaseButtonState.Error(it.message ?: "Error on startPurchaseWorkflow"))
}
private fun onPending(
product: Product,
detail: ProductDetail,
purchase: Purchase
) = flow<PurchaseButtonState> {
CoreLogger.d(LogTag.STORE, "onPending: $detail")
emit(PurchaseButtonState.Pending(detail))
// Workaround: Override detail.planName by product.planName.
subscriptionManager.subscribe(detail.copy(planName = product.planName), purchase)
}.catch {
emit(PurchaseButtonState.Error(it.message ?: "Error on subscribe"))
}
private fun onSuccess(product: Product) = flow {
CoreLogger.d(LogTag.STORE, "onPurchased: $product")
emit(PurchaseButtonState.Success(product))
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import me.proton.android.core.payment.domain.model.ProductDetail
import me.proton.android.core.payment.presentation.model.Product
sealed interface PurchaseButtonState {
data object Idle : PurchaseButtonState
data object Loading : PurchaseButtonState
data class Pending(val product: ProductDetail) : PurchaseButtonState
data class Success(val product: Product) : PurchaseButtonState
data class Error(val message: String, val enabled: Boolean = true) : PurchaseButtonState
}
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import me.proton.core.compose.viewmodel.BaseViewModel
import javax.inject.Inject
@HiltViewModel
class PurchaseButtonViewModel @Inject constructor(
private val purchaseProcessor: PurchaseButtonProcessor
) : BaseViewModel<PurchaseButtonAction, PurchaseButtonState>(
initialAction = PurchaseButtonAction.Init,
initialState = PurchaseButtonState.Idle
) {
override fun onAction(action: PurchaseButtonAction): Flow<PurchaseButtonState> = purchaseProcessor.onAction(action)
override suspend fun FlowCollector<PurchaseButtonState>.onError(throwable: Throwable) {
emit(PurchaseButtonState.Error(throwable.message ?: "Unknown error"))
}
}
@@ -0,0 +1,100 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CardElevation
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.PreviewLightDark
import ch.protonmail.android.design.compose.theme.ProtonDimens.BorderSize
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import ch.protonmail.android.design.compose.theme.bodySmallHint
import me.proton.android.core.payment.presentation.model.Subscription
@Composable
fun SubscriptionCard(
subscription: Subscription,
modifier: Modifier = Modifier,
shape: Shape = CardDefaults.shape,
colors: CardColors = CardDefaults.cardColors(
containerColor = ProtonTheme.colors.backgroundNorm
),
elevation: CardElevation = CardDefaults.cardElevation(),
border: BorderStroke? = BorderStroke(
width = BorderSize.Default,
color = ProtonTheme.colors.borderStrong
)
) {
Card(
modifier = modifier,
shape = shape,
colors = colors,
elevation = elevation,
border = border
) {
Column(modifier = Modifier.padding(Spacing.Medium)) {
ProductHeader(subscription.header)
EntitlementList(subscription.entitlements)
if (subscription.additionalText.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(vertical = Spacing.Medium),
color = ProtonTheme.colors.separatorNorm
)
subscription.additionalText.forEach {
Text(
modifier = Modifier.padding(Spacing.Small),
text = it,
style = ProtonTheme.typography.bodySmallHint
)
}
}
}
}
}
@Composable
@PreviewLightDark
internal fun SubscriptionCardPreview() {
ProtonTheme {
SubscriptionCard(
subscription = Subscription.test,
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@Composable
@PreviewLightDark
internal fun SubscriptionCardFreePreview() {
ProtonTheme {
SubscriptionCard(
subscription = Subscription.test_free,
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import me.proton.android.core.payment.presentation.model.Subscription
@Composable
fun SubscriptionItem(subscription: Subscription, modifier: Modifier = Modifier) {
SubscriptionCard(
subscription = subscription,
modifier = modifier
)
}
@Composable
@PreviewLightDark
internal fun SubscriptionItemPreview() {
ProtonTheme {
SubscriptionItem(
subscription = Subscription.test,
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@@ -0,0 +1,102 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ch.protonmail.android.design.compose.component.ProtonCenteredProgress
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import ch.protonmail.android.design.compose.viewmodel.hiltViewModelOrNull
import me.proton.android.core.payment.presentation.model.Subscription
@Composable
fun SubscriptionList(modifier: Modifier = Modifier, viewModel: SubscriptionListViewModel? = hiltViewModelOrNull()) {
val state = viewModel?.state?.collectAsStateWithLifecycle()?.value
SubscriptionList(
modifier = modifier,
onRetryClicked = { viewModel?.perform(SubscriptionListAction.Load()) },
state = state ?: SubscriptionListState.Loading
)
}
@Composable
fun SubscriptionList(
modifier: Modifier = Modifier,
onRetryClicked: () -> Unit = {},
state: SubscriptionListState
) {
when (state) {
is SubscriptionListState.Data -> SubscriptionList(
modifier = modifier,
list = state.list
)
is SubscriptionListState.Error -> ProtonErrorRetryLayout(
modifier = modifier,
description = state.message,
onClick = { onRetryClicked() }
)
is SubscriptionListState.Loading -> ProtonCenteredProgress(
modifier = modifier
)
}
}
@Composable
fun SubscriptionList(list: List<Subscription>, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
list.forEach { item ->
SubscriptionItem(
subscription = item,
modifier = Modifier.padding(bottom = Spacing.Medium)
)
}
}
}
@Composable
@PreviewLightDark
internal fun SubscriptionListPreview() {
ProtonTheme {
SubscriptionList(
list = listOf(Subscription.test_free, Subscription.test, Subscription.test_pass2022),
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = false)
private fun SubscriptionListErrorPreview() {
ProtonTheme {
SubscriptionList(
state = SubscriptionListState.Error("An error occurs. Please retry."),
modifier = Modifier.padding(Spacing.Medium)
)
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
sealed interface SubscriptionListOperation
sealed interface SubscriptionListAction : SubscriptionListOperation {
data class Load(val unused: Long = System.currentTimeMillis()) : SubscriptionListAction
}
@@ -0,0 +1,26 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import me.proton.android.core.payment.presentation.model.Subscription
sealed interface SubscriptionListState {
data object Loading : SubscriptionListState
data class Data(val list: List<Subscription>) : SubscriptionListState
data class Error(val message: String) : SubscriptionListState
}
@@ -0,0 +1,89 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.component
import android.content.Context
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import me.proton.android.core.payment.domain.model.SubscriptionDetail
import me.proton.android.core.payment.domain.usecase.GetCurrentSubscriptions
import me.proton.android.core.payment.presentation.R
import me.proton.android.core.payment.presentation.model.Subscription
import me.proton.core.compose.viewmodel.BaseViewModel
import me.proton.core.util.android.datetime.DateTimeFormat
import me.proton.core.util.android.datetime.DateTimeFormat.DateTimeForm.MEDIUM_DATE
import javax.inject.Inject
@HiltViewModel
class SubscriptionListViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val getCurrentSubscriptions: GetCurrentSubscriptions,
private val dateTimeFormat: DateTimeFormat
) : BaseViewModel<SubscriptionListAction, SubscriptionListState>(
initialAction = SubscriptionListAction.Load(),
initialState = SubscriptionListState.Loading
) {
private val res = context.resources
override fun onAction(action: SubscriptionListAction): Flow<SubscriptionListState> = flow {
when (action) {
is SubscriptionListAction.Load -> emitAll(onLoad())
}
}
private fun onLoad() = flow {
emit(SubscriptionListState.Loading)
val subscriptions = getCurrentSubscriptions()
val list = subscriptions.map {
Subscription(
header = it.header,
entitlements = it.entitlements,
additionalText = listOfNotNull(it.getRenewalText(), it.getManagedByText())
)
}
emit(SubscriptionListState.Data(list))
}.catch {
emit(SubscriptionListState.Error(it.message ?: "Error in onLoad"))
}
override suspend fun FlowCollector<SubscriptionListState>.onError(throwable: Throwable) {
emit(SubscriptionListState.Error(throwable.message ?: "Unknown error"))
}
private fun SubscriptionDetail.getRenewalText() = when {
renew == null -> null
periodEnd == null -> null
else -> {
val formattedDate = dateTimeFormat.format(periodEnd!!.toLong(), MEDIUM_DATE)
res.getString(R.string.payment_subscription_renew_auto_renew_on, formattedDate)
}
}
private fun SubscriptionDetail.getManagedByText() = when {
name == "free" -> null
managedBy == 1 -> res.getString(R.string.payment_subscription_managed_by_apple)
managedBy == 2 -> res.getString(R.string.payment_subscription_managed_by_google)
else -> res.getString(R.string.payment_subscription_managed_by_other)
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.extension
import coil.annotation.ExperimentalCoilApi
import coil.disk.DiskCache
/**
* Executes the given [block] function on this [DiskCache.Editor] and [DiskCache.Editor.commit] it,
* or [DiskCache.Editor.abort] it when an exception is thrown.
*/
@OptIn(ExperimentalCoilApi::class)
@Suppress("TooGenericExceptionCaught", "SwallowedException")
suspend inline fun <R> DiskCache.Editor.use(crossinline block: suspend (DiskCache.Editor) -> R): R {
var exception: Throwable? = null
try {
return block(this)
} catch (e: Throwable) {
exception = e
throw e
} finally {
when {
exception == null -> commit()
else ->
try {
abort()
} catch (abortException: Throwable) {
// cause.addSuppressed(abortException) // ignored here
}
}
}
}
@@ -0,0 +1,99 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.extension
import android.content.Context
import me.proton.android.core.payment.presentation.R
import uniffi.proton_mail_uniffi.OtherErrorReason
import uniffi.proton_mail_uniffi.OtherErrorReason.InvalidParameter
import uniffi.proton_mail_uniffi.OtherErrorReason.Other
import uniffi.proton_mail_uniffi.ProtonError
import uniffi.proton_mail_uniffi.ProtonError.Network
import uniffi.proton_mail_uniffi.ProtonError.OtherReason
import uniffi.proton_mail_uniffi.ProtonError.ServerError
import uniffi.proton_mail_uniffi.ProtonError.SessionExpired
import uniffi.proton_mail_uniffi.ProtonError.Unexpected
import uniffi.proton_mail_uniffi.SessionErrorReason
import uniffi.proton_mail_uniffi.UnexpectedError
import uniffi.proton_mail_uniffi.UserApiServiceError
import uniffi.proton_mail_uniffi.UserApiServiceError.BadGateway
import uniffi.proton_mail_uniffi.UserApiServiceError.BadRequest
import uniffi.proton_mail_uniffi.UserApiServiceError.InternalServerError
import uniffi.proton_mail_uniffi.UserApiServiceError.NotFound
import uniffi.proton_mail_uniffi.UserApiServiceError.NotImplemented
import uniffi.proton_mail_uniffi.UserApiServiceError.OtherHttpError
import uniffi.proton_mail_uniffi.UserApiServiceError.ServiceUnavailable
import uniffi.proton_mail_uniffi.UserApiServiceError.TooManyRequest
import uniffi.proton_mail_uniffi.UserApiServiceError.Unauthorized
import uniffi.proton_mail_uniffi.UserApiServiceError.UnprocessableEntity
import uniffi.proton_mail_uniffi.UserSessionError
fun UserSessionError.getErrorMessage(context: Context) = when (this) {
is UserSessionError.Other -> this.v1.getErrorMessage(context)
is UserSessionError.Reason -> this.v1.getErrorMessage()
}
fun SessionErrorReason.getErrorMessage() = when (this) {
SessionErrorReason.UNKNOWN_LABEL -> "UNKNOWN_LABEL"
SessionErrorReason.DUPLICATE_CONTEXT -> "DUPLICATE_CONTEXT"
}
fun ProtonError.getErrorMessage(context: Context) = when (this) {
is OtherReason -> v1.getErrorMessage()
is ServerError -> v1.getErrorMessage()
is Unexpected -> v1.getErrorMessage()
is Network -> context.getString(R.string.presentation_general_connection_error)
is SessionExpired -> context.getString(R.string.presentation_error_general)
}
fun UserApiServiceError.getErrorMessage() = when (this) {
is BadRequest -> v1
is OtherHttpError -> v2
is BadGateway -> v1
is InternalServerError -> v1
is NotFound -> v1
is NotImplemented -> v1
is ServiceUnavailable -> v1
is TooManyRequest -> v1
is Unauthorized -> v1
is UnprocessableEntity -> v1
}
fun UnexpectedError.getErrorMessage() = when (this) {
UnexpectedError.CRYPTO -> "CRYPTO"
UnexpectedError.DATABASE -> "DATABASE"
UnexpectedError.FILE_SYSTEM -> "FILE_SYSTEM"
UnexpectedError.INTERNAL -> "INTERNAL"
UnexpectedError.INVALID_ARGUMENT -> "INVALID_ARGUMENT"
UnexpectedError.MEMORY -> "MEMORY"
UnexpectedError.NETWORK -> "NETWORK"
UnexpectedError.OS -> "OS"
UnexpectedError.QUEUE -> "QUEUE"
UnexpectedError.UNKNOWN -> "UNKNOWN"
UnexpectedError.API -> "API"
UnexpectedError.DRAFT -> "DRAFT"
UnexpectedError.ERROR_MAPPING -> "ERROR_MAPPING"
UnexpectedError.CONFIG -> "CONFIG"
}.also {
throw IllegalStateException("UnexpectedError: $it")
}
fun OtherErrorReason.getErrorMessage() = when (this) {
is InvalidParameter -> "InvalidParameter"
is Other -> this.v1
}
@@ -0,0 +1,181 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.model
import me.proton.android.core.payment.domain.model.ProductEntitlement
import me.proton.android.core.payment.domain.model.ProductHeader
data class Product(
val planName: String,
val productId: String,
val accountId: String,
val cycle: Int,
val header: ProductHeader,
val entitlements: List<ProductEntitlement>,
val renewalText: String?
) {
companion object {
val test = Product(
planName = "bundle2022",
productId = "giapmail_bundle2022_12_renewing",
accountId = "cus_google_1234",
cycle = 12,
header = ProductHeader(
title = "Proton Unlimited",
description = "Comprehensive privacy and security with all Proton services combined.",
priceText = "CHF99.00",
cycleText = "Per year",
starred = true
),
entitlements = listOf(
ProductEntitlement.Description(
iconName = null,
text = "1 of 1 user",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "1 of 1 address",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "500 GB storage",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "And the free features of all other Proton products",
hint = ""
)
),
renewalText = "Welcome offer. Auto renews at CHF 129.48/year"
)
val test_mail2022_1 = Product(
planName = "mail2022",
productId = "giapmail_mail2022_1_renewing",
accountId = "cus_google_1234",
cycle = 1,
header = ProductHeader(
title = "Mail Plus",
description = "Secure email with advanced features for your everyday communication.",
priceText = "CHF0.99",
cycleText = "Per month",
starred = false
),
entitlements = listOf(
ProductEntitlement.Description(
iconName = null,
text = "15 GB storage",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "10 email addresses",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Support for 1 custom email domain",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Unlimited folders, labels, and filters",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "25 personal calendars",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "And the free features of all other Proton products",
hint = ""
)
),
renewalText = "Welcome offer. Auto renews at CHF 5.39/month"
)
val test_pass2022_1 = Product(
planName = "pass2022",
productId = "giappass_pass2022_1_renewing",
accountId = "cus_google_1234",
cycle = 1,
header = ProductHeader(
title = "Pass Plus",
description = "For next-level password management and identity protection.",
priceText = "CHF0.99",
cycleText = "Per month",
starred = false
),
entitlements = listOf(
ProductEntitlement.Description(
iconName = null,
text = "Unlimited logins and notes",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Unlimited devices",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "20 vaults",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Share each vault with up to 10 people",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Unlimited email aliases",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Integrated 2FA authenticator",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Custom fields",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Priority support",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "And the free features of all other Proton products",
hint = ""
)
),
renewalText = "Welcome offer. Auto renews at CHF 5.39/month"
)
}
}
@@ -0,0 +1,206 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.core.payment.presentation.model
import me.proton.android.core.payment.domain.model.ProductEntitlement
import me.proton.android.core.payment.domain.model.ProductHeader
data class Subscription(
val header: ProductHeader,
val entitlements: List<ProductEntitlement>,
val additionalText: List<String>
) {
companion object {
val test = Subscription(
header = ProductHeader(
title = "Proton Unlimited",
description = "Comprehensive privacy and security with all Proton services combined.",
priceText = "CHF129.48",
cycleText = "Per year",
starred = false
),
entitlements = listOf(
ProductEntitlement.Progress(
startText = "Mail storage",
iconName = null,
endText = "100 MB of 500 MB",
min = 0,
max = 500,
current = 100
),
ProductEntitlement.Progress(
startText = "Drive storage",
iconName = null,
endText = "260 MB of 500 MB",
min = 0,
max = 500,
current = 260
),
ProductEntitlement.Progress(
startText = "Pass storage",
iconName = null,
endText = "480 MB of 500 MB",
min = 0,
max = 500,
current = 480
),
ProductEntitlement.Description(
iconName = null,
text = "1 of 1 user",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "1 of 1 address",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "500 GB storage",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "And the free features of all other Proton products",
hint = ""
)
),
additionalText = listOf(
"Your plan will automatically renew on 15 March 2025.",
"Your subscription can't be managed inside mobile app."
)
)
val test_pass2022 = Subscription(
header = ProductHeader(
title = "Pass Plus",
description = "For next-level password management and identity protection.",
priceText = "CHF0.99",
cycleText = "Per month",
starred = false
),
entitlements = listOf(
ProductEntitlement.Description(
iconName = null,
text = "Unlimited logins and notes",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Unlimited devices",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "20 vaults",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Share each vault with up to 10 people",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Unlimited email aliases",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Integrated 2FA authenticator",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Custom fields",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Priority support",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "And the free features of all other Proton products",
hint = ""
)
),
additionalText = listOf(
"Your plan will automatically renew on 15 March 2025.",
"Your subscription can't be managed inside mobile app."
)
)
val test_free = Subscription(
header = ProductHeader(
title = "Proton Free",
description = "Current plan",
priceText = "",
cycleText = "",
starred = false
),
entitlements = listOf(
ProductEntitlement.Progress(
startText = "Mail storage",
iconName = null,
endText = "100 MB of 500 MB",
min = 0,
max = 500,
current = 100
),
ProductEntitlement.Progress(
startText = "Drive storage",
iconName = null,
endText = "260 MB of 500 MB",
min = 0,
max = 500,
current = 260
),
ProductEntitlement.Description(
iconName = null,
text = "1 of 1 user",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "1 of 1 address",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "1 of 3 calendar",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "Free VPN on single device",
hint = ""
),
ProductEntitlement.Description(
iconName = null,
text = "And the free features of all other Proton products",
hint = ""
)
),
additionalText = emptyList()
)
}
}
@@ -0,0 +1,174 @@
/*
* Copyright (C) 2025 Proton AG
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("UseComposableActions")
package me.proton.android.core.payment.presentation.screen
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ch.protonmail.android.design.compose.component.appbar.ProtonNavigationIcon
import ch.protonmail.android.design.compose.component.appbar.ProtonTopAppBar
import ch.protonmail.android.design.compose.theme.ProtonDimens.Spacing
import ch.protonmail.android.design.compose.theme.ProtonTheme
import ch.protonmail.android.design.compose.theme.titleLargeNorm
import ch.protonmail.android.design.compose.theme.titleMediumNorm
import ch.protonmail.android.design.compose.viewmodel.hiltViewModelOrNull
import me.proton.android.core.payment.presentation.R
import me.proton.android.core.payment.presentation.component.ProductList
import me.proton.android.core.payment.presentation.component.ProductListAction
import me.proton.android.core.payment.presentation.component.ProductListState
import me.proton.android.core.payment.presentation.component.ProductListViewModel
import me.proton.android.core.payment.presentation.component.SubscriptionList
import me.proton.android.core.payment.presentation.component.SubscriptionListAction
import me.proton.android.core.payment.presentation.component.SubscriptionListState
import me.proton.android.core.payment.presentation.component.SubscriptionListViewModel
import me.proton.android.core.payment.presentation.model.Product
import me.proton.android.core.payment.presentation.model.Subscription
@Composable
fun SubscriptionScreen(
onClose: () -> Unit = {},
onSuccess: (Product) -> Unit = {},
onErrorMessage: (String) -> Unit = {},
subscriptionViewModel: SubscriptionListViewModel? = hiltViewModelOrNull(),
productViewModel: ProductListViewModel? = hiltViewModelOrNull()
) {
val subscriptionState = subscriptionViewModel?.state?.collectAsStateWithLifecycle()?.value
?: SubscriptionListState.Data(listOf(Subscription.test_free))
val productState = productViewModel?.state?.collectAsStateWithLifecycle()?.value
?: ProductListState.Data(listOf(Product.test, Product.test_mail2022_1))
SubscriptionScreen(
onClose = onClose,
onSuccess = onSuccess,
onErrorMessage = onErrorMessage,
onRetryClicked = {
subscriptionViewModel?.perform(SubscriptionListAction.Load())
productViewModel?.perform(ProductListAction.Load())
},
subscriptionState = subscriptionState,
productState = productState
)
}
@Composable
fun SubscriptionScreen(
onClose: () -> Unit = {},
onSuccess: (Product) -> Unit = {},
onErrorMessage: (String) -> Unit = {},
onRetryClicked: () -> Unit = {},
subscriptionState: SubscriptionListState,
productState: ProductListState
) {
val subscriptionList = remember(subscriptionState) { (subscriptionState as? SubscriptionListState.Data)?.list }
val productList = remember(productState) { (productState as? ProductListState.Data)?.list }
val areUpgradeAvailable = remember(productList) { productList?.isNotEmpty() ?: false }
Scaffold(
topBar = {
ProtonTopAppBar(
title = {
Text(
text = stringResource(id = R.string.payment_subscription_title),
style = ProtonTheme.typography.titleMediumNorm
)
},
navigationIcon = { ProtonNavigationIcon(onClick = onClose) },
backgroundColor = ProtonTheme.colors.backgroundNorm
)
}
) { paddingValues ->
BoxWithConstraints(modifier = Modifier.padding(paddingValues)) {
// Force SubscriptionList to fill maxHeight on loading/error.
val subscriptionHeight = when {
subscriptionList == null -> maxHeight
else -> Dp.Unspecified
}
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
SubscriptionList(
modifier = Modifier.padding(Spacing.Medium).height(subscriptionHeight),
onRetryClicked = onRetryClicked,
state = subscriptionState
)
if (areUpgradeAvailable) {
Text(
text = stringResource(R.string.payment_subscription_upgrade_your_plan),
modifier = Modifier.padding(bottom = Spacing.Medium).fillMaxWidth(),
textAlign = TextAlign.Center,
style = ProtonTheme.typography.titleLargeNorm
)
}
ProductList(
modifier = Modifier.padding(Spacing.Medium),
onSuccess = {
onRetryClicked()
onSuccess(it)
},
onErrorMessage = { onErrorMessage(it) },
onRetryClicked = onRetryClicked,
state = productState
)
}
}
}
}
@Composable
@PreviewLightDark
internal fun SubscriptionScreenPreview() {
ProtonTheme { SubscriptionScreen() }
}
@Composable
@PreviewLightDark
internal fun SubscriptionScreenErrorPreview() {
ProtonTheme {
SubscriptionScreen(
subscriptionState = SubscriptionListState.Error("An error occurs. Please retry."),
productState = ProductListState.Error("An error occurs. Please retry.")
)
}
}
@Composable
@PreviewScreenSizes
internal fun SubscriptionScreenSizePreview() {
ProtonTheme { SubscriptionScreen() }
}
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2025 Proton AG
~ This file is part of Proton AG.
~
~ Proton Mail is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ Proton Mail is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
-->
<resources>
<string name="payment_error_title">Something went wrong</string>
<string name="payment_error_description">We couldn\'t load this page. Please refresh the page or check your internet connection.</string>
<string name="payment_purchase_button_get">Get %1$s</string>
<string name="payment_purchase_button_subscribed">Subscribed to %1$s</string>
<string name="payment_purchase_button_product_not_found">Product not found in Store</string>
<string name="payment_pay_monthly">Pay monthly</string>
<string name="payment_pay_annually">Pay annually</string>
<string name="payment_pay_biennially">Pay every 2 years</string>
<plurals name="payment_pay_other">
<item quantity="one">Pay for %d month</item>
<item quantity="other">Pay for %d months</item>
</plurals>
<string name="payment_welcome_offer_renew_monthly">Welcome offer. Auto renews at %1$s/month</string>
<string name="payment_welcome_offer_renew_annually">Welcome offer. Auto renews at %1$s/year</string>
<string name="payment_welcome_offer_renew_biennially">Welcome offer. Auto renews at %1$s every 2 years</string>
<plurals name="payment_welcome_offer_renew_other">
<item quantity="one">Welcome offer. Auto renews at %1$s/month</item>
<item quantity="other">Welcome offer. Auto renews at %1$s every %2$s months</item>
</plurals>
<string name="payment_subscription_title">Subscription</string>
<string name="payment_subscription_upgrade_your_plan">Upgrade your plan</string>
<string name="payment_subscription_renew_auto_renew_on">Your plan will automatically renew on %1$s.</string>
<string name="payment_subscription_managed_by_google">Your subscription is managed by Google Play.</string>
<string name="payment_subscription_managed_by_apple">Your subscription is managed by Apple Store.</string>
<string name="payment_subscription_managed_by_other">Your subscription can’t be managed inside the mobile app.</string>
</resources>