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 @@ -17,6 +17,10 @@ service Profile {

rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse);

// SetUsername sets the caller's username, replacing any username already
// set.
rpc SetUsername(SetUsernameRequest) returns (SetUsernameResponse);

// SetProfilePicture sets the caller's profile picture to a blob they have
// already uploaded via BlobStorage, replacing any picture already set.
//
Expand Down Expand Up @@ -90,6 +94,31 @@ message SetDisplayNameResponse {
moderation.v1.FlaggedCategory flagged_category = 2;
}

message SetUsernameRequest {
// Username is the new username to set.
common.v1.Username username = 1 [(validate.rules).message.required = true];

common.v1.Auth auth = 10 [(validate.rules).message.required = true];
}

message SetUsernameResponse {
Result result = 1;
enum Result {
OK = 0;
INVALID_USERNAME = 1;
DENIED = 2;
ALREADY_TAKEN = 3;
FAILED_MODERATED = 4;
INSUFFICIENT_BALANCE = 5;
RESERVED_WORD = 6;
}

// The best-fit category that tripped moderation, mirroring the Moderation
// service's vocabulary. Set only when result == FAILED_MODERATED; NONE
// otherwise.
moderation.v1.FlaggedCategory flagged_category = 2;
}

message SetProfilePictureRequest {
// The blob holding the ORIGINAL image the caller uploaded. It must be owned
// by the caller and READY; the server derives the remaining renditions from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ class ProfileController @Inject constructor(
.onSuccess { mergeLocalProfile { it.copy(displayName = displayName) } }
}

/**
* Claims [username] as the caller's public Flipcash handle, replacing any
* username already set.
*/
suspend fun setUsername(
username: String,
): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.setUsername(username, owner)
// Reflect the change locally so anything observing the profile (e.g. a setup flow
// deciding which steps remain) sees it without waiting for a refresh.
.onSuccess { mergeLocalProfile { it.copy(username = username) } }
}

/**
* Updates the caller's tip card customization with the given hex color string.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,25 @@ internal class ProfileApi @Inject constructor(
}
}

/**
* Sets the username for a user, replacing any username already set.
*/
suspend fun setUsername(
username: String,
owner: Ed25519.KeyPair
): ProfileService.SetUsernameResponse {
val request = ProfileService.SetUsernameRequest.newBuilder()
.setUsername(username.asUsername())
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

return withContext(Dispatchers.IO) {
api.setUsername(request)
}
}

