Skip to content

Commit 095c6d7

Browse files
authored
Merge pull request #3 from spoo-me/feat/tests-and-gaps
Tests, API-backed feature gaps, and Fluent emoji everywhere
2 parents 4b796b8 + 10728b1 commit 095c6d7

24 files changed

Lines changed: 848 additions & 31 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ jobs:
3232
distribution: temurin
3333
java-version: 21
3434
- uses: gradle/actions/setup-gradle@v5
35-
# Lint first: a lint failure should not cost a full APK build.
35+
# Lint and tests first: neither should cost a full APK build.
3636
- name: Lint
3737
run: ./gradlew :app:lintDebug
38+
- name: Unit tests
39+
run: ./gradlew :app:testDebugUnitTest
3840
- name: Build debug APK
3941
run: ./gradlew :app:assembleDebug
4042
# Unsigned, but proves the R8/minify path before launch week does.

app/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,9 @@ dependencies {
8383
implementation(libs.navigation3.ui)
8484
implementation(libs.kotlinx.serialization.json)
8585
implementation(libs.spoo.sdk)
86+
87+
testImplementation(libs.junit)
88+
testImplementation(libs.kotlin.test)
89+
testImplementation(libs.kotlinx.coroutines.test)
90+
testImplementation(libs.turbine)
8691
}

app/src/main/kotlin/me/spoo/android/data/Links.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,16 @@ data class SpooLink(
2323
/** Whether known bots are kept from following the link. */
2424
val blockBots: Boolean = false,
2525
val createdAtMillis: Long? = null,
26+
/** Most recent click, when the link has ever been clicked. */
27+
val lastClickMillis: Long? = null,
2628
) {
2729
val shortUrl: String get() = "spoo.me/$shortCode"
2830
val active: Boolean get() = status == LinkUiStatus.Active
2931
val clickLimited: Boolean get() = maxClicks != null
3032
}
3133

3234
/** Sort order for the links list. */
33-
enum class LinkSort { Recent, Clicks }
35+
enum class LinkSort { Recent, Clicks, LastClick }
3436

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

116+
/** Why an alias can't be used, or [Free] when it can. */
117+
enum class AliasStatus { Free, Taken, Reserved, Invalid, Unknown }
118+
114119
/** The accepted emoji catalogue plus the alias-length policy. */
115120
data class EmojiCatalog(
116121
val maxGraphemes: Int,
117122
val entries: List<EmojiChoice>,
118123
)
119124

120125
/** A dimension the stats screens can filter by. */
121-
enum class StatsDim { Country, Browser, Os, Referrer }
126+
enum class StatsDim(
127+
/** Human label; the enum name alone reads UTMSOURCE. */
128+
val display: String,
129+
) {
130+
Country("Country"),
131+
Browser("Browser"),
132+
Os("OS"),
133+
Referrer("Referrer"),
134+
Device("Device"),
135+
UtmSource("UTM source"),
136+
}
122137

