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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ jobs:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v5
# Lint first: a lint failure should not cost a full APK build.
# Lint and tests first: neither should cost a full APK build.
- name: Lint
run: ./gradlew :app:lintDebug
- name: Unit tests
run: ./gradlew :app:testDebugUnitTest
- name: Build debug APK
run: ./gradlew :app:assembleDebug
# Unsigned, but proves the R8/minify path before launch week does.
Expand Down
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,9 @@ dependencies {
implementation(libs.navigation3.ui)
implementation(libs.kotlinx.serialization.json)
implementation(libs.spoo.sdk)

testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
}
21 changes: 19 additions & 2 deletions app/src/main/kotlin/me/spoo/android/data/Links.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@ data class SpooLink(
/** Whether known bots are kept from following the link. */
val blockBots: Boolean = false,
val createdAtMillis: Long? = null,
/** Most recent click, when the link has ever been clicked. */
val lastClickMillis: Long? = null,
) {
val shortUrl: String get() = "spoo.me/$shortCode"
val active: Boolean get() = status == LinkUiStatus.Active
val clickLimited: Boolean get() = maxClicks != null
}

/** Sort order for the links list. */
enum class LinkSort { Recent, Clicks }
enum class LinkSort { Recent, Clicks, LastClick }

/**
* The server-side view of the links list. Search, sort and filters ride
Expand Down Expand Up @@ -111,14 +113,27 @@ data class EmojiChoice(
val keywords: List<String> = emptyList(),
)

/** Why an alias can't be used, or [Free] when it can. */
enum class AliasStatus { Free, Taken, Reserved, Invalid, Unknown }

/** The accepted emoji catalogue plus the alias-length policy. */
data class EmojiCatalog(
val maxGraphemes: Int,
val entries: List<EmojiChoice>,
)

/** A dimension the stats screens can filter by. */
enum class StatsDim { Country, Browser, Os, Referrer }
enum class StatsDim(
/** Human label; the enum name alone reads UTMSOURCE. */
val display: String,
) {
Country("Country"),
Browser("Browser"),
Os("OS"),
Referrer("Referrer"),
Device("Device"),
UtmSource("UTM source"),
}

