diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd8d94e..f3d5199 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 579b603..0bc7d7e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/main/kotlin/me/spoo/android/data/Links.kt b/app/src/main/kotlin/me/spoo/android/data/Links.kt index 2a768a2..aa0168f 100644 --- a/app/src/main/kotlin/me/spoo/android/data/Links.kt +++ b/app/src/main/kotlin/me/spoo/android/data/Links.kt @@ -23,6 +23,8 @@ 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 @@ -30,7 +32,7 @@ data class SpooLink( } /** 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 @@ -111,6 +113,9 @@ data class EmojiChoice( val keywords: List = 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, @@ -118,7 +123,17 @@ data class EmojiCatalog( ) /** 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 } @@ -143,6 +158,8 @@ data class LinkStats( val browsers: List, val os: List, val referrers: List, + val devices: List = emptyList(), + val utmSources: List = emptyList(), ) { data class Slice( val label: String, diff --git a/app/src/main/kotlin/me/spoo/android/data/LinksRepository.kt b/app/src/main/kotlin/me/spoo/android/data/LinksRepository.kt index 3e37cd7..caa88ef 100644 --- a/app/src/main/kotlin/me/spoo/android/data/LinksRepository.kt +++ b/app/src/main/kotlin/me/spoo/android/data/LinksRepository.kt @@ -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 } diff --git a/app/src/main/kotlin/me/spoo/android/data/MockLinksRepository.kt b/app/src/main/kotlin/me/spoo/android/data/MockLinksRepository.kt index 27630b2..d542da6 100644 --- a/app/src/main/kotlin/me/spoo/android/data/MockLinksRepository.kt +++ b/app/src/main/kotlin/me/spoo/android/data/MockLinksRepository.kt @@ -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) @@ -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() override fun cachedStats( @@ -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 = @@ -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]), ) } @@ -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 { @@ -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( diff --git a/app/src/main/kotlin/me/spoo/android/data/SdkLinksRepository.kt b/app/src/main/kotlin/me/spoo/android/data/SdkLinksRepository.kt index 0f4b4c1..47601c6 100644 --- a/app/src/main/kotlin/me/spoo/android/data/SdkLinksRepository.kt +++ b/app/src/main/kotlin/me/spoo/android/data/SdkLinksRepository.kt @@ -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 @@ -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 @@ -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() @@ -303,6 +317,8 @@ class SdkLinksRepository( Dimension.BROWSER, Dimension.OS, Dimension.REFERRER, + Dimension.DEVICE, + Dimension.UTM_SOURCE, ), metrics = listOf( @@ -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() }, ) @@ -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>.slices( @@ -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())) diff --git a/app/src/main/kotlin/me/spoo/android/data/SwitchingLinksRepository.kt b/app/src/main/kotlin/me/spoo/android/data/SwitchingLinksRepository.kt index 5e4f9fe..6adc0e3 100644 --- a/app/src/main/kotlin/me/spoo/android/data/SwitchingLinksRepository.kt +++ b/app/src/main/kotlin/me/spoo/android/data/SwitchingLinksRepository.kt @@ -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) } diff --git a/app/src/main/kotlin/me/spoo/android/ui/components/CreateLinkSheet.kt b/app/src/main/kotlin/me/spoo/android/ui/components/CreateLinkSheet.kt index 5f8dd9c..767ad6a 100644 --- a/app/src/main/kotlin/me/spoo/android/ui/components/CreateLinkSheet.kt +++ b/app/src/main/kotlin/me/spoo/android/ui/components/CreateLinkSheet.kt @@ -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 @@ -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 @@ -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 @@ -90,6 +94,8 @@ fun CreateLinkSheet( initialUrl: String?, state: CreateState, emojiCatalog: EmojiCatalog?, + aliasStatus: AliasStatus, + onAliasChanged: (String) -> Unit, onEmojiMode: () -> Unit, onSubmit: (CreateLinkRequest) -> Unit, onDismiss: () -> Unit, @@ -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. @@ -155,6 +163,8 @@ private fun FormPhase( submitting: Boolean, error: FriendlyError?, emojiCatalog: EmojiCatalog?, + aliasStatus: AliasStatus, + onAliasChanged: (String) -> Unit, onEmojiMode: () -> Unit, onSubmit: (CreateLinkRequest) -> Unit, ) { @@ -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) @@ -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()) { @@ -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, ) { @@ -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 @@ -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)) diff --git a/app/src/main/kotlin/me/spoo/android/ui/components/DimIcon.kt b/app/src/main/kotlin/me/spoo/android/ui/components/DimIcon.kt index 10c52ba..4fdb05f 100644 --- a/app/src/main/kotlin/me/spoo/android/ui/components/DimIcon.kt +++ b/app/src/main/kotlin/me/spoo/android/ui/components/DimIcon.kt @@ -5,7 +5,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Computer +import androidx.compose.material.icons.outlined.DeviceUnknown import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Smartphone +import androidx.compose.material.icons.outlined.TabletAndroid import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -134,6 +138,26 @@ private val BRAND_DOMAINS = "chrome os" to "chromeos.google", ) +/** Device-type glyph: the values are a fixed vocabulary, so real icons. */ +@Composable +fun DeviceIcon( + label: String, + modifier: Modifier = Modifier, + size: Dp = 20.dp, +) { + Icon( + when (label.lowercase()) { + "mobile" -> Icons.Outlined.Smartphone + "tablet" -> Icons.Outlined.TabletAndroid + "desktop" -> Icons.Outlined.Computer + else -> Icons.Outlined.DeviceUnknown + }, + contentDescription = label, + modifier = modifier.size(size), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + /** Neutral monogram circle for values with no natural artwork (browsers). */ @Composable fun Monogram( @@ -150,7 +174,7 @@ fun Monogram( contentAlignment = Alignment.Center, ) { Text( - label.firstOrNull()?.uppercase() ?: "?", + label.firstOrNull(Char::isLetterOrDigit)?.uppercase() ?: "?", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/kotlin/me/spoo/android/ui/components/FluentEmoji.kt b/app/src/main/kotlin/me/spoo/android/ui/components/FluentEmoji.kt index 69f946c..d195cbd 100644 --- a/app/src/main/kotlin/me/spoo/android/ui/components/FluentEmoji.kt +++ b/app/src/main/kotlin/me/spoo/android/ui/components/FluentEmoji.kt @@ -42,6 +42,20 @@ fun FluentEmoji( ) } +/** + * Whether a codepoint has bundled Fluent artwork. Deliberately narrow: a + * "not ASCII" test also catches the middle dot that separates every widget + * label, whose asset lookup then fails on every single render. + */ +internal fun isEmojiCodePoint(cp: Int): Boolean = + cp in 0x1F000..0x1FAFF || + cp in 0x2600..0x27BF || + cp in 0x2B00..0x2BFF || + cp in 0x2190..0x21FF + +/** Variation selector 16: a presentation hint, never its own glyph. */ +internal const val VARIATION_SELECTOR_16 = 0xFE0F + /** * Text whose emoji render as Fluent 3D inline images. Aliases are * emoji-only or ASCII-only (API rule), so anything non-ASCII here is an @@ -57,7 +71,7 @@ fun EmojiText( overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, ) { - if (text.all { it.code < 128 }) { + if (text.codePoints().noneMatch(::isEmojiCodePoint)) { Text( text, modifier = modifier, @@ -77,7 +91,11 @@ fun EmojiText( val cp = text.codePointAt(i) val count = Character.charCount(cp) val piece = text.substring(i, i + count) - if (cp < 128) { + if (cp == VARIATION_SELECTOR_16) { + i += count + continue + } + if (!isEmojiCodePoint(cp)) { append(piece) } else { val id = cp.toString(16) diff --git a/app/src/main/kotlin/me/spoo/android/ui/components/StatsContent.kt b/app/src/main/kotlin/me/spoo/android/ui/components/StatsContent.kt index f444121..fc1fbbe 100644 --- a/app/src/main/kotlin/me/spoo/android/ui/components/StatsContent.kt +++ b/app/src/main/kotlin/me/spoo/android/ui/components/StatsContent.kt @@ -233,6 +233,36 @@ fun StatsContent( }, ) } + item(key = "devices") { + Breakdown( + title = "Devices", + slices = stats.devices, + activeValues = params.filters[StatsDim.Device].orEmpty(), + labelFor = { it.replaceFirstChar(Char::uppercase) }, + icon = { DeviceIcon(it) }, + onToggle = + if (filterable) { + { onParamsChange(params.toggling(StatsDim.Device, it)) } + } else { + null + }, + ) + } + item(key = "utm-sources") { + Breakdown( + title = "UTM sources", + slices = stats.utmSources, + activeValues = params.filters[StatsDim.UtmSource].orEmpty(), + labelFor = { it }, + icon = { Monogram(it) }, + onToggle = + if (filterable) { + { onParamsChange(params.toggling(StatsDim.UtmSource, it)) } + } else { + null + }, + ) + } item(key = "countries") { Breakdown( title = "Countries", diff --git a/app/src/main/kotlin/me/spoo/android/ui/screens/AnalyticsScreen.kt b/app/src/main/kotlin/me/spoo/android/ui/screens/AnalyticsScreen.kt index 81bd2cf..eeb205f 100644 --- a/app/src/main/kotlin/me/spoo/android/ui/screens/AnalyticsScreen.kt +++ b/app/src/main/kotlin/me/spoo/android/ui/screens/AnalyticsScreen.kt @@ -60,6 +60,7 @@ import me.spoo.android.data.StatsParams import me.spoo.android.ui.components.BottomFade import me.spoo.android.ui.components.BrandIcon import me.spoo.android.ui.components.CountryFlag +import me.spoo.android.ui.components.DeviceIcon import me.spoo.android.ui.components.Favicon import me.spoo.android.ui.components.Monogram import me.spoo.android.ui.components.StatsContent @@ -287,6 +288,24 @@ private fun FilterSheet( if (value.contains('.')) Favicon(value, size = 18.dp) else Monogram(value, size = 18.dp) }, ) + FilterGroup( + "Device", + stats?.devices, + StatsDim.Device, + params, + onParamsChange, + labelFor = { it.replaceFirstChar(Char::uppercase) }, + icon = { DeviceIcon(it, size = 18.dp) }, + ) + FilterGroup( + "UTM source", + stats?.utmSources, + StatsDim.UtmSource, + params, + onParamsChange, + labelFor = { it }, + icon = { Monogram(it, size = 18.dp) }, + ) Spacer(Modifier.height(sheetBottomPadding())) } } diff --git a/app/src/main/kotlin/me/spoo/android/ui/screens/LinksScreen.kt b/app/src/main/kotlin/me/spoo/android/ui/screens/LinksScreen.kt index 704b273..a206022 100644 --- a/app/src/main/kotlin/me/spoo/android/ui/screens/LinksScreen.kt +++ b/app/src/main/kotlin/me/spoo/android/ui/screens/LinksScreen.kt @@ -176,6 +176,7 @@ fun LinksScreen( val sort by viewModel.sort.collectAsState() val createState by viewModel.createState.collectAsState() val emojiCatalog by viewModel.emojiCatalog.collectAsState() + val aliasStatus by viewModel.aliasStatus.collectAsState() val editState by viewModel.editState.collectAsState() val actionMessage by viewModel.actionMessage.collectAsState() val selection by viewModel.selection.collectAsState() @@ -357,8 +358,13 @@ fun LinksScreen( ToggleButton( checked = sort == LinkSort.Clicks, onCheckedChange = { viewModel.sort.value = LinkSort.Clicks }, - shapes = ButtonGroupDefaults.connectedTrailingButtonShapes(), + shapes = ButtonGroupDefaults.connectedMiddleButtonShapes(), ) { Text("Top clicks") } + ToggleButton( + checked = sort == LinkSort.LastClick, + onCheckedChange = { viewModel.sort.value = LinkSort.LastClick }, + shapes = ButtonGroupDefaults.connectedTrailingButtonShapes(), + ) { Text("Active") } Spacer(Modifier.weight(1f)) // Bare at rest; a secondaryContainer tint appears only // while a filter is active (state via affordance, no @@ -514,6 +520,8 @@ fun LinksScreen( initialUrl = sharedUrl, state = createState, emojiCatalog = emojiCatalog, + aliasStatus = aliasStatus, + onAliasChanged = { viewModel.aliasInput.value = it }, onEmojiMode = viewModel::ensureEmojiCatalog, onSubmit = viewModel::create, onDismiss = { diff --git a/app/src/main/kotlin/me/spoo/android/ui/screens/links/LinksViewModel.kt b/app/src/main/kotlin/me/spoo/android/ui/screens/links/LinksViewModel.kt index 57de85e..30429a6 100644 --- a/app/src/main/kotlin/me/spoo/android/ui/screens/links/LinksViewModel.kt +++ b/app/src/main/kotlin/me/spoo/android/ui/screens/links/LinksViewModel.kt @@ -3,6 +3,7 @@ package me.spoo.android.ui.screens.links import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -12,9 +13,11 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import me.spoo.android.SpooApp +import me.spoo.android.data.AliasStatus import me.spoo.android.data.CreateLinkRequest import me.spoo.android.data.EmojiCatalog import me.spoo.android.data.FriendlyError @@ -55,7 +58,7 @@ sealed interface EditState { ) : EditState } -@OptIn(FlowPreview::class) +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) class LinksViewModel( private val repository: LinksRepository = SpooApp.graph.linksRepository, ) : ViewModel() { @@ -75,6 +78,25 @@ class LinksViewModel( } } + /** The alias currently typed in the create sheet, for live checking. */ + val aliasInput = MutableStateFlow("") + + /** + * Why [aliasInput] can't be used, or [AliasStatus.Free]. Prevention + * only: anything unknown passes and the server stays the backstop. + */ + val aliasStatus: StateFlow = + aliasInput + .debounce(400) + .distinctUntilChanged() + .mapLatest { alias -> + if (alias.isBlank()) { + AliasStatus.Free + } else { + runCatching { repository.aliasStatus(alias) }.getOrDefault(AliasStatus.Unknown) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AliasStatus.Free) + /** Pull-to-refresh. */ val refreshing = MutableStateFlow(false) @@ -146,6 +168,10 @@ class LinksViewModel( fun resetCreate() { _createState.value = CreateState.Idle + // A StateFlow conflates an identical value, so a surviving draft + // would never re-check its alias on reopen and could stay stuck + // on a stale "taken". + aliasInput.value = "" } private val _editState = MutableStateFlow(EditState.Idle) diff --git a/app/src/main/kotlin/me/spoo/android/widget/SpooWidget.kt b/app/src/main/kotlin/me/spoo/android/widget/SpooWidget.kt index 055a77e..74ef573 100644 --- a/app/src/main/kotlin/me/spoo/android/widget/SpooWidget.kt +++ b/app/src/main/kotlin/me/spoo/android/widget/SpooWidget.kt @@ -2,6 +2,7 @@ package me.spoo.android.widget import android.content.Context import android.content.Intent +import android.graphics.Bitmap import androidx.compose.runtime.remember import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp @@ -30,11 +31,13 @@ import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.padding +import androidx.glance.layout.size import androidx.glance.material3.ColorProviders import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.text.FontFamily @@ -44,6 +47,8 @@ import androidx.glance.text.TextStyle import kotlinx.coroutines.flow.first import me.spoo.android.MainActivity import me.spoo.android.SpooApp +import me.spoo.android.ui.components.VARIATION_SELECTOR_16 +import me.spoo.android.ui.components.isEmojiCodePoint import me.spoo.android.ui.theme.spooColorScheme import java.text.NumberFormat @@ -229,16 +234,7 @@ class SpooWidget : GlanceAppWidget() { Alignment.Top }, ) { - Text( - config.label, - style = - TextStyle( - color = GlanceTheme.colors.onSurfaceVariant, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - ), - maxLines = 1, - ) + EmojiLabel(context, config.label) Spacer(GlanceModifier.height(4.dp)) val label = NumberFormat.getIntegerInstance().format(data.total) val compact = size.width.value < 220f || size.height.value < 100f @@ -288,6 +284,72 @@ class SpooWidget : GlanceAppWidget() { } } + /** + * The mono micro-label, with emoji drawn from the bundled Fluent + * artwork: a Row can mix text runs with asset-backed images. + * + * Glance only ships generated layouts up to ten children, so a label + * that would need more falls back to one Text — the system font still + * draws the emoji, just not in Fluent. + */ + @androidx.compose.runtime.Composable + private fun EmojiLabel( + context: Context, + label: String, + ) { + val style = + TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + ) + // Segment first so the child count is known before emitting: a + // Row that overflows Glance's cap renders nothing at all. + val parts = mutableListOf>() + val run = StringBuilder() + var i = 0 + while (i < label.length) { + val cp = label.codePointAt(i) + val count = Character.charCount(cp) + val piece = label.substring(i, i + count) + val art = if (isEmojiCodePoint(cp)) WidgetIconCache.emoji(context, cp) else null + if (art == null) { + if (cp != VARIATION_SELECTOR_16) run.append(piece) + } else { + if (run.isNotEmpty()) { + parts += run.toString() to null + run.clear() + } + parts += piece to art + } + i += count + } + if (run.isNotEmpty()) parts += run.toString() to null + + if (parts.none { it.second != null } || parts.size > MAX_LABEL_CHILDREN) { + Text(label, style = style, maxLines = 1) + return + } + Row(verticalAlignment = Alignment.CenterVertically) { + parts.forEach { (text, art) -> + if (art == null) { + Text(text, style = style, maxLines = 1) + } else { + Image( + provider = ImageProvider(art), + contentDescription = text, + modifier = GlanceModifier.size(13.dp), + ) + } + } + } + } + + private companion object { + // Glance ships generated layouts for at most this many children. + const val MAX_LABEL_CHILDREN = 10 + } + @androidx.compose.runtime.Composable private fun SignedOutContent(context: Context) { Column( diff --git a/app/src/main/kotlin/me/spoo/android/widget/WidgetConfig.kt b/app/src/main/kotlin/me/spoo/android/widget/WidgetConfig.kt index d7d55c3..888f09f 100644 --- a/app/src/main/kotlin/me/spoo/android/widget/WidgetConfig.kt +++ b/app/src/main/kotlin/me/spoo/android/widget/WidgetConfig.kt @@ -73,7 +73,7 @@ data class WidgetConfig( get() = listOfNotNull( scope?.let { "/$it" }, - if (chart.timeChart) null else effectiveDimension.name.uppercase(), + if (chart.timeChart) null else effectiveDimension.display.uppercase(), metricLabel, rangeLabel, if (filters.isNotEmpty()) "FILTERED" else null, @@ -109,7 +109,9 @@ data class WidgetData( val series: List, val slices: List, ) { - fun encodeSlices() = slices.joinToString("\n") { "${it.label}\t${it.count}" } + // Tab-separated, newline-delimited: a label carrying either would + // corrupt the record, so escape on the way in. + fun encodeSlices() = slices.joinToString("\n") { "${it.label.escapeSeparators()}\t${it.count}" } companion object { fun decodeSlices(raw: String?): List = @@ -117,8 +119,42 @@ data class WidgetData( val tab = line.lastIndexOf('\t') if (tab <= 0) return@mapNotNull null val count = line.substring(tab + 1).toIntOrNull() ?: return@mapNotNull null - LinkStats.Slice(line.substring(0, tab), count) + LinkStats.Slice(line.substring(0, tab).unescapeSeparators(), count) } + + private fun String.escapeSeparators() = replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t") + + private fun String.unescapeSeparators(): String { + val out = StringBuilder(length) + var i = 0 + while (i < length) { + val c = this[i] + if (c == '\\' && i + 1 < length) { + when (this[i + 1]) { + 'n' -> { + out.append('\n') + i += 2 + } + 't' -> { + out.append('\t') + i += 2 + } + '\\' -> { + out.append('\\') + i += 2 + } + else -> { + out.append(c) + i++ + } + } + } else { + out.append(c) + i++ + } + } + return out.toString() + } } } @@ -132,6 +168,8 @@ fun LinkStats.toWidgetData(config: WidgetConfig) = StatsDim.Browser -> browsers StatsDim.Os -> os StatsDim.Referrer -> referrers + StatsDim.Device -> devices + StatsDim.UtmSource -> utmSources }, ) diff --git a/app/src/main/kotlin/me/spoo/android/widget/WidgetConfigActivity.kt b/app/src/main/kotlin/me/spoo/android/widget/WidgetConfigActivity.kt index 6e116b3..e9a759a 100644 --- a/app/src/main/kotlin/me/spoo/android/widget/WidgetConfigActivity.kt +++ b/app/src/main/kotlin/me/spoo/android/widget/WidgetConfigActivity.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumFlexibleTopAppBar import androidx.compose.material3.RadioButton @@ -83,6 +84,8 @@ import me.spoo.android.data.StatsDim import me.spoo.android.data.StatsMetric import me.spoo.android.ui.components.BrandIcon import me.spoo.android.ui.components.CountryFlag +import me.spoo.android.ui.components.DeviceIcon +import me.spoo.android.ui.components.EmojiText import me.spoo.android.ui.components.Favicon import me.spoo.android.ui.components.Monogram import me.spoo.android.ui.components.countryDisplayName @@ -321,7 +324,19 @@ private fun ConfigScreen( Triple(StatsDim.Browser, "Browser", null), Triple(StatsDim.Os, "OS", null), Triple(StatsDim.Referrer, "Referrer", null), + ), + selected = config.dimension, + onSelect = { config = config.copy(dimension = it) }, + ) + } + item { Spacer(Modifier.height(6.dp)) } + item { + ToggleRow( + options = + listOf( Triple(StatsDim.Country, "Country", null), + Triple(StatsDim.Device, "Device", null), + Triple(StatsDim.UtmSource, "UTM source", null), ), selected = config.dimension, onSelect = { config = config.copy(dimension = it) }, @@ -371,7 +386,7 @@ private fun ConfigScreen( ) { Favicon(host = faviconHost(link.originalUrl), size = 20.dp) Spacer(Modifier.width(12.dp)) - Text( + EmojiText( "/${link.shortCode}", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), @@ -430,6 +445,28 @@ private fun ConfigScreen( }, ) } + item { + FilterGroup( + "Device", + stats?.devices, + StatsDim.Device, + config, + onConfigChange = { config = it }, + labelFor = { it.replaceFirstChar(Char::uppercase) }, + icon = { DeviceIcon(it, size = 18.dp) }, + ) + } + item { + FilterGroup( + "UTM source", + stats?.utmSources, + StatsDim.UtmSource, + config, + onConfigChange = { config = it }, + labelFor = { it }, + icon = { Monogram(it, size = 18.dp) }, + ) + } item { Spacer(Modifier.height(16.dp)) } } } @@ -529,10 +566,9 @@ private fun WidgetPreview( Arrangement.Top }, ) { - Text( + EmojiText( config.label, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, + style = LocalTextStyle.current.copy(fontSize = 11.sp, fontFamily = FontFamily.Monospace), color = widgetScheme.onSurfaceVariant, maxLines = 1, ) diff --git a/app/src/main/kotlin/me/spoo/android/widget/WidgetIconCache.kt b/app/src/main/kotlin/me/spoo/android/widget/WidgetIconCache.kt index 092b7b6..087eaef 100644 --- a/app/src/main/kotlin/me/spoo/android/widget/WidgetIconCache.kt +++ b/app/src/main/kotlin/me/spoo/android/widget/WidgetIconCache.kt @@ -21,6 +21,10 @@ import java.util.concurrent.ConcurrentHashMap object WidgetIconCache { private val memory = ConcurrentHashMap() + // Fluent has no model for a handful of multi-codepoint emoji; remember + // the misses so a widget doesn't retry the asset on every render. + private val missingEmoji = ConcurrentHashMap.newKeySet() + /** The favicon host behind a dimension value, null when none applies. */ fun hostFor( dim: StatsDim, @@ -30,6 +34,7 @@ object WidgetIconCache { StatsDim.Browser, StatsDim.Os -> brandDomain(label) StatsDim.Referrer -> label.takeIf { it.contains('.') } StatsDim.Country -> null // flags are emoji, no fetch needed + StatsDim.Device, StatsDim.UtmSource -> null // no favicon identity } fun get( @@ -72,4 +77,24 @@ object WidgetIconCache { context: Context, host: String, ) = File(context.cacheDir, "widget-icons/${host.replace('/', '_')}.png") + + /** + * Bundled Fluent artwork for one emoji codepoint, null when Fluent has + * no single-codepoint model for it. Assets are tiny webps; decoding + * once per process is fine for label-sized use. + */ + fun emoji( + context: Context, + codePoint: Int, + ): Bitmap? { + val key = "emoji/${codePoint.toString(16)}" + memory[key]?.let { return it } + if (codePoint in missingEmoji) return null + val bitmap = + runCatching { + context.assets.open("$key.webp").use(BitmapFactory::decodeStream) + }.getOrNull() + if (bitmap == null) missingEmoji += codePoint else memory[key] = bitmap + return bitmap + } } diff --git a/app/src/test/kotlin/me/spoo/android/data/ErrorsTest.kt b/app/src/test/kotlin/me/spoo/android/data/ErrorsTest.kt new file mode 100644 index 0000000..d2f3640 --- /dev/null +++ b/app/src/test/kotlin/me/spoo/android/data/ErrorsTest.kt @@ -0,0 +1,32 @@ +package me.spoo.android.data + +import me.spoo.SpooDecodeException +import me.spoo.SpooIOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +// SDK exceptions with internal constructors (validation, rate limit, ...) +// are exercised end to end against the live backend instead; these cover +// the publicly constructible paths and the fallback contract. +class ErrorsTest { + @Test + fun `io failures become a connectivity sentence`() { + val friendly = friendlyError(SpooIOException("timeout", null), "fallback") + assertEquals("Can't reach spoo.me. Check your connection and try again.", friendly.message) + assertNull(friendly.field) + } + + @Test + fun `decode failures suggest updating the app`() { + val friendly = friendlyError(SpooDecodeException("bad json", null), "fallback") + assertEquals("Unexpected response from the server. Try updating the app.", friendly.message) + } + + @Test + fun `unknown failures fall back verbatim`() { + val friendly = friendlyError(IllegalStateException("boom"), "Couldn't create the link.") + assertEquals("Couldn't create the link.", friendly.message) + assertNull(friendly.field) + } +} diff --git a/app/src/test/kotlin/me/spoo/android/data/MockLinksRepositoryTest.kt b/app/src/test/kotlin/me/spoo/android/data/MockLinksRepositoryTest.kt new file mode 100644 index 0000000..8e0cae3 --- /dev/null +++ b/app/src/test/kotlin/me/spoo/android/data/MockLinksRepositoryTest.kt @@ -0,0 +1,127 @@ +package me.spoo.android.data + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +// The mock is the offline stand-in for the server list endpoint, so its +// query engine has to honor the same contract: filter, search, sort, +// then page. +class MockLinksRepositoryTest { + @Test + fun `refresh resets to the first page and more exists`() = + runTest { + val repo = MockLinksRepository() + repo.refresh(LinksQuery()) + assertEquals(25, repo.links.value.size) + assertTrue(repo.hasMore.value) + } + + @Test + fun `loadMore appends a page without duplicates`() = + runTest { + val repo = MockLinksRepository() + repo.refresh(LinksQuery()) + repo.loadMore() + val links = repo.links.value + assertEquals(50, links.size) + assertEquals(links.size, links.map { it.id }.distinct().size) + } + + @Test + fun `click sort is descending by total clicks`() = + runTest { + val repo = MockLinksRepository() + repo.refresh(LinksQuery(sort = LinkSort.Clicks)) + val clicks = repo.links.value.map { it.totalClicks } + assertEquals(clicks.sortedDescending(), clicks) + } + + @Test + fun `search narrows across the whole set, not the visible page`() = + runTest { + val repo = MockLinksRepository() + // Matches sit past the first page, so a search that filtered + // only the loaded page would return fewer of them. + repo.refresh(LinksQuery()) + val firstPage = repo.links.value + val onPage = firstPage.count { it.originalUrl.contains("dev.to") } + repo.refresh(LinksQuery(search = "dev.to")) + val found = repo.links.value + assertTrue(found.size > onPage, "search saw only the loaded page") + assertTrue(found.all { it.originalUrl.contains("dev.to", ignoreCase = true) }) + } + + @Test + fun `password filter keeps only protected links`() = + runTest { + val repo = MockLinksRepository() + repo.refresh(LinksQuery(filter = LinksFilter(passwordProtected = true))) + assertTrue(repo.links.value.isNotEmpty()) + assertTrue(repo.links.value.all { it.hasPassword }) + } + + @Test + fun `last-click sort is descending, and every stamp is plausible`() = + runTest { + val repo = MockLinksRepository() + repo.refresh(LinksQuery(sort = LinkSort.LastClick)) + val links = repo.links.value + val stamps = links.mapNotNull { it.lastClickMillis } + assertEquals(stamps.sortedDescending(), stamps) + // A last click before the link existed, or in the future, is + // the kind of nonsense that only shows up in a demo. + val now = System.currentTimeMillis() + links.forEach { link -> + val clicked = link.lastClickMillis ?: return@forEach + assertTrue(clicked <= now, "${'$'}{link.shortCode} clicked in the future") + link.createdAtMillis?.let { + assertTrue(clicked >= it, "${'$'}{link.shortCode} clicked before it existed") + } + } + } + + @Test + fun `never-clicked links sort after clicked ones`() = + runTest { + val repo = MockLinksRepository() + val fresh = repo.create(CreateLinkRequest(url = "https://example.com", alias = "brand-new")) + assertEquals(null, fresh.lastClickMillis) + repo.refresh(LinksQuery(sort = LinkSort.LastClick)) + // On whatever page is visible, a clicked link never follows an + // unclicked one. + val seen = repo.links.value.map { it.lastClickMillis } + val firstNull = seen.indexOfFirst { it == null } + if (firstNull >= 0) assertTrue(seen.drop(firstNull).all { it == null }) + } + + @Test + fun `alias status separates taken from reserved and invalid`() = + runTest { + val repo = MockLinksRepository() + repo.refresh(LinksQuery()) + val taken = + repo.links.value + .first() + .shortCode + // A blocked alias is not automatically a taken one: the copy + // under the field depends on telling these apart. + assertEquals(AliasStatus.Taken, repo.aliasStatus(taken)) + assertEquals(AliasStatus.Reserved, repo.aliasStatus("api")) + assertEquals(AliasStatus.Invalid, repo.aliasStatus("hi")) + assertEquals(AliasStatus.Free, repo.aliasStatus("definitely-free-alias")) + } + + @Test + fun `refresh after a filtered query with null reruns the same query`() = + runTest { + val repo = MockLinksRepository() + repo.refresh(LinksQuery(filter = LinksFilter(passwordProtected = true))) + val filtered = repo.links.value.size + repo.refresh(null) + assertEquals(filtered, repo.links.value.size) + assertTrue(repo.links.value.all { it.hasPassword }) + } +} diff --git a/app/src/test/kotlin/me/spoo/android/ui/components/EmojiTest.kt b/app/src/test/kotlin/me/spoo/android/ui/components/EmojiTest.kt new file mode 100644 index 0000000..9f4a81d --- /dev/null +++ b/app/src/test/kotlin/me/spoo/android/ui/components/EmojiTest.kt @@ -0,0 +1,32 @@ +package me.spoo.android.ui.components + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +// Widget labels join with a middle dot, so "is this ASCII?" is the wrong +// question to ask about a codepoint: it sends every separator down the +// artwork path, where the asset lookup fails on every render. +class EmojiTest { + @Test + fun `label separators are not treated as emoji`() { + assertFalse(isEmojiCodePoint('·'.code)) + assertFalse(isEmojiCodePoint('/'.code)) + assertFalse(isEmojiCodePoint('A'.code)) + assertFalse(isEmojiCodePoint('9'.code)) + } + + @Test + fun `real emoji are detected`() { + assertTrue(isEmojiCodePoint("🎮".codePointAt(0))) + assertTrue(isEmojiCodePoint("🔥".codePointAt(0))) + assertTrue(isEmojiCodePoint("😎".codePointAt(0))) + assertTrue(isEmojiCodePoint("✨".codePointAt(0))) + } + + @Test + fun `a plain widget label needs no artwork at all`() { + val label = "CLICKS · 30D" + assertTrue(label.codePoints().noneMatch(::isEmojiCodePoint)) + } +} diff --git a/app/src/test/kotlin/me/spoo/android/ui/components/ValidationTest.kt b/app/src/test/kotlin/me/spoo/android/ui/components/ValidationTest.kt new file mode 100644 index 0000000..60093c8 --- /dev/null +++ b/app/src/test/kotlin/me/spoo/android/ui/components/ValidationTest.kt @@ -0,0 +1,69 @@ +package me.spoo.android.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ValidationTest { + @Test + fun `normalizeUrl prepends https when no scheme was typed`() { + assertEquals("https://example.com/promo", normalizeUrl("example.com/promo")) + assertEquals("https://spoo.me", normalizeUrl(" spoo.me ")) + } + + @Test + fun `normalizeUrl leaves explicit schemes alone, typos included`() { + assertEquals("http://example.com", normalizeUrl("http://example.com")) + assertEquals("htp://example.com", normalizeUrl("htp://example.com")) + assertEquals("spoo://oauth/callback", normalizeUrl("spoo://oauth/callback")) + } + + @Test + fun `normalizeUrl keeps empty input empty`() { + assertEquals("", normalizeUrl(" ")) + } + + @Test + fun `isLikelyUrl accepts hosts with a dot and localhost`() { + assertTrue(isLikelyUrl("example.com")) + assertTrue(isLikelyUrl("https://sub.domain.dev/path?q=1")) + assertTrue(isLikelyUrl("http://localhost:8000/x")) + } + + @Test + fun `isLikelyUrl rejects non-urls`() { + assertFalse(isLikelyUrl("")) + assertFalse(isLikelyUrl("not a url")) + assertFalse(isLikelyUrl("hello")) + assertFalse(isLikelyUrl("htp://typo.example.com")) + assertFalse(isLikelyUrl("ftp://example.com")) + } + + @Test + fun `password policy needs length, letter, digit, and @ or dot`() { + assertTrue(isAcceptablePassword("word.word.42")) + assertTrue(isAcceptablePassword("Str0ng@pass")) + assertFalse(isAcceptablePassword("short.1")) + assertFalse(isAcceptablePassword("no-digits.here")) + assertFalse(isAcceptablePassword("0123456789")) + assertFalse(isAcceptablePassword("letters4nddigits")) + } + + @Test + fun `alias characters are alphanumerics, underscore, hyphen`() { + assertTrue("Fable-42_x".all(::isAliasChar)) + assertFalse(isAliasChar(' ')) + assertFalse(isAliasChar('/')) + assertFalse(isAliasChar('é')) + } + + @Test + fun `suggested passwords always satisfy the policy`() { + repeat(200) { + val suggestion = suggestPassword() + assertTrue(isAcceptablePassword(suggestion), "rejected: $suggestion") + assertTrue(Regex("^[a-z]+\\.[a-z]+\\.[a-z]+\\.\\d{3}$").matches(suggestion), "shape: $suggestion") + } + } +} diff --git a/app/src/test/kotlin/me/spoo/android/widget/WidgetConfigTest.kt b/app/src/test/kotlin/me/spoo/android/widget/WidgetConfigTest.kt new file mode 100644 index 0000000..5a70edc --- /dev/null +++ b/app/src/test/kotlin/me/spoo/android/widget/WidgetConfigTest.kt @@ -0,0 +1,118 @@ +package me.spoo.android.widget + +import androidx.datastore.preferences.core.mutablePreferencesOf +import me.spoo.android.data.StatsDim +import me.spoo.android.data.StatsMetric +import kotlin.test.Test +import kotlin.test.assertEquals + +class WidgetConfigTest { + @Test + fun `config round-trips through glance state`() { + val config = + WidgetConfig( + chart = WidgetChart.Treemap, + font = WidgetFont.Serif, + dimension = StatsDim.Referrer, + metric = StatsMetric.UniqueClicks, + scope = "mixtape", + filters = mapOf(StatsDim.Country to "DE", StatsDim.Browser to "Firefox"), + rangeDays = 90, + ) + val prefs = mutablePreferencesOf() + prefs.writeWidgetConfig(config) + assertEquals(config, prefs.readWidgetConfig()) + } + + @Test + fun `all-time range survives the zero encoding`() { + val prefs = mutablePreferencesOf() + prefs.writeWidgetConfig(WidgetConfig(rangeDays = null)) + assertEquals(null, prefs.readWidgetConfig().rangeDays) + } + + @Test + fun `empty state falls back to defaults, not crashes`() { + assertEquals(WidgetConfig(), mutablePreferencesOf().readWidgetConfig()) + } + + @Test + fun `unknown enum names fall back instead of crashing`() { + val prefs = mutablePreferencesOf() + prefs.writeWidgetConfig(WidgetConfig()) + prefs[WidgetKeys.STYLE] = "Outline" + prefs[WidgetKeys.FONT] = "ComicSans" + val read = prefs.readWidgetConfig() + assertEquals(WidgetChart.Wave, read.chart) + assertEquals(WidgetFont.Flex, read.font) + } + + @Test + fun `saving a config drops the previous scope's cached data`() { + val prefs = mutablePreferencesOf() + prefs.writeWidgetData(WidgetData(total = 5, series = listOf(1, 2), slices = emptyList())) + prefs.writeWidgetConfig(WidgetConfig(scope = "other")) + assertEquals(0L, prefs.readWidgetData().total) + assertEquals(emptyList(), prefs.readWidgetData().series) + } + + @Test + fun `label uses display names, so UTM source does not read UTMSOURCE`() { + val config = WidgetConfig(chart = WidgetChart.Treemap, dimension = StatsDim.UtmSource) + assertEquals("UTM SOURCE · CLICKS · 30D", config.label) + assertEquals( + "DEVICE · CLICKS · 30D", + WidgetConfig(chart = WidgetChart.Bubbles, dimension = StatsDim.Device).label, + ) + } + + @Test + fun `slices with tabs and newlines in labels round-trip`() { + val data = + WidgetData( + total = 3, + series = listOf(1, 1, 1), + slices = + listOf( + // The encoding is tab-separated, newline-delimited: + // both separators have to survive inside a label. + me.spoo.android.data.LinkStats + .Slice("Samsung\tInternet", 2), + me.spoo.android.data.LinkStats + .Slice("multi\nline", 1), + ), + ) + val prefs = mutablePreferencesOf() + prefs.writeWidgetData(data) + assertEquals(data.slices, prefs.readWidgetData().slices) + } + + @Test + fun `label carries scope, dimension, metric, range, and filter flag`() { + val config = + WidgetConfig( + chart = WidgetChart.Bubbles, + dimension = StatsDim.Browser, + metric = StatsMetric.Clicks, + scope = "mixtape", + filters = mapOf(StatsDim.Country to "DE"), + rangeDays = 7, + ) + assertEquals("/mixtape · BROWSER · CLICKS · 7D · FILTERED", config.label) + assertEquals("CLICKS · 30D", WidgetConfig().label) + } + + @Test + fun `picker shells prefill their chart`() { + assertEquals(WidgetChart.Bars, WidgetConfig.presetFor("x.BarsWidgetReceiver").chart) + assertEquals(WidgetChart.Number, WidgetConfig.presetFor("x.CountWidgetReceiver").chart) + assertEquals(null, WidgetConfig.presetFor("x.CountWidgetReceiver").rangeDays) + assertEquals(WidgetChart.Wave, WidgetConfig.presetFor(null).chart) + } + + @Test + fun `map chart always breaks down by country`() { + val config = WidgetConfig(chart = WidgetChart.Map, dimension = StatsDim.Browser) + assertEquals(StatsDim.Country, config.effectiveDimension) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 268a066..2c7e4e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ coreKtx = "1.16.0" lifecycle = "2.9.2" kotlinxSerialization = "1.11.0" spooSdk = "0.1.0" +coroutines = "1.10.2" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } @@ -28,6 +29,10 @@ glance-appwidget = { module = "androidx.glance:glance-appwidget", version = "1.2 glance-material3 = { module = "androidx.glance:glance-material3", version = "1.2.0-rc01" } work-runtime = { module = "androidx.work:work-runtime-ktx", version = "2.10.3" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +junit = { module = "junit:junit", version = "4.13.2" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +turbine = { module = "app.cash.turbine:turbine", version = "1.2.1" } compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }