Skip to content
Merged
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 @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope
import com.flipcash.app.auth.AuthManager
import com.flipcash.app.auth.internal.accounts.AccountProfileFetcher
import com.flipcash.app.auth.internal.accounts.AccountProfileName
import com.flipcash.app.auth.internal.accounts.CachedAccountProfile
import com.flipcash.app.auth.internal.accounts.AccountRecord
import com.flipcash.services.models.asHandle
import com.flipcash.features.login.R
Expand Down Expand Up @@ -129,9 +130,8 @@ class AccountSelectionViewModel @Inject constructor(
.filterIsInstance<Event.Load>()
// Nothing outside drives this screen, so the pipeline seeds its own first read.
.onStart { emit(Event.Load) }
.map { derive(authManager.accounts.all()) }
.onEach { derived ->
val profiles = authManager.accountProfiles.all()
.map { derive(authManager.accounts.all()) to authManager.accountProfiles.all() }
.onEach { (derived, profiles) ->
dispatchEvent(
Event.OnAccountsLoaded(
accounts = derived.map { (record, cluster) ->
Expand All @@ -141,11 +141,11 @@ class AccountSelectionViewModel @Inject constructor(
)
)
}
.flatMapLatest { derived ->
derived.asFlow().flatMapMerge { merge(balance(it), profile(it)) }
.flatMapLatest { (derived, profiles) ->
derived.asFlow().flatMapMerge { merge(balance(it), profile(it, profiles)) }
}
.onEach { dispatchEvent(it) }
// Derivation is PBKDF2 plus a SLIP-10 chain, and the fetches are network calls;
// Derivation is PBKDF2 plus a SLIP-10 chain, and the balance fetches are network calls;
// neither belongs on the thread drawing the list.
.flowOn(dispatchers.IO)
.launchIn(viewModelScope)
Expand Down Expand Up @@ -225,11 +225,11 @@ class AccountSelectionViewModel @Inject constructor(

private fun AccountRecord.toUiModel(
cluster: AccountCluster?,
profiles: Map<String, AccountProfileName>,
profiles: Map<String, CachedAccountProfile>,
): AccountUiModel {
val mnemonic = runCatching { mnemonicManager.fromEntropyBase64(entropy) }.getOrNull()
val owner = cluster?.authorityPublicKey?.base58()
val profile = owner?.let { profiles[it] }
val profile = owner?.let { profiles[it]?.name }
return AccountUiModel(
// A record that will not derive has no owner key to be keyed on; its creation
// timestamp is the only other thing distinguishing it.
Expand Down Expand Up @@ -297,13 +297,19 @@ class AccountSelectionViewModel @Inject constructor(
}

/**
* One account's username and display name, fetched signed as that account, so every row is
* named and not just the signed-in one. A failure keeps whatever the cache gave the row.
* One account's username and display name, fetched by the user id cached from its last
* sign-in on this device. A row with no cached user id resolves one through Login first, as a
* last resort. A failure keeps whatever the cache gave the row.
*/
private fun profile(entry: Pair<AccountRecord, Result<AccountCluster>>): Flow<Event> = flow {
private fun profile(
entry: Pair<AccountRecord, Result<AccountCluster>>,
profiles: Map<String, CachedAccountProfile>,
): Flow<Event> = flow {
val (record, cluster) = entry
val owner = cluster.getOrNull() ?: return@flow
profileFetcher.fetch(owner.authority.keyPair, owner.authorityPublicKey.base58())
val ownerAddress = owner.authorityPublicKey.base58()
val userId = profiles[ownerAddress]?.userId
profileFetcher.fetch(owner.authority.keyPair, ownerAddress, userId)
.onSuccess { emit(Event.OnProfileResolved(record.entropy, it)) }
.onFailure { error ->
trace(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,43 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.flipcash.app.auth.AuthManager
import com.flipcash.app.auth.internal.accounts.AccountProfileCache
import com.flipcash.app.auth.internal.accounts.AccountProfileFetcher
import com.flipcash.app.auth.internal.accounts.CachedAccountProfile
import com.flipcash.app.auth.internal.accounts.AccountProfileName
import com.flipcash.app.auth.internal.accounts.AccountRecord
import com.flipcash.app.auth.internal.accounts.AccountStore
import com.flipcash.app.core.MainCoroutineRule
import com.flipcash.app.core.dispatchers.TestDispatchers
import com.getcode.crypt.DerivedKey
import com.getcode.crypt.MnemonicPhrase
import com.getcode.opencode.controllers.TokenController
import com.getcode.opencode.managers.MnemonicManager
import com.getcode.opencode.model.accounts.AccountCluster
import com.getcode.solana.keys.PublicKey
import com.getcode.solana.keys.base58
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import com.getcode.util.resources.FakeResourceHelper
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

/**
Expand Down Expand Up @@ -88,6 +103,9 @@ class AccountSelectionViewModelStateTest {
)
}

@After
fun tearDown() = unmockkAll()

@Test
fun `load marks which account is signed in`() = runTest(mainCoroutineRule.dispatcher) {
val store = RecordingAccountStore(listOf(record("a", 2_000L), record("b", 1_000L)))
Expand Down Expand Up @@ -170,23 +188,106 @@ class AccountSelectionViewModelStateTest {
assertEquals(listOf("b"), viewModel.stateFlow.value.accounts.map { it.entropy })
}

/** A fetched profile renames only its own row, and the username wins over the display name. */
/**
* A single non-active, derivable row with [cached] as its cache entry. Real derivation reaches
* android.util.Base64, a stub on the JVM, so the cluster is faked; the row only needs an owner
* key to look up the cache and fetch with.
*/
private fun TestScope.loadNonActiveRow(
cached: CachedAccountProfile,
fetcher: AccountProfileFetcher = profileFetcher,
tokens: TokenController = offlineTokens(),
): AccountSelectionViewModel {
val phrase = MnemonicPhrase(MnemonicPhrase.Kind.L12, List(12) { "abandon" })
val ownerKey = PublicKey(List(32) { 7 })
mockkObject(DerivedKey)
every { DerivedKey.derive(any(), phrase) } returns mockk(relaxed = true)
mockkObject(AccountCluster)
every { AccountCluster.newInstance(any(), any()) } returns
mockk(relaxed = true) { every { authorityPublicKey } returns ownerKey }
val cache: AccountProfileCache = mock {
onBlocking { all() } doReturn mapOf(ownerKey.base58() to cached)
}
whenever(authManager.accounts).thenReturn(RecordingAccountStore(listOf(record("a", 1_000L))))
whenever(authManager.accountProfiles).thenReturn(cache)
whenever(authManager.currentEntropy).thenReturn("b")
whenever(mnemonicManager.fromEntropyBase64("a")).thenReturn(phrase)
return AccountSelectionViewModel(
authManager = authManager,
mnemonicManager = mnemonicManager,
tokenController = tokens,
profileFetcher = fetcher,
resources = resources,
dispatchers = TestDispatchers(testScheduler),
)
}

private fun offlineTokens(): TokenController = mock {
onBlocking { fetchTokenBalances(any()) } doReturn Result.failure(IllegalStateException("offline"))
}

/**
* A row with no stored user id asks the fetcher to resolve one (the fetcher's Login fallback).
* The switcher itself never switches the session: [AuthManager.login] is not called.
*/
@Test
fun `a row with no stored user id is fetched without a user id`() =
runTest(mainCoroutineRule.dispatcher) {
val fetcher: AccountProfileFetcher = mock {
onBlocking { fetch(any(), any(), isNull()) } doReturn
Result.success(AccountProfileName(username = "sally_streamer", displayName = "Sally"))
}
val viewModel = loadNonActiveRow(
cached = CachedAccountProfile(userId = null, name = null),
fetcher = fetcher,
)
advanceUntilIdle()

assertEquals("@sally_streamer", viewModel.stateFlow.value.accounts.single().name)
verify(fetcher).fetch(any(), any(), isNull())
verify(authManager, never()).login(any(), any(), any(), any())
}

/** A failed fetch, Login included, keeps whatever name the cache gave the row. */
@Test
fun `a fetched profile retitles its row`() = runTest(mainCoroutineRule.dispatcher) {
val store = RecordingAccountStore(listOf(record("a", 2_000L), record("b", 1_000L)))
val viewModel = viewModel(store, current = "b", TestDispatchers(testScheduler))
advanceUntilIdle()
fun `a failed fetch keeps the cached name`() =
runTest(mainCoroutineRule.dispatcher) {
val fetcher: AccountProfileFetcher = mock {
onBlocking { fetch(any(), any(), anyOrNull()) } doReturn
Result.failure(IllegalStateException("offline"))
}
val viewModel = loadNonActiveRow(
cached = CachedAccountProfile(
userId = null,
name = AccountProfileName(username = "sally_streamer", displayName = "Sally"),
),
fetcher = fetcher,
)
advanceUntilIdle()

val row = viewModel.stateFlow.value.accounts.single()
assertEquals("@sally_streamer", row.name)
assertFalse(row.notFound)
}

viewModel.dispatchEvent(
AccountSelectionViewModel.Event.OnProfileResolved(
entropy = "a",
name = AccountProfileName(username = "sally_streamer", displayName = "Sally"),
/** A stored user id goes straight to the fetch; the fetched names replace the cached. */
@Test
fun `a row with a stored user id is retitled by a profile fetch`() =
runTest(mainCoroutineRule.dispatcher) {
val userId = listOf<Byte>(1, 2, 3)
val fetcher: AccountProfileFetcher = mock {
onBlocking { fetch(any(), any(), eq(userId)) } doReturn
Result.success(AccountProfileName(username = null, displayName = "Sally"))
}
val viewModel = loadNonActiveRow(
cached = CachedAccountProfile(userId = userId, name = null),
fetcher = fetcher,
)
)
advanceUntilIdle()
advanceUntilIdle()

assertEquals(listOf("@sally_streamer", ""), viewModel.stateFlow.value.accounts.map { it.name })
}
assertEquals("Sally", viewModel.stateFlow.value.accounts.single().name)
verify(fetcher).fetch(any(), any(), eq(userId))
}

private val mnemonicName = "Apple ... Elder"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class NameEntryViewModelTest {
dispatchers = TestDispatchers(testScheduler)
every { userManager.state } returns MutableStateFlow(UserManager.State())
every { userManager.profile } returns null
whenever(profileController.setDisplayName(any())).thenReturn(Result.success(Unit))
whenever(profileController.setDisplayName(any())).thenReturn(Result.success<String?>(null))

val vm = createViewModel()
vm.dispatchEvent(NameEntryViewModel.Event.CheckName(DisplayNameSource.Onboarding))
Expand All @@ -85,7 +85,7 @@ class NameEntryViewModelTest {
dispatchers = TestDispatchers(testScheduler)
every { userManager.state } returns MutableStateFlow(UserManager.State())
every { userManager.profile } returns profileNamed("Ada")
whenever(profileController.setDisplayName(any())).thenReturn(Result.success(Unit))
whenever(profileController.setDisplayName(any())).thenReturn(Result.success<String?>(null))

val vm = createViewModel()
vm.dispatchEvent(NameEntryViewModel.Event.CheckName(DisplayNameSource.MyAccount))
Expand Down
Loading
Loading