Skip to content

Commit

Permalink
Send switch pixel only when user changes to a different sync account (#…
Browse files Browse the repository at this point in the history
…5524)

Task/Issue URL: https://app.asana.com/0/1200156640058969/1209059053489790/f 

### Description
Ensure we send a switch pixel only when the user has changed to another sync account.

### Steps to test this PR
!! In order to test this you will need 2 devices, and add filter in logcat to only visualize pixels

_Feature 1_
- [x] Fresh install the app in both devices
- [x] Device A create a sync account
- [x] Device A go to settings -> sync (signed in state) -> Sync with Another Device
- [x] Device B go to settings -> sync (signed out state) -> Sync with Another Device
- [x] Device A read QR code in Device B
- [x] Ensure Device A didn't send pixel `sync_user_switched_account`

_Feature 2_
- [x] logout from both sync accounts in both devices
- [x] Device A create a sync account
- [x] Device B create a sync account
- [x] Device A go to settings -> sync (signed in state) -> Sync with Another Device
- [x] Device B go to settings -> sync (signed in state) -> Sync with Another Device
- [x] Device A read QR code in Device B
- [x] Ensure Device A sends pixel `sync_user_switched_account`


### UI changes
| Before  | After |
| ------ | ----- |
!(Upload before screenshot)|(Upload after screenshot)|
  • Loading branch information
cmonfortep authored Jan 23, 2025
1 parent 23df15c commit d5ed5a5
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,12 @@ class EnterCodeViewModel @Inject constructor(
private suspend fun authFlow(
pastedCode: String,
) {
val userSignedIn = syncAccountRepository.isSignedIn()
val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey
when (val result = syncAccountRepository.processCode(pastedCode)) {
is Result.Success -> {
val commandSuccess = if (userSignedIn) {
val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey
val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK
val commandSuccess = if (userSwitchedAccount) {
syncPixels.fireUserSwitchedAccount()
SwitchAccountSuccess
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import com.duckduckgo.sync.impl.getOrNull
import com.duckduckgo.sync.impl.onFailure
import com.duckduckgo.sync.impl.onSuccess
import com.duckduckgo.sync.impl.pixels.SyncPixels
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.AskToSwitchAccount
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.FinishWithError
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.LoginSuccess
Expand Down Expand Up @@ -136,15 +135,17 @@ class SyncWithAnotherActivityViewModel @Inject constructor(

fun onQRCodeScanned(qrCode: String) {
viewModelScope.launch(dispatchers.io()) {
val userSignedIn = syncAccountRepository.isSignedIn()
val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey
when (val result = syncAccountRepository.processCode(qrCode)) {
is Error -> {
emitError(result, qrCode)
}

is Success -> {
val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey
syncPixels.fireLoginPixel()
val commandSuccess = if (userSignedIn) {
val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK
val commandSuccess = if (userSwitchedAccount) {
syncPixels.fireUserSwitchedAccount()
SwitchAccountSuccess
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.sync

import com.duckduckgo.sync.impl.AccountInfo

object SyncAccountFixtures {
val accountA = AccountInfo(
userId = "userIdA",
primaryKey = "primaryKeyA",
deviceName = "deviceNameA",
deviceId = "deviceIdA",
isSignedIn = true,
secretKey = "secretKeyA",
)

val accountB = AccountInfo(
userId = "userIdB",
primaryKey = "primaryKeyB",
deviceName = "deviceNameB",
deviceId = "deviceIdB",
isSignedIn = true,
secretKey = "secretKeyB",
)

val noAccount = AccountInfo()
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import app.cash.turbine.test
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
import com.duckduckgo.feature.toggles.api.Toggle.State
import com.duckduckgo.sync.SyncAccountFixtures.accountA
import com.duckduckgo.sync.SyncAccountFixtures.accountB
import com.duckduckgo.sync.SyncAccountFixtures.noAccount
import com.duckduckgo.sync.TestSyncFixtures.jsonConnectKeyEncoded
import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded
import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN
Expand Down Expand Up @@ -83,6 +86,7 @@ internal class EnterCodeViewModelTest {

@Test
fun whenUserClicksOnPasteCodeThenClipboardIsPasted() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount)
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)

testee.onPasteCodeClicked()
Expand All @@ -92,8 +96,12 @@ internal class EnterCodeViewModelTest {

@Test
fun whenUserClicksOnPasteCodeWithRecoveryCodeThenProcessCode() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount)
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true))
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA)
Success(true)
}

testee.onPasteCodeClicked()

Expand All @@ -106,8 +114,12 @@ internal class EnterCodeViewModelTest {

@Test
fun whenUserClicksOnPasteCodeWithConnectCodeThenProcessCode() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount)
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonConnectKeyEncoded)
whenever(syncAccountRepository.processCode(jsonConnectKeyEncoded)).thenReturn(Success(true))
whenever(syncAccountRepository.processCode(jsonConnectKeyEncoded)).thenAnswer {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA)
Success(true)
}

testee.onPasteCodeClicked()

Expand All @@ -120,6 +132,7 @@ internal class EnterCodeViewModelTest {

@Test
fun whenPastedInvalidCodeThenAuthStateError() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount)
whenever(clipboard.pasteFromClipboard()).thenReturn("invalid code")
whenever(syncAccountRepository.processCode("invalid code")).thenReturn(Error(code = INVALID_CODE.code))

Expand All @@ -135,6 +148,7 @@ internal class EnterCodeViewModelTest {
@Test
fun whenProcessCodeButUserSignedInThenShowError() = runTest {
syncFeature.seamlessAccountSwitching().setRawStoredState(State(false))
whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA)
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = ALREADY_SIGNED_IN.code))

Expand All @@ -149,6 +163,7 @@ internal class EnterCodeViewModelTest {

@Test
fun whenProcessCodeButUserSignedInThenOfferToSwitchAccount() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA)
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = ALREADY_SIGNED_IN.code))

Expand All @@ -163,7 +178,11 @@ internal class EnterCodeViewModelTest {

@Test
fun whenUserAcceptsToSwitchAccountThenPerformAction() = runTest {
whenever(syncAccountRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenReturn(Success(true))
whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA)
whenever(syncAccountRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenAnswer {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountB)
Success(true)
}

testee.onUserAcceptedJoiningNewAccount(jsonRecoveryKeyEncoded)

Expand All @@ -175,9 +194,12 @@ internal class EnterCodeViewModelTest {
}

@Test
fun whenSignedInUserScansRecoveryCodeAndLoginSucceedsThenReturnSwitchAccount() = runTest {
whenever(syncAccountRepository.isSignedIn()).thenReturn(true)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true))
fun whenSignedInUserProcessCodeSucceedsAndAccountChangedThenReturnSwitchAccount() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountB)
Success(true)
}
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)

testee.commands().test {
Expand All @@ -190,8 +212,11 @@ internal class EnterCodeViewModelTest {

@Test
fun whenSignedOutUserScansRecoveryCodeAndLoginSucceedsThenReturnLoginSuccess() = runTest {
whenever(syncAccountRepository.isSignedIn()).thenReturn(false)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true))
whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA)
Success(true)
}
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)

testee.commands().test {
Expand All @@ -204,6 +229,7 @@ internal class EnterCodeViewModelTest {

@Test
fun whenProcessCodeAndLoginFailsThenShowError() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount)
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = LOGIN_FAILED.code))

Expand All @@ -218,6 +244,7 @@ internal class EnterCodeViewModelTest {

@Test
fun whenProcessCodeAndConnectFailsThenShowError() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount)
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonConnectKeyEncoded)
whenever(syncAccountRepository.processCode(jsonConnectKeyEncoded)).thenReturn(Error(code = CONNECT_FAILED.code))

Expand All @@ -232,6 +259,7 @@ internal class EnterCodeViewModelTest {

@Test
fun whenProcessCodeAndCreateAccountFailsThenShowError() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount)
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = CREATE_ACCOUNT_FAILED.code))