123138
/** The countable thing a stats query asks for. */
124139
enum class StatsMetric { Clicks, UniqueClicks }
@@ -143,6 +158,8 @@ data class LinkStats(
143158
val browsers: List<Slice>,
144159
val os: List<Slice>,
145160
val referrers: List<Slice>,
161+
val devices: List<Slice> = emptyList(),
162+
val utmSources: List<Slice> = emptyList(),
146163
) {
147164
data class Slice(
148165
val label: String,

app/src/main/kotlin/me/spoo/android/data/LinksRepository.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,7 @@ interface LinksRepository {
5858

5959
/** The accepted emoji-alias catalogue; changes rarely, cached upstream. */
6060
suspend fun emojiCatalog(): EmojiCatalog
61+
62+
/** Whether [alias] can be claimed, and when not, why. */
63+
suspend fun aliasStatus(alias: String): AliasStatus
6164
}

app/src/main/kotlin/me/spoo/android/data/MockLinksRepository.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class MockLinksRepository : LinksRepository {
113113
when (query.sort) {
114114
LinkSort.Recent -> list
115115
LinkSort.Clicks -> list.sortedByDescending { it.totalClicks }
116+
LinkSort.LastClick -> list.sortedByDescending { it.lastClickMillis ?: Long.MIN_VALUE }
116117
}
117118
}
118119
_links.value = full.take(visible)
@@ -271,6 +272,19 @@ class MockLinksRepository : LinksRepository {
271272
return EmojiCatalog(maxGraphemes = 15, entries = MOCK_EMOJI)
272273
}
273274

275+
override suspend fun aliasStatus(alias: String): AliasStatus {
276+
delay(250)
277+
// Aliases are case-sensitive server-side; RESERVED_ALIASES stands in
278+
// for the platform's reserved set so the copy path is demoable.
279+
return when {
280+
alias.lowercase() in RESERVED_ALIASES -> AliasStatus.Reserved
281+
!alias.any(Char::isLetterOrDigit) && alias.isNotEmpty() -> AliasStatus.Free
282+
alias.length < 3 && alias.all { it.code < 128 } -> AliasStatus.Invalid
283+
all.value.any { it.shortCode == alias } -> AliasStatus.Taken
284+
else -> AliasStatus.Free
285+
}
286+
}
287+
274288
private val statsCache = mutableMapOf<String, LinkStats>()
275289

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

336352
val points = days.coerceIn(7, 120)
337353
val weights =
@@ -365,6 +381,8 @@ class MockLinksRepository : LinksRepository {
365381
browsers = slices(BROWSERS, params.filters[StatsDim.Browser]),
366382
os = slices(OSES, params.filters[StatsDim.Os]),
367383
referrers = slices(REFERRERS, params.filters[StatsDim.Referrer]),
384+
devices = slices(DEVICES, params.filters[StatsDim.Device]),
385+
utmSources = slices(UTM_SOURCES, params.filters[StatsDim.UtmSource]),
368386
)
369387
}
370388

@@ -390,6 +408,16 @@ class MockLinksRepository : LinksRepository {
390408
maxClicks = maxClicks,
391409
expireAtMillis = expiresInDays?.let { System.currentTimeMillis() + it * 86_400_000L },
392410
createdAtMillis = System.currentTimeMillis() - ageDays * 86_400_000L,
411+
// Busier links were clicked more recently, always inside the
412+
// link's own lifetime; deterministic per link.
413+
lastClickMillis =
414+
if (clicks == 0) {
415+
null
416+
} else {
417+
val lifetime = ageDays.coerceAtLeast(1) * 86_400_000L
418+
val idle = lifetime / (1 + clicks / 500).coerceAtMost(24)
419+
System.currentTimeMillis() - (idle + id.hashCode().mod(6) * 3_600_000L).coerceAtMost(lifetime)
420+
},
393421
)
394422

395423
private companion object {
@@ -439,6 +467,24 @@ class MockLinksRepository : LinksRepository {
439467
"Linux" to 0.09f,
440468
)
441469

470+
val DEVICES =
471+
listOf(
472+
"mobile" to 0.58f,
473+
"desktop" to 0.33f,
474+
"tablet" to 0.07f,
475+
"unknown" to 0.02f,
476+
)
477+
val UTM_SOURCES =
478+
listOf(
479+
"(none)" to 0.61f,
480+
"newsletter" to 0.14f,
481+
"twitter" to 0.11f,
482+
"producthunt" to 0.08f,
483+
"discord" to 0.06f,
484+
)
485+
486+
val RESERVED_ALIASES = setOf("api", "login", "signup", "admin", "stats", "dashboard")
487+
442488
// Offline stand-in for /api/v1/emoji-set: real groups, tiny slices.
443489
val MOCK_EMOJI =
444490
listOf(

app/src/main/kotlin/me/spoo/android/data/SdkLinksRepository.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import kotlinx.serialization.json.contentOrNull
99
import kotlinx.serialization.json.jsonPrimitive
1010
import kotlinx.serialization.json.longOrNull
1111
import me.spoo.AccountStatsRequest
12+
import me.spoo.AliasIssue
1213
import me.spoo.AliasKind
1314
import me.spoo.AuthenticationException
1415
import me.spoo.Dimension
@@ -90,6 +91,7 @@ class SdkLinksRepository(
9091
when (sort) {
9192
LinkSort.Recent -> SortBy.CREATED_AT
9293
LinkSort.Clicks -> SortBy.TOTAL_CLICKS
94+
LinkSort.LastClick -> SortBy.LAST_CLICK
9395
},
9496
sortOrder = SortOrder.DESCENDING,
9597
// The typed status param only speaks active/inactive; expired and
@@ -231,6 +233,18 @@ class SdkLinksRepository(
231233
// changes; the set is public and near-static, so one fetch per process.
232234
private var cachedCatalog: EmojiCatalog? = null
233235

236+
override suspend fun aliasStatus(alias: String): AliasStatus =
237+
withSession {
238+
val check = clientProvider().links.checkAlias(alias)
239+
when {
240+
check.available -> AliasStatus.Free
241+
check.reason == AliasIssue.TAKEN -> AliasStatus.Taken
242+
check.reason == AliasIssue.RESERVED -> AliasStatus.Reserved
243+
check.reason != null -> AliasStatus.Invalid
244+
else -> AliasStatus.Unknown
245+
}
246+
}
247+
234248
override suspend fun emojiCatalog(): EmojiCatalog {
235249
cachedCatalog?.let { return it }
236250
val set = clientProvider().emoji.set()
@@ -303,6 +317,8 @@ class SdkLinksRepository(
303317
Dimension.BROWSER,
304318
Dimension.OS,
305319
Dimension.REFERRER,
320+
Dimension.DEVICE,
321+
Dimension.UTM_SOURCE,
306322
),
307323
metrics =
308324
listOf(
@@ -318,6 +334,8 @@ class SdkLinksRepository(
318334
StatsDim.Browser -> FilterDimension.BROWSER
319335
StatsDim.Os -> FilterDimension.OS
320336
StatsDim.Referrer -> FilterDimension.REFERRER
337+
StatsDim.Device -> FilterDimension.DEVICE
338+
StatsDim.UtmSource -> FilterDimension.UTM_SOURCE
321339
} to values.toList()
322340
},
323341
)
@@ -332,6 +350,8 @@ class SdkLinksRepository(
332350
browsers = slices("${metricKey}_by_browser", metricKey),
333351
os = slices("${metricKey}_by_os", metricKey),
334352
referrers = slices("${metricKey}_by_referrer", metricKey),
353+
devices = slices("${metricKey}_by_device", metricKey),
354+
utmSources = slices("${metricKey}_by_utm_source", metricKey),
335355
)
336356

337357
private fun Map<String, List<JsonObject>>.slices(
@@ -380,6 +400,7 @@ class SdkLinksRepository(
380400
privateStats = privateStats ?: false,
381401
blockBots = blockBots ?: false,
382402
createdAtMillis = createdAt?.toEpochMilliseconds(),
403+
lastClickMillis = lastClick?.toEpochMilliseconds(),
383404
)
384405

385406
private fun Instant.toDayLabel(): String = SimpleDateFormat("MMM d", Locale.US).format(Date(toEpochMilliseconds()))

app/src/main/kotlin/me/spoo/android/data/SwitchingLinksRepository.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,6 @@ class SwitchingLinksRepository(
8181
) = active.cachedStats(shortCode, params)
8282

8383
override suspend fun emojiCatalog() = active.emojiCatalog()
84+
85+
override suspend fun aliasStatus(alias: String) = active.aliasStatus(alias)
8486
}

app/src/main/kotlin/me/spoo/android/ui/components/CreateLinkSheet.kt

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import androidx.compose.material3.TooltipDefaults
5454
import androidx.compose.material3.rememberModalBottomSheetState
5555
import androidx.compose.material3.rememberTooltipState
5656
import androidx.compose.runtime.Composable
57+
import androidx.compose.runtime.LaunchedEffect
5758
import androidx.compose.runtime.getValue
5859
import androidx.compose.runtime.mutableStateOf
5960
import androidx.compose.runtime.remember
@@ -64,6 +65,8 @@ import androidx.compose.ui.Alignment
6465
import androidx.compose.ui.Modifier
6566
import androidx.compose.ui.platform.LocalClipboardManager
6667
import androidx.compose.ui.platform.LocalContext
68+
import androidx.compose.ui.semantics.contentDescription
69+
import androidx.compose.ui.semantics.semantics
6770
import androidx.compose.ui.text.AnnotatedString
6871
import androidx.compose.ui.text.input.KeyboardType
6972
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -73,6 +76,7 @@ import androidx.compose.ui.unit.IntSize
7376
import androidx.compose.ui.unit.dp
7477
import androidx.compose.ui.unit.sp
7578
import kotlinx.coroutines.launch
79+
import me.spoo.android.data.AliasStatus
7680
import me.spoo.android.data.CreateLinkRequest
7781
import me.spoo.android.data.EmojiCatalog
7882
import me.spoo.android.data.ErrorField
@@ -90,6 +94,8 @@ fun CreateLinkSheet(
9094
initialUrl: String?,
9195
state: CreateState,
9296
emojiCatalog: EmojiCatalog?,
97+
aliasStatus: AliasStatus,
98+
onAliasChanged: (String) -> Unit,
9399
onEmojiMode: () -> Unit,
94100
onSubmit: (CreateLinkRequest) -> Unit,
95101
onDismiss: () -> Unit,
@@ -134,6 +140,8 @@ fun CreateLinkSheet(
134140
submitting = state is CreateState.Submitting,
135141
error = (state as? CreateState.Failed)?.error,
136142
emojiCatalog = emojiCatalog,
143+
aliasStatus = aliasStatus,
144+
onAliasChanged = onAliasChanged,
137145
onEmojiMode = {
138146
onEmojiMode()
139147
// The picker deserves the room: pop the sheet open.
@@ -155,6 +163,8 @@ private fun FormPhase(
155163
submitting: Boolean,
156164
error: FriendlyError?,
157165
emojiCatalog: EmojiCatalog?,
166+
aliasStatus: AliasStatus,
167+
onAliasChanged: (String) -> Unit,
158168
onEmojiMode: () -> Unit,
159169
onSubmit: (CreateLinkRequest) -> Unit,
160170
) {
@@ -175,6 +185,19 @@ private fun FormPhase(
175185
val emojiCount = emojiPicks.codePointCount(0, emojiPicks.length)
176186
val emojiMax = emojiCatalog?.maxGraphemes ?: Int.MAX_VALUE
177187

188+
// Live availability: report the effective alias upstream, debounced
189+
// and checked in the view model; "taken" comes back as [aliasTaken].
190+
val effectiveAlias = if (emojiAlias) emojiPicks else alias.trim()
191+
LaunchedEffect(effectiveAlias) { onAliasChanged(effectiveAlias) }
192+
val aliasBlocked = aliasStatus != AliasStatus.Free && aliasStatus != AliasStatus.Unknown
193+
val aliasMessage =
194+
when (aliasStatus) {
195+
AliasStatus.Taken -> "Already taken"
196+
AliasStatus.Reserved -> "Reserved by spoo.me"
197+
AliasStatus.Invalid -> "Not a usable alias"
198+
else -> null
199+
}
200+
178201
// Prevention first, server as backstop: local checks gate the button;
179202
// whatever still comes back lands on the field the server names.
180203
val urlOk = isLikelyUrl(url)
@@ -216,15 +239,40 @@ private fun FormPhase(
216239
// icon swaps back. In emoji mode the field is a read-only composed
217240
// display, so an invalid alias can't be typed.
218241
OutlinedTextField(
219-
value = if (emojiAlias) emojiPicks.emojiPresentationAll() else alias,
242+
// Emoji live in the prefix slot as Fluent artwork. The text slot
243+
// carries a zero-width space when picks exist: an empty unfocused
244+
// field hides its prefix and floats the label back down.
245+
value =
246+
when {
247+
!emojiAlias -> alias
248+
emojiPicks.isEmpty() -> ""
249+
else -> "\u200B"
250+
},
220251
// The API's alias charset, enforced at the keyboard: no error to show.
221252
onValueChange = { if (!emojiAlias) alias = it.filter(::isAliasChar) },
222253
modifier = Modifier.fillMaxWidth(),
223254
readOnly = emojiAlias,
224255
label = { Text(if (emojiAlias) "Emoji alias" else "Alias") },
225-
placeholder = { Text("Random if empty", maxLines = 1) },
226-
prefix = { Text("spoo.me/") },
227-
isError = error?.field == ErrorField.Alias,
256+
placeholder =
257+
if (emojiAlias && emojiPicks.isNotEmpty()) {
258+
null
259+
} else {
260+
{ Text("Random if empty", maxLines = 1) }
261+
},
262+
prefix = {
263+
Row(verticalAlignment = Alignment.CenterVertically) {
264+
Text("spoo.me/")
265+
if (emojiAlias && emojiPicks.isNotEmpty()) {
266+
EmojiText(
267+
emojiPicks.emojiPresentationAll(),
268+
maxLines = 1,
269+
overflow = TextOverflow.Ellipsis,
270+
modifier = Modifier.semantics { contentDescription = "Alias $emojiPicks" },
271+
)
272+
}
273+
}
274+
},
275+
isError = aliasBlocked || error?.field == ErrorField.Alias,
228276
trailingIcon = {
229277
Row {
230278
if (emojiAlias && emojiPicks.isNotEmpty()) {
@@ -241,7 +289,10 @@ private fun FormPhase(
241289
IconButton(
242290
onClick = {
243291
emojiAlias = !emojiAlias
244-
if (emojiAlias) onEmojiMode()
292+
// Leaving emoji mode drops the picks: keeping them
293+
// hidden would submit a random code while the user
294+
// believes an alias is set.
295+
if (emojiAlias) onEmojiMode() else emojiPicks = ""
245296
},
246297
enabled = !submitting,
247298
) {
@@ -255,6 +306,7 @@ private fun FormPhase(
255306
supportingText =
256307
when {
257308
error?.field == ErrorField.Alias -> ({ Text(error.message) })
309+
aliasMessage != null -> ({ Text(aliasMessage) })
258310
emojiAlias && emojiCatalog != null && emojiCount > 0 ->
259311
({ Text("$emojiCount/${emojiCatalog.maxGraphemes}") })
260312
else -> null
@@ -410,7 +462,7 @@ private fun FormPhase(
410462
)
411463
},
412464
modifier = Modifier.fillMaxWidth(),
413-
enabled = !submitting && urlOk && passwordOk,
465+
enabled = !submitting && urlOk && passwordOk && !aliasBlocked,
414466
) {
415467
if (submitting) {
416468
LoadingIndicator(modifier = Modifier.height(24.dp))

0 commit comments

Comments
 (0)