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