Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ class RealSyncCodeDispatcher @Inject constructor(
* or null for intermediate events the caller should ignore. Both Host and Joiner roles are
* reachable here; mirrors [mapV2LinkingEventToOutcome] (login on Joiner.Done, preserve abort reason).
*/
private fun mapV2PresentEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
private suspend fun mapV2PresentEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
is ExchangeV2Event.SessionStarted -> event.linkingCode?.let { DispatchOutcome.LinkingCodeReady(it) }
is ExchangeV2Event.Transition -> when (event.to) {
ExchangeV2State.Joiner.Confirming ->
Expand Down Expand Up @@ -321,7 +321,7 @@ class RealSyncCodeDispatcher @Inject constructor(
* Translate one runner event into a terminal [DispatchOutcome] for the v2 scanner flow,
* or null for intermediate events the caller should ignore.
*/
private fun mapV2LinkingEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
private suspend fun mapV2LinkingEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
is ExchangeV2Event.Transition -> when (event.to) {
ExchangeV2State.Joiner.Confirming ->
DispatchOutcome.JoinerConfirmationRequested(peerName = runner.peerName, peerKind = peerKind)
Expand Down Expand Up @@ -376,7 +376,7 @@ class RealSyncCodeDispatcher @Inject constructor(
* cid=ddg logs in directly; cid=3party upgrades the account via
* [SyncAccountRepository.joinAccountFromThirdPartyRecoveryCode].
*/
private fun loginWithV2RecoveryCode(b64: String, peerKind: PeerKind?): DispatchOutcome {
private suspend fun loginWithV2RecoveryCode(b64: String, peerKind: PeerKind?): DispatchOutcome {
val parsed = decodeV2Recovery(b64) ?: return DispatchOutcome.Failed("Couldn't parse received recovery code as v2.0")
return when (parsed.cid) {
CID_DDG -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import com.squareup.anvil.annotations.*
import com.squareup.moshi.*
import dagger.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import logcat.LogPriority.ERROR
Expand Down Expand Up @@ -96,7 +97,7 @@ interface SyncAccountRepository {
* adopted locally; otherwise a new one is created on the server. Either path leaves the
* local store ready for [getThirdPartyRecoveryCode].
*/
fun createThirdPartyCredential(): Result<Boolean>
suspend fun createThirdPartyCredential(): Result<Boolean>

/**
* Fetches the 3party credential from the server, decrypts the SP using the account's secretKey,
Expand Down Expand Up @@ -127,7 +128,7 @@ interface SyncAccountRepository {
* populated, credentialId=ddg, scopedPassword populated with SP. SyncStore is written only
* after all three network calls succeed; observers never see an intermediate state.
*/
fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean>
suspend fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean>

/**
* Returns a recovery code that a 3rd-party browser can use to sign in and access this
Expand Down Expand Up @@ -625,7 +626,7 @@ class AppSyncAccountRepository @Inject constructor(
}
}

override fun createThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.create()
override suspend fun createThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.create()

override fun refreshThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.refresh()

Expand Down Expand Up @@ -758,7 +759,7 @@ class AppSyncAccountRepository @Inject constructor(
)
}

override fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean> {
override suspend fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean> {
if (!syncFeature.canUseV2ConnectFlow().isEnabled()) {
return Error(reason = "JoinFrom3party: canUseV2ConnectFlow is disabled")
}
Expand Down Expand Up @@ -1338,15 +1339,15 @@ class AppSyncAccountRepository @Inject constructor(
return this
}

private fun <T> retryingOnTransientError(block: () -> Result<T>): Result<T> {
private suspend fun <T> retryingOnTransientError(block: () -> Result<T>): Result<T> {
var attempt = 0
while (true) {
val result = block()
val code = (result as? Error)?.code
if (code != null && isRetryableTransient(code) && attempt < MAX_UPGRADE_RETRIES) {
attempt++
logcat { "Sync-ScopedToken: upgrade call transient error (code=$code); retry $attempt/$MAX_UPGRADE_RETRIES" }
runCatching { Thread.sleep(upgradeRetryDelayMillis * attempt) }
delay(upgradeRetryDelayMillis * attempt)
continue
}
return result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.duckduckgo.sync.store.SyncStore
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.Moshi
import dagger.SingleInstanceIn
import kotlinx.coroutines.delay
import logcat.LogPriority.ERROR
import logcat.logcat
import javax.inject.Inject
Expand All @@ -46,7 +47,7 @@ interface ThirdPartyCredentialManager {
* already created it, the existing credential is adopted locally; otherwise a new one is created
* on the server. Either path leaves [SyncStore.scopedPassword] populated.
*/
fun create(): Result<Boolean>
suspend fun create(): Result<Boolean>

/**
* Fetches the 3party credential from the server, decrypts its SP using the account's primary
Expand Down Expand Up @@ -78,7 +79,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
@VisibleForTesting
internal var rateLimitRetryDelayMillis: Long = RATE_LIMIT_RETRY_DELAY_MILLIS

override fun create(): Result<Boolean> {
override suspend fun create(): Result<Boolean> {
val inputs = when (val r = validateCreatePreconditions()) {
is Success -> r.data
is Error -> return r
Expand Down Expand Up @@ -150,7 +151,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
return AdoptResult.Adopted
}

private fun mintAndPostNewCredential(inputs: CreateInputs): Result<Boolean> {
private suspend fun mintAndPostNewCredential(inputs: CreateInputs): Result<Boolean> {
logcat { "Sync-ScopedToken: generating new 3party credential" }

// Re-auth against the existing ddg credential's twice_hashed_password.
Expand Down Expand Up @@ -214,7 +215,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
* the credential POST means the server writes them atomically, removing the need for a
* separate `POST /sync/keys/.../set-if-absent` call (and the race window that opened).
*/
private fun buildKeysForNewThirdPartyCredential(
private suspend fun buildKeysForNewThirdPartyCredential(
token: String,
newSpBase64: String,
hkdfSalt: ByteArray,
Expand Down Expand Up @@ -317,7 +318,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
)
}

private fun postCreateAccessCredential(
private suspend fun postCreateAccessCredential(
inputs: CreateInputs,
request: CreateAccessCredentialRequest,
newSpBase64: String,
Expand Down Expand Up @@ -359,7 +360,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
* Other 409 variants (e.g. `credential_already_exists`) are NOT retried — they're returned
* to the caller, which handles them via the adopt path.
*/
private fun postWithConcurrentModificationRetry(
private suspend fun postWithConcurrentModificationRetry(
token: String,
request: CreateAccessCredentialRequest,
): Result<Boolean> {
Expand All @@ -372,7 +373,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
if (isConcurrentMod && attempt < MAX_CONCURRENT_MODIFICATION_RETRIES) {
attempt++
logcat { "Sync-ScopedToken: 3party credential POST hit concurrent_modification; retry $attempt/$MAX_CONCURRENT_MODIFICATION_RETRIES" }
runCatching { Thread.sleep(CONCURRENT_MODIFICATION_RETRY_DELAY_MILLIS * attempt) }
delay(CONCURRENT_MODIFICATION_RETRY_DELAY_MILLIS * attempt)
continue
}
return result
Expand Down Expand Up @@ -476,7 +477,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
* Retry an idempotent scoped-credential call on rate-limit.
* A bounded linear backoff recovers since the limit is per-window and the call is idempotent.
*/
private fun <T> retryingOnRateLimit(block: () -> Result<T>): Result<T> {
private suspend fun <T> retryingOnRateLimit(block: () -> Result<T>): Result<T> {
var attempt = 0
while (true) {
val result = block()
Expand All @@ -485,7 +486,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
if (rateLimited && attempt < MAX_RATE_LIMIT_RETRIES) {
attempt++
logcat { "Sync-ScopedToken: rate-limited (code=$code); retry $attempt/$MAX_RATE_LIMIT_RETRIES" }
runCatching { Thread.sleep(rateLimitRetryDelayMillis * attempt) }
delay(rateLimitRetryDelayMillis * attempt)
continue
}
return result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,8 +433,9 @@ class RealExchangeV2Runner @Inject constructor(
* (or `recovery_code_unavailable` if it can't be produced), then advance the SM.
*/
private fun shareRecoveryCodeAndAdvanceLocked() {
val responseOk = sendRecoveryCodeResponse()
appScope.launch(dispatchers.io()) {
if (session == null) return@launch
val responseOk = sendRecoveryCodeResponse()
mutex.withLock {
val s = session ?: return@withLock
if (s.currentState != ExchangeV2State.Host.Sending) return@withLock
Expand Down Expand Up @@ -657,7 +658,7 @@ class RealExchangeV2Runner @Inject constructor(
* `recovery_code_unavailable` to the peer + emitted a SessionError event, and the SM
* should be driven to [ExchangeV2State.Host.Aborted] rather than Done).
*/
private fun sendRecoveryCodeResponse(): Boolean {
private suspend fun sendRecoveryCodeResponse(): Boolean {
val peer = peerChannelId ?: return false
val peerKey = peerPublicKey ?: return false
// Recovery code per peer kind. Per spec §"Exchange Share Recovery Code": create the host
Expand Down Expand Up @@ -703,7 +704,7 @@ class RealExchangeV2Runner @Inject constructor(
}
}

private fun provisionForThirdPartyPeer(): Result<String> {
private suspend fun provisionForThirdPartyPeer(): Result<String> {
when (val ddg = recoveryCodeProvider.createDdgAccountIfNeeded()) {
is Result.Success -> Unit
is Result.Error -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ interface RecoveryCodeProvider {
* 3party, if needed, extend the account."* No-op if a 3party credential already exists locally
* ([SyncStore.scopedPassword] set); otherwise creates one. Failure surfaces as [Result.Error].
*/
fun createThirdPartyCredentialIfNeeded(): Result<Unit>
suspend fun createThirdPartyCredentialIfNeeded(): Result<Unit>
}

@ContributesBinding(AppScope::class)
Expand All @@ -73,7 +73,7 @@ class RealRecoveryCodeProvider @Inject constructor(
}
}

override fun createThirdPartyCredentialIfNeeded(): Result<Unit> {
override suspend fun createThirdPartyCredentialIfNeeded(): Result<Unit> {
if (syncStore.scopedPassword != null) return Result.Success(Unit)
return when (val r = syncAccountRepository.createThirdPartyCredential()) {
is Result.Success -> Result.Success(Unit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import com.duckduckgo.sync.store.ScopedPassword
import com.duckduckgo.sync.store.SyncStore
import com.squareup.moshi.Moshi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
Expand Down Expand Up @@ -1245,7 +1246,7 @@ class AppSyncAccountRepositoryTest {
// Per-manager logic is covered in ThirdPartyCredentialManagerTest.

@Test
fun whenCreateThirdPartyCredentialThenDelegatesToManager() {
fun whenCreateThirdPartyCredentialThenDelegatesToManager() = runTest {
whenever(thirdPartyCredentialManager.create()).thenReturn(Success(true))

val result = syncRepo.createThirdPartyCredential()
Expand Down Expand Up @@ -1288,7 +1289,7 @@ class AppSyncAccountRepositoryTest {
// joinAccountFromThirdPartyRecoveryCode

@Test
fun whenJoinAccountFromThirdPartyAndAllStepsSucceedThenAtomicStoreUsesDdgTokenFromSecondLogin() {
fun whenJoinAccountFromThirdPartyAndAllStepsSucceedThenAtomicStoreUsesDdgTokenFromSecondLogin() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()

val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
Expand All @@ -1306,7 +1307,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenJoinAccountFromThirdPartySucceedsThenScopedPasswordIsWrittenFromLocalRecoveryCode() {
fun whenJoinAccountFromThirdPartySucceedsThenScopedPasswordIsWrittenFromLocalRecoveryCode() = runTest {
// Defensive: even if the BE response omits encrypted_3party_credential (so performLogin's
// server-decoded SP path is a no-op), the join should still leave scopedPassword populated
// from the locally-parsed recovery code.
Expand All @@ -1320,7 +1321,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenJoinAccountFromThirdPartyThenStep7aPostUpgradeLoginUsesUnrestrictedScope() {
fun whenJoinAccountFromThirdPartyThenStep7aPostUpgradeLoginUsesUnrestrictedScope() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()

syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
Expand All @@ -1345,7 +1346,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenStep7aPostUpgradeLoginReturns401ThenAbortsWithoutAtomicStore() {
fun whenStep7aPostUpgradeLoginReturns401ThenAbortsWithoutAtomicStore() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(step7aResult = step7aLoginUnauthorized)

val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
Expand All @@ -1356,7 +1357,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenStep7aPostUpgradeLoginFailsWithNon401ThenAbortsWithoutAtomicStore() {
fun whenStep7aPostUpgradeLoginFailsWithNon401ThenAbortsWithoutAtomicStore() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(step7aResult = step7aLoginServerError)

val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
Expand All @@ -1370,7 +1371,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenJoinAccountFromThirdPartyAndAccountAlreadyHasDdgCredentialThenAbortsBeforePost() {
fun whenJoinAccountFromThirdPartyAndAccountAlreadyHasDdgCredentialThenAbortsBeforePost() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(accountAlreadyHasDdg = true)

val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
Expand All @@ -1383,7 +1384,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenStep7PostRateLimitedThenRetriesAndSucceeds() {
fun whenStep7PostRateLimitedThenRetriesAndSucceeds() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
whenever(syncApi.createAccessCredential(anyString(), eq("ddg"), any()))
.thenReturn(Error(code = API_CODE.TOO_MANY_REQUESTS_2.code, reason = "rate limited"), Success(true))
Expand All @@ -1395,7 +1396,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenStep7aLoginServerErrorThenRetriesAndSucceeds() {
fun whenStep7aLoginServerErrorThenRetriesAndSucceeds() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
whenever(syncApi.login(eq(userId), any(), eq(deviceId), any(), any(), eq<String?>(null)))
.thenReturn(step7aLoginServerError, step7aLoginSuccess)
Expand All @@ -1415,7 +1416,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenStep7aLoginTransportErrorThenRetriesAndSucceeds() {
fun whenStep7aLoginTransportErrorThenRetriesAndSucceeds() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
whenever(syncApi.login(eq(userId), any(), eq(deviceId), any(), any(), eq<String?>(null)))
.thenReturn(step7aLoginTransportError, step7aLoginSuccess)
Expand All @@ -1427,7 +1428,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenStep7aLogin401ThenAbortsWithoutRetry() {
fun whenStep7aLogin401ThenAbortsWithoutRetry() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(step7aResult = step7aLoginUnauthorized)

val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
Expand All @@ -1438,7 +1439,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenStep7Post409AlreadyExistsThenAbortsWithoutRetry() {
fun whenStep7Post409AlreadyExistsThenAbortsWithoutRetry() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
whenever(syncApi.createAccessCredential(anyString(), eq("ddg"), any()))
.thenReturn(Error(code = API_CODE.COUNT_LIMIT.code, reason = "already exists"))
Expand All @@ -1451,7 +1452,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenStep7PostRateLimitedBeyondRetryLimitThenAbortsWithoutStore() {
fun whenStep7PostRateLimitedBeyondRetryLimitThenAbortsWithoutStore() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
whenever(syncApi.createAccessCredential(anyString(), eq("ddg"), any()))
.thenReturn(Error(code = API_CODE.TOO_MANY_REQUESTS_1.code, reason = "rate limited"))
Expand All @@ -1465,7 +1466,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenJoinAccountFromThirdPartyAndCodeIsLaterMinorVersionThenAccepted() {
fun whenJoinAccountFromThirdPartyAndCodeIsLaterMinorVersionThenAccepted() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(version = "2.5")

val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
Expand All @@ -1474,7 +1475,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenJoinAccountFromThirdPartyAndCodeIsBareMajorVersionThenAccepted() {
fun whenJoinAccountFromThirdPartyAndCodeIsBareMajorVersionThenAccepted() = runTest {
// "2" is the spec's common shorthand for "2.0" (Transport TD 1214486492252757).
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(version = "2")

Expand All @@ -1484,7 +1485,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenJoinAccountFromThirdPartyAndCodeIsMajorVersion3ThenRejectedWithoutNetwork() {
fun whenJoinAccountFromThirdPartyAndCodeIsMajorVersion3ThenRejectedWithoutNetwork() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(version = "3.0")

val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
Expand All @@ -1494,7 +1495,7 @@ class AppSyncAccountRepositoryTest {
}

@Test
fun whenJoinAccountFromThirdPartyAndCodeCidIsNotThirdPartyThenRejectedWithoutNetwork() {
fun whenJoinAccountFromThirdPartyAndCodeCidIsNotThirdPartyThenRejectedWithoutNetwork() = runTest {
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(cid = "ddg")

val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
Expand Down
Loading
Loading