Merge branch 'fix/cp-2714-account_creation_failure' into 'master'

Network timeout can cause account creation failure

See merge request proton/mobile/android/proton-libs!416
This commit is contained in:
Mateusz Armatys 2021-11-22 10:50:17 +00:00
commit 5c785f61e9
34 changed files with 922 additions and 184 deletions

View File

@ -1,3 +1,18 @@
## Auth [1.18.4], Network [1.15.8], Util Kotlin [1.15.3]
22 Nov, 2021
### Dependencies
- Contact 1.19.2
- Payment 1.17.4
### Changes
- Minimum (and the default) network timeout in `ApiClient` is 30 seconds
- Recover from error when creating new accounts ("Username already taken or not allowed")
- Recover from errors while setting up user keys after creating an account ("Primary key exists")
## EventManager Version [1.19.2]
Nov 19, 2021

View File

@ -23,7 +23,7 @@ plugins {
kotlin("android")
}
libVersion = Version(1, 18, 3)
libVersion = Version(1, 18, 4)
android()

View File

@ -0,0 +1,37 @@
/*
* Copyright (c) 2021 Proton Technologies AG
* This file is part of Proton Technologies 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.auth.domain.usecase
import me.proton.core.auth.domain.usecase.signup.PerformCreateExternalEmailUser
import me.proton.core.auth.domain.usecase.signup.PerformCreateUser
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes
/** Error from [SetupPrimaryKeys]. */
fun Throwable.primaryKeyExists(): Boolean {
val httpError = (this as? ApiException)?.error as? ApiResult.Error.Http
return httpError?.proton?.code == ResponseCodes.NOT_ALLOWED
}
/** Error from [PerformCreateUser] or [PerformCreateExternalEmailUser]. */
fun Throwable.userAlreadyExists(): Boolean {
val httpError = (this as? ApiException)?.error as? ApiResult.Error.Http
return httpError?.proton?.code == ResponseCodes.USER_CREATE_NAME_INVALID
}

View File

@ -51,53 +51,51 @@ class SetupPrimaryKeys @Inject constructor(
suspend operator fun invoke(
userId: UserId,
password: EncryptedString,
accountType: AccountType
) = with(userManager.getUser(userId)) {
if (keys.primary() == null) {
val username = when (accountType) {
AccountType.External -> {
checkNotNull(emailSplit?.username) { "Email username is needed to setup primary keys." }
accountType: AccountType,
) {
val user = userManager.getUser(userId, refresh = true)
if (user.keys.primary() != null) return
val username = if (accountType == AccountType.External) {
checkNotNull(user.emailSplit?.username) { "Email username is needed to setup primary keys." }
} else {
checkNotNull(user.name) { "Username is needed." }
}
val domain = if (accountType == AccountType.External) {
checkNotNull(user.emailSplit?.domain) { "Email domain is needed to setup primary keys." }
} else {
domainRepository.getAvailableDomains().first()
}
val email = if (accountType == AccountType.External) {
checkNotNull(user.email) { "Email is needed." }
} else {
"$username@$domain"
}
val modulus = authRepository.randomModulus()
password.decrypt(keyStoreCrypto).toByteArray().use { decryptedPassword ->
val auth = srpCrypto.calculatePasswordVerifier(
username = email,
password = decryptedPassword.array,
modulusId = modulus.modulusId,
modulus = modulus.modulus
)
if (accountType == AccountType.Internal) {
if (userAddressRepository.getAddresses(userId, refresh = true).firstInternalOrNull() == null) {
userAddressRepository.createAddress(userId, username, domain)
}
else -> checkNotNull(name) { "Username is needed." }
}
val domain = when (accountType) {
AccountType.External -> {
checkNotNull(emailSplit?.domain) { "Email domain is needed to setup primary keys." }
}
else -> domainRepository.getAvailableDomains().first()
}
val email = when (accountType) {
AccountType.External -> checkNotNull(email) { "Email is needed." }
else -> "$username@$domain"
}
val modulus = authRepository.randomModulus()
password.decrypt(keyStoreCrypto).toByteArray().use { decryptedPassword ->
val auth = srpCrypto.calculatePasswordVerifier(
username = email,
password = decryptedPassword.array,
modulusId = modulus.modulusId,
modulus = modulus.modulus
)
if (accountType == AccountType.Internal) {
if (userAddressRepository.getAddresses(userId).firstInternalOrNull() == null) {
userAddressRepository.createAddress(
sessionUserId = userId,
displayName = username,
domain = domain
)
}
}
userManager.setupPrimaryKeys(
sessionUserId = userId,
username = username,
domain = domain,
auth = auth,
password = decryptedPassword.array
)
}
userManager.setupPrimaryKeys(
sessionUserId = userId,
username = username,
domain = domain,
auth = auth,
password = decryptedPassword.array
)
}
}
}

View File

@ -24,8 +24,8 @@ import me.proton.core.crypto.common.keystore.KeyStoreCrypto
import me.proton.core.crypto.common.keystore.decrypt
import me.proton.core.crypto.common.keystore.use
import me.proton.core.crypto.common.srp.SrpCrypto
import me.proton.core.domain.entity.UserId
import me.proton.core.user.domain.entity.CreateUserType
import me.proton.core.user.domain.entity.User
import me.proton.core.user.domain.repository.UserRepository
import javax.inject.Inject
@ -40,8 +40,9 @@ class PerformCreateExternalEmailUser @Inject constructor(
email: String,
password: EncryptedString,
referrer: String?
): User {
): UserId {
require(email.isNotBlank()) { "Email must not be empty." }
val modulus = authRepository.randomModulus()
password.decrypt(keyStoreCrypto).toByteArray().use { decryptedPassword ->
@ -57,7 +58,7 @@ class PerformCreateExternalEmailUser @Inject constructor(
referrer = referrer,
type = CreateUserType.Normal,
auth = auth
)
).userId
}
}
}

View File

@ -24,8 +24,8 @@ import me.proton.core.crypto.common.keystore.KeyStoreCrypto
import me.proton.core.crypto.common.keystore.decrypt
import me.proton.core.crypto.common.keystore.use
import me.proton.core.crypto.common.srp.SrpCrypto
import me.proton.core.domain.entity.UserId
import me.proton.core.user.domain.entity.CreateUserType
import me.proton.core.user.domain.entity.User
import me.proton.core.user.domain.repository.UserRepository
import javax.inject.Inject
@ -42,13 +42,14 @@ class PerformCreateUser @Inject constructor(
recoveryEmail: String?,
recoveryPhone: String?,
referrer: String?,
type: CreateUserType,
): User {
type: CreateUserType
): UserId {
require(
(recoveryEmail == null && recoveryPhone == null) ||
(recoveryEmail == null && recoveryPhone != null) ||
(recoveryEmail != null && recoveryPhone == null)
recoveryEmail == null && recoveryPhone == null ||
recoveryEmail == null && recoveryPhone != null ||
recoveryEmail != null && recoveryPhone == null
) { "Recovery Email and Phone could not be set together" }
val modulus = authRepository.randomModulus()
password.decrypt(keyStoreCrypto).toByteArray().use { decryptedPassword ->
@ -66,7 +67,7 @@ class PerformCreateUser @Inject constructor(
referrer = referrer,
type = type,
auth = auth
)
).userId
}
}
}