/** The countable thing a stats query asks for. */
enum class StatsMetric { Clicks, UniqueClicks }
Expand All @@ -143,6 +158,8 @@ data class LinkStats(
val browsers: List<Slice>,
val os: List<Slice>,
val referrers: List<Slice>,
val devices: List<Slice> = emptyList(),
val utmSources: List<Slice> = emptyList(),
) {
data class Slice(
val label: String,
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/kotlin/me/spoo/android/data/LinksRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,7 @@ interface LinksRepository {

/** The accepted emoji-alias catalogue; changes rarely, cached upstream. */
suspend fun emojiCatalog(): EmojiCatalog

/** Whether [alias] can be claimed, and when not, why. */
suspend fun aliasStatus(alias: String): AliasStatus
}
46 changes: 46 additions & 0 deletions app/src/main/kotlin/me/spoo/android/data/MockLinksRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class MockLinksRepository : LinksRepository {
when (query.sort) {
LinkSort.Recent -> list
LinkSort.Clicks -> list.sortedByDescending { it.totalClicks }
LinkSort.LastClick -> list.sortedByDescending { it.lastClickMillis ?: Long.MIN_VALUE }
}
}
_links.value = full.take(visible)
Expand Down Expand Up @@ -271,6 +272,19 @@ class MockLinksRepository : LinksRepository {
return EmojiCatalog(maxGraphemes = 15, entries = MOCK_EMOJI)
}

override suspend fun aliasStatus(alias: String): AliasStatus {
delay(250)
// Aliases are case-sensitive server-side; RESERVED_ALIASES stands in
// for the platform's reserved set so the copy path is demoable.
return when {
alias.lowercase() in RESERVED_ALIASES -> AliasStatus.Reserved
!alias.any(Char::isLetterOrDigit) && alias.isNotEmpty() -> AliasStatus.Free
alias.length < 3 && alias.all { it.code < 128 } -> AliasStatus.Invalid
all.value.any { it.shortCode == alias } -> AliasStatus.Taken
else -> AliasStatus.Free
}
}

private val statsCache = mutableMapOf<String, LinkStats>()

override fun cachedStats(
Expand Down Expand Up @@ -332,6 +346,8 @@ class MockLinksRepository : LinksRepository {
share(REFERRERS, params.filters[StatsDim.Referrer])?.let { total *= it }
share(BROWSERS, params.filters[StatsDim.Browser])?.let { total *= it }
share(OSES, params.filters[StatsDim.Os])?.let { total *= it }
share(DEVICES, params.filters[StatsDim.Device])?.let { total *= it }
share(UTM_SOURCES, params.filters[StatsDim.UtmSource])?.let { total *= it }

val points = days.coerceIn(7, 120)
val weights =
Expand Down Expand Up @@ -365,6 +381,8 @@ class MockLinksRepository : LinksRepository {
browsers = slices(BROWSERS, params.filters[StatsDim.Browser]),
os = slices(OSES, params.filters[StatsDim.Os]),
referrers = slices(REFERRERS, params.filters[StatsDim.Referrer]),
devices = slices(DEVICES, params.filters[StatsDim.Device]),
utmSources = slices(UTM_SOURCES, params.filters[StatsDim.UtmSource]),
)
}

Expand All @@ -390,6 +408,16 @@ class MockLinksRepository : LinksRepository {
maxClicks = maxClicks,
expireAtMillis = expiresInDays?.let { System.currentTimeMillis() + it * 86_400_000L },
createdAtMillis = System.currentTimeMillis() - ageDays * 86_400_000L,
// Busier links were clicked more recently, always inside the
// link's own lifetime; deterministic per link.
lastClickMillis =
if (clicks == 0) {
null
} else {
val lifetime = ageDays.coerceAtLeast(1) * 86_400_000L
val idle = lifetime / (1 + clicks / 500).coerceAtMost(24)
System.currentTimeMillis() - (idle + id.hashCode().mod(6) * 3_600_000L).coerceAtMost(lifetime)
},
)

private companion object {
Expand Down Expand Up @@ -439,6 +467,24 @@ class MockLinksRepository : LinksRepository {
"Linux" to 0.09f,
)

val DEVICES =
listOf(
"mobile" to 0.58f,
"desktop" to 0.33f,
"tablet" to 0.07f,
"unknown" to 0.02f,
)
val UTM_SOURCES =
listOf(
"(none)" to 0.61f,
"newsletter" to 0.14f,
"twitter" to 0.11f,
"producthunt" to 0.08f,
"discord" to 0.06f,
)

val RESERVED_ALIASES = setOf("api", "login", "signup", "admin", "stats", "dashboard")

// Offline stand-in for /api/v1/emoji-set: real groups, tiny slices.
val MOCK_EMOJI =
listOf(
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/kotlin/me/spoo/android/data/SdkLinksRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import me.spoo.AccountStatsRequest
import me.spoo.AliasIssue
import me.spoo.AliasKind
import me.spoo.AuthenticationException
import me.spoo.Dimension
Expand Down Expand Up @@ -90,6 +91,7 @@ class SdkLinksRepository(
when (sort) {
LinkSort.Recent -> SortBy.CREATED_AT
LinkSort.Clicks -> SortBy.TOTAL_CLICKS
LinkSort.LastClick -> SortBy.LAST_CLICK
},
sortOrder = SortOrder.DESCENDING,
// The typed status param only speaks active/inactive; expired and
Expand Down Expand Up @@ -231,6 +233,18 @@ class SdkLinksRepository(
// changes; the set is public and near-static, so one fetch per process.
private var cachedCatalog: EmojiCatalog? = null

override suspend fun aliasStatus(alias: String): AliasStatus =
withSession {
val check = clientProvider().links.checkAlias(alias)
when {
check.available -> AliasStatus.Free
check.reason == AliasIssue.TAKEN -> AliasStatus.Taken
check.reason == AliasIssue.RESERVED -> AliasStatus.Reserved
check.reason != null -> AliasStatus.Invalid
else -> AliasStatus.Unknown
}
}

override suspend fun emojiCatalog(): EmojiCatalog {
cachedCatalog?.let { return it }
val set = clientProvider().emoji.set()
Expand Down Expand Up @@ -303,6 +317,8 @@ class SdkLinksRepository(
Dimension.BROWSER,
Dimension.OS,
Dimension.REFERRER,
Dimension.DEVICE,
Dimension.UTM_SOURCE,
),
metrics =
listOf(
Expand All @@ -318,6 +334,8 @@ class SdkLinksRepository(
StatsDim.Browser -> FilterDimension.BROWSER
StatsDim.Os -> FilterDimension.OS
StatsDim.Referrer -> FilterDimension.REFERRER
StatsDim.Device -> FilterDimension.DEVICE
StatsDim.UtmSource -> FilterDimension.UTM_SOURCE
} to values.toList()
},
)
Expand All @@ -332,6 +350,8 @@ class SdkLinksRepository(
browsers = slices("${metricKey}_by_browser", metricKey),
os = slices("${metricKey}_by_os", metricKey),
referrers = slices("${metricKey}_by_referrer", metricKey),
devices = slices("${metricKey}_by_device", metricKey),
utmSources = slices("${metricKey}_by_utm_source", metricKey),
)

private fun Map<String, List<JsonObject>>.slices(
Expand Down Expand Up @@ -380,6 +400,7 @@ class SdkLinksRepository(
privateStats = privateStats ?: false,
blockBots = blockBots ?: false,
createdAtMillis = createdAt?.toEpochMilliseconds(),
lastClickMillis = lastClick?.toEpochMilliseconds(),
)

private fun Instant.toDayLabel(): String = SimpleDateFormat("MMM d", Locale.US).format(Date(toEpochMilliseconds()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,6 @@ class SwitchingLinksRepository(
) = active.cachedStats(shortCode, params)

override suspend fun emojiCatalog() = active.emojiCatalog()

override suspend fun aliasStatus(alias: String) = active.aliasStatus(alias)
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand All @@ -64,6 +65,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
Expand All @@ -73,6 +76,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.spoo.android.data.AliasStatus
import me.spoo.android.data.CreateLinkRequest
import me.spoo.android.data.EmojiCatalog
import me.spoo.android.data.ErrorField
Expand All @@ -90,6 +94,8 @@ fun CreateLinkSheet(
initialUrl: String?,
state: CreateState,
emojiCatalog: EmojiCatalog?,
aliasStatus: AliasStatus,
onAliasChanged: (String) -> Unit,
onEmojiMode: () -> Unit,
onSubmit: (CreateLinkRequest) -> Unit,
onDismiss: () -> Unit,
Expand Down Expand Up @@ -134,6 +140,8 @@ fun CreateLinkSheet(
submitting = state is CreateState.Submitting,
error = (state as? CreateState.Failed)?.error,
emojiCatalog = emojiCatalog,
aliasStatus = aliasStatus,
onAliasChanged = onAliasChanged,
onEmojiMode = {
onEmojiMode()
// The picker deserves the room: pop the sheet open.
Expand All @@ -155,6 +163,8 @@ private fun FormPhase(
submitting: Boolean,
error: FriendlyError?,
emojiCatalog: EmojiCatalog?,
aliasStatus: AliasStatus,
onAliasChanged: (String) -> Unit,
onEmojiMode: () -> Unit,
onSubmit: (CreateLinkRequest) -> Unit,
) {
Expand All @@ -175,6 +185,19 @@ private fun FormPhase(
val emojiCount = emojiPicks.codePointCount(0, emojiPicks.length)
val emojiMax = emojiCatalog?.maxGraphemes ?: Int.MAX_VALUE

// Live availability: report the effective alias upstream, debounced
// and checked in the view model; "taken" comes back as [aliasTaken].
val effectiveAlias = if (emojiAlias) emojiPicks else alias.trim()
LaunchedEffect(effectiveAlias) { onAliasChanged(effectiveAlias) }
val aliasBlocked = aliasStatus != AliasStatus.Free && aliasStatus != AliasStatus.Unknown
val aliasMessage =
when (aliasStatus) {
AliasStatus.Taken -> "Already taken"
AliasStatus.Reserved -> "Reserved by spoo.me"
AliasStatus.Invalid -> "Not a usable alias"
else -> null
}

// Prevention first, server as backstop: local checks gate the button;
// whatever still comes back lands on the field the server names.
val urlOk = isLikelyUrl(url)
Expand Down Expand Up @@ -216,15 +239,40 @@ private fun FormPhase(
// icon swaps back. In emoji mode the field is a read-only composed
// display, so an invalid alias can't be typed.
OutlinedTextField(
value = if (emojiAlias) emojiPicks.emojiPresentationAll() else alias,
// Emoji live in the prefix slot as Fluent artwork. The text slot
// carries a zero-width space when picks exist: an empty unfocused
// field hides its prefix and floats the label back down.
value =
when {
!emojiAlias -> alias
emojiPicks.isEmpty() -> ""
else -> "\u200B"
},
// The API's alias charset, enforced at the keyboard: no error to show.
onValueChange = { if (!emojiAlias) alias = it.filter(::isAliasChar) },
modifier = Modifier.fillMaxWidth(),
readOnly = emojiAlias,
label = { Text(if (emojiAlias) "Emoji alias" else "Alias") },
placeholder = { Text("Random if empty", maxLines = 1) },
prefix = { Text("spoo.me/") },
isError = error?.field == ErrorField.Alias,
placeholder =
if (emojiAlias && emojiPicks.isNotEmpty()) {
null
} else {
{ Text("Random if empty", maxLines = 1) }
},
prefix = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("spoo.me/")
if (emojiAlias && emojiPicks.isNotEmpty()) {
EmojiText(
emojiPicks.emojiPresentationAll(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.semantics { contentDescription = "Alias $emojiPicks" },
)
}
}
},
isError = aliasBlocked || error?.field == ErrorField.Alias,
trailingIcon = {
Row {
if (emojiAlias && emojiPicks.isNotEmpty()) {
Expand All @@ -241,7 +289,10 @@ private fun FormPhase(
IconButton(
onClick = {
emojiAlias = !emojiAlias
if (emojiAlias) onEmojiMode()
// Leaving emoji mode drops the picks: keeping them
// hidden would submit a random code while the user
// believes an alias is set.
if (emojiAlias) onEmojiMode() else emojiPicks = ""
},
enabled = !submitting,
) {
Expand All @@ -255,6 +306,7 @@ private fun FormPhase(
supportingText =
when {
error?.field == ErrorField.Alias -> ({ Text(error.message) })
aliasMessage != null -> ({ Text(aliasMessage) })
emojiAlias && emojiCatalog != null && emojiCount > 0 ->
({ Text("$emojiCount/${emojiCatalog.maxGraphemes}") })
else -> null
Expand Down Expand Up @@ -410,7 +462,7 @@ private fun FormPhase(
)
},
modifier = Modifier.fillMaxWidth(),
enabled = !submitting && urlOk && passwordOk,
enabled = !submitting && urlOk && passwordOk && !aliasBlocked,
) {
if (submitting) {
LoadingIndicator(modifier = Modifier.height(24.dp))
Expand Down
Loading
Loading