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 @@ -61,6 +61,13 @@ message UserId {
}];
}

// Username is a user's unique handle on Flipcash. It uses the same character
// set as X — letters, digits and underscores — with the exception that it must
// be lowercase.
message Username {
string value = 1 [(validate.rules).string.pattern = "^[a-z0-9_]{2,15}$"];
}

message ChatId {
// value has the following structure:
// - 32 byte hash for DMs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,20 @@ import "google/protobuf/timestamp.proto";
import "validate/validate.proto";

message UserProfile {
// The ID of the user this profile belongs to. Always set, so a caller that
// looked the profile up by username learns the user's ID from the response.
common.v1.UserId user_id = 9 [(validate.rules).message.required = true];

// Display name is the display name of the user (if found).
string display_name = 1 [(validate.rules).string = {
min_len: 0
max_len: 64
}];

// The user's username on Flipcash. Public, so it is returned for any user,
// not just the caller. Unset when the user hasn't claimed one yet.
common.v1.Username username = 8;

// Social profiles are links to external social accounts
repeated SocialProfile social_profiles = 2 [(validate.rules).repeated = {
min_items: 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ service Profile {
}

message GetProfileRequest {
common.v1.UserId user_id = 1 [(validate.rules).message.required = true];
// The user whose profile is being fetched, identified either by their user
// ID or by their username. Exactly one must be set.
oneof identifier {
option (validate.required) = true;

common.v1.UserId user_id = 1;
common.v1.Username username = 3;
}

// Optional auth to retrieve private profile information for self
common.v1.Auth auth = 2;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ message Identifier {
oneof kind {
option (validate.required) = true;

common.v1.PhoneNumber phone = 1;
common.v1.UserId user_id = 2;
common.v1.PhoneNumber phone = 1;
common.v1.UserId user_id = 2;
common.v1.Username username = 3;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.services.controllers

import com.flipcash.services.models.GetUserProfileError
import com.flipcash.services.models.LinkingToken
import com.flipcash.services.models.ProfileIdentifier
import com.flipcash.services.models.SocialAccount
import com.flipcash.services.models.SocialAccountLinkRequest
import com.flipcash.services.models.SocialAccountUnlinkRequest
Expand Down Expand Up @@ -68,11 +69,21 @@ class ProfileController @Inject constructor(

suspend fun getProfileForUser(
userId: ID,
): Result<UserProfile> {
): Result<UserProfile> = getProfile(ProfileIdentifier.UserId(userId))

/**
* Fetches a profile by its owner's public Flipcash handle. The response carries
* the user's ID, so a caller that only had the username learns it from here.
*/
suspend fun getProfileForUsername(
username: String,
): Result<UserProfile> = getProfile(ProfileIdentifier.Username(username))

private suspend fun getProfile(identifier: ProfileIdentifier): Result<UserProfile> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.getProfile(userId, owner)
return repository.getProfile(identifier, owner)
}

suspend fun setDisplayName(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ class ResolverController @Inject constructor(
suspend fun resolve(userId: ID): Result<PublicKey> =
resolve(ResolveIdentifier.UserId(userId))

/** Resolves a username to its owner's on-chain address. */
suspend fun resolve(username: String): Result<PublicKey> =
resolve(ResolveIdentifier.Username(username))

private suspend fun resolve(identifier: ResolveIdentifier): Result<PublicKey> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.flipcash.services.internal.domain
import com.codeinc.flipcash.gen.profile.v1.Model
import com.codeinc.flipcash.gen.profile.v1.emailAddressOrNull
import com.codeinc.flipcash.gen.profile.v1.phoneNumberOrNull
import com.codeinc.flipcash.gen.profile.v1.usernameOrNull
import com.flipcash.services.internal.network.extensions.toId
import com.flipcash.services.internal.network.extensions.toMediaItem
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.VerifiableContactMethod
Expand All @@ -25,6 +27,9 @@ class UserProfileMapper @Inject constructor(
Instant.fromEpochSeconds(from.joinTs.seconds, from.joinTs.nanos)
} else null,
tipCardColor = if (from.hasTipCardCustomization()) from.tipCardCustomization.color.hex else null,
userId = if (from.hasUserId()) from.userId.toId() else null,
// Public, so it is returned for any user — absent only when unclaimed.
username = from.usernameOrNull?.value,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import com.codeinc.flipcash.gen.profile.v1.ProfileGrpcKt
import com.codeinc.flipcash.gen.profile.v1.ProfileService
import com.flipcash.services.internal.annotations.FlipcashManagedChannel
import com.flipcash.services.internal.network.extensions.asUserId
import com.flipcash.services.internal.network.extensions.asUsername
import com.flipcash.services.internal.network.extensions.authenticate
import com.flipcash.services.internal.network.extensions.linkingToken
import com.flipcash.services.models.ProfileIdentifier
import com.flipcash.services.models.SocialAccountLinkRequest
import com.flipcash.services.models.SocialAccountUnlinkRequest
import com.flipcash.services.models.chat.BlobId
import com.getcode.ed25519.Ed25519
import com.getcode.opencode.internal.network.core.GrpcApi
import com.getcode.opencode.model.core.ID
import com.getcode.utils.toByteString
import com.codeinc.flipcash.gen.profile.v1.validate
import dev.bmcreations.protovalidate.orThrow
Expand All @@ -32,14 +33,24 @@ internal class ProfileApi @Inject constructor(
.withWaitForReady()

/**
* Gets the profile for a user
* Gets the profile for a user, keyed by either their user ID or their username.
*/
suspend fun getProfile(userId: ID, owner: Ed25519.KeyPair): ProfileService.GetProfileResponse {
suspend fun getProfile(
identifier: ProfileIdentifier,
owner: Ed25519.KeyPair,
): ProfileService.GetProfileResponse {
val request = ProfileService.GetProfileRequest.newBuilder()
.setUserId(userId.asUserId())
.apply {
when (identifier) {
is ProfileIdentifier.UserId -> setUserId(identifier.userId.asUserId())
is ProfileIdentifier.Username -> setUsername(identifier.username.asUsername())
}
}
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

return withContext(Dispatchers.IO) {
api.getProfile(request)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.flipcash.services.models.ResolveIdentifier
import com.flipcash.services.internal.annotations.FlipcashManagedChannel
import com.flipcash.services.internal.network.extensions.asUserId
import com.flipcash.services.internal.network.extensions.authenticate
import com.flipcash.services.internal.network.extensions.asUsername
import com.getcode.ed25519.Ed25519.KeyPair
import com.getcode.opencode.internal.network.core.GrpcApi
import dev.bmcreations.protovalidate.orThrow
Expand Down Expand Up @@ -50,6 +51,8 @@ internal class ResolverApi @Inject constructor(
builder.setPhone(Common.PhoneNumber.newBuilder().setValue(phone.phoneNumber))
is ResolveIdentifier.UserId ->
builder.setUserId(userId.asUserId())
is ResolveIdentifier.Username ->
builder.setUsername(username.asUsername())
}.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ internal fun ID.asUserId(): Common.UserId {
return Common.UserId.newBuilder().setValue(toByteString()).build()
}

internal fun String.asUsername(): Common.Username {
return Common.Username.newBuilder().setValue(this).build()
}

internal fun Instant.asTimestamp(): Timestamp {
return Timestamp.newBuilder().setSeconds(this.epochSeconds).build()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,8 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata {
phoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) },
email = emailAddress.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) },
profilePicture = if (hasProfilePicture()) profilePicture.toMediaItem() else null,
userId = if (hasUserId()) userId.toId() else null,
username = if (hasUsername()) username.value else null,
)
},
pointers = member.pointersList.map { it.toPointer() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.flipcash.services.internal.network.extensions.toFlaggedCategory
import com.getcode.opencode.utils.toValidationOrElse
import com.flipcash.services.models.GetUserProfileError
import com.flipcash.services.models.LinkSocialAccountError
import com.flipcash.services.models.ProfileIdentifier
import com.flipcash.services.models.SetDisplayNameError
import com.flipcash.services.models.SetProfilePictureError
import com.flipcash.services.models.SocialAccountLinkRequest
Expand All @@ -18,18 +19,17 @@ import com.flipcash.services.models.chat.MediaItem
import com.flipcash.services.internal.network.extensions.toMediaItem
import com.getcode.ed25519.Ed25519
import com.getcode.opencode.internal.network.extensions.foldWithSuppression
import com.getcode.opencode.model.core.ID
import javax.inject.Inject

internal class ProfileService @Inject constructor(
private val api: ProfileApi,
) {
suspend fun getProfile(
userId: ID,
identifier: ProfileIdentifier,
owner: Ed25519.KeyPair,
): Result<Model.UserProfile> {
return runCatching {
api.getProfile(userId, owner)
api.getProfile(identifier, owner)
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.flipcash.services.internal.domain.SocialAccountMapper
import com.flipcash.services.internal.domain.UserProfileMapper
import com.flipcash.services.internal.network.services.ProfileService
import com.flipcash.services.models.GetUserProfileError
import com.flipcash.services.models.ProfileIdentifier
import com.flipcash.services.models.SocialAccount
import com.flipcash.services.models.SocialAccountLinkRequest
import com.flipcash.services.models.SocialAccountUnlinkRequest
Expand All @@ -12,16 +13,18 @@ import com.flipcash.services.models.chat.BlobId
import com.flipcash.services.models.chat.MediaItem
import com.flipcash.services.repository.ProfileRepository
import com.getcode.ed25519.Ed25519
import com.getcode.opencode.model.core.ID
import com.getcode.utils.ErrorUtils

internal class InternalProfileRepository(
private val service: ProfileService,
private val userProfileMapper: UserProfileMapper,
private val socialAccountMapper: SocialAccountMapper,
): ProfileRepository {
override suspend fun getProfile(userId: ID, owner: Ed25519.KeyPair): Result<UserProfile> {
return service.getProfile(userId, owner)
override suspend fun getProfile(
identifier: ProfileIdentifier,
owner: Ed25519.KeyPair,
): Result<UserProfile> {
return service.getProfile(identifier, owner)
.map { userProfileMapper.map(it) }
.onFailure {
if (it !is GetUserProfileError.NotFound) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.flipcash.services.models

import com.getcode.opencode.model.core.ID

/**
* Whose profile to fetch. Mirrors the `GetProfileRequest.identifier` oneof: a
* profile is looked up by exactly one of a user ID or a username.
*/
sealed interface ProfileIdentifier {
data class UserId(val userId: ID) : ProfileIdentifier
data class Username(val username: String) : ProfileIdentifier
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import com.getcode.opencode.model.core.ID

/**
* What to resolve to an on-chain address. Mirrors the resolver `Identifier` oneof:
* a resolution is keyed by exactly one of a phone number or a user ID.
* a resolution is keyed by exactly one of a phone number, a user ID, or a username.
*/
sealed interface ResolveIdentifier {
data class Phone(val phone: ContactMethod.Phone) : ResolveIdentifier
data class UserId(val userId: ID) : ResolveIdentifier
data class Username(val username: String) : ResolveIdentifier
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.services.models

import android.os.Parcelable
import com.flipcash.services.models.chat.MediaItem
import com.getcode.opencode.model.core.ID
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlin.time.Instant
Expand All @@ -20,6 +21,12 @@ data class UserProfile(
val joinedAt: Instant? = null,
// The hex color for the user's tip card customization. Null when unset.
val tipCardColor: String? = null,
// The ID of the user this profile belongs to. Server-provided on any fetched
// profile; null for a locally-constructed one.
val userId: ID? = null,
// The user's public Flipcash handle. Public, so it is present for any user —
// null when they haven't claimed one yet.
val username: String? = null,
): Parcelable {
/** The phone number only when it has been verified — backwards-compatible accessor. */
val verifiedPhoneNumber: String? get() = phoneNumber?.takeIf { it.verified }?.value
Expand All @@ -34,6 +41,8 @@ data class UserProfile(
phoneNumber = null,
email = null,
tipCardColor = null,
userId = null,
username = null,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package com.flipcash.services.repository

import com.flipcash.services.models.ProfileIdentifier
import com.flipcash.services.models.SocialAccountLinkRequest
import com.flipcash.services.models.SocialAccount
import com.flipcash.services.models.SocialAccountUnlinkRequest
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.BlobId
import com.flipcash.services.models.chat.MediaItem
import com.getcode.ed25519.Ed25519
import com.getcode.opencode.model.core.ID

interface ProfileRepository {
suspend fun getProfile(userId: ID, owner: Ed25519.KeyPair): Result<UserProfile>
suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair): Result<UserProfile>
suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair): Result<Unit>
suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair): Result<MediaItem>
suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String): Result<Unit>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.VerifiableContactMethod
import com.flipcash.services.models.chat.BlobId
import com.flipcash.services.models.chat.MediaItem
import com.flipcash.services.models.ProfileIdentifier
import com.flipcash.services.repository.ProfileRepository
import com.flipcash.services.user.UserManager
import com.getcode.ed25519.Ed25519
Expand Down Expand Up @@ -306,7 +307,7 @@ private class FakeProfileRepository : ProfileRepository {
var linkSocialAccountResult: Result<SocialAccount> = Result.failure(RuntimeException("not configured"))
var unlinkSocialAccountResult: Result<Unit> = Result.success(Unit)

override suspend fun getProfile(userId: ID, owner: Ed25519.KeyPair) = getProfileResult
override suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair) = getProfileResult
override suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair) = setDisplayNameResult
override suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair) = setProfilePictureResult
override suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String) = updateTipCardResult
Expand Down
Loading