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
@@ -1,5 +1,6 @@
package com.flipcash.app.core.bill

import com.flipcash.services.models.UserProfile
import com.getcode.opencode.internal.manager.VerifiedState
import com.getcode.opencode.model.financial.LocalFiat
import com.getcode.opencode.model.financial.Token
Expand Down Expand Up @@ -94,6 +95,6 @@ sealed interface Scannable {

data class TipCard(
override val data: List<Byte>,
val username: String,
val user: UserProfile
) : Scannable
}
16 changes: 16 additions & 0 deletions apps/flipcash/shared/tipping/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
alias(libs.plugins.flipcash.android.feature)
}

android {
namespace = "${Gradle.flipcashNamespace}.shared.tipping"
}

dependencies {
testImplementation(kotlin("test"))
testImplementation(libs.bundles.unit.testing)
testImplementation(libs.robolectric)

implementation(project(":services:flipcash"))
implementation(project(":services:opencode"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.flipcash.shared.tipping

import com.flipcash.app.core.bill.Scannable
import com.flipcash.services.controllers.ProfileController
import com.flipcash.services.controllers.ResolverController
import com.flipcash.services.models.UserProfile
import com.flipcash.services.user.UserManager
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.core.OpenCodePayload
import com.getcode.opencode.model.core.PayloadKind
import com.getcode.opencode.model.core.UserId
import javax.inject.Inject
import javax.inject.Singleton

/**
* Orchestrates tipping flows.
*
* Resolves the [UserProfile] needed to render a tip card and assembles the scannable
* [Scannable.TipCard] itself — mirroring how the cash screen builds a [Scannable.Payable]
* bill to present. The tip code encodes the recipient's [ID] (a 16-byte user id).
*/
@Singleton
class TippingCoordinator @Inject constructor(
private val profileController: ProfileController,
private val userManager: UserManager,
private val resolverController: ResolverController,
) {
/** The signed-in user's id ([UserManager.accountId]), or null if unavailable. */
val currentUserId: ID?
get() = userManager.accountId

/**
* Resolves the [UserProfile] for [userId] — e.g. a tip counterparty identified by a
* scanned code — so a tip card can be rendered for them. Delegates to the server-backed
* profile fetch and propagates its [Result].
*/
suspend fun resolveProfile(userId: ID): Result<UserProfile> =
profileController.getProfileForUser(userId)

/**
* The current user's profile, used to build their own tip card. Prefers the cached
* [UserManager.profile] and falls back to a server refresh when it isn't available yet.
*/
suspend fun currentUserProfile(): Result<UserProfile> =
userManager.profile?.let { Result.success(it) }
?: profileController.updateUserProfile()

/**
* Builds the current user's own scannable tip card — their [currentUserId] encoded into the
* tip code, plus their profile for rendering — the way the cash screen builds a bill to
* present. Fails if there's no signed-in user or their profile can't be resolved.
*/
suspend fun resolveTipCard(): Result<Scannable.TipCard> {
val userId = currentUserId
?: return Result.failure(IllegalStateException("No signed-in user to build a tip card for"))
return currentUserProfile().map { tipCard(userId, it) }
}

/**
* Resolves [userId]'s profile and builds their scannable tip card — for generating a card
* for another user (e.g. a scanned counterparty).
*/
suspend fun resolveTipCard(userId: ID): Result<Scannable.TipCard> =
resolveProfile(userId).map { tipCard(userId, it) }

/**
* Assembles the scannable [Scannable.TipCard]: the tip [OpenCodePayload] encoding [userId]
* as the scannable code data, plus [profile] for rendering.
*/
private fun tipCard(userId: ID, profile: UserProfile): Scannable.TipCard {
val payload = OpenCodePayload(kind = PayloadKind.Tip, value = UserId(userId))
return Scannable.TipCard(data = payload.codeData.toList(), user = profile)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.flipcash.shared.tipping

import com.flipcash.services.controllers.ProfileController
import com.flipcash.services.controllers.ResolverController
import com.flipcash.services.models.UserProfile
import com.flipcash.services.user.UserManager
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame

class TippingCoordinatorTest {

private val profileController = mockk<ProfileController>()
private val userManager = mockk<UserManager>()
private val resolverController = mockk<ResolverController>()
private val coordinator = TippingCoordinator(profileController, userManager, resolverController)

private fun profile(name: String) = UserProfile(
displayName = name,
socialAccounts = emptyList(),
phoneNumber = null,
email = null,
)

@Test
fun `resolveProfile delegates to ProfileController`() = runTest {
val userId = listOf<Byte>(1, 2, 3)
val expected = profile("Alice")
coEvery { profileController.getProfileForUser(userId) } returns Result.success(expected)

val result = coordinator.resolveProfile(userId)

assertSame(expected, result.getOrNull())
}

@Test
fun `currentUserProfile returns the cached profile when present`() = runTest {
val cached = profile("Me")
every { userManager.profile } returns cached

val result = coordinator.currentUserProfile()

assertSame(cached, result.getOrNull())
}

@Test
fun `currentUserProfile refreshes from the server when nothing is cached`() = runTest {
val fetched = profile("Fresh")
every { userManager.profile } returns null
coEvery { profileController.updateUserProfile() } returns Result.success(fetched)

val result = coordinator.currentUserProfile()

assertSame(fetched, result.getOrNull())
}

@Test
fun `currentUserId reflects UserManager accountId`() {
val id = listOf<Byte>(9, 9)
every { userManager.accountId } returns id

assertEquals(id, coordinator.currentUserId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ class OpenCodePayloadTests {

@Test
fun tipPayloadEncoding() {
val userId: List<Byte> = (1..16).map { it.toByte() }
val payload = OpenCodePayload(
kind = PayloadKind.Tip,
value = Username("bob"),
value = UserId(userId),
)

val encoded = payload.encode()
Expand All @@ -42,23 +43,24 @@ class OpenCodePayloadTests {

assertEquals(OpenCodePayload.LENGTH, encoded.size)
assertEquals(PayloadKind.Tip.value, decoded.kind.value)
// The username is hash-padded on encode and recovered by stripping at the '.' delimiter.
assertEquals("bob", decoded.username)
// The 16-byte user id round-trips.
assertEquals(userId, decoded.userId)
// Tip payloads carry no fiat amount and no nonce.
assertNull(decoded.fiat)
assertEquals(emptyList<Byte>(), decoded.nonce)
}

@Test
fun tipPayloadEncodingFullLengthUsername() {
val username = "fifteencharname" // exactly USERNAME_LENGTH (15)
val payload = OpenCodePayload(
fun tipPayloadUserIdIsWrittenAtOffsetOne() {
val userId: List<Byte> = (1..16).map { it.toByte() }
val encoded = OpenCodePayload(
kind = PayloadKind.Tip,
value = Username(username),
)

val decoded = OpenCodePayload.Companion.fromList(payload.encode())
value = UserId(userId),
).encode()

assertEquals(username, decoded.username)
assertEquals(PayloadKind.Tip.value, encoded[0].toInt())
assertEquals(userId, encoded.subList(1, 17))
// Trailing bytes reserved / zero.
assertEquals(listOf<Byte>(0, 0, 0), encoded.subList(17, 20))
}
}
Loading
Loading