From a32dc0c486f10abb04dec04e44536b4595775bf8 Mon Sep 17 00:00:00 2001 From: Neil Marietta Date: Thu, 13 Mar 2025 20:04:10 +0100 Subject: [PATCH] Added Payment support (Google + Rust). --- app/build.gradle.kts | 4 + .../ch/protonmail/android/navigation/Home.kt | 2 +- .../android/navigation/LauncherViewModel.kt | 12 +- .../navigation/LauncherViewModelTest.kt | 4 + gradle/libs.versions.toml | 7 +- .../repository/UserSessionRepository.kt | 10 +- .../compose/component/ExpandableHeader.kt | 85 +++++++ .../component/ExposedDropdownMenuCard.kt | 136 ++++++++++++ .../component/appbar/ProtonNavigationIcon.kt | 56 +++++ .../compose/viewmodel/OptionalViewModel.kt | 4 +- settings.gradle.kts | 7 + .../payment-google/dagger/build.gradle.kts | 56 +++++ .../google/dagger/CorePaymentGoogleModule.kt | 48 ++++ .../core/payment-google/data/build.gradle.kts | 53 +++++ .../payment/google/data/PaymentManagerImpl.kt | 85 +++++++ .../google/data/PurchaseStoreListener.kt | 65 ++++++ .../google/data/extension/BillingClientExt.kt | 118 ++++++++++ .../data/extension/ProductDetailsResultExt.kt | 36 +++ .../google/data/model/BillingProductMapper.kt | 42 ++++ .../data/model/BillingPurchaseMapper.kt | 35 +++ .../presentation/build.gradle.kts | 66 ++++++ .../presentation/PurchaseOrchestratorImpl.kt | 105 +++++++++ shared/core/payment/dagger/build.gradle.kts | 54 +++++ .../core/payment/dagger/CorePaymentModule.kt | 51 +++++ shared/core/payment/data/build.gradle.kts | 64 ++++++ .../payment/data/IconResourceManagerRust.kt | 39 ++++ .../payment/data/SubscriptionManagerRust.kt | 149 +++++++++++++ .../core/payment/data/model/RustPlanMapper.kt | 119 ++++++++++ shared/core/payment/domain/build.gradle.kts | 47 ++++ .../payment/domain/IconResourceManager.kt | 26 +++ .../android/core/payment/domain/LogTag.kt | 24 ++ .../core/payment/domain/PaymentException.kt | 37 +++ .../core/payment/domain/PaymentManager.kt | 49 ++++ .../core/payment/domain/PurchaseListener.kt | 25 +++ .../payment/domain/SubscriptionManager.kt | 48 ++++ .../payment/domain/model/ProductDetail.kt | 62 ++++++ .../core/payment/domain/model/Purchase.kt | 40 ++++ .../domain/model/SubscriptionDetail.kt | 28 +++ .../domain/usecase/GetAvailableUpgrades.kt | 66 ++++++ .../domain/usecase/GetCurrentSubscriptions.kt | 48 ++++ .../payment/presentation/build.gradle.kts | 80 +++++++ .../presentation/src/main/AndroidManifest.xml | 29 +++ .../presentation/ActivityResultContracts.kt | 35 +++ .../presentation/IconResourceFetcher.kt | 92 ++++++++ .../presentation/PaymentOrchestrator.kt | 72 ++++++ .../presentation/PurchaseOrchestrator.kt | 32 +++ .../presentation/SubscriptionActivity.kt | 83 +++++++ .../presentation/component/EntitlementList.kt | 135 +++++++++++ .../presentation/component/ProductCard.kt | 210 ++++++++++++++++++ .../presentation/component/ProductHeader.kt | 119 ++++++++++ .../presentation/component/ProductItem.kt | 64 ++++++ .../presentation/component/ProductList.kt | 175 +++++++++++++++ .../component/ProductListOperation.kt | 24 ++ .../component/ProductListState.kt | 26 +++ .../component/ProductListViewModel.kt | 93 ++++++++ .../component/ProtonErrorRetryLayout.kt | 91 ++++++++ .../presentation/component/PurchaseButton.kt | 155 +++++++++++++ .../component/PurchaseButtonOperation.kt | 28 +++ .../component/PurchaseButtonProcessor.kt | 25 +++ .../PurchaseButtonProcessorNative.kt | 116 ++++++++++ .../component/PurchaseButtonState.kt | 29 +++ .../component/PurchaseButtonViewModel.kt | 39 ++++ .../component/SubscriptionCard.kt | 100 +++++++++ .../component/SubscriptionItem.kt | 45 ++++ .../component/SubscriptionList.kt | 102 +++++++++ .../component/SubscriptionListOperation.kt | 24 ++ .../component/SubscriptionListState.kt | 26 +++ .../component/SubscriptionListViewModel.kt | 89 ++++++++ .../extension/DiskCacheEditorExtension.kt | 47 ++++ .../extension/ResultErrorExtension.kt | 99 +++++++++ .../payment/presentation/model/Product.kt | 181 +++++++++++++++ .../presentation/model/Subscription.kt | 206 +++++++++++++++++ .../presentation/screen/SubscriptionScreen.kt | 174 +++++++++++++++ .../src/main/res/values/strings.xml | 51 +++++ 74 files changed, 4901 insertions(+), 7 deletions(-) create mode 100644 presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/ExpandableHeader.kt create mode 100644 presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/ExposedDropdownMenuCard.kt create mode 100644 presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/appbar/ProtonNavigationIcon.kt create mode 100644 shared/core/payment-google/dagger/build.gradle.kts create mode 100644 shared/core/payment-google/dagger/src/main/kotlin/me/proton/android/core/payment/google/dagger/CorePaymentGoogleModule.kt create mode 100644 shared/core/payment-google/data/build.gradle.kts create mode 100644 shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/PaymentManagerImpl.kt create mode 100644 shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/PurchaseStoreListener.kt create mode 100644 shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/extension/BillingClientExt.kt create mode 100644 shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/extension/ProductDetailsResultExt.kt create mode 100644 shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/model/BillingProductMapper.kt create mode 100644 shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/model/BillingPurchaseMapper.kt create mode 100644 shared/core/payment-google/presentation/build.gradle.kts create mode 100644 shared/core/payment-google/presentation/src/main/kotlin/me/proton/android/core/payment/google/presentation/PurchaseOrchestratorImpl.kt create mode 100644 shared/core/payment/dagger/build.gradle.kts create mode 100644 shared/core/payment/dagger/src/main/kotlin/me/proton/android/core/payment/dagger/CorePaymentModule.kt create mode 100644 shared/core/payment/data/build.gradle.kts create mode 100644 shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/IconResourceManagerRust.kt create mode 100644 shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/SubscriptionManagerRust.kt create mode 100644 shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/model/RustPlanMapper.kt create mode 100644 shared/core/payment/domain/build.gradle.kts create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/IconResourceManager.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/LogTag.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PaymentException.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PaymentManager.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PurchaseListener.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/SubscriptionManager.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/ProductDetail.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/Purchase.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/SubscriptionDetail.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/usecase/GetAvailableUpgrades.kt create mode 100644 shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/usecase/GetCurrentSubscriptions.kt create mode 100644 shared/core/payment/presentation/build.gradle.kts create mode 100644 shared/core/payment/presentation/src/main/AndroidManifest.xml create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/ActivityResultContracts.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/IconResourceFetcher.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/PaymentOrchestrator.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/PurchaseOrchestrator.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/SubscriptionActivity.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/EntitlementList.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductCard.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductHeader.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductItem.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductList.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListOperation.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListState.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListViewModel.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProtonErrorRetryLayout.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButton.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonOperation.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonProcessor.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonProcessorNative.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonState.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonViewModel.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionCard.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionItem.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionList.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListOperation.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListState.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListViewModel.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/extension/DiskCacheEditorExtension.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/extension/ResultErrorExtension.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/model/Product.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/model/Subscription.kt create mode 100644 shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/screen/SubscriptionScreen.kt create mode 100644 shared/core/payment/presentation/src/main/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4eabc0cdad..233a3fb718 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } } diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt index 35292acefa..101640b0dd 100644 --- a/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt @@ -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() } ) ) } diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt index dd1fb22aac..6a1eb693f1 100644 --- a/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt @@ -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() { diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt index 6f354c8367..3c10b780f6 100644 --- a/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt +++ b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt @@ -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() + private val paymentOrchestrator = mockk() private val setPrimaryAccount = mockk() private val userSessionRepository = mockk() private val notificationsPermissionOrchestrator = mockk(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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0feea621a2..5d2fef6dae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/repository/UserSessionRepository.kt b/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/repository/UserSessionRepository.kt index 22cbd09475..ab7858ee10 100644 --- a/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/repository/UserSessionRepository.kt +++ b/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/repository/UserSessionRepository.kt @@ -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() +} diff --git a/presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/ExpandableHeader.kt b/presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/ExpandableHeader.kt new file mode 100644 index 0000000000..1646b93135 --- /dev/null +++ b/presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/ExpandableHeader.kt @@ -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 . + */ + +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") + } + ) +} diff --git a/presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/ExposedDropdownMenuCard.kt b/presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/ExposedDropdownMenuCard.kt new file mode 100644 index 0000000000..37dda3c199 --- /dev/null +++ b/presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/ExposedDropdownMenuCard.kt @@ -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 . + */ + +@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, + 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) + } + } +} diff --git a/presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/appbar/ProtonNavigationIcon.kt b/presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/appbar/ProtonNavigationIcon.kt new file mode 100644 index 0000000000..810852a48e --- /dev/null +++ b/presentation-compose/src/main/java/ch/protonmail/android/design/compose/component/appbar/ProtonNavigationIcon.kt @@ -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 . + */ + +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() } +} diff --git a/presentation-compose/src/main/java/ch/protonmail/android/design/compose/viewmodel/OptionalViewModel.kt b/presentation-compose/src/main/java/ch/protonmail/android/design/compose/viewmodel/OptionalViewModel.kt index 3230f9e08d..ed7e46bdf8 100644 --- a/presentation-compose/src/main/java/ch/protonmail/android/design/compose/viewmodel/OptionalViewModel.kt +++ b/presentation-compose/src/main/java/ch/protonmail/android/design/compose/viewmodel/OptionalViewModel.kt @@ -30,9 +30,9 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner * @see [hiltViewModel]. */ @Composable -inline fun hiltViewModelOrNull(): VM? { +inline fun hiltViewModelOrNull(key: String? = null): VM? { return when (LocalViewModelStoreOwner.current) { - is HasDefaultViewModelProviderFactory -> hiltViewModel() + is HasDefaultViewModelProviderFactory -> hiltViewModel(key = key) else -> null } } diff --git a/settings.gradle.kts b/settings.gradle.kts index bd156015b4..e7ba31acc4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") diff --git a/shared/core/payment-google/dagger/build.gradle.kts b/shared/core/payment-google/dagger/build.gradle.kts new file mode 100644 index 0000000000..f3bc1961b9 --- /dev/null +++ b/shared/core/payment-google/dagger/build.gradle.kts @@ -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 . + */ + +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) +} diff --git a/shared/core/payment-google/dagger/src/main/kotlin/me/proton/android/core/payment/google/dagger/CorePaymentGoogleModule.kt b/shared/core/payment-google/dagger/src/main/kotlin/me/proton/android/core/payment/google/dagger/CorePaymentGoogleModule.kt new file mode 100644 index 0000000000..7425c0e266 --- /dev/null +++ b/shared/core/payment-google/dagger/src/main/kotlin/me/proton/android/core/payment/google/dagger/CorePaymentGoogleModule.kt @@ -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 . + */ + +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 + } +} diff --git a/shared/core/payment-google/data/build.gradle.kts b/shared/core/payment-google/data/build.gradle.kts new file mode 100644 index 0000000000..27d0146ee2 --- /dev/null +++ b/shared/core/payment-google/data/build.gradle.kts @@ -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 . + */ + +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) +} diff --git a/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/PaymentManagerImpl.kt b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/PaymentManagerImpl.kt new file mode 100644 index 0000000000..31df40c4e9 --- /dev/null +++ b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/PaymentManagerImpl.kt @@ -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 . + */ + +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): List = 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 = newClient().withConnection { + purchaseStoreListener.purchases + .onSubscription { getProductPurchasesAsync(purchaseStoreListener) } + .first() + } + + override fun observeStorePurchases(): Flow> = callbackFlow { + val job = launch { purchaseStoreListener.purchases.collect { send(it) } } + newClient().withConnection { + getProductPurchasesAsync(purchaseStoreListener) + job.join() + } + awaitClose { job.cancel() } + } +} diff --git a/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/PurchaseStoreListener.kt b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/PurchaseStoreListener.kt new file mode 100644 index 0000000000..2e92026829 --- /dev/null +++ b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/PurchaseStoreListener.kt @@ -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 . + */ + +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>() + + val purchases: SharedFlow> = mutablePurchases.asSharedFlow() + + override fun onPurchasesUpdated(result: BillingResult, list: List?) { + CoreLogger.d(LogTag.DEFAULT, "onPurchasesUpdated -> $list") + list?.let { onPurchaseListUpdated(list.map { it.toPurchase() }) } + } + + override fun onQueryPurchasesResponse(result: BillingResult, list: List) { + CoreLogger.d(LogTag.DEFAULT, "onQueryPurchasesResponse -> $list") + onPurchaseListUpdated(list.map { it.toPurchase() }) + } + + override fun onPurchaseListUpdated(list: List) { + CoreLogger.d(LogTag.DEFAULT, "onPurchaseListUpdated -> $list") + scopeProvider.GlobalIOSupervisedScope.launch { + mutablePurchases.emit(list) + } + } +} diff --git a/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/extension/BillingClientExt.kt b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/extension/BillingClientExt.kt new file mode 100644 index 0000000000..ebd7380fbc --- /dev/null +++ b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/extension/BillingClientExt.kt @@ -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 . + */ + +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 BillingClient.withConnection(retry: Int = Int.MAX_VALUE, block: suspend BillingClient.() -> R): R { + var remainingRetry = retry + val mutableConnectionState = MutableStateFlow(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): 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) +} diff --git a/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/extension/ProductDetailsResultExt.kt b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/extension/ProductDetailsResultExt.kt new file mode 100644 index 0000000000..d537f28ca2 --- /dev/null +++ b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/extension/ProductDetailsResultExt.kt @@ -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 . + */ + +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? { + billingResult.checkOrThrow() + return productDetailsList +} + +inline fun ProductDetailsResult.fold( + onSuccess: (value: ProductDetailsResult) -> R, + onFailure: (exception: Throwable) -> R +): R { + return when (val exception = billingResult.getExceptionOrNull()) { + null -> onSuccess(this) + else -> onFailure(exception) + } +} diff --git a/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/model/BillingProductMapper.kt b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/model/BillingProductMapper.kt new file mode 100644 index 0000000000..3fce1c21fc --- /dev/null +++ b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/model/BillingProductMapper.kt @@ -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 . + */ + +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 } +) diff --git a/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/model/BillingPurchaseMapper.kt b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/model/BillingPurchaseMapper.kt new file mode 100644 index 0000000000..332449fd5f --- /dev/null +++ b/shared/core/payment-google/data/src/main/kotlin/me/proton/android/core/payment/google/data/model/BillingPurchaseMapper.kt @@ -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 . + */ + +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 + } +) diff --git a/shared/core/payment-google/presentation/build.gradle.kts b/shared/core/payment-google/presentation/build.gradle.kts new file mode 100644 index 0000000000..b6799d4dc4 --- /dev/null +++ b/shared/core/payment-google/presentation/build.gradle.kts @@ -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 . + */ + +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) +} diff --git a/shared/core/payment-google/presentation/src/main/kotlin/me/proton/android/core/payment/google/presentation/PurchaseOrchestratorImpl.kt b/shared/core/payment-google/presentation/src/main/kotlin/me/proton/android/core/payment/google/presentation/PurchaseOrchestratorImpl.kt new file mode 100644 index 0000000000..0ec4a0a24b --- /dev/null +++ b/shared/core/payment-google/presentation/src/main/kotlin/me/proton/android/core/payment/google/presentation/PurchaseOrchestratorImpl.kt @@ -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 . + */ + +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() + } + } +} diff --git a/shared/core/payment/dagger/build.gradle.kts b/shared/core/payment/dagger/build.gradle.kts new file mode 100644 index 0000000000..922c93dab9 --- /dev/null +++ b/shared/core/payment/dagger/build.gradle.kts @@ -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 . + */ + +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) +} diff --git a/shared/core/payment/dagger/src/main/kotlin/me/proton/android/core/payment/dagger/CorePaymentModule.kt b/shared/core/payment/dagger/src/main/kotlin/me/proton/android/core/payment/dagger/CorePaymentModule.kt new file mode 100644 index 0000000000..d153e68608 --- /dev/null +++ b/shared/core/payment/dagger/src/main/kotlin/me/proton/android/core/payment/dagger/CorePaymentModule.kt @@ -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 . + */ + +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 +} diff --git a/shared/core/payment/data/build.gradle.kts b/shared/core/payment/data/build.gradle.kts new file mode 100644 index 0000000000..fb674f903d --- /dev/null +++ b/shared/core/payment/data/build.gradle.kts @@ -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 . + */ + +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) +} diff --git a/shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/IconResourceManagerRust.kt b/shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/IconResourceManagerRust.kt new file mode 100644 index 0000000000..03e0f1b5a7 --- /dev/null +++ b/shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/IconResourceManagerRust.kt @@ -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 . + */ + +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 + } + } +} diff --git a/shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/SubscriptionManagerRust.kt b/shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/SubscriptionManagerRust.kt new file mode 100644 index 0000000000..88fd16af0e --- /dev/null +++ b/shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/SubscriptionManagerRust.kt @@ -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 . + */ + +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 { + 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 { + 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) + } +} diff --git a/shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/model/RustPlanMapper.kt b/shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/model/RustPlanMapper.kt new file mode 100644 index 0000000000..b8fc1232e2 --- /dev/null +++ b/shared/core/payment/data/src/main/kotlin/me/proton/android/core/payment/data/model/RustPlanMapper.kt @@ -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 . + */ + +@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() +) diff --git a/shared/core/payment/domain/build.gradle.kts b/shared/core/payment/domain/build.gradle.kts new file mode 100644 index 0000000000..90156b9a71 --- /dev/null +++ b/shared/core/payment/domain/build.gradle.kts @@ -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 . + */ + +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) +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/IconResourceManager.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/IconResourceManager.kt new file mode 100644 index 0000000000..d8ac29ce09 --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/IconResourceManager.kt @@ -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 . + */ + +package me.proton.android.core.payment.domain + +import javax.inject.Singleton + +@Singleton +interface IconResourceManager { + + suspend fun getBitmap(iconName: String): ByteArray? +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/LogTag.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/LogTag.kt new file mode 100644 index 0000000000..dca95564e6 --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/LogTag.kt @@ -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 . + */ + +package me.proton.android.core.payment.domain + +object LogTag { + + const val DEFAULT = "core.payment.default" + const val STORE = "core.payment.store.default" +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PaymentException.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PaymentException.kt new file mode 100644 index 0000000000..3b8d27b7d0 --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PaymentException.kt @@ -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 . + */ + +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 + } + } +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PaymentManager.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PaymentManager.kt new file mode 100644 index 0000000000..ae989beb91 --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PaymentManager.kt @@ -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 . + */ + +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): List + + /** + * Get existing [Purchase] from Store. + * + * @throws PaymentException + */ + suspend fun getStorePurchases(): List + + /** + * Observe [Purchase] from Store. + * + * @throws PaymentException + */ + fun observeStorePurchases(): Flow> +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PurchaseListener.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PurchaseListener.kt new file mode 100644 index 0000000000..b762753392 --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/PurchaseListener.kt @@ -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 . + */ + +package me.proton.android.core.payment.domain + +import me.proton.android.core.payment.domain.model.Purchase + +interface PurchaseListener { + + fun onPurchaseListUpdated(list: List) +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/SubscriptionManager.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/SubscriptionManager.kt new file mode 100644 index 0000000000..699a1a1136 --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/SubscriptionManager.kt @@ -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 . + */ + +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 + + + /** + * Get all available [ProductDetail] from BE. + */ + suspend fun getAvailable(): List + + /** + * Subscribe to [product] using [purchase] on BE. + * + * Note: Create Proton Token and subscribe. + * + * @throws PaymentException + */ + suspend fun subscribe(product: ProductDetail, purchase: Purchase) +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/ProductDetail.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/ProductDetail.kt new file mode 100644 index 0000000000..b0a228ba35 --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/ProductDetail.kt @@ -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 . + */ + +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 +) + +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 +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/Purchase.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/Purchase.kt new file mode 100644 index 0000000000..39b2655bfc --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/Purchase.kt @@ -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 . + */ + +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 } + } +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/SubscriptionDetail.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/SubscriptionDetail.kt new file mode 100644 index 0000000000..ad6d983996 --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/model/SubscriptionDetail.kt @@ -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 . + */ + +package me.proton.android.core.payment.domain.model + +data class SubscriptionDetail( + val name: String, + val header: ProductHeader, + val entitlements: List, + val price: ProductPrice?, + val renew: ProductPrice?, + val periodEnd: Long?, + val managedBy: Int? +) diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/usecase/GetAvailableUpgrades.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/usecase/GetAvailableUpgrades.kt new file mode 100644 index 0000000000..a8a5431c01 --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/usecase/GetAvailableUpgrades.kt @@ -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 . + */ + +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 { + 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 + ) + ) + } + } +} diff --git a/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/usecase/GetCurrentSubscriptions.kt b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/usecase/GetCurrentSubscriptions.kt new file mode 100644 index 0000000000..597a8829db --- /dev/null +++ b/shared/core/payment/domain/src/main/kotlin/me/proton/android/core/payment/domain/usecase/GetCurrentSubscriptions.kt @@ -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 . + */ + +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 { + 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 + ) + ) + } + } +} diff --git a/shared/core/payment/presentation/build.gradle.kts b/shared/core/payment/presentation/build.gradle.kts new file mode 100644 index 0000000000..e0410686de --- /dev/null +++ b/shared/core/payment/presentation/build.gradle.kts @@ -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 . + */ + +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) +} diff --git a/shared/core/payment/presentation/src/main/AndroidManifest.xml b/shared/core/payment/presentation/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c13a8aa465 --- /dev/null +++ b/shared/core/payment/presentation/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/ActivityResultContracts.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/ActivityResultContracts.kt new file mode 100644 index 0000000000..9186a01337 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/ActivityResultContracts.kt @@ -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 . + */ + +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() { + + 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 + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/IconResourceFetcher.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/IconResourceFetcher.kt new file mode 100644 index 0000000000..0493351436 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/IconResourceFetcher.kt @@ -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 . + */ + +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 { + + 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 { + + override fun create( + data: IconResource, + options: Options, + imageLoader: ImageLoader + ): Fetcher = IconResourceFetcher(context, data, manager) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/PaymentOrchestrator.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/PaymentOrchestrator.kt new file mode 100644 index 0000000000..6a689fea6e --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/PaymentOrchestrator.kt @@ -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 . + */ + +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? = null + + private var onUpgradeResultListener: ((result: Boolean) -> Unit)? = {} + + private fun registerUpgradeResult(caller: ActivityResultCaller): ActivityResultLauncher = + caller.registerForActivityResult(StartSubscription) { + onUpgradeResultListener?.invoke(it) + } + + private fun checkRegistered(launcher: ActivityResultLauncher?) = + 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 +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/PurchaseOrchestrator.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/PurchaseOrchestrator.kt new file mode 100644 index 0000000000..73a6183bc0 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/PurchaseOrchestrator.kt @@ -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 . + */ + +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 + ) +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/SubscriptionActivity.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/SubscriptionActivity.kt new file mode 100644 index 0000000000..dcc28020ed --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/SubscriptionActivity.kt @@ -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 . + */ + +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() + } + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/EntitlementList.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/EntitlementList.kt new file mode 100644 index 0000000000..42aa6193de --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/EntitlementList.kt @@ -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 . + */ + +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) { + 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) + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductCard.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductCard.kt new file mode 100644 index 0000000000..22bdbbfe0f --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductCard.kt @@ -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 . + */ + +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() + ) + } + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductHeader.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductHeader.kt new file mode 100644 index 0000000000..79c916a3e0 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductHeader.kt @@ -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 . + */ + +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) + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductItem.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductItem.kt new file mode 100644 index 0000000000..4e9c878bfd --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductItem.kt @@ -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 . + */ + +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) + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductList.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductList.kt new file mode 100644 index 0000000000..7d52d73ac2 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductList.kt @@ -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 . + */ + +@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, + 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.rememberCyclesStringResource() = remember(this) { + fold(mutableSetOf()) { 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) + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListOperation.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListOperation.kt new file mode 100644 index 0000000000..f955eac742 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListOperation.kt @@ -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 . + */ + +package me.proton.android.core.payment.presentation.component + +sealed interface ProductListOperation + +sealed interface ProductListAction : ProductListOperation { + data class Load(val unused: Long = System.currentTimeMillis()) : ProductListAction +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListState.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListState.kt new file mode 100644 index 0000000000..5e4eee5c70 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListState.kt @@ -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 . + */ + +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) : ProductListState + data class Error(val message: String) : ProductListState +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListViewModel.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListViewModel.kt new file mode 100644 index 0000000000..d883bc2f4d --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProductListViewModel.kt @@ -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 . + */ + +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( + initialAction = ProductListAction.Load(), + initialState = ProductListState.Loading +) { + + private val res = context.resources + + override fun onAction(action: ProductListAction): Flow = 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.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) + } + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProtonErrorRetryLayout.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProtonErrorRetryLayout.kt new file mode 100644 index 0000000000..2ce044fdac --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/ProtonErrorRetryLayout.kt @@ -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 . + */ + +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() + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButton.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButton.kt new file mode 100644 index 0000000000..1e0dea7233 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButton.kt @@ -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 . + */ + +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) + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonOperation.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonOperation.kt new file mode 100644 index 0000000000..a14096f1a1 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonOperation.kt @@ -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 . + */ + +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 +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonProcessor.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonProcessor.kt new file mode 100644 index 0000000000..570dd57ec7 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonProcessor.kt @@ -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 . + */ + +package me.proton.android.core.payment.presentation.component + +import kotlinx.coroutines.flow.Flow + +interface PurchaseButtonProcessor { + + fun onAction(action: PurchaseButtonAction): Flow +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonProcessorNative.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonProcessorNative.kt new file mode 100644 index 0000000000..62d17fabb3 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonProcessorNative.kt @@ -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 . + */ + +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 = 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 { + 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)) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonState.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonState.kt new file mode 100644 index 0000000000..6556f3e8a0 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonState.kt @@ -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 . + */ + +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 +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonViewModel.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonViewModel.kt new file mode 100644 index 0000000000..0077af664f --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/PurchaseButtonViewModel.kt @@ -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 . + */ + +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( + initialAction = PurchaseButtonAction.Init, + initialState = PurchaseButtonState.Idle +) { + + override fun onAction(action: PurchaseButtonAction): Flow = purchaseProcessor.onAction(action) + + override suspend fun FlowCollector.onError(throwable: Throwable) { + emit(PurchaseButtonState.Error(throwable.message ?: "Unknown error")) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionCard.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionCard.kt new file mode 100644 index 0000000000..096e8657be --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionCard.kt @@ -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 . + */ + +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) + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionItem.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionItem.kt new file mode 100644 index 0000000000..1837eeb080 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionItem.kt @@ -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 . + */ + +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) + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionList.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionList.kt new file mode 100644 index 0000000000..69199e4e65 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionList.kt @@ -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 . + */ + +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, 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) + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListOperation.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListOperation.kt new file mode 100644 index 0000000000..3efef98769 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListOperation.kt @@ -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 . + */ + +package me.proton.android.core.payment.presentation.component + +sealed interface SubscriptionListOperation + +sealed interface SubscriptionListAction : SubscriptionListOperation { + data class Load(val unused: Long = System.currentTimeMillis()) : SubscriptionListAction +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListState.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListState.kt new file mode 100644 index 0000000000..28d4db34a0 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListState.kt @@ -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 . + */ + +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) : SubscriptionListState + data class Error(val message: String) : SubscriptionListState +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListViewModel.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListViewModel.kt new file mode 100644 index 0000000000..7e75043b8c --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/component/SubscriptionListViewModel.kt @@ -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 . + */ + +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( + initialAction = SubscriptionListAction.Load(), + initialState = SubscriptionListState.Loading +) { + + private val res = context.resources + + override fun onAction(action: SubscriptionListAction): Flow = 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.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) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/extension/DiskCacheEditorExtension.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/extension/DiskCacheEditorExtension.kt new file mode 100644 index 0000000000..ebffafaaef --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/extension/DiskCacheEditorExtension.kt @@ -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 . + */ + +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 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 + } + } + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/extension/ResultErrorExtension.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/extension/ResultErrorExtension.kt new file mode 100644 index 0000000000..fcdb1c1890 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/extension/ResultErrorExtension.kt @@ -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 . + */ + +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 +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/model/Product.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/model/Product.kt new file mode 100644 index 0000000000..b824d65029 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/model/Product.kt @@ -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 . + */ + +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, + 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" + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/model/Subscription.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/model/Subscription.kt new file mode 100644 index 0000000000..6195ca4539 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/model/Subscription.kt @@ -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 . + */ + +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, + val additionalText: List +) { + + 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() + ) + } +} diff --git a/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/screen/SubscriptionScreen.kt b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/screen/SubscriptionScreen.kt new file mode 100644 index 0000000000..4f5e367a42 --- /dev/null +++ b/shared/core/payment/presentation/src/main/kotlin/me/proton/android/core/payment/presentation/screen/SubscriptionScreen.kt @@ -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 . + */ + +@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() } +} diff --git a/shared/core/payment/presentation/src/main/res/values/strings.xml b/shared/core/payment/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000000..e0ec133c49 --- /dev/null +++ b/shared/core/payment/presentation/src/main/res/values/strings.xml @@ -0,0 +1,51 @@ + + + + Something went wrong + We couldn\'t load this page. Please refresh the page or check your internet connection. + + Get %1$s + Subscribed to %1$s + Product not found in Store + + Pay monthly + Pay annually + Pay every 2 years + + Pay for %d month + Pay for %d months + + + Welcome offer. Auto renews at %1$s/month + Welcome offer. Auto renews at %1$s/year + Welcome offer. Auto renews at %1$s every 2 years + + Welcome offer. Auto renews at %1$s/month + Welcome offer. Auto renews at %1$s every %2$s months + + + Subscription + Upgrade your plan + Your plan will automatically renew on %1$s. + + Your subscription is managed by Google Play. + Your subscription is managed by Apple Store. + Your subscription can’t be managed inside the mobile app. + +