Expand All @@ -246,6 +274,7 @@ internal class EnterCodeViewModelTest {

@Test
fun whenProcessCodeAndGenericErrorThenDoNothing() = runTest {
whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount)
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = GENERIC_ERROR.code))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import app.cash.turbine.test
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
import com.duckduckgo.feature.toggles.api.Toggle.State
import com.duckduckgo.sync.SyncAccountFixtures.accountA
import com.duckduckgo.sync.SyncAccountFixtures.accountB
import com.duckduckgo.sync.SyncAccountFixtures.noAccount
import com.duckduckgo.sync.TestSyncFixtures
import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded
import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN
Expand Down Expand Up @@ -133,6 +136,7 @@ class SyncWithAnotherDeviceViewModelTest {
@Test
fun whenUserScansRecoveryCodeButSignedInThenCommandIsError() = runTest {
syncFeature.seamlessAccountSwitching().setRawStoredState(State(false))
whenever(syncRepository.getAccountInfo()).thenReturn(accountA)
whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = ALREADY_SIGNED_IN.code))
testee.commands().test {
testee.onQRCodeScanned(jsonRecoveryKeyEncoded)
Expand All @@ -145,6 +149,7 @@ class SyncWithAnotherDeviceViewModelTest {

@Test
fun whenUserScansRecoveryCodeButSignedInThenCommandIsAskToSwitchAccount() = runTest {
whenever(syncRepository.getAccountInfo()).thenReturn(accountA)
whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = ALREADY_SIGNED_IN.code))
testee.commands().test {
testee.onQRCodeScanned(jsonRecoveryKeyEncoded)
Expand All @@ -157,7 +162,11 @@ class SyncWithAnotherDeviceViewModelTest {

@Test
fun whenUserAcceptsToSwitchAccountThenPerformAction() = runTest {
whenever(syncRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenReturn(Success(true))
whenever(syncRepository.getAccountInfo()).thenReturn(accountA)
whenever(syncRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenAnswer {
whenever(syncRepository.getAccountInfo()).thenReturn(accountB)
Success(true)
}

testee.onUserAcceptedJoiningNewAccount(jsonRecoveryKeyEncoded)

Expand All @@ -170,8 +179,11 @@ class SyncWithAnotherDeviceViewModelTest {

@Test
fun whenSignedInUserScansRecoveryCodeAndLoginSucceedsThenReturnSwitchAccount() = runTest {
whenever(syncRepository.isSignedIn()).thenReturn(true)
whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true))
whenever(syncRepository.getAccountInfo()).thenReturn(accountA)
whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer {
whenever(syncRepository.getAccountInfo()).thenReturn(accountB)
Success(true)
}

testee.commands().test {
testee.onQRCodeScanned(jsonRecoveryKeyEncoded)
Expand All @@ -184,8 +196,11 @@ class SyncWithAnotherDeviceViewModelTest {

@Test
fun whenSignedOutUserScansRecoveryCodeAndLoginSucceedsThenReturnLoginSuccess() = runTest {
whenever(syncRepository.isSignedIn()).thenReturn(false)
whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true))
whenever(syncRepository.getAccountInfo()).thenReturn(noAccount)
whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer {
whenever(syncRepository.getAccountInfo()).thenReturn(accountB)
Success(true)
}

testee.commands().test {
testee.onQRCodeScanned(jsonRecoveryKeyEncoded)
Expand All @@ -198,6 +213,7 @@ class SyncWithAnotherDeviceViewModelTest {

@Test
fun whenUserScansRecoveryQRCodeAndConnectDeviceFailsThenCommandIsError() = runTest {
whenever(syncRepository.getAccountInfo()).thenReturn(noAccount)
whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = LOGIN_FAILED.code))
testee.commands().test {
testee.onQRCodeScanned(jsonRecoveryKeyEncoded)
Expand Down

0 comments on commit d5ed5a5

Please sign in to comment.