fix(plan): Fetching subscription would crash if app is offline.

This commit is contained in:
Mateusz Armatys
2025-03-12 16:07:06 +01:00
parent 51afd436e7
commit 5520c7f923
11 changed files with 154 additions and 67 deletions
@@ -18,7 +18,6 @@
package me.proton.core.payment.presentation.viewmodel
import java.util.Optional
import android.app.Activity
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -52,7 +51,7 @@ import me.proton.core.payment.domain.usecase.PaymentProvider.GoogleInAppPurchase
import me.proton.core.payment.presentation.LogTag
import me.proton.core.payment.presentation.viewmodel.ProtonPaymentEvent.Error
import me.proton.core.plan.domain.entity.DynamicPlan
import me.proton.core.plan.domain.entity.SubscriptionManagement
import me.proton.core.plan.domain.entity.SubscriptionManagement.GOOGLE_MANAGED
import me.proton.core.plan.domain.usecase.GetDynamicSubscription
import me.proton.core.plan.domain.usecase.PerformGiapPurchase
import me.proton.core.presentation.app.ActivityProvider
@@ -60,6 +59,7 @@ import me.proton.core.presentation.viewmodel.ProtonViewModel
import me.proton.core.util.kotlin.CoreLogger
import me.proton.core.util.kotlin.coroutine.ResultCollector
import me.proton.core.util.kotlin.coroutine.launchWithResultContext
import java.util.Optional
import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
@@ -106,12 +106,15 @@ internal class ProtonPaymentButtonViewModel @Inject constructor(
val lastEvent = flow {
emit(ProtonPaymentEvent.Loading)
val subscription = userId?.let { getCurrentSubscription(it) }
if (subscription?.external != null && subscription.external == SubscriptionManagement.GOOGLE_MANAGED
&& subscription.deeplink != null
) {
emit(Error.SubscriptionManagedByOtherApp(userId, subscription.deeplink!!))
return@flow
if (userId != null) {
val subscription = getCurrentSubscription(userId)
if (subscription == null) {
emit(Error.Generic(Exception("Could not get current subscription.")))
return@flow
} else if (subscription.external == GOOGLE_MANAGED && subscription.deeplink != null) {
emit(Error.SubscriptionManagedByOtherApp(userId, subscription.deeplink!!))
return@flow
}
}
when (resolvedPaymentProvider) {
@@ -27,9 +27,8 @@ import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.launch
import me.proton.core.domain.entity.UserId
import me.proton.core.observability.domain.ObservabilityManager
import me.proton.core.observability.domain.metrics.CheckoutBillingSubscribeTotal
import me.proton.core.observability.domain.metrics.CheckoutGiapBillingCreatePaymentTokenTotal
import me.proton.core.observability.domain.metrics.CheckoutGiapBillingLaunchBillingTotal
import me.proton.core.observability.domain.metrics.CheckoutGiapBillingProductQueryTotal
import me.proton.core.observability.domain.metrics.CheckoutGiapBillingQuerySubscriptionsTotal
@@ -44,6 +43,7 @@ import me.proton.core.payment.domain.usecase.GetPreferredPaymentProvider
import me.proton.core.payment.domain.usecase.PaymentProvider
import me.proton.core.payment.presentation.viewmodel.ProtonPaymentButtonViewModel.ButtonState
import me.proton.core.plan.domain.entity.DynamicPlan
import me.proton.core.plan.domain.entity.DynamicSubscription
import me.proton.core.plan.domain.usecase.GetDynamicSubscription
import me.proton.core.plan.domain.usecase.PerformGiapPurchase
import me.proton.core.presentation.app.ActivityProvider
@@ -82,6 +82,7 @@ class ProtonPaymentButtonViewModelTest : CoroutinesTest by CoroutinesTest() {
fun setUp() {
MockKAnnotations.init(this)
convertToObservabilityGiapStatus = FakeConvertToObservabilityGiapStatus()
coEvery { getCurrentSubscription(any()) } returns mockk<DynamicSubscription>()
tested = ProtonPaymentButtonViewModel(
activityProvider,
Optional.of(convertToObservabilityGiapStatus),
@@ -337,6 +338,23 @@ class ProtonPaymentButtonViewModelTest : CoroutinesTest by CoroutinesTest() {
)
}
}
@Test
fun `subscription is null`() = coroutinesTest {
// GIVEN
coEvery { getCurrentSubscription(any()) } returns null
val events = tested.paymentEvents(1)
events.test {
// WHEN
tested.onPayClicked(1, "CHF", 12, PaymentProvider.CardPayment, mockk(), UserId("uid")).join()
// THEN
assertEquals(ProtonPaymentEvent.Loading, awaitItem())
val err = assertIs<ProtonPaymentEvent.Error.Generic>(awaitItem())
assertEquals("Could not get current subscription.", err.throwable.message)
}
}
}
private class FakeConvertToObservabilityGiapStatus : ConvertToObservabilityGiapStatus {
@@ -25,8 +25,6 @@ import me.proton.core.payment.domain.usecase.GoogleServicesUtils
import me.proton.core.payment.domain.usecase.PaymentProvider
import me.proton.core.plan.domain.SupportUpgradePaidPlans
import me.proton.core.plan.domain.entity.SubscriptionManagement
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.extension.canReadSubscription
import java.util.Optional
import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
@@ -36,17 +34,13 @@ class CanUpgradeFromMobile @Inject constructor(
private val getAvailablePaymentProviders: GetAvailablePaymentProviders,
private val getCurrentSubscription: GetDynamicSubscription,
private val googleServicesUtils: Optional<GoogleServicesUtils>,
private val userManager: UserManager
) {
suspend operator fun invoke(userId: UserId): Boolean {
if (!supportPaidPlans) {
return false
}
if (!userManager.getUser(userId).canReadSubscription()) {
return false
}
val subscription = getCurrentSubscription(userId)
val subscription = getCurrentSubscription(userId) ?: return false
if (subscription.external != null && subscription.external != SubscriptionManagement.GOOGLE_MANAGED) {
return false
}
@@ -19,21 +19,26 @@
package me.proton.core.plan.domain.usecase
import me.proton.core.domain.entity.UserId
import me.proton.core.payment.domain.repository.PaymentsRepository
import me.proton.core.plan.domain.entity.DynamicSubscription
import me.proton.core.plan.domain.repository.PlansRepository
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.extension.canReadSubscription
import me.proton.core.util.kotlin.runCatchingCheckedExceptions
import javax.inject.Inject
/**
* Gets current active dynamic subscription a user has.
* Authorized. This means that it could only be used for upgrades. New accounts created during sign ups logically do not
* have existing subscriptions.
* NOTE: You may want to call [me.proton.core.user.domain.extension.canReadSubscription] before calling this.
*/
public class GetDynamicSubscription @Inject constructor(
private val plansRepository: PlansRepository
private val plansRepository: PlansRepository,
private val userManager: UserManager
) {
public suspend operator fun invoke(userId: UserId): DynamicSubscription {
return plansRepository.getDynamicSubscriptions(userId).first()
}
public suspend operator fun invoke(userId: UserId): DynamicSubscription? = runCatchingCheckedExceptions {
when {
userManager.getUser(userId).canReadSubscription() -> plansRepository.getDynamicSubscriptions(userId).first()
else -> null
}
}.getOrNull()
}
@@ -44,7 +44,7 @@ class CanUpgradeFromMobileTest {
@MockK
private lateinit var getAvailablePaymentProviders: GetAvailablePaymentProviders
@MockK(relaxed = true)
@MockK
private lateinit var getCurrentSubscription: GetDynamicSubscription
@MockK
@@ -53,9 +53,6 @@ class CanUpgradeFromMobileTest {
@MockK
private lateinit var optionalGoogleServicesUtils: Optional<GoogleServicesUtils>
@MockK
private lateinit var userManager: UserManager
private val testUserId = UserId("user-id")
private lateinit var useCase: CanUpgradeFromMobile
@@ -66,8 +63,7 @@ class CanUpgradeFromMobileTest {
supportPaidPlans = true,
getAvailablePaymentProviders = getAvailablePaymentProviders,
getCurrentSubscription = getCurrentSubscription,
googleServicesUtils = optionalGoogleServicesUtils,
userManager = userManager
googleServicesUtils = optionalGoogleServicesUtils
)
}
@@ -78,8 +74,7 @@ class CanUpgradeFromMobileTest {
supportPaidPlans = false,
getAvailablePaymentProviders = getAvailablePaymentProviders,
getCurrentSubscription = getCurrentSubscription,
googleServicesUtils = optionalGoogleServicesUtils,
userManager = userManager
googleServicesUtils = optionalGoogleServicesUtils
)
// WHEN
val result = useCase(testUserId)
@@ -91,7 +86,8 @@ class CanUpgradeFromMobileTest {
fun `can upgrade returns false when no payment providers available`() = runTest {
// GIVEN
coEvery { getAvailablePaymentProviders() } returns emptySet()
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.NoOrganization }
coEvery { getCurrentSubscription(testUserId) } returns
mockk { every { external } returns SubscriptionManagement.GOOGLE_MANAGED }
// WHEN
val result = useCase(testUserId)
// THEN
@@ -102,7 +98,8 @@ class CanUpgradeFromMobileTest {
fun `can upgrade returns false when only PayPal payment provider is available`() = runTest {
// GIVEN
coEvery { getAvailablePaymentProviders() } returns setOf(PaymentProvider.PayPal)
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.NoOrganization }
coEvery { getCurrentSubscription(testUserId) } returns
mockk { every { external } returns SubscriptionManagement.GOOGLE_MANAGED }
// WHEN
val result = useCase(testUserId)
// THEN
@@ -114,14 +111,12 @@ class CanUpgradeFromMobileTest {
// GIVEN
every { optionalGoogleServicesUtils.getOrNull() } returns googleServicesUtils
every { googleServicesUtils.isGooglePlayServicesAvailable() } returns GoogleServicesAvailability.Success
coEvery { getCurrentSubscription(testUserId) } returns mockk {
every { external } returns SubscriptionManagement.GOOGLE_MANAGED
}
coEvery { getCurrentSubscription(testUserId) } returns
mockk { every { external } returns SubscriptionManagement.GOOGLE_MANAGED }
coEvery { getAvailablePaymentProviders() } returns setOf(
PaymentProvider.CardPayment,
PaymentProvider.GoogleInAppPurchase
)
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.NoOrganization }
// WHEN
val result = useCase(testUserId)
// THEN
@@ -133,13 +128,11 @@ class CanUpgradeFromMobileTest {
// GIVEN
every { optionalGoogleServicesUtils.getOrNull() } returns googleServicesUtils
every { googleServicesUtils.isGooglePlayServicesAvailable() } returns GoogleServicesAvailability.ServiceInvalid
coEvery { getCurrentSubscription(testUserId) } returns mockk {
every { external } returns SubscriptionManagement.GOOGLE_MANAGED
}
coEvery { getCurrentSubscription(testUserId) } returns
mockk { every { external } returns SubscriptionManagement.GOOGLE_MANAGED }
coEvery { getAvailablePaymentProviders() } returns setOf(
PaymentProvider.GoogleInAppPurchase
)
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.NoOrganization }
// WHEN
val result = useCase(testUserId)
// THEN
@@ -149,14 +142,12 @@ class CanUpgradeFromMobileTest {
@Test
fun `can upgrade returns false for Proton Managed when payment providers available`() = runTest {
// GIVEN
coEvery { getCurrentSubscription(testUserId) } returns mockk {
every { external } returns SubscriptionManagement.PROTON_MANAGED
}
coEvery { getCurrentSubscription(testUserId) } returns
mockk { every { external } returns SubscriptionManagement.PROTON_MANAGED }
coEvery { getAvailablePaymentProviders() } returns setOf(
PaymentProvider.CardPayment,
PaymentProvider.GoogleInAppPurchase
)
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.NoOrganization }
// WHEN
val result = useCase(testUserId)
// THEN
@@ -164,9 +155,9 @@ class CanUpgradeFromMobileTest {
}
@Test
fun `can upgrade returns false for members of organizations`() = runTest {
fun `can upgrade returns false if subscription is null`() = runTest {
// GIVEN
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.OrganizationMember }
coEvery { getCurrentSubscription(testUserId) } returns null
// WHEN
val result = useCase(testUserId)
// THEN
@@ -18,7 +18,10 @@
package me.proton.core.plan.domain.usecase
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import me.proton.core.domain.entity.UserId
@@ -27,15 +30,23 @@ import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes
import me.proton.core.plan.domain.entity.dynamicSubscription
import me.proton.core.plan.domain.repository.PlansRepository
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.entity.Role
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class GetDynamicSubscriptionTest {
// region mocks
private val repository = mockk<PlansRepository>(relaxed = true)
@MockK(relaxed = true)
private lateinit var repository: PlansRepository
@MockK
private lateinit var userManager: UserManager
// endregion
// region test data
@@ -48,19 +59,21 @@ class GetDynamicSubscriptionTest {
@Before
fun beforeEveryTest() {
useCase = GetDynamicSubscription(repository)
MockKAnnotations.init(this)
useCase = GetDynamicSubscription(repository, userManager)
}
@Test
fun `get subscription returns success, enqueue getsubscription observability success`() = runTest {
// GIVEN
coEvery { repository.getDynamicSubscriptions(testUserId) } returns testSubscriptions
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.NoOrganization }
// WHEN
val result = useCase.invoke(testUserId)
// THEN
assertEquals(testSubscription, result)
assertNotNull(result)
assertEquals(0L, result.amount)
assertEquals(testSubscription, result)
assertEquals(0L, result?.amount)
}
@Test
@@ -72,13 +85,11 @@ class GetDynamicSubscriptionTest {
RuntimeException("Test error")
)
)
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.OrganizationAdmin }
// WHEN
val throwable = assertFailsWith(ApiException::class) {
useCase.invoke(testUserId)
}
val result = useCase.invoke(testUserId)
// THEN
assertNotNull(throwable)
assertEquals("Test error", throwable.message)
assertNull(result)
}
@Test
@@ -94,7 +105,28 @@ class GetDynamicSubscriptionTest {
)
)
)
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.NoOrganization }
// WHEN
assertFailsWith<ApiException> { useCase.invoke(testUserId) }
val result = useCase.invoke(testUserId)
assertNull(result)
}
@Test
fun `no subscription if user is member of organization`() = runTest {
// GIVEN
coEvery { userManager.getUser(testUserId) } returns mockk { every { role } returns Role.OrganizationMember }
// WHEN
val result = useCase.invoke(testUserId)
assertNull(result)
}
@Test
fun `exception is rethrown for runtime exception`() = runTest {
// GIVEN
coEvery { userManager.getUser(testUserId) } throws CancellationException("Error")
// WHEN
assertFailsWith<CancellationException> { useCase.invoke(testUserId) }
}
}
@@ -22,6 +22,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import me.proton.core.compose.viewmodel.stopTimeoutMillis
@@ -42,6 +43,7 @@ public class UpgradeStorageInfoViewModel @Inject constructor(
) : ProtonViewModel() {
public val state: StateFlow<AccountStorageState> = shouldUpgradeStorage()
.map { it.toAccountStorageState() }
.catch { emit(Hidden) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis), INITIAL_STATE)
internal companion object {
@@ -49,4 +49,14 @@ class UpgradeStorageInfoViewModelTest : CoroutinesTest by CoroutinesTest() {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `state is Hidden when exception is thrown`() = coroutinesTest {
every { shouldUpgradeStorage() } throws Exception()
tested.state.test {
assertEquals(AccountStorageState.Hidden, awaitItem())
expectNoEvents()
}
}
}
@@ -66,7 +66,7 @@ internal class CheckUnredeemedGooglePurchase @Inject constructor(
val findUnacknowledgedGooglePurchase = findUnacknowledgedGooglePurchase.getOrNull() ?: return null
if (PaymentProvider.GoogleInAppPurchase !in getAvailablePaymentProviders()) return null
val subscription = getCurrentSubscription(userId)
val subscription = getCurrentSubscription(userId) ?: return null
val subscriptionCustomerId = subscription.customerId
val googlePurchases = if (subscriptionCustomerId != null) {
listOfNotNull(findUnacknowledgedGooglePurchase.byCustomer(subscriptionCustomerId))
@@ -22,11 +22,8 @@ import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import me.proton.core.account.domain.entity.AccountType
import me.proton.core.domain.entity.AppStore
import me.proton.core.domain.entity.UserId
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.ApiResult
import me.proton.core.payment.domain.entity.GooglePurchase
import me.proton.core.payment.domain.entity.ProductId
import me.proton.core.payment.domain.usecase.FindUnacknowledgedGooglePurchase
@@ -199,7 +196,7 @@ class CheckUnredeemedGooglePurchaseTest {
coEvery { findUnacknowledgedGooglePurchase.byCustomer(customerA) } returns googlePurchase
coEvery { getAvailablePaymentProviders.invoke() } returns PaymentProvider.entries.toSet()
coEvery { getCurrentSubscription.invoke(userId) } returns mockk {
coEvery { getCurrentSubscription.invoke(userId) } returns mockk<DynamicSubscription> {
every { customerId } returns customerA
every { external } returns SubscriptionManagement.PROTON_MANAGED
}
@@ -238,7 +235,7 @@ class CheckUnredeemedGooglePurchaseTest {
coEvery { findUnacknowledgedGooglePurchase.byCustomer(any()) } returns googlePurchase
coEvery { getAvailablePaymentProviders.invoke() } returns PaymentProvider.entries.toSet()
coEvery { getCurrentSubscription.invoke(userId) } returns mockk {
coEvery { getCurrentSubscription.invoke(userId) } returns mockk<DynamicSubscription> {
every { customerId } returns customerB
every { external } returns SubscriptionManagement.GOOGLE_MANAGED
}
@@ -279,7 +276,7 @@ class CheckUnredeemedGooglePurchaseTest {
coEvery { findUnacknowledgedGooglePurchase.byCustomer(customerA) } returns googlePurchase
coEvery { getAvailablePaymentProviders.invoke() } returns PaymentProvider.entries.toSet()
coEvery { getCurrentSubscription.invoke(userId) } returns mockk {
coEvery { getCurrentSubscription.invoke(userId) } returns mockk<DynamicSubscription> {
every { customerId } returns customerA
every { external } returns SubscriptionManagement.GOOGLE_MANAGED
every { name } returns planB
@@ -318,7 +315,7 @@ class CheckUnredeemedGooglePurchaseTest {
coEvery { findUnacknowledgedGooglePurchase.byCustomer(customerA) } returns googlePurchase
coEvery { getAvailablePaymentProviders.invoke() } returns PaymentProvider.entries.toSet()
coEvery { getCurrentSubscription.invoke(userId) } returns mockk {
coEvery { getCurrentSubscription.invoke(userId) } returns mockk<DynamicSubscription> {
every { customerId } returns "customer-A"
every { external } returns SubscriptionManagement.GOOGLE_MANAGED
every { name } returns "plan-A"
@@ -444,9 +441,9 @@ class CheckUnredeemedGooglePurchaseTest {
}
@Test
fun `returns null on network error`() = runTest {
fun `returns null if null subscription`() = runTest {
coEvery { getAvailablePaymentProviders.invoke() } returns PaymentProvider.entries.toSet()
coEvery { getCurrentSubscription.invoke(any()) } throws ApiException(ApiResult.Error.Connection())
coEvery { getCurrentSubscription.invoke(any()) } returns null
assertNull(tested(mockk()))
}
}
@@ -0,0 +1,35 @@
/*
* 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/>.
*/
package me.proton.core.util.kotlin
/**
* Runs the given block of code and returns the result.
* Any [RuntimeException] or [Error] are re-thrown
* (e.g. [kotlin.coroutines.cancellation.CancellationException] or [OutOfMemoryError]).
*/
@Suppress("TooGenericExceptionCaught")
inline fun <R> runCatchingCheckedExceptions(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: RuntimeException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}