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 @@ -9,7 +9,7 @@ class AbbreviatedLinkTest {
fun `an id link keeps the first five characters of the id`() {
assertEquals(
"app.flipcash.com/tip/b0ced…",
"https://app.flipcash.com/tip/b0ced1f2a3b4c5d6".abbreviatedLink(),
"https://app.flipcash.com/tip/b0ced1f2-a3b4-c5d6-e7f8-091a2b3c4d5e".abbreviatedLink(),
)
}

Expand Down Expand Up @@ -41,7 +41,7 @@ class AbbreviatedLinkTest {
fun `a segment too long to be a handle is abbreviated`() {
assertEquals(
"flipcash.com/abcde…",
"https://flipcash.com/abcdefghijklmnop".abbreviatedLink(),
"https://flipcash.com/${"abcde" + "f".repeat(26)}".abbreviatedLink(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import com.flipcash.features.tipping.R
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.controllers.ProfileController
import com.flipcash.services.models.GetUserProfileError
import com.flipcash.services.models.MaxUsernameLength
import com.flipcash.services.models.MinUsernameLength
import com.flipcash.services.user.UserManager
import com.getcode.manager.BottomBarManager
import com.getcode.util.resources.ResourceHelper
Expand All @@ -23,12 +25,12 @@ import kotlinx.coroutines.flow.onEach
import javax.inject.Inject

/**
* The server's handle bounds — `^[a-z0-9_]{2,15}$`, the same pattern the claim screen writes against.
* Enforced as the field is typed so a lookup can't fail request validation, which surfaces only as a
* generic error.
* The contract's handle bounds — `common.v1.Username`, `^[a-z0-9_]{2,30}$`. Wider than what the claim
* screen lets someone pick, since this looks up handles the server assigned. Enforced as the field is
* typed so a lookup can't fail request validation, which surfaces only as a generic error.
*/
internal const val MinHandleLength = 2
internal const val MaxHandleLength = 15
internal const val MinHandleLength = MinUsernameLength
internal const val MaxHandleLength = MaxUsernameLength

/**
* Turning a typed `@handle` into somewhere to go.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.flipcash.app.userprofile.internal.username

import com.flipcash.services.models.MaxUsernameLength
import com.flipcash.services.models.MaxClaimableUsernameLength
import com.flipcash.services.models.MinUsernameLength

/**
Expand All @@ -13,12 +13,12 @@ import com.flipcash.services.models.MinUsernameLength
*/
internal sealed class LengthComplaint(message: String) : IllegalArgumentException(message) {
class TooShort : LengthComplaint("Username shorter than $MinUsernameLength characters")
class TooLong : LengthComplaint("Username longer than $MaxUsernameLength characters")
class TooLong : LengthComplaint("Username longer than $MaxClaimableUsernameLength characters")
}

/** The complaint [username] would earn on submit, or null when its length is acceptable. */
internal fun lengthComplaint(username: String): LengthComplaint? = when {
username.length < MinUsernameLength -> LengthComplaint.TooShort()
username.length > MaxUsernameLength -> LengthComplaint.TooLong()
username.length > MaxClaimableUsernameLength -> LengthComplaint.TooLong()
else -> null
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import com.flipcash.app.core.extensions.onResult
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.features.userprofile.R
import com.flipcash.services.controllers.ProfileController
import com.flipcash.services.models.MaxUsernameLength
import com.flipcash.services.models.MaxClaimableUsernameLength
import com.flipcash.services.models.MinUsernameLength
import com.flipcash.services.models.ModerationResult
import com.flipcash.services.models.SetUsernameError
Expand Down Expand Up @@ -188,7 +188,7 @@ class UsernameEntryViewModel @Inject constructor(
title = resources.getString(R.string.error_title_usernameTooLong),
message = resources.getString(
R.string.error_description_usernameTooLong,
MaxUsernameLength,
MaxClaimableUsernameLength,
),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.flipcash.app.userprofile.internal.username

import com.flipcash.services.models.MaxUsernameLength
import com.flipcash.services.models.MaxClaimableUsernameLength
import com.flipcash.services.models.MinUsernameLength
import kotlin.test.Test
import kotlin.test.assertIs
Expand All @@ -25,12 +25,12 @@ class LengthComplaintTest {

@Test
fun `exactly the maximum passes`() {
assertNull(lengthComplaint("a".repeat(MaxUsernameLength)))
assertNull(lengthComplaint("a".repeat(MaxClaimableUsernameLength)))
}

@Test
fun `one character past the maximum is too long`() {
assertIs<LengthComplaint.TooLong>(lengthComplaint("a".repeat(MaxUsernameLength + 1)))
assertIs<LengthComplaint.TooLong>(lengthComplaint("a".repeat(MaxClaimableUsernameLength + 1)))
}

// The charset is the input transformation's job, not this one's — a handle of the right length
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,16 @@ class MentionDetectionTest {
}

@Test
fun `handles outside 2 to 15 characters are not mentions`() {
fun `handles outside 2 to 30 characters are not mentions`() {
assertEquals(emptyList(), handles("@a"))
assertEquals(emptyList(), handles("@abcdefghijklmnop"))
assertEquals(listOf("abcdefghijklmno"), handles("@abcdefghijklmno"))
assertEquals(emptyList(), handles("@" + "a".repeat(31)))
assertEquals(listOf("a".repeat(30)), handles("@" + "a".repeat(30)))
}

@Test
fun `a handle longer than 15 characters is a mention`() {
assertEquals(listOf("ted_livingston_2"), handles("hi @ted_livingston_2!"))
assertEquals(listOf("ted_livingston_2"), handles("hi @Ted_Livingston_2"))
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.shared.chat.ui

import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.isUsernameShaped

/**
* One `@handle` found in message text: the span it occupies, covering the `@` and the handle, and
Expand Down Expand Up @@ -33,14 +34,20 @@ fun detectMentions(text: String, links: List<DetectedUrl>): List<DetectedMention
val start = mention.range.first
val end = mention.range.last + 1
if (links.any { it.start < end && start < it.end }) return@mapNotNull null
DetectedMention(start = start, end = end, username = handle.value.lowercase())
val username = handle.value.lowercase()
if (!username.isUsernameShaped()) return@mapNotNull null
DetectedMention(start = start, end = end, username = username)
}.toList()
}

// The preceding character is matched and left out of the mention's range, as iOS does for want of
// a lookbehind in Swift Regex, so the two platforms run one pattern.
//
// The handle's length is not bounded here: the candidate is any run of handle characters, and
// `detectMentions` confirms each against `common.v1.Username` via `isUsernameShaped()` (on the
// lowercased text, since usernames are stored lowercase), so the contract owns the 2..30 bound.
private val MENTION_PATTERN =
Regex("""(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@])""")
Regex("""(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]+))(?![A-Za-z0-9_@])""")

/**
* The mentions in the text this content's bubble draws, as offsets into that text: the body of a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -771,12 +771,19 @@ class AppRouterTest {

@Test
fun `classify ignores a vanity path that isn't shaped like a handle`() {
// Too short, too long, and outside the server's charset.
// Too short, too long, and outside the contract's charset.
assertNull(router.classify(DeepLink("https://flipcash.com/a")))
assertNull(router.classify(DeepLink("https://flipcash.com/sixteencharacter")))
assertNull(router.classify(DeepLink("https://flipcash.com/${"a".repeat(31)}")))
assertNull(router.classify(DeepLink("https://flipcash.com/sally.streamer")))
}

@Test
fun `classify opens a vanity handle past the claim cap`() {
val type = router.classify(DeepLink("https://flipcash.com/ted_livingston_2"))
assertIs<DeeplinkType.Profile>(type)
assertEquals(ProfileAddress.ByUsername("ted_livingston_2"), type.address)
}

@Test
fun `classify ignores a multi-segment path on the vanity host`() {
assertNull(router.classify(DeepLink("https://flipcash.com/sally/streamer")))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,43 @@
package com.flipcash.services.models

import com.codeinc.flipcash.gen.common.v1.Common
import com.codeinc.flipcash.gen.common.v1.validate
import dev.bmcreations.protovalidate.ValidationResult

/**
* The `@` a handle is shown with.
*
* Usernames are stored, sent and matched bare — the server's `^[a-z0-9_]{2,15}$` charset has no room
* Usernames are stored, sent and matched bare — the contract's `^[a-z0-9_]{2,30}$` charset has no room
* for an `@`, and [ProfileIdentifier.Username] / [ResolveIdentifier.Username] carry them that way.
* The prefix is presentation, so it is added here rather than at each surface that shows one.
*/
const val HandlePrefix = "@"

/** Mirrors the server's `^[a-z0-9_]{2,15}$` validation on `common.v1.Username`. */
/**
* The bounds `common.v1.Username` allows (`^[a-z0-9_]{2,30}$`). What a handle may be on the wire, so
* what the app must be able to read, deeplink and mention; [isUsernameShaped] asks the generated
* validator rather than restating them.
*/
const val MinUsernameLength = 2
const val MaxUsernameLength = 15
const val MaxUsernameLength = 30

/**
* The server's own charset and bounds. Kept here rather than at the one screen that types a
* username, because two other places have to recognise one without being able to ask the server:
* the `flipcash.com/<username>` deeplink, which must not mistake `/download` for a handle, and the
* tip card's link row, which decides whether a URL's last segment is readable or an opaque id.
* The longest username the claim screen lets someone pick. A product limit tighter than the
* contract's [MaxUsernameLength]: handles the server assigns can run longer than this.
*/
private val UsernamePattern = Regex("^[a-z0-9_]{$MinUsernameLength,$MaxUsernameLength}$")
const val MaxClaimableUsernameLength = 15

/**
* Whether this could be a username — the charset and length the server accepts, with or without a
* leading `@`. A yes means only "shaped like one"; whether it is actually claimed is the server's
* answer, not ours.
* Whether this could be a username — what `common.v1.Username` accepts, with or without a leading
* `@`. A yes means only "shaped like one"; whether it is actually claimed is the server's answer.
*
* Delegates to the contract's generated validator so the protos stay the single source of truth.
* Two places rely on it without being able to ask the server: the `flipcash.com/<username>`
* deeplink, which must not mistake `/download` for a handle, and the tip card's link row, which
* decides whether a URL's last segment is readable or an opaque id.
*/
fun String.isUsernameShaped(): Boolean = UsernamePattern.matches(removePrefix(HandlePrefix))
fun String.isUsernameShaped(): Boolean =
Common.Username.newBuilder().setValue(removePrefix(HandlePrefix)).build().validate() is ValidationResult.Valid

/**
* This username as a handle — `sally_streamer` becomes `@sally_streamer`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue

/**
* The shape rule stands in for the server's `^[a-z0-9_]{2,15}$`, and three places rely on it without
* The shape rule stands in for the contract's `^[a-z0-9_]{2,30}$`, and three places rely on it without
* being able to ask: the vanity deeplink, the tip card's link row, and the entry screen's own
* pre-submit check. Mirrors iOS `UsernameValidatorTests`.
*/
Expand Down Expand Up @@ -35,6 +35,15 @@ class HandleTest {
assertTrue("a".repeat(MaxUsernameLength).isUsernameShaped())
}

@Test
fun `handles past the claim cap are still username shaped`() {
assertTrue("ted_livingston_2".isUsernameShaped())
assertTrue("@ted_livingston_2".isUsernameShaped())
assertTrue("a".repeat(MaxClaimableUsernameLength + 1).isUsernameShaped())
assertTrue("a".repeat(30).isUsernameShaped())
assertFalse("a".repeat(31).isUsernameShaped())
}

@Test
fun `either side of the length range is not`() {
assertFalse("a".repeat(MinUsernameLength - 1).isUsernameShaped())
Expand Down
Loading