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
52 changes: 48 additions & 4 deletions PixelDefinitions/pixels/definitions/sync_setup.json5
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@
"type": "string",
"description": "This device's credential kind. Always ddg for the native client",
"enum": ["ddg", "3party"]
},
{
"key": "reason",
"type": "string",
"description": "Why the scanned code failed parsing/recognition. Emitted from v2 call sites when the parser rejects the code.",
"enum": ["unrecognized_code", "needs_upgrade", "incompatible_code"]
}
]
},
Expand Down Expand Up @@ -182,6 +188,12 @@
"type": "string",
"description": "This device's credential kind. Always ddg for the native client",
"enum": ["ddg", "3party"]
},
{
"key": "reason",
"type": "string",
"description": "Why the pasted code failed parsing/recognition. Emitted from v2 call sites when the parser rejects the code.",
"enum": ["unrecognized_code", "needs_upgrade", "incompatible_code"]
}
]
},
Expand Down Expand Up @@ -237,20 +249,52 @@
"suffixes": ["form_factor"],
"parameters": [
"appVersion",
{
"key": "source",
"type": "string",
"description": "Which sync setup flow failed",
"enum": ["connect", "exchange"]
},
{
"key": "reason",
"type": "string",
"description": "Why the v2 setup failed, aligned with the error codes we handle",
"description": "Why the v2 setup failed, per the canonical reason vocabulary in the v2 sync pixels spec (Exchange V2 Unified Algorithm)",
"enum": [
"session_timeout",
"transport_failure",
"needs_upgrade",
"already_upgraded",
"invalid_credentials",
"pairing_unavailable",
"account_creation_failed",
"account_upgrade_failed",
"already_upgraded",
"already_paired",
"recovery_code_preparation_failed",
"missing_3party_credential",
"undecryptable_3party_credential",
"account_extend_failed",
"missing_3party_key",
"local_storage_failed",
"peer_recovery_code_unavailable",
"unexpected_second_hello",
"unexpected_event",
"pairing_session_not_ready",
"relay_channel_unavailable",
"protocol_error",
"transport_failure",
"unexpected_failure"
]
},
{
"key": "timeout_stage",
"type": "string",
"description": "Which phase of the v2 setup was in progress when reason=session_timeout. Emitted only when reason is session_timeout; omitted otherwise",
"enum": [
"waiting_for_peer_hello",
"waiting_for_peer_status",
"waiting_for_confirmation",
"waiting_for_recovery_code",
"logging_in"
]
},
{
"key": "flow_version",
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,22 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.NO_RECOVERY_CODE
import com.duckduckgo.sync.impl.AccountErrorCodes.PAIRING_CANCELLED
import com.duckduckgo.sync.impl.AccountErrorCodes.PAIRING_FAILED
import com.duckduckgo.sync.impl.AccountErrorCodes.PAIRING_REJECTED
import com.duckduckgo.sync.impl.AccountErrorCodes.PAIRING_SESSION_NOT_READY
import com.duckduckgo.sync.impl.AccountErrorCodes.PAIRING_UNAVAILABLE
import com.duckduckgo.sync.impl.AccountErrorCodes.PEER_RECOVERY_CODE_UNAVAILABLE
import com.duckduckgo.sync.impl.AccountErrorCodes.RECOVERY_CODE_PREPARATION_FAILED
import com.duckduckgo.sync.impl.AccountErrorCodes.RELAY_CHANNEL_UNAVAILABLE
import com.duckduckgo.sync.impl.AccountErrorCodes.SESSION_TIMEOUT
import com.duckduckgo.sync.impl.AccountErrorCodes.UNEXPECTED_EVENT
import com.duckduckgo.sync.impl.AccountErrorCodes.UNEXPECTED_SECOND_HELLO
import com.duckduckgo.sync.impl.exchange.v2.ExchangeV2CodeParseResult
import com.duckduckgo.sync.impl.exchange.v2.ExchangeV2Event
import com.duckduckgo.sync.impl.exchange.v2.ExchangeV2Message
import com.duckduckgo.sync.impl.exchange.v2.ExchangeV2QrCode
import com.duckduckgo.sync.impl.exchange.v2.ExchangeV2Runner
import com.duckduckgo.sync.impl.exchange.v2.ExchangeV2State
import com.duckduckgo.sync.impl.exchange.v2.LocalTrigger
import com.duckduckgo.sync.impl.exchange.v2.SessionErrorKind
import com.duckduckgo.sync.impl.pixels.SyncPixels.PeerKind
import com.duckduckgo.sync.impl.pixels.SyncPixels.SetupPath
import com.duckduckgo.sync.impl.pixels.SyncPixels.SetupRole
Expand Down Expand Up @@ -124,7 +131,7 @@ class RealSyncCodeDispatcher @Inject constructor(
private fun mapV2PresentEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
is ExchangeV2Event.SessionStarted -> event.linkingCode?.let { DispatchOutcome.LinkingCodeReady(it) }
is ExchangeV2Event.Transition -> mapV2Transition(event, peerKind)
is ExchangeV2Event.SessionError -> sessionErrorToOutcome(event.message)
is ExchangeV2Event.SessionError -> sessionErrorToOutcome(event)
else -> null
}

Expand Down Expand Up @@ -289,7 +296,7 @@ class RealSyncCodeDispatcher @Inject constructor(

private fun mapV2LinkingEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
is ExchangeV2Event.Transition -> mapV2Transition(event, peerKind)
is ExchangeV2Event.SessionError -> sessionErrorToOutcome(event.message)
is ExchangeV2Event.SessionError -> sessionErrorToOutcome(event)
else -> null
}

Expand Down Expand Up @@ -317,21 +324,32 @@ class RealSyncCodeDispatcher @Inject constructor(
is ExchangeV2Message.RecoveryCodeDenied ->
DispatchOutcome.Failed("peer_denied_recovery_code", PAIRING_REJECTED.code)
is ExchangeV2Message.RecoveryCodeUnavailable ->
DispatchOutcome.Failed("peer_recovery_code_unavailable", PAIRING_UNAVAILABLE.code)
DispatchOutcome.Failed("peer_recovery_code_unavailable", PEER_RECOVERY_CODE_UNAVAILABLE.code)
else -> DispatchOutcome.Failed("peer_aborted", PAIRING_REJECTED.code)
}
ExchangeV2State.Joiner.AbortedLocal -> joinerAbortedLocalToOutcome(transition.localTrigger, transition.trigger)
ExchangeV2State.Aborted -> DispatchOutcome.Failed("negotiation_aborted", NEGOTIATION_ABORTED.code)
ExchangeV2State.Aborted -> DispatchOutcome.Failed("negotiation_aborted", UNEXPECTED_EVENT.code)
else -> null
}

private fun sessionErrorToOutcome(message: String): DispatchOutcome {
val match = VERSION_TOO_NEW_REGEX.find(message)
return if (match != null) {
DispatchOutcome.UpgradeRequired(codeMajor = match.groupValues[1].toIntOrNull() ?: -1)
} else {
DispatchOutcome.Failed(message, PAIRING_FAILED.code)
private fun sessionErrorToOutcome(event: ExchangeV2Event.SessionError): DispatchOutcome {
val match = VERSION_TOO_NEW_REGEX.find(event.message)
if (match != null) {
return DispatchOutcome.UpgradeRequired(codeMajor = match.groupValues[1].toIntOrNull() ?: -1)
}
// The structured kind lets sync_setup_ended_failed distinguish v2 protocol violations from a
// generic transport failure. Unknown falls back to PAIRING_FAILED → transport_failure.
val code = when (event.kind) {
SessionErrorKind.SessionTimeout -> SESSION_TIMEOUT.code
SessionErrorKind.UnexpectedSecondHello -> UNEXPECTED_SECOND_HELLO.code
SessionErrorKind.UnexpectedEvent -> UNEXPECTED_EVENT.code
SessionErrorKind.PairingSessionNotReady -> PAIRING_SESSION_NOT_READY.code
SessionErrorKind.RelayChannelUnavailable -> RELAY_CHANNEL_UNAVAILABLE.code
SessionErrorKind.RecoveryCodePreparationFailed -> RECOVERY_CODE_PREPARATION_FAILED.code
SessionErrorKind.MalformedRelayRequest -> NEGOTIATION_ABORTED.code
SessionErrorKind.Unknown -> PAIRING_FAILED.code
}
return DispatchOutcome.Failed(event.message, code, timeoutStage = event.timeoutStage)
}

private fun hostAbortedToOutcome(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,10 @@ class AppSyncAccountRepository @Inject constructor(
)
}
logcat(ERROR) { "Sync-ScopedToken: /access-credentials/ddg POST failed: ${postResult.reason}" }
return postResult.copy(reason = "JoinFrom3party: ${postResult.reason}")
return postResult.copy(
code = AccountErrorCodes.ACCOUNT_UPGRADE_FAILED.code,
reason = "JoinFrom3party: ${postResult.reason}",
)
}

// Step 7 — Native login as the new ddg credential. Required: without a login inside the
Expand Down Expand Up @@ -1494,6 +1497,20 @@ enum class AccountErrorCodes(val code: Int) {
NO_RECOVERY_CODE(62),
PAIRING_FAILED(63),
UNEXPECTED_EVENT(64),
SESSION_TIMEOUT(65),
ACCOUNT_CREATION_FAILED(66),
ACCOUNT_UPGRADE_FAILED(67),
RECOVERY_CODE_PREPARATION_FAILED(68),
MISSING_3PARTY_CREDENTIAL(69),
UNDECRYPTABLE_3PARTY_CREDENTIAL(70),
ACCOUNT_EXTEND_FAILED(71),
MISSING_3PARTY_KEY(72),
LOCAL_STORAGE_FAILED(73),
PEER_RECOVERY_CODE_UNAVAILABLE(74),
ALREADY_PAIRED(75),
UNEXPECTED_SECOND_HELLO(76),
PAIRING_SESSION_NOT_READY(77),
RELAY_CHANNEL_UNAVAILABLE(78),
}

sealed interface SyncAuthCode {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.duckduckgo.sync.impl
import com.duckduckgo.sync.impl.pixels.SyncPixels.PeerKind
import com.duckduckgo.sync.impl.pixels.SyncPixels.SetupPath
import com.duckduckgo.sync.impl.pixels.SyncPixels.SetupRole
import com.duckduckgo.sync.impl.pixels.SyncPixels.TimeoutStage
import kotlinx.coroutines.flow.Flow

/**
Expand Down Expand Up @@ -170,5 +171,11 @@ sealed interface DispatchOutcome {
val path: SetupPath? = null,
val myRole: SetupRole? = null,
val peerKind: PeerKind? = null,
/**
* Populated only when [code] is [AccountErrorCodes.SESSION_TIMEOUT] — identifies which phase
* of the flow was in progress at the deadline so the "Setup failed" pixel can split
* `reason=session_timeout` by user-facing stage.
*/
val timeoutStage: TimeoutStage? = null,
) : DispatchOutcome
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ import javax.inject.Inject
*
* Spec: Transport TD (Asana 1214486492252757) §BE Relay + §Polling for messages.
*/
/** Own channel is gone on the relay (HTTP 404/410 on poll). Peer or BE TTL deleted it. */
class ChannelGone(val status: Int) : RuntimeException("Poll got $status — channel gone")

/** Malformed poll request (HTTP 400). Client-side protocol bug. */
class PollBadRequest(val status: Int) : RuntimeException("Poll rejected with $status — malformed request")

/** Poll auth/policy failure (HTTP 401/403). Credentials invalidated or server-side policy denied. */
class PollAuthDenied(val status: Int) : RuntimeException("Poll denied with $status — auth or policy")

interface ExchangeV2Channel {

/**
Expand All @@ -52,10 +61,10 @@ interface ExchangeV2Channel {
): Result<Unit>

/**
* Poll loop on [ownChannelId], emitting decrypted + parsed messages. See spec §Polling.
* - Completes on a non-recoverable HTTP status; transient statuses keep polling.
* - Throws [EnvelopeVersionTooNew] (too-new version) and [EnvelopeDecryptFailure]
* (decrypt failure) as terminal; drops unknown message types (forward-compat).
* Poll loop on [ownChannelId], emitting decrypted + parsed messages.
* - Transient statuses (5xx / 429 / network / timeouts) keep polling; the 5-min session timer catches persistent transient failures.
* - Throws [ChannelGone] on 404/410 (channel deleted or TTL'd), [PollBadRequest] on 400, [PollAuthDenied] on 401/403
* - Throws [EnvelopeVersionTooNew] on a too-new envelope and [EnvelopeDecryptFailure] on decrypt failure
*/
fun poll(ownChannelId: String, ownPrivateKeyBase64: String): Flow<ExchangeV2Message>

Expand Down Expand Up @@ -103,12 +112,12 @@ class RealExchangeV2Channel @Inject constructor(
}
}
is Result.Error -> {
if (outcome.code in NON_RECOVERABLE_HTTP_CODES) {
// Re-polling won't recover; stop.
logcat { "Sync-ExchangeV2: ending poll on $ownChannelId — non-recoverable status ${outcome.code} (${outcome.reason})" }
return@flow
when (outcome.code) {
404, 410 -> throw ChannelGone(outcome.code)
400 -> throw PollBadRequest(outcome.code)
401, 403 -> throw PollAuthDenied(outcome.code)
else -> logcat(ERROR) { "Sync-ExchangeV2: transient poll error ${outcome.code}: ${outcome.reason}, retrying" }
}
logcat(ERROR) { "Sync-ExchangeV2: transient poll error ${outcome.code}: ${outcome.reason}, retrying" }
}
}
delay(POLL_INTERVAL_MS)
Expand All @@ -134,15 +143,5 @@ class RealExchangeV2Channel @Inject constructor(

companion object {
private const val POLL_INTERVAL_MS: Long = 1_000L

// Statuses that won't recover by re-polling; transient ones (5xx / 429 / 418 / timeouts)
// are deliberately absent so they keep polling.
private val NON_RECOVERABLE_HTTP_CODES = setOf(
400, // Bad Request — malformed poll
401, // Unauthorized
403, // Forbidden
404, // Not Found — channel gone (the normal end when the peer/server closed it)
410, // Gone — channel permanently removed
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

package com.duckduckgo.sync.impl.exchange.v2

import com.duckduckgo.sync.impl.pixels.SyncPixels.TimeoutStage

/**
* Observable events emitted by the v2 exchange runner. Downstream consumers
* subscribe to the runner's event flow and react.
Expand Down Expand Up @@ -54,11 +56,26 @@ sealed interface ExchangeV2Event {
val linkingCode: String?,
) : ExchangeV2Event

/** A transport or protocol-level failure during bootstrap / poll / send. */
/**
* A transport or protocol-level failure during bootstrap / poll / send.
*/
data class SessionError(
override val timestampMs: Long,
val message: String,
val kind: SessionErrorKind = SessionErrorKind.Unknown,
val timeoutStage: TimeoutStage? = null,
) : ExchangeV2Event
}

enum class RejectReason { ImplicitAbort, SameAccount, UnknownMessageDropped }

enum class SessionErrorKind {
SessionTimeout,
UnexpectedSecondHello,
UnexpectedEvent,
PairingSessionNotReady,
RelayChannelUnavailable,
RecoveryCodePreparationFailed,
MalformedRelayRequest,
Unknown,
}
Loading
Loading