View File

@ -0,0 +1,282 @@
/*
* Copyright (c) 2021 Proton Technologies AG
* This file is part of Proton Technologies 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.auth.domain.usecase
import io.mockk.Called
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runBlockingTest
import me.proton.core.account.domain.entity.AccountType
import me.proton.core.auth.domain.entity.Modulus
import me.proton.core.auth.domain.repository.AuthRepository
import me.proton.core.crypto.common.keystore.EncryptedString
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
import me.proton.core.crypto.common.srp.Auth
import me.proton.core.crypto.common.srp.SrpCrypto
import me.proton.core.domain.entity.UserId
import me.proton.core.key.domain.entity.key.PrivateKey
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes.APP_VERSION_BAD
import me.proton.core.network.domain.ResponseCodes.NOT_ALLOWED
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.entity.User
import me.proton.core.user.domain.entity.UserAddress
import me.proton.core.user.domain.entity.UserKey
import me.proton.core.user.domain.repository.DomainRepository
import me.proton.core.user.domain.repository.UserAddressRepository
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class SetupPrimaryKeysTest {
private val decryptedPassword = "decrypted-password"
private val encryptedPassword: EncryptedString = "encrypted-password"
private val testUsername = "test-username"
private val testDomain = "example.com"
private val testEmail = "$testUsername@$testDomain"
private val testModulus = Modulus(modulusId = "test-id", modulus = "test-modulus")
private val testUserId = UserId("test-user-id")
private val testAuth = Auth(
version = 0,
modulusId = testModulus.modulusId,
salt = "test-salt",
verifier = "test-verifier"
)
private lateinit var userManager: UserManager
private lateinit var userAddressRepository: UserAddressRepository
private lateinit var authRepository: AuthRepository
private lateinit var domainRepository: DomainRepository
private lateinit var srpCrypto: SrpCrypto
private lateinit var keyStoreCrypto: KeyStoreCrypto
private lateinit var tested: SetupPrimaryKeys
@Before
fun setUp() {
userManager = mockk()
userAddressRepository = mockk()
authRepository = mockk()
domainRepository = mockk()
srpCrypto = mockk()
keyStoreCrypto = mockk()
tested = SetupPrimaryKeys(
userManager,
userAddressRepository,
authRepository,
domainRepository,
srpCrypto,
keyStoreCrypto
)
}
@Test
fun `primary key already exists`() = runBlockingTest {
userManager.mockGetUser(mockUser(withPrimaryPrivateKey = true))
tested.invoke(testUserId, encryptedPassword, mockk())
coVerify { userManager.setupPrimaryKeys(any(), any(), any(), any(), any()) wasNot Called }
coVerify { userAddressRepository wasNot Called }
coVerify { authRepository wasNot Called }
coVerify { domainRepository wasNot Called }
coVerify { srpCrypto wasNot Called }
coVerify { keyStoreCrypto wasNot Called }
}
@Test
fun `setup primary keys for internal account`() = runBlockingTest {
authRepository.mockRandomModulus()
domainRepository.mockGetAvailableDomains()
keyStoreCrypto.mockDecrypt()
srpCrypto.mockCalculatePasswordVerifier(testEmail)
userAddressRepository.mockCreateAddress(displayName = testUsername)
userAddressRepository.mockGetAddress()
userManager.mockGetUser(mockUser(username = testUsername))
userManager.mockSetupPrimaryKeys(testUsername)
tested.invoke(testUserId, encryptedPassword, AccountType.Internal)
coVerify(exactly = 1) { userAddressRepository.createAddress(testUserId, testUsername, testDomain) }
coVerify(exactly = 1) {
userManager.setupPrimaryKeys(
testUserId,
testUsername,
testDomain,
testAuth,
withArg { it contentEquals decryptedPassword.toByteArray() }
)
}
}
@Test
fun `setup primary keys for external account`() = runBlockingTest {
authRepository.mockRandomModulus()
domainRepository.mockGetAvailableDomains()
keyStoreCrypto.mockDecrypt()
srpCrypto.mockCalculatePasswordVerifier(testEmail)
userAddressRepository.mockCreateAddress(displayName = testUsername)
userAddressRepository.mockGetAddress()
userManager.mockGetUser(mockUser(userEmail = testEmail))
userManager.mockSetupPrimaryKeys(testUsername)
tested.invoke(testUserId, encryptedPassword, AccountType.External)
coVerify { userAddressRepository wasNot Called }
coVerify(exactly = 1) {
userManager.setupPrimaryKeys(
testUserId,
testUsername,
testDomain,
testAuth,
withArg { it contentEquals decryptedPassword.toByteArray() }
)
}
}
@Test
fun `fails to recover from error when creating address`() = runBlockingTest {
authRepository.mockRandomModulus()
domainRepository.mockGetAvailableDomains()
keyStoreCrypto.mockDecrypt()
srpCrypto.mockCalculatePasswordVerifier(testEmail)
val data = ApiResult.Error.ProtonData(NOT_ALLOWED, "User has already set up an address")
val apiException = ApiException(ApiResult.Error.Http(400, "Bad request", data))
userAddressRepository.mockCreateAddress(displayName = testUsername, withException = apiException)
userAddressRepository.mockGetAddress()
userManager.mockGetUser(mockUser(username = testUsername))
userManager.mockSetupPrimaryKeys(testUsername)
val result = assertFailsWith(ApiException::class) {
tested.invoke(testUserId, encryptedPassword, AccountType.Internal)
}
assertEquals("User has already set up an address", result.message)
}
@Test
fun `fails to recover from error when setting up keys`() = runBlockingTest {
authRepository.mockRandomModulus()
domainRepository.mockGetAvailableDomains()
keyStoreCrypto.mockDecrypt()
srpCrypto.mockCalculatePasswordVerifier(testEmail)
userAddressRepository.mockCreateAddress(displayName = testUsername)
userAddressRepository.mockGetAddress()
userManager.mockGetUser(mockUser(username = testUsername))
val data = ApiResult.Error.ProtonData(NOT_ALLOWED, "Primary key exists")
val apiException = ApiException(ApiResult.Error.Http(400, "Bad request", data))
userManager.mockSetupPrimaryKeys(testUsername, withException = apiException)
val result = assertFailsWith(ApiException::class) {
tested.invoke(testUserId, encryptedPassword, AccountType.Internal)
}
assertEquals("Primary key exists", result.message)
}
@Test
fun `rethrows other exceptions`() = runBlockingTest {
val data = ApiResult.Error.ProtonData(APP_VERSION_BAD, "Unsupported API version")
val apiException = ApiException(ApiResult.Error.Http(400, "Bad request", data))
coEvery { userManager.getUser(testUserId, any()) } throws apiException
val result = assertFailsWith<ApiException> {
tested.invoke(testUserId, encryptedPassword, mockk())
}
assertEquals("Unsupported API version", result.message)
}
//region Mock helpers
private fun mockUser(
userEmail: String? = null,
username: String? = null,
withPrimaryPrivateKey: Boolean = false
): User {
return mockk {
every { email } returns userEmail
every { name } returns username
every { keys } returns if (withPrimaryPrivateKey) {
val userPrivateKey = mockk<PrivateKey> { every { isPrimary } returns true }
val userKey = mockk<UserKey> { every { privateKey } returns userPrivateKey }
listOf(userKey)
} else emptyList()
}
}
private fun AuthRepository.mockRandomModulus() {
coEvery { randomModulus() } returns testModulus
}
private fun DomainRepository.mockGetAvailableDomains() {
coEvery { getAvailableDomains() } returns listOf(testDomain)
}
private fun KeyStoreCrypto.mockDecrypt() {
every { decrypt(encryptedPassword) } returns decryptedPassword
}
private fun SrpCrypto.mockCalculatePasswordVerifier(email: String) {
every {
calculatePasswordVerifier(
email,
any(),
testModulus.modulusId,
testModulus.modulus
)
} returns testAuth
}
private fun UserAddressRepository.mockCreateAddress(displayName: String, withException: Throwable? = null) {
coEvery { createAddress(testUserId, displayName, testDomain) } answers {
if (withException != null) {
throw withException
} else {
mockk()
}
}
}
private fun UserAddressRepository.mockGetAddress(vararg responses: List<UserAddress>) {
coEvery { getAddresses(testUserId, any()) }.apply {
if (responses.isNotEmpty()) {
returnsMany(responses.toList())
} else {
returns(emptyList())
}
}
}
private fun UserManager.mockGetUser(vararg users: User) {
coEvery { getUser(testUserId, any()) } returnsMany users.toList()
}
private fun UserManager.mockSetupPrimaryKeys(username: String, withException: Throwable? = null) {
coEvery { setupPrimaryKeys(testUserId, username, testDomain, testAuth, any()) }.apply {
if (withException != null) {
throws(withException)
} else {
returns(mockk { every { userId } returns testUserId })
}
}
}
//endregion
}

View File

@ -31,6 +31,7 @@ import me.proton.core.crypto.common.keystore.EncryptedString
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
import me.proton.core.crypto.common.srp.Auth
import me.proton.core.crypto.common.srp.SrpCrypto
import me.proton.core.network.domain.ApiException
import me.proton.core.user.domain.entity.CreateUserType
import me.proton.core.user.domain.repository.UserRepository
import org.junit.Assert.assertEquals
@ -38,6 +39,7 @@ import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertSame
class PerformCreateExternalEmailUserTest {
// region mocks
@ -50,6 +52,7 @@ class PerformCreateExternalEmailUserTest {
// region test data
private val testPassword = "test-password"
private val testEncryptedPassword = "encrypted-$testPassword"
private val testEmail = "test-email"
private val testModulus = Modulus(modulusId = "test-id", modulus = "test-modulus")
private val testAuth = Auth(
@ -66,14 +69,23 @@ class PerformCreateExternalEmailUserTest {
@Before
fun beforeEveryTest() {
// GIVEN
useCase = PerformCreateExternalEmailUser(authRepository, userRepository, srpCrypto, keyStoreCrypto)
useCase = PerformCreateExternalEmailUser(
authRepository,
userRepository,
srpCrypto,
keyStoreCrypto
)
every {
srpCrypto.calculatePasswordVerifier(testEmail, any(), any(), any())
} returns testAuth
every { keyStoreCrypto.decrypt(any<String>()) } returns testPassword
every { keyStoreCrypto.encrypt(any<String>()) } returns "encrypted-$testPassword"
every { keyStoreCrypto.encrypt(any<String>()) } returns testEncryptedPassword
coEvery { authRepository.randomModulus() } returns testModulus
coEvery {
userRepository.createExternalEmailUser(any(), any(), any(), any(), any())
} returns mockk(relaxed = true)
}
@Test
@ -125,4 +137,21 @@ class PerformCreateExternalEmailUserTest {
throwable.message
)
}
@Test
fun `user already exists`() = runBlockingTest {
val apiException = mockk<ApiException>()
coEvery {
userRepository.createExternalEmailUser(any(), any(), any(), any(), any())
} throws apiException
val result = assertFailsWith(ApiException::class) {
useCase.invoke(
testEmail,
keyStoreCrypto.encrypt(testPassword),
referrer = null
)
}
assertSame(apiException, result)
}
}

View File

@ -31,14 +31,15 @@ import me.proton.core.crypto.common.keystore.EncryptedString
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
import me.proton.core.crypto.common.srp.Auth
import me.proton.core.crypto.common.srp.SrpCrypto
import me.proton.core.network.domain.ApiException
import me.proton.core.user.domain.entity.CreateUserType
import me.proton.core.user.domain.repository.UserRepository
import org.junit.Before
import org.junit.Test
import java.lang.IllegalArgumentException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertSame
class PerformCreateUserTest {
// region mocks
@ -52,6 +53,7 @@ class PerformCreateUserTest {
// region test data
private val testUsername = "test-username"
private val testPassword = "test-password"
private val testEncryptedPassword = "encrypted-$testPassword"
private val testEmail = "test-email"
private val testPhone = "test-phone"
private val testModulus = Modulus(modulusId = "test-id", modulus = "test-modulus")
@ -74,9 +76,12 @@ class PerformCreateUserTest {
srpCrypto.calculatePasswordVerifier(testUsername, any(), any(), any())
} returns testAuth
every { keyStoreCrypto.decrypt(any<String>()) } returns testPassword
every { keyStoreCrypto.encrypt(any<String>()) } returns "encrypted-$testPassword"
every { keyStoreCrypto.encrypt(any<String>()) } returns testEncryptedPassword
coEvery { authRepository.randomModulus() } returns testModulus
coEvery {
userRepository.createUser(any(), any(), any(), any(), any(), any(), any())
} returns mockk(relaxed = true)
}
@Test
@ -219,4 +224,25 @@ class PerformCreateUserTest {
throwable.message
)
}
@Test
fun `user already exists and cannot log in`() = runBlockingTest {
val apiException = mockk<ApiException>()
coEvery {
userRepository.createUser(any(), any(), any(), any(), any(), any(), any())
} throws apiException
val result = assertFailsWith(ApiException::class) {
useCase.invoke(
testEmail,
keyStoreCrypto.encrypt(testPassword),
recoveryEmail = null,
recoveryPhone = null,
referrer = null,
type = CreateUserType.Normal
)
}
assertSame(apiException, result)
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2020 Proton Technologies AG
* Copyright (c) 2021 Proton Technologies AG
* This file is part of Proton Technologies AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
@ -16,14 +16,9 @@
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.network.data
package me.proton.core.auth.presentation
/**
* Contains general constants response codes.
*
* @author Dino Kadrikj.
*/
object ResponseCodes {
const val OK = 1000
const val NOT_EXISTS = 2501
object LogTag {
/** Tag for marking when a flow has failed with an exception, but it will be retried. */
const val FLOW_ERROR_RETRY = "core.auth.presentation.flow.retry"
}

View File

@ -31,12 +31,16 @@ import me.proton.core.auth.domain.AccountWorkflowHandler
import me.proton.core.auth.domain.usecase.SetupInternalAddress
import me.proton.core.auth.domain.usecase.SetupPrimaryKeys
import me.proton.core.auth.domain.usecase.UnlockUserPrimaryKey
import me.proton.core.auth.domain.usecase.primaryKeyExists
import me.proton.core.auth.presentation.LogTag
import me.proton.core.crypto.common.keystore.EncryptedString
import me.proton.core.domain.entity.UserId
import me.proton.core.presentation.viewmodel.ProtonViewModel
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.extension.firstInternalOrNull
import me.proton.core.usersettings.domain.usecase.SetupUsername
import me.proton.core.util.kotlin.CoreLogger
import me.proton.core.util.kotlin.retryOnceWhen
import javax.inject.Inject
@HiltViewModel
@ -90,6 +94,8 @@ class CreateAddressViewModel @Inject constructor(
}.let {
emit(it)
}
}.retryOnceWhen(Throwable::primaryKeyExists) {
CoreLogger.e(LogTag.FLOW_ERROR_RETRY, it, "Retrying to upgrade an account")
}.catch { error ->
emit(State.Error.Message(error.message))
}.onEach {

View File

@ -33,14 +33,18 @@ import kotlinx.coroutines.launch
import me.proton.core.account.domain.entity.AccountType
import me.proton.core.auth.domain.AccountWorkflowHandler
import me.proton.core.auth.domain.entity.BillingDetails
import me.proton.core.auth.domain.usecase.PostLoginAccountSetup
import me.proton.core.auth.domain.usecase.CreateLoginSession
import me.proton.core.auth.domain.usecase.PostLoginAccountSetup
import me.proton.core.auth.domain.usecase.primaryKeyExists
import me.proton.core.auth.presentation.LogTag
import me.proton.core.crypto.common.keystore.EncryptedString
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
import me.proton.core.crypto.common.keystore.encrypt
import me.proton.core.domain.entity.UserId
import me.proton.core.humanverification.domain.HumanVerificationManager
import me.proton.core.humanverification.presentation.HumanVerificationOrchestrator
import me.proton.core.util.kotlin.CoreLogger
import me.proton.core.util.kotlin.retryOnceWhen
import javax.inject.Inject
@HiltViewModel
@ -101,6 +105,8 @@ internal class LoginViewModel @Inject constructor(
val result = postLoginAccountSetup(sessionInfo, encryptedPassword, requiredAccountType, billingDetails)
emit(State.AccountSetupResult(result))
}.retryOnceWhen(Throwable::primaryKeyExists) {
CoreLogger.e(LogTag.FLOW_ERROR_RETRY, it, "Retrying login flow")
}.catch { error ->
emit(State.ErrorMessage(error.message))
}.onEach { state ->

View File

@ -42,6 +42,8 @@ import me.proton.core.auth.domain.usecase.SetupAccountCheck.Result.UserCheckErro
import me.proton.core.auth.domain.usecase.SetupInternalAddress
import me.proton.core.auth.domain.usecase.SetupPrimaryKeys
import me.proton.core.auth.domain.usecase.UnlockUserPrimaryKey
import me.proton.core.auth.domain.usecase.primaryKeyExists
import me.proton.core.auth.presentation.LogTag
import me.proton.core.crypto.common.keystore.EncryptedString
import me.proton.core.domain.entity.UserId
import me.proton.core.network.domain.ApiException
@ -49,6 +51,8 @@ import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.session.SessionProvider
import me.proton.core.presentation.viewmodel.ProtonViewModel
import me.proton.core.user.domain.UserManager
import me.proton.core.util.kotlin.CoreLogger
import me.proton.core.util.kotlin.retryOnceWhen
import javax.inject.Inject
@HiltViewModel
@ -122,6 +126,8 @@ class SecondFactorViewModel @Inject constructor(
}.let {
emit(it)
}
}.retryOnceWhen(Throwable::primaryKeyExists) {
CoreLogger.e(LogTag.FLOW_ERROR_RETRY, it, "Retrying second factor flow")
}.catch { error ->
if (error.isUnrecoverableError()) {
emit(State.Error.Unrecoverable)

View File

@ -33,8 +33,10 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import me.proton.core.account.domain.entity.AccountType
import me.proton.core.auth.domain.usecase.PerformLogin
import me.proton.core.auth.domain.usecase.signup.PerformCreateExternalEmailUser
import me.proton.core.auth.domain.usecase.signup.PerformCreateUser
import me.proton.core.auth.domain.usecase.userAlreadyExists
import me.proton.core.auth.presentation.entity.signup.RecoveryMethod
import me.proton.core.auth.presentation.entity.signup.RecoveryMethodType
import me.proton.core.auth.presentation.entity.signup.SubscriptionDetails
@ -45,8 +47,6 @@ import me.proton.core.crypto.common.keystore.encrypt
import me.proton.core.humanverification.domain.HumanVerificationManager
import me.proton.core.humanverification.presentation.HumanVerificationOrchestrator
import me.proton.core.humanverification.presentation.onHumanVerificationFailed
import me.proton.core.humanverification.presentation.onHumanVerificationNeeded
import me.proton.core.humanverification.presentation.onHumanVerificationSucceeded
import me.proton.core.network.domain.client.ClientIdProvider
import me.proton.core.payment.domain.entity.SubscriptionCycle
import me.proton.core.payment.presentation.PaymentsOrchestrator
@ -55,6 +55,7 @@ import me.proton.core.plan.presentation.PlansOrchestrator
import me.proton.core.presentation.savedstate.flowState
import me.proton.core.presentation.savedstate.state
import me.proton.core.user.domain.entity.createUserType
import me.proton.core.util.kotlin.catchWhen
import me.proton.core.util.kotlin.exhaustive
import javax.inject.Inject
@ -67,6 +68,7 @@ internal class SignupViewModel @Inject constructor(
private val paymentsOrchestrator: PaymentsOrchestrator,
private val clientIdProvider: ClientIdProvider,
private val humanVerificationManager: HumanVerificationManager,
private val performLogin: PerformLogin,
humanVerificationOrchestrator: HumanVerificationOrchestrator,
savedStateHandle: SavedStateHandle
) : AuthViewModel(humanVerificationManager, humanVerificationOrchestrator) {
@ -162,12 +164,21 @@ internal class SignupViewModel @Inject constructor(
* previously set [AccountType].
* @see currentAccountType public property
*/
fun startCreateUserWorkflow() = viewModelScope.launch {
fun startCreateUserWorkflow() {
_userCreationState.tryEmit(State.Idle)
val password by lazy { requireNotNull(_password) { "Password is not set (initialized)." } }
when (currentAccountType) {
AccountType.Username,
AccountType.Internal -> createUser()
AccountType.External -> createExternalUser()
AccountType.Internal -> {
val username = requireNotNull(username) { "Username is not set." }
createUser(username, password)
}
AccountType.External -> {
val email = requireNotNull(externalEmail) { "External email is not set." }
createExternalUser(email, password)
}
}.exhaustive
}
@ -207,49 +218,55 @@ internal class SignupViewModel @Inject constructor(
// endregion
// region private functions
private suspend fun createUser() = flow {
val username = requireNotNull(username) { "Username is not set." }
val encryptedPassword = requireNotNull(_password) { "Password is not set (initialized)." }
emit(State.Processing)
private fun createUser(username: String, encryptedPassword: EncryptedString) {
flow {
emit(State.Processing)
val verification = _recoveryMethod?.let {
val email = if (it.type == RecoveryMethodType.EMAIL) {
it.destination
} else null
val phone = if (it.type == RecoveryMethodType.SMS) {
it.destination
} else null
Pair(email, phone)
} ?: run {
Pair(null, null)
}
val verification = _recoveryMethod?.let {
val email = if (it.type == RecoveryMethodType.EMAIL) {
it.destination
} else null
val phone = if (it.type == RecoveryMethodType.SMS) {
it.destination
} else null
Pair(email, phone)
} ?: run {
Pair(null, null)
}
val result = performCreateUser(
username = username, password = encryptedPassword, recoveryEmail = verification.first,
recoveryPhone = verification.second, referrer = null, type = currentAccountType.createUserType()
)
emit(State.Success(result.userId.id, username, encryptedPassword))
}.catch { error ->
emit(State.Error.Message(error.message))
}.onEach {
_userCreationState.tryEmit(it)
}.launchIn(viewModelScope)
val result = performCreateUser(
username = username, password = encryptedPassword, recoveryEmail = verification.first,
recoveryPhone = verification.second, referrer = null, type = currentAccountType.createUserType()
)
emit(State.Success(result.id, username, encryptedPassword))
}.catchWhen(Throwable::userAlreadyExists) {
val userId = performLogin.invoke(username, encryptedPassword).userId
emit(State.Success(userId.id, username, encryptedPassword))
}.catch { error ->
emit(State.Error.Message(error.message))
}.onEach {
_userCreationState.tryEmit(it)
}.launchIn(viewModelScope)
}
private suspend fun createExternalUser() = flow {
val externalEmail = requireNotNull(externalEmail) { "External email is not set." }
val encryptedPassword = requireNotNull(_password) { "Password is not set (initialized)." }
emit(State.Processing)
val user = performCreateExternalEmailUser(
email = externalEmail,
password = encryptedPassword,
referrer = null
)
emit(State.Success(user.userId.id, externalEmail, encryptedPassword))
}.catch { error ->
emit(State.Error.Message(error.message))
}.onEach {
_userCreationState.tryEmit(it)
}.launchIn(viewModelScope)
private fun createExternalUser(externalEmail: String, encryptedPassword: EncryptedString) {
flow {
emit(State.Processing)
val userId = performCreateExternalEmailUser(
email = externalEmail,
password = encryptedPassword,
referrer = null
)
emit(State.Success(userId.id, externalEmail, encryptedPassword))
}.catchWhen(Throwable::userAlreadyExists) {
val userId = performLogin.invoke(externalEmail, encryptedPassword).userId
emit(State.Success(userId.id, externalEmail, encryptedPassword))
}.catch { error ->
emit(State.Error.Message(error.message))
}.onEach {
_userCreationState.tryEmit(it)
}.launchIn(viewModelScope)
}
private fun onUserCreationStateRestored(state: State) {
if (state == State.Processing) {

View File

@ -21,6 +21,7 @@ package me.proton.core.auth.presentation.viewmodel
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -35,6 +36,7 @@ import me.proton.core.humanverification.domain.HumanVerificationManager
import me.proton.core.humanverification.presentation.HumanVerificationOrchestrator
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes
import me.proton.core.test.android.ArchTest
import me.proton.core.test.kotlin.CoroutinesTest
import me.proton.core.test.kotlin.assertIs
@ -214,6 +216,39 @@ class LoginViewModelTest : ArchTest, CoroutinesTest {
}
}
@Test
fun `login is retried on Primary Key Exists error`() = coroutinesTest {
val sessionInfo = mockSessionInfo()
coEvery { createLoginSession.invoke(any(), any(), any()) } returns sessionInfo
coEvery {
postLoginAccountSetup.invoke(any(), any(), any(), any())
} throws ApiException(
ApiResult.Error.Http(
400,
"Bad request",
ApiResult.Error.ProtonData(ResponseCodes.NOT_ALLOWED, "Primary key exists")
)
)
viewModel.state.test {
// WHEN
viewModel.startLoginWorkflow(testUserName, testPassword, mockk())
// THEN
assertIs<LoginViewModel.State.Processing>(awaitItem())
assertIs<LoginViewModel.State.Processing>(awaitItem()) // retried
val errorState = awaitItem()
assertTrue(errorState is LoginViewModel.State.ErrorMessage)
assertEquals("Primary key exists", errorState.message)
cancelAndIgnoreRemainingEvents()
}
coVerify(exactly = 2) { createLoginSession.invoke(testUserName, testPassword, any()) }
coVerify(exactly = 2) { postLoginAccountSetup.invoke(any(), testPassword, any(), any()) }
}
private fun mockSessionInfo() = mockk<SessionInfo> {
every { userId } returns testUserId
}

View File

@ -24,6 +24,7 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import me.proton.core.account.domain.entity.AccountType
import me.proton.core.auth.domain.usecase.PerformLogin
import me.proton.core.auth.domain.usecase.signup.PerformCreateExternalEmailUser
import me.proton.core.auth.domain.usecase.signup.PerformCreateUser
import me.proton.core.auth.presentation.entity.signup.RecoveryMethod
@ -34,6 +35,7 @@ import me.proton.core.humanverification.domain.HumanVerificationManager
import me.proton.core.humanverification.presentation.HumanVerificationOrchestrator
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes
import me.proton.core.network.domain.client.ClientIdProvider
import me.proton.core.payment.presentation.PaymentsOrchestrator
import me.proton.core.plan.presentation.PlansOrchestrator
@ -44,6 +46,7 @@ import me.proton.core.user.domain.entity.User
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertTrue
class SignupViewModelTest : ArchTest, CoroutinesTest {
@ -57,6 +60,7 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
private val plansOrchestrator = mockk<PlansOrchestrator>(relaxed = true)
private val paymentsOrchestrator = mockk<PaymentsOrchestrator>(relaxed = true)
private val clientIdProvider = mockk<ClientIdProvider>(relaxed = true)
private val performLogin = mockk<PerformLogin>()
// endregion
@ -84,6 +88,15 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
keys = emptyList()
)
private val usernameTakenError: ApiException
get() = ApiException(
ApiResult.Error.Http(
409,
"Conflict",
ApiResult.Error.ProtonData(ResponseCodes.USER_CREATE_NAME_INVALID, "Username taken")
)
)
// endregion
private lateinit var viewModel: SignupViewModel
@ -98,6 +111,7 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
paymentsOrchestrator,
clientIdProvider,
humanVerificationManager,
performLogin,
humanVerificationOrchestrator,
mockk(relaxed = true)
)
@ -113,7 +127,7 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
referrer = null,
type = any()
)
} returns testUser.copy(name = testUsername)
} returns testUser.userId
coEvery {
performCreateExternalUser.invoke(
@ -121,19 +135,15 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
password = any(),
referrer = null
)
} returns testUser.copy(name = testUsername)
} returns testUser.userId
}
@Test
fun `create Internal user no username no password set`() = coroutinesTest {
viewModel.userCreationState.test {
// WHEN
viewModel.startCreateUserWorkflow()
// THEN
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
assertTrue(awaitItem() is SignupViewModel.State.Idle)
val errorItem = awaitItem()
assertTrue(errorItem is SignupViewModel.State.Error.Message)
assertEquals("Username is not set.", errorItem.message)
assertEquals("Username is not set.", throwable.message)
coVerify(exactly = 0) {
performCreateUser(
@ -154,13 +164,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
viewModel.setPassword(testPassword)
viewModel.userCreationState.test {
// WHEN
viewModel.startCreateUserWorkflow()
// THEN
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
assertTrue(awaitItem() is SignupViewModel.State.Idle)
val errorItem = awaitItem()
assertTrue(errorItem is SignupViewModel.State.Error.Message)
assertEquals("Username is not set.", errorItem.message)
assertEquals("Username is not set.", throwable.message)
coVerify(exactly = 0) {
performCreateUser(
@ -180,13 +186,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
// GIVEN
viewModel.username = testUsername
viewModel.userCreationState.test {
// WHEN
viewModel.startCreateUserWorkflow()
// THEN
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
assertTrue(awaitItem() is SignupViewModel.State.Idle)
val errorItem = awaitItem()
assertTrue(errorItem is SignupViewModel.State.Error.Message)
assertEquals("Password is not set (initialized).", errorItem.message)
assertEquals("Password is not set (initialized).", throwable.message)
coVerify(exactly = 0) {
performCreateUser(
@ -342,13 +344,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
// GIVEN
viewModel.currentAccountType = AccountType.External
viewModel.userCreationState.test {
// WHEN
viewModel.startCreateUserWorkflow()
// THEN
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
assertTrue(awaitItem() is SignupViewModel.State.Idle)
val errorItem = awaitItem()
assertTrue(errorItem is SignupViewModel.State.Error.Message)
assertEquals("External email is not set.", errorItem.message)
assertEquals("External email is not set.", throwable.message)
coVerify(exactly = 0) {
performCreateExternalUser(
@ -366,13 +364,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
viewModel.currentAccountType = AccountType.External
viewModel.setPassword(testPassword)
viewModel.userCreationState.test {
// WHEN
viewModel.startCreateUserWorkflow()
// THEN
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
assertTrue(awaitItem() is SignupViewModel.State.Idle)
val errorItem = awaitItem()
assertTrue(errorItem is SignupViewModel.State.Error.Message)
assertEquals("External email is not set.", errorItem.message)
assertEquals("External email is not set.", throwable.message)
coVerify(exactly = 0) {
performCreateExternalUser(
@ -390,13 +384,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
viewModel.currentAccountType = AccountType.External
viewModel.externalEmail = testEmail
viewModel.userCreationState.test {
// WHEN
viewModel.startCreateUserWorkflow()
// THEN
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
assertTrue(awaitItem() is SignupViewModel.State.Idle)
val errorItem = awaitItem()
assertTrue(errorItem is SignupViewModel.State.Error.Message)
assertEquals("Password is not set (initialized).", errorItem.message)
assertEquals("Password is not set (initialized).", throwable.message)
coVerify(exactly = 0) {
performCreateExternalUser(
@ -476,4 +466,100 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
}
}
}
@Test
fun `tries login if internal username taken`() = coroutinesTest {
coEvery {
performCreateUser.invoke(
username = testUsername,
password = any(),
recoveryEmail = any(),
recoveryPhone = any(),
referrer = null,
type = any()
)
} throws usernameTakenError
coEvery { performLogin.invoke(testUsername, any()) } returns mockk {
every { userId } returns testUser.userId
}
// GIVEN
viewModel.username = testUsername
viewModel.setPassword(testPassword)
viewModel.userCreationState.test {
// WHEN
viewModel.startCreateUserWorkflow()
// THEN
assertTrue(awaitItem() is SignupViewModel.State.Idle)
assertTrue(awaitItem() is SignupViewModel.State.Processing)
val successItem = awaitItem()
assertTrue(successItem is SignupViewModel.State.Success)
assertEquals(testUser.userId.id, successItem.userId)
coVerify(exactly = 1) {
performCreateUser(
username = testUsername,
password = "encrypted-$testPassword",
recoveryEmail = null,
recoveryPhone = null,
referrer = null,
type = CreateUserType.Normal
)
}
coVerify(exactly = 1) {
performLogin(
username = testUsername,
password = "encrypted-$testPassword"
)
}
}
}
@Test
fun `tries login if External username taken`() = coroutinesTest {
coEvery {
performCreateExternalUser.invoke(
email = testEmail,
password = any(),
referrer = null
)
} throws usernameTakenError
coEvery { performLogin.invoke(testEmail, any()) } returns mockk {
every { userId } returns testUser.userId
}
// GIVEN
viewModel.currentAccountType = AccountType.External
viewModel.externalEmail = testEmail
viewModel.setPassword(testPassword)
viewModel.userCreationState.test {
// WHEN
viewModel.startCreateUserWorkflow()
// THEN
assertTrue(awaitItem() is SignupViewModel.State.Idle)
assertTrue(awaitItem() is SignupViewModel.State.Processing)
val successItem = awaitItem()
assertTrue(successItem is SignupViewModel.State.Success)
assertEquals(testUser.userId.id, successItem.userId)
coVerify(exactly = 1) {
performCreateExternalUser(
email = testEmail,
password = "encrypted-$testPassword",
referrer = null
)
}
coVerify(exactly = 1) {
performLogin(
username = testEmail,
password = "encrypted-$testPassword"
)
}
}
}
}

View File

@ -23,7 +23,7 @@ plugins {
kotlin("android")
}
libVersion = Version(1, 19, 1)
libVersion = Version(1, 19, 2)
android()

View File

@ -32,7 +32,7 @@ import me.proton.core.contact.domain.repository.ContactRemoteDataSource
import me.proton.core.domain.entity.UserId
import me.proton.core.network.data.ApiProvider
import me.proton.core.network.data.ProtonErrorException
import me.proton.core.network.data.ResponseCodes
import me.proton.core.network.domain.ResponseCodes
import me.proton.core.network.domain.onError
import me.proton.core.network.domain.onSuccess
import javax.inject.Inject

View File

@ -23,7 +23,7 @@ plugins {
kotlin("android")
}
libVersion = Version(1, 15, 7)
libVersion = Version(1, 15, 8)
android()

View File

@ -98,6 +98,9 @@ class ApiManagerFactory(
@VisibleForTesting
val baseOkHttpClient by lazy {
require(apiClient.timeoutSeconds >= ApiClient.MIN_TIMEOUT_SECONDS) {
"Minimum timeout for ApiClient is ${ApiClient.MIN_TIMEOUT_SECONDS} seconds."
}
val builder = OkHttpClient.Builder()
.cache(cache())
.connectTimeout(apiClient.timeoutSeconds, TimeUnit.SECONDS)

View File

@ -20,6 +20,7 @@ package me.proton.core.network.data.mapper
import me.proton.core.network.data.protonApi.Details
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes
import me.proton.core.network.domain.handlers.HumanVerificationNeededHandler
import me.proton.core.network.domain.humanverification.HumanVerificationAvailableMethods
import me.proton.core.network.domain.humanverification.VerificationMethod
@ -43,7 +44,7 @@ fun Details.toHumanVerificationEntity(): HumanVerificationAvailableMethods =
fun ApiResult.Error.ProtonData.parseDetails(errorCode: Int, details: Details?): ApiResult.Error.ProtonData {
when (errorCode) {
HumanVerificationNeededHandler.ERROR_CODE_HUMAN_VERIFICATION -> {
ResponseCodes.HUMAN_VERIFICATION_REQUIRED -> {
humanVerification = details?.toHumanVerificationEntity()
}
}

View File

@ -20,7 +20,7 @@ package me.proton.core.network.data.protonApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.proton.core.network.data.ResponseCodes
import me.proton.core.network.domain.ResponseCodes
@Serializable
data class GenericResponse(

View File

@ -45,6 +45,7 @@ import me.proton.core.network.domain.DohProvider
import me.proton.core.network.domain.DohService
import me.proton.core.network.domain.NetworkPrefs
import me.proton.core.network.domain.NetworkStatus
import me.proton.core.network.domain.ResponseCodes
import me.proton.core.network.domain.handlers.ProtonForceUpdateHandler
import me.proton.core.network.domain.handlers.RefreshTokenHandler
import me.proton.core.network.domain.client.ClientId
@ -297,7 +298,7 @@ internal class ApiManagerTests {
coEvery { backend.invoke<TestResult>(any()) } returns
ApiResult.Error.Http(
400, "",
ApiResult.Error.ProtonData(ProtonForceUpdateHandler.ERROR_CODE_FORCE_UPDATE_APP_TOO_OLD, "")
ApiResult.Error.ProtonData(ResponseCodes.APP_VERSION_BAD, "")
)
val result = apiManager.invoke { test() }
assertTrue(result is ApiResult.Error)
@ -309,7 +310,7 @@ internal class ApiManagerTests {
coEvery { backend.invoke<TestResult>(any()) } returns
ApiResult.Error.Http(
400, "",
ApiResult.Error.ProtonData(ProtonForceUpdateHandler.ERROR_CODE_FORCE_UPDATE_API_TOO_OLD, "")
ApiResult.Error.ProtonData(ResponseCodes.API_VERSION_INVALID, "")
)
val result = apiManager.invoke { test() }
assertTrue(result is ApiResult.Error)

View File

@ -42,9 +42,10 @@ interface ApiClient {
/**
* Timeout for internal api call attempt (due to error handling logic there might be internal
* calls in a single API call by the client.
* calls in a single API call by the client).
* The returned value must be greater than or equal to [MIN_TIMEOUT_SECONDS].
*/
val timeoutSeconds: Long get() = 10L
val timeoutSeconds: Long get() = MIN_TIMEOUT_SECONDS
/**
* Global timeout for DoH logic.
@ -88,4 +89,8 @@ interface ApiClient {
* @param errorMessage the localized error message the user should see.
*/
fun forceUpdate(errorMessage: String)
companion object {
const val MIN_TIMEOUT_SECONDS = 30L
}
}

View File

@ -0,0 +1,36 @@
/*
* Copyright (c) 2021 Proton Technologies AG
* This file is part of Proton Technologies 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.network.domain
/**
* Contains general constants response codes.
*
* @author Dino Kadrikj.
*/
object ResponseCodes {
const val OK = 1000
const val NOT_ALLOWED = 2011
const val NOT_EXISTS = 2501
const val APP_VERSION_BAD = 5003
const val API_VERSION_INVALID = 5005
const val HUMAN_VERIFICATION_REQUIRED = 9001
const val USER_CREATE_NAME_INVALID = 12_081
const val USER_CREATE_TOKEN_INVALID = 12_087
const val PAYMENTS_SUBSCRIPTION_NOT_EXISTS = 22_110
}

View File

@ -22,6 +22,7 @@ import me.proton.core.network.domain.ApiBackend
import me.proton.core.network.domain.ApiErrorHandler
import me.proton.core.network.domain.ApiManager
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes
import me.proton.core.network.domain.client.ClientIdProvider
import me.proton.core.network.domain.humanverification.HumanVerificationListener
import me.proton.core.network.domain.session.SessionId
@ -44,15 +45,11 @@ class HumanVerificationInvalidHandler<Api>(
val clientId = clientIdProvider.getClientId(sessionId) ?: return error
// Invalid verification code ?
if (error is ApiResult.Error.Http && error.proton?.code == ERROR_CODE_HUMAN_VERIFICATION_INVALID_CODE) {
if (error is ApiResult.Error.Http && error.proton?.code == ResponseCodes.USER_CREATE_TOKEN_INVALID) {
humanVerificationListener.onHumanVerificationInvalid(clientId)
// Directly retry (could raise 9001, and then be handled by HumanVerificationNeededHandler).
return backend(call)
}
return error
}
companion object {
const val ERROR_CODE_HUMAN_VERIFICATION_INVALID_CODE = 12087
}
}

View File

@ -24,6 +24,7 @@ import me.proton.core.network.domain.ApiBackend
import me.proton.core.network.domain.ApiErrorHandler
import me.proton.core.network.domain.ApiManager
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes.HUMAN_VERIFICATION_REQUIRED
import me.proton.core.network.domain.client.ClientIdProvider
import me.proton.core.network.domain.client.ClientId
import me.proton.core.network.domain.humanverification.HumanVerificationAvailableMethods
@ -52,7 +53,7 @@ class HumanVerificationNeededHandler<Api>(
val clientId = clientIdProvider.getClientId(sessionId) ?: return error
// Recoverable with human verification ?
if (error !is ApiResult.Error.Http || error.proton?.code != ERROR_CODE_HUMAN_VERIFICATION) return error
if (error !is ApiResult.Error.Http || error.proton?.code != HUMAN_VERIFICATION_REQUIRED) return error
// Do we have details ?
val details = error.proton.humanVerification ?: return error
@ -78,8 +79,6 @@ class HumanVerificationNeededHandler<Api>(
}
companion object {
const val ERROR_CODE_HUMAN_VERIFICATION = 9001
private val verificationDebounceMs = TimeUnit.SECONDS.toMillis(5)
private val staticMutex: Mutex = Mutex()
private val clientMutexMap: MutableMap<ClientId, Mutex> = HashMap()

View File

@ -22,6 +22,7 @@ import me.proton.core.network.domain.ApiClient
import me.proton.core.network.domain.ApiErrorHandler
import me.proton.core.network.domain.ApiManager
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes
/**
* Handles force update response.
@ -44,11 +45,9 @@ class ProtonForceUpdateHandler<Api>(private val apiClient: ApiClient) :
}
companion object {
const val ERROR_CODE_FORCE_UPDATE_APP_TOO_OLD = 5003
const val ERROR_CODE_FORCE_UPDATE_API_TOO_OLD = 5005
private val ERROR_CODE_FORCE_UPDATE = listOf(
ERROR_CODE_FORCE_UPDATE_APP_TOO_OLD,
ERROR_CODE_FORCE_UPDATE_API_TOO_OLD
ResponseCodes.APP_VERSION_BAD,
ResponseCodes.API_VERSION_INVALID
)
}
}

View File

@ -23,7 +23,7 @@ plugins {
kotlin("android")
}
libVersion = Version(1, 17, 3)
libVersion = Version(1, 17, 4)
android()

View File

@ -21,9 +21,9 @@ package me.proton.core.payment.domain.usecase
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.network.domain.ResponseCodes.PAYMENTS_SUBSCRIPTION_NOT_EXISTS
import me.proton.core.payment.domain.entity.Subscription
import me.proton.core.payment.domain.repository.PaymentsRepository
import me.proton.core.util.kotlin.exhaustive
import javax.inject.Inject
/**
@ -40,15 +40,11 @@ class GetCurrentSubscription @Inject constructor(
paymentsRepository.getSubscription(userId)
} catch (exception: ApiException) {
val error = exception.error
if (error is ApiResult.Error.Http && error.proton?.code == NO_ACTIVE_SUBSCRIPTION) {
if (error is ApiResult.Error.Http && error.proton?.code == PAYMENTS_SUBSCRIPTION_NOT_EXISTS) {
return null
} else {
throw exception
}
}
}
companion object {
const val NO_ACTIVE_SUBSCRIPTION = 22110
}
}

View File

@ -24,7 +24,7 @@ import kotlinx.coroutines.test.runBlockingTest
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.network.domain.session.SessionId
import me.proton.core.network.domain.ResponseCodes
import me.proton.core.payment.domain.entity.Subscription
import me.proton.core.payment.domain.repository.PaymentsRepository
import org.junit.Before
@ -99,7 +99,7 @@ class GetCurrentSubscriptionTest {
httpCode = 123,
"http error",
ApiResult.Error.ProtonData(
code = GetCurrentSubscription.NO_ACTIVE_SUBSCRIPTION,
code = ResponseCodes.PAYMENTS_SUBSCRIPTION_NOT_EXISTS,
error = "no active subscription"
)
)

View File

@ -24,7 +24,7 @@ plugins {
kotlin("plugin.serialization")
}
libVersion = Version(1, 15, 2)
libVersion = Version(1, 15, 3)
dependencies {

View File

@ -0,0 +1,57 @@
/*
* Copyright (c) 2021 Proton Technologies AG
* This file is part of Proton Technologies 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
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.retryWhen
/**
* Catches an error that matches a given [predicate], and performs an [action] if such an error is caught.
* If an error from upstream doesn't match the [predicate], it's passed downstream.
*/
inline fun <T> Flow<T>.catchWhen(
crossinline predicate: suspend (Throwable) -> Boolean,
crossinline action: suspend FlowCollector<T>.() -> Unit
): Flow<T> {
return catch { error ->
if (predicate(error)) {
action()
} else {
throw error
}
}
}
/**
* Retries the collection of the flow, in case an exception occurred in upstream flow, and [predicate] returns `true`.
* If the flow will be retried, [onBeforeRetryAction] will be called before, with a [Throwable] that caused the retry.
* @see retryWhen
*/
inline fun <T> Flow<T>.retryOnceWhen(
crossinline predicate: suspend (Throwable) -> Boolean,
crossinline onBeforeRetryAction: suspend (cause: Throwable) -> Unit
): Flow<T> {
return retryWhen { cause, attempt ->
val willRetry = predicate(cause) && attempt < 1
if (willRetry) onBeforeRetryAction.invoke(cause)
willRetry
}
}

View File

@ -0,0 +1,104 @@
/*
* Copyright (c) 2021 Proton Technologies AG
* This file is part of Proton Technologies 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
import app.cash.turbine.test
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Assert.assertEquals
import org.junit.Test
class FlowUtilsTest {
@Test
fun `catches known error`() = runBlockingTest {
flow {
emit(1)
throw TestError
}.catchWhen({ it is TestError }) {
emit(2)
}.test {
assertEquals(1, awaitItem())
assertEquals(2, awaitItem())
awaitComplete()
}
}
@Test
fun `does not catch unknown error`() = runBlockingTest {
flow {
emit(1)
error("random error")
}.catchWhen({ it is TestError }) {
emit(2)
}.test {
assertEquals(1, awaitItem())
assertEquals("random error", awaitError().message)
}
}
@Test
fun `re-catches unknown error`() = runBlockingTest {
flow {
emit(1)
error("random error")
}.catchWhen({ it is TestError }) {
emit(2)
}.catch {
emit(3)
}.test {
assertEquals(1, awaitItem())
assertEquals(3, awaitItem())
awaitComplete()
}
}
@Test
fun `retries after detecting known error`() = runBlockingTest {
var retryCount = 0
flow {
emit(1)
throw TestError
}.retryOnceWhen({ it is TestError }) {
retryCount += 1
}.test {
assertEquals(1, awaitItem())
assertEquals(1, awaitItem())
assertEquals(TestError, awaitError())
}
assertEquals(1, retryCount)
}
@Test
fun `does not retry after encountering unknown error`() = runBlockingTest {
var retryCount = 0
flow {
emit(1)
error("random error")
}.retryOnceWhen({ it is TestError }) {
retryCount += 1
}.test {
assertEquals(1, awaitItem())
assertEquals("random error", awaitError().message)
}
assertEquals(0, retryCount)
}
private object TestError : Throwable()
}