/**
* Sets the caller's profile picture to a blob they have already uploaded via
* BlobStorage. The server derives the DISPLAY/THUMBNAIL renditions and returns
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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.SetUsernameError
import com.flipcash.services.models.SocialAccountLinkRequest
import com.flipcash.services.models.SocialAccountUnlinkRequest
import com.flipcash.services.models.UnlinkSocialAccountError
Expand Down Expand Up @@ -63,6 +64,31 @@ internal class ProfileService @Inject constructor(
)
}

suspend fun setUsername(
username: String,
owner: Ed25519.KeyPair,
): Result<Unit> {
return runCatching {
api.setUsername(username, owner)
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
ProfileService.SetUsernameResponse.Result.OK -> Result.success(Unit)
ProfileService.SetUsernameResponse.Result.INVALID_USERNAME -> Result.failure(SetUsernameError.InvalidUsername())
ProfileService.SetUsernameResponse.Result.DENIED -> Result.failure(SetUsernameError.Denied())
ProfileService.SetUsernameResponse.Result.ALREADY_TAKEN -> Result.failure(SetUsernameError.AlreadyTaken())
ProfileService.SetUsernameResponse.Result.FAILED_MODERATED ->
Result.failure(SetUsernameError.FailedModerated(response.flaggedCategory.toFlaggedCategory()))
ProfileService.SetUsernameResponse.Result.INSUFFICIENT_BALANCE -> Result.failure(SetUsernameError.InsufficientBalance())
ProfileService.SetUsernameResponse.Result.RESERVED_WORD -> Result.failure(SetUsernameError.ReservedWord())
ProfileService.SetUsernameResponse.Result.UNRECOGNIZED -> Result.failure(SetUsernameError.Unrecognized())
null -> Result.failure(SetUsernameError.Unrecognized())
}
},
onFailure = { Result.failure(it.toValidationOrElse { cause -> SetUsernameError.Other(cause) }) }
)
}

suspend fun setProfilePicture(
blobId: BlobId,
owner: Ed25519.KeyPair,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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.SetDisplayNameError
import com.flipcash.services.models.SetUsernameError
import com.flipcash.services.models.SocialAccount
import com.flipcash.services.models.SocialAccountLinkRequest
import com.flipcash.services.models.SocialAccountUnlinkRequest
Expand Down Expand Up @@ -38,7 +40,34 @@ internal class InternalProfileRepository(
owner: Ed25519.KeyPair
): Result<Unit> {
return service.setDisplayName(displayName, owner)
.onFailure { ErrorUtils.handleError(it) }
.onFailure {
// The rejections below are the server answering a user's choice of
// display name, not a fault worth reporting.
val expected = it is SetDisplayNameError.InvalidDisplayName ||
it is SetDisplayNameError.FailedModerated
if (!expected) {
ErrorUtils.handleError(it)
}
}
}

override suspend fun setUsername(
username: String,
owner: Ed25519.KeyPair
): Result<Unit> {
return service.setUsername(username, owner)
.onFailure {
// The rejections below are the server answering a user's choice of
// username, not a fault worth reporting.
val expected = it is SetUsernameError.InvalidUsername ||
it is SetUsernameError.AlreadyTaken ||
it is SetUsernameError.ReservedWord ||
it is SetUsernameError.FailedModerated ||
it is SetUsernameError.InsufficientBalance
if (!expected) {
ErrorUtils.handleError(it)
}
}
}

override suspend fun setProfilePicture(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,23 @@ sealed class SetDisplayNameError(
data class Other(override val cause: Throwable? = null) : SetDisplayNameError(message = cause?.message, cause = cause), NotifiableError
}

sealed class SetUsernameError(
override val message: String? = null,
override val cause: Throwable? = null
): CodeServerError(message, cause) {
class InvalidUsername: SetUsernameError("Invalid username")
class Denied: SetUsernameError("Denied")
// Another user already holds this username.
class AlreadyTaken: SetUsernameError("Username already taken")
class FailedModerated(val category: ModerationResult.FlaggedCategory) : SetUsernameError("Content flagged: $category")
// Claiming this username costs more than the caller can pay.
class InsufficientBalance: SetUsernameError("Insufficient balance")
// The username is on the server's reserved list and cannot be claimed.
class ReservedWord: SetUsernameError("Reserved word")
class Unrecognized : SetUsernameError("Unrecognized"), NotifiableError
data class Other(override val cause: Throwable? = null) : SetUsernameError(message = cause?.message, cause = cause), NotifiableError
}

sealed class SetProfilePictureError(
override val message: String? = null,
override val cause: Throwable? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.getcode.ed25519.Ed25519
interface ProfileRepository {
suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair): Result<UserProfile>
suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair): Result<Unit>
suspend fun setUsername(username: 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>
suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair): Result<SocialAccount>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,13 +302,15 @@ class ProfileControllerTest {
private class FakeProfileRepository : ProfileRepository {
var getProfileResult: Result<UserProfile> = Result.failure(RuntimeException("not configured"))
var setDisplayNameResult: Result<Unit> = Result.success(Unit)
var setUsernameResult: Result<Unit> = Result.success(Unit)
var setProfilePictureResult: Result<MediaItem> = Result.failure(RuntimeException("not configured"))
var updateTipCardResult: Result<Unit> = Result.success(Unit)
var linkSocialAccountResult: Result<SocialAccount> = Result.failure(RuntimeException("not configured"))
var unlinkSocialAccountResult: Result<Unit> = Result.success(Unit)

override suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair) = getProfileResult
override suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair) = setDisplayNameResult
override suspend fun setUsername(username: String, owner: Ed25519.KeyPair) = setUsernameResult
override suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair) = setProfilePictureResult
override suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String) = updateTipCardResult
override suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair) =
Expand Down
Loading