diff --git a/android/core/bin/main/tech/kadence/core/Blocker.kt b/android/core/bin/main/tech/kadence/core/Blocker.kt new file mode 100644 index 0000000..1061f66 --- /dev/null +++ b/android/core/bin/main/tech/kadence/core/Blocker.kt @@ -0,0 +1,251 @@ +/** + * The blocking policy, ported so the phone can enforce it offline. + * + * The server decides *whether* blocking is on and *what* is on the list + * (`backend/app/services/blocker_service.py`); every client enforces it. Online, the + * server's `policy.active` is authoritative. Offline, this file has to reach the same + * answer on its own — otherwise losing signal would quietly switch the focus guard off, + * which is the one moment you least want it off. + * + * Two API details worth knowing, both verified against `policy()`: + * + * * `policy.domains` and `policy.apps` are **empty whenever `active` is false**, while + * `all_domains` / `all_apps` are always populated. So a client must read the `all_*` + * fields and gate on activity itself — which `desktop/.../controller.py` also does. + * * `mode: "always"` and `"scheduled"` mean the guard runs with no timer at all, so + * enforcement cannot be tied to the timer's lifecycle. + */ +package tech.kadence.core + +import java.time.ZoneId +import java.time.ZonedDateTime + +/** One `blocker.schedule[]` entry. `days` is ISO weekdays with **Monday = 0**. */ +data class BlockWindow( + val label: String = "", + val start: String = "00:00", + val end: String = "23:59", + val days: List = emptyList(), +) + +/** The `blocker` subtree of the settings document. */ +data class BlockerRules( + val enabled: Boolean = false, + val mode: String = "during_work", + val blockDuringBreaks: Boolean = false, + val blockedDomains: List = emptyList(), + val allowedDomains: List = emptyList(), + val subdomainExpansions: List = DEFAULT_SUBDOMAINS, + val blockedApps: List = emptyList(), + val killBlockedApps: Boolean = true, + val schedule: List = emptyList(), + val strict: Boolean = false, + val notifyOnBlock: Boolean = true, +) { + companion object { + /** The backend's `subdomain_expansions` default. */ + val DEFAULT_SUBDOMAINS = listOf("www", "m", "mobile", "app", "web", "i", "old", "new") + } +} + +/** Just enough of the running interval for the policy to be decided. */ +data class IntervalFacts( + val kind: String, + val status: String, +) { + val isActiveStatus: Boolean get() = status == "running" || status == "paused" + val isWork: Boolean get() = kind == "work" +} + +/** The verdict, carrying the same `reason` string the server would have sent. */ +data class PolicyVerdict(val active: Boolean, val reason: String) + +/** + * `youtube.com` -> itself plus `www.` and `m.`; `*.youtube.com` -> the bare domain plus + * every configured subdomain; `*gram*` -> nothing expandable, returned as a leftover. + * + * A port of `expand_entry()`. Returns `(hostnames, unexpandablePattern)`; with + * `wildcard` set, `*.youtube.com` returns both at once — the concrete + * names block on their own, and the pattern says "every other subdomain too", + * which the URL-bar matcher (here) and the desktop's resolver stub take from there. + */ +fun expandEntry(raw: String, labels: List, wildcard: Boolean = false): Pair, String?> { + val entry = raw.trim().lowercase().trimStart('.') + if (entry.isEmpty()) return emptyList() to null + + if (entry.startsWith("*.")) { + val base = entry.removePrefix("*.") + if (base.isEmpty() || '*' in base) return emptyList() to entry + val hosts = listOf(base) + labels.map { "$it.$base" } + return hosts to (entry.takeIf { wildcard }) + } + + if ('*' in entry) return emptyList() to entry + + return listOf(entry, "www.$entry", "m.$entry") to null +} + +/** + * Whether an allow-list entry — itself maskable — covers this hostname. + * + * A plain entry covers itself and its subdomains, matching what a plain entry does on + * the block side (`youtube.com` blocked catches `music.youtube.com`). An exception used + * to be glob-matched whatever it looked like, so the two halves of one list read + * identically and behaved differently. A masked entry is glob-matched as before, which + * is still how you allow something narrower than a whole subtree. + * + * Parity with `blocker_service.is_allowed`; `BlockerTest` pins it. + */ +fun isAllowed(host: String, allowMasks: List): Boolean = allowMasks.any { entry -> + if (isMask(entry)) globMatches(entry, host) else host == entry || host.endsWith(".$entry") +} + +/** Normalised, de-duplicated allow masks, the way `expand_domains()` prepares them. */ +fun allowMasksOf(rules: BlockerRules): List = rules.allowedDomains + .filter { it.isNotBlank() } + .map { it.trim().lowercase().trimStart('.') } + +/** One label of a hostname: what may legally sit in front of `.example.com`. */ +private val LABEL_RE = Regex("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") + +/** What `subdomainExpansions` asked for, split three ways. See [parseExpansions]. */ +data class Expansion( + val labels: List = emptyList(), + val wildcard: Boolean = false, + val rejected: List = emptyList(), +) + +/** + * `subdomainExpansions` split into labels, the `*` flag, and rejects — the port of + * `parse_expansions()`. + * + * A bare `*` is the user saying *every* subdomain, and it is supported rather than + * refused: the labels still expand into hostnames (which block without help), while + * [Expansion.wildcard] tells the matcher to cover the rest by mask. Before that, `*` + * was treated as a label, so `*.telegram.org` expanded to the literal string + * `*.telegram.org` — not a hostname, which the desktop app dropped without a word, + * `unexpandable` stayed empty, and `web.telegram.org` was never blocked. Anything + * that is neither a label nor `*` is still refused, and reported as a reject. + */ +fun parseExpansions(raw: List): Expansion { + val labels = LinkedHashSet() + val rejected = LinkedHashSet() + var wildcard = false + for (entry in raw) { + val label = entry.trim().lowercase() + if (label.isEmpty()) continue + when { + label == "*" -> wildcard = true + LABEL_RE.matches(label) -> labels.add(label) + else -> rejected.add(label) + } + } + return Expansion(labels.toList(), wildcard, rejected.toList()) +} + +/** Expansion entries neither a label nor `*`, so the UI can flag them. */ +fun invalidExpansions(rules: BlockerRules): List = + parseExpansions(rules.subdomainExpansions).rejected + +private fun expansionOf(rules: BlockerRules): Expansion = + parseExpansions(rules.subdomainExpansions) + +/** + * Every hostname a hosts file would be pointed at the redirect IP, allow list already + * subtracted — the client-side twin of `expand_domains()`, used when offline and as a + * cross-check against the server's `all_domains`. + */ +fun expandDomains(rules: BlockerRules): List = expandDomainsWithOrigin(rules).keys.toList() + +/** + * The same expansion, but remembering which block-list entry produced each hostname. + * + * Worth keeping, because the host alone does not identify the rule: `*.reddit.com` + * expands to the bare `reddit.com`, yet the mask does not *match* that bare name (see + * [globMatches]). Without the provenance the overlay would have to say "blocked: + * reddit.com" when the honest answer is "blocked by `*.reddit.com`". + * + * Insertion-ordered, and the first entry to claim a host keeps it. + */ +fun expandDomainsWithOrigin(rules: BlockerRules): Map { + val allowMasks = allowMasksOf(rules) + val expansion = expansionOf(rules) + val origin = LinkedHashMap() + for (raw in rules.blockedDomains) { + val entry = raw.trim().lowercase().trimStart('.') + val (expanded, _) = expandEntry(raw, expansion.labels, expansion.wildcard) + for (host in expanded) { + if (!isAllowed(host, allowMasks)) origin.putIfAbsent(host, entry) + } + } + return origin +} + +/** + * Block-list entries a hosts file cannot express — the patterns this matcher takes + * over in the URL bar, and that the desktop app answers at DNS level when it can + * route the zone. The UI shows them as "enforced beyond the hosts file". + */ +fun unexpandableMasks(rules: BlockerRules): List { + val expansion = expansionOf(rules) + val leftovers = LinkedHashSet() + for (raw in rules.blockedDomains) { + val (_, pattern) = expandEntry(raw, expansion.labels, expansion.wildcard) + if (pattern != null) leftovers.add(pattern) + } + return leftovers.toList() +} + +/** + * Whether any schedule window covers this moment. + * + * Both the weekday and the time of day are read in the **server's** timezone, so + * travelling does not shift your blocking windows. (The backend's `schedule_active()` + * takes the weekday from the process's local clock while taking the time from the + * configured zone; reading both from one zone is the behaviour that was intended.) + */ +fun scheduleActive(rules: BlockerRules, now: ZonedDateTime): Boolean { + val weekday = now.dayOfWeek.value - 1 + val moment = now.toLocalTime() + return rules.schedule.any { window -> + if (weekday !in window.days) return@any false + val start = parseClockTime(window.start) ?: return@any false + val end = parseClockTime(window.end) ?: return@any false + inWindow(moment, start, end) + } +} + +/** + * The port of `policy()`'s decision, reason strings included — so the Blocker screen + * reads identically whether the verdict came from the server or from here. + */ +fun evaluatePolicy( + rules: BlockerRules, + current: IntervalFacts?, + now: ZonedDateTime, +): PolicyVerdict { + if (!rules.enabled) return PolicyVerdict(false, "disabled") + + return when (rules.mode) { + "always" -> PolicyVerdict(true, "always on") + "scheduled" -> if (scheduleActive(rules, now)) { + PolicyVerdict(true, "inside a blocking window") + } else { + PolicyVerdict(false, "outside every window") + } + else -> when { + current != null && current.isWork && current.isActiveStatus -> + PolicyVerdict(true, "a work interval is running") + + current != null && !current.isWork && rules.blockDuringBreaks && + current.isActiveStatus -> + PolicyVerdict(true, "blocking stays on through breaks") + + else -> PolicyVerdict(false, "no work interval is running") + } + } +} + +/** Convenience for callers that only have an IANA zone id string. */ +fun zoneOrDefault(timezone: String?): ZoneId = + runCatching { ZoneId.of(timezone?.trim().orEmpty()) }.getOrElse { ZoneId.systemDefault() } diff --git a/android/core/bin/main/tech/kadence/core/Format.kt b/android/core/bin/main/tech/kadence/core/Format.kt new file mode 100644 index 0000000..8017156 --- /dev/null +++ b/android/core/bin/main/tech/kadence/core/Format.kt @@ -0,0 +1,87 @@ +/** + * Formatting shared by every screen, so numbers read the same everywhere. + * + * A literal port of `frontend/src/lib/format.ts`, down to the rounding. The web app, + * the QML `Theme.humanMinutes()` and this file have to agree: the same pomodoro seen + * in a browser, on the desktop and on the phone must not render three different ways. + * `FormatTest` asserts the exact outputs the TypeScript produces. + */ +package tech.kadence.core + +import java.time.LocalDate +import kotlin.math.roundToInt + +/** `"18:24"`, and `"1:05:00"` only once there is an hour to show. */ +fun clock(totalSeconds: Int): String { + val safe = totalSeconds.coerceAtLeast(0) + val hours = safe / 3600 + val minutes = (safe % 3600) / 60 + val seconds = safe % 60 + return if (hours > 0) { + "$hours:${pad(minutes)}:${pad(seconds)}" + } else { + "${pad(minutes)}:${pad(seconds)}" + } +} + +/** The web app's `clock()` takes a float and rounds; a drifting tick can land here. */ +fun clock(totalSeconds: Double): String = clock(totalSeconds.roundToInt()) + +/** + * `125` -> `"2h 5m"`, `120` -> `"2h"`, `5` -> `"5m"`. + * + * Negative input is clamped to zero rather than reproducing JavaScript's `"-1h -5m"`: + * a negative duration is a bug upstream, and printing it as nonsense hides it. + */ +fun humanMinutes(minutes: Int): String { + val rounded = minutes.coerceAtLeast(0) + val hours = rounded / 60 + val rest = rounded % 60 + return when { + hours > 0 && rest > 0 -> "${hours}h ${rest}m" + hours > 0 -> "${hours}h" + else -> "${rest}m" + } +} + +fun humanMinutes(minutes: Double): String = humanMinutes(minutes.roundToInt()) + +/** Interval kind -> the label every client shows. */ +val KIND_LABEL: Map = mapOf( + "work" to "Focus", + "short_break" to "Short break", + "long_break" to "Long break", +) + +/** + * Task bucket -> heading. + * + * Note `active`: the web app says "Would be nice to start" where the QML desktop says + * "In progress". The web app is the design reference for this client, so the web + * wording wins — see `frontend/src/lib/format.ts`. + */ +val BUCKET_LABEL: Map = mapOf( + "overdue" to "Overdue", + "today" to "Due today", + "active" to "Would be nice to start", + "upcoming" to "Coming up", + "someday" to "No deadline", +) + +/** The order the buckets are laid out in, top to bottom. */ +val BUCKET_ORDER: List = listOf("overdue", "today", "active", "upcoming", "someday") + +/** `""` when undated, `"3d overdue"` when late, otherwise the ISO due date. */ +fun dueLabel(dueDate: String?, daysOverdue: Int): String = when { + dueDate.isNullOrBlank() -> "" + daysOverdue > 0 -> "${daysOverdue}d overdue" + else -> dueDate +} + +/** `"2026-10-19"` -> `"10/19"`, for a chart axis with no room for a year. */ +fun shortDate(iso: String): String = iso.drop(5).replaceFirst("-", "/") + +/** Monday = 0, matching the backend's `BlockWindow.days` and the heatmap's columns. */ +fun weekdayOf(iso: String): Int = LocalDate.parse(iso).dayOfWeek.value - 1 + +private fun pad(value: Int): String = value.toString().padStart(2, '0') diff --git a/android/core/bin/main/tech/kadence/core/Glob.kt b/android/core/bin/main/tech/kadence/core/Glob.kt new file mode 100644 index 0000000..842ebdd --- /dev/null +++ b/android/core/bin/main/tech/kadence/core/Glob.kt @@ -0,0 +1,89 @@ +/** + * Python's `fnmatch`, in Kotlin. + * + * The block lists are shared across clients and were written for a Python backend: + * `steam*`, `*discord*`, `*.youtube.com`. Both the server + * (`backend/app/services/blocker_service.py`) and the desktop enforcer + * (`desktop/kadence_desktop/blocking/processes.py`) match them with `fnmatch`, so the + * phone has to use the same grammar or the same list would mean two different things. + * + * `fnmatch` is not shell globbing: `*` matches *everything*, separators included, which + * is exactly why `*gram*` catches `instagram.com`. + */ +package tech.kadence.core + +/** The wildcard characters whose presence makes an entry a mask rather than a literal. */ +private const val WILDCARDS = "*?[" + +/** Whether this entry is a mask at all — the branch `processes.matches()` takes first. */ +fun isMask(pattern: String): Boolean = pattern.any { it in WILDCARDS } + +/** + * Translate an `fnmatch` pattern to a `Regex` anchored at both ends. + * + * Compiled patterns are cached: the app matcher runs this against every block-list + * entry several times a second while a focus interval is live. + */ +fun globToRegex(pattern: String): Regex = globCache.getOrPut(pattern) { + val out = StringBuilder() + var i = 0 + while (i < pattern.length) { + when (val c = pattern[i]) { + '*' -> { + out.append(".*") + i++ + } + '?' -> { + out.append('.') + i++ + } + '[' -> { + // Find the closing bracket. `]` immediately after `[` or `[!` is a + // literal, which is why the scan starts past it. + var j = i + 1 + if (j < pattern.length && (pattern[j] == '!' || pattern[j] == '^')) j++ + if (j < pattern.length && pattern[j] == ']') j++ + while (j < pattern.length && pattern[j] != ']') j++ + if (j >= pattern.length) { + // Unterminated class: `fnmatch` treats the `[` as a literal. + out.append("\\[") + i++ + } else { + val body = pattern.substring(i + 1, j) + out.append('[') + when { + body.startsWith('!') -> out.append('^').append(quoteClass(body.drop(1))) + body.startsWith('^') -> out.append("\\^").append(quoteClass(body.drop(1))) + else -> out.append(quoteClass(body)) + } + out.append(']') + i = j + 1 + } + } + else -> { + out.append(Regex.escape(c.toString())) + i++ + } + } + } + Regex(out.toString(), RegexOption.DOT_MATCHES_ALL) +} + +/** `fnmatch(text, pattern)` — case-sensitive, like Python's on Linux. */ +fun globMatches(pattern: String, text: String): Boolean = + globToRegex(pattern).matches(text) + +/** + * Inside a character class only `]`, `\` and `^` need care; `*` and `?` are literal + * there, so they must not be escaped into something a regex reads differently. + */ +private fun quoteClass(body: String): String = buildString { + for (c in body) { + when (c) { + '\\', ']', '^', '[', '&' -> append('\\').append(c) + else -> append(c) + } + } +} + +private val globCache = HashMap() diff --git a/android/core/bin/main/tech/kadence/core/GlyphFrame.kt b/android/core/bin/main/tech/kadence/core/GlyphFrame.kt new file mode 100644 index 0000000..594e911 --- /dev/null +++ b/android/core/bin/main/tech/kadence/core/GlyphFrame.kt @@ -0,0 +1,224 @@ +/** + * What to light up on the Nothing Phone (3)'s rear Glyph Matrix. + * + * Deliberately pure: the SDK wants a flat array of per-dot brightness, so all the + * interesting work — which dots make a "4", where the ring's ticks fall, how it drains + * — is arithmetic that needs no device, no `.aar` and no Android at all. The whole rear + * display can therefore be developed and regression-tested on a laptop, with + * [asciiOf] rendering any frame as art you can read in a test failure. + * + * The matrix is 25x25 = 625 dots on Phone (3) (`Glyph.DEVICE_23112`), but the length is + * read from `Common.getDeviceMatrixLength()` at runtime, so nothing here hard-codes 25 + * beyond a default. + */ +package tech.kadence.core + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin + +/** A mutable grid of dot brightness, 0..255. */ +class GlyphFrame(val size: Int = DEFAULT_SIZE) { + val dots = IntArray(size * size) + + fun set(x: Int, y: Int, brightness: Int) { + if (x < 0 || y < 0 || x >= size || y >= size) return + dots[y * size + x] = brightness.coerceIn(0, 255) + } + + fun get(x: Int, y: Int): Int = + if (x < 0 || y < 0 || x >= size || y >= size) 0 else dots[y * size + x] + + /** The flat array `GlyphMatrixManager.setMatrixFrame` expects. */ + fun render(): IntArray = dots.copyOf() + + val litCount: Int get() = dots.count { it > 0 } + + companion object { + const val DEFAULT_SIZE = 25 + } +} + +/** Brightness levels, kept together so "paused looks dimmer" is one decision. */ +object GlyphBrightness { + const val FULL = 255 + const val DIM = 90 + const val FAINT = 40 +} + +/** + * A 3x5 dot font. Small enough that two digits fit beside each other inside the ring, + * large enough to read at arm's length on a desk. + */ +private val DIGITS: Map> = mapOf( + '0' to arrayOf("###", "# #", "# #", "# #", "###"), + '1' to arrayOf(" #", " #", " #", " #", " #"), + '2' to arrayOf("###", " #", "###", "# ", "###"), + '3' to arrayOf("###", " #", "###", " #", "###"), + '4' to arrayOf("# #", "# #", "###", " #", " #"), + '5' to arrayOf("###", "# ", "###", " #", "###"), + '6' to arrayOf("###", "# ", "###", "# #", "###"), + '7' to arrayOf("###", " #", " #", " #", " #"), + '8' to arrayOf("###", "# #", "###", "# #", "###"), + '9' to arrayOf("###", "# #", "###", " #", "###"), +) + +private const val GLYPH_W = 3 +private const val GLYPH_H = 5 +private const val GLYPH_GAP = 1 + +/** Draw a run of digits with its top-left corner at ([x], [y]). */ +fun GlyphFrame.drawDigits(text: String, x: Int, y: Int, brightness: Int) { + var cursor = x + for (ch in text) { + val glyph = DIGITS[ch] + if (glyph == null) { + cursor += GLYPH_W + GLYPH_GAP + continue + } + for (row in 0 until GLYPH_H) { + for (col in 0 until GLYPH_W) { + if (glyph[row][col] == '#') set(cursor + col, y + row, brightness) + } + } + cursor += GLYPH_W + GLYPH_GAP + } +} + +/** Width in dots of [text] in the 3x5 font. */ +fun digitsWidth(text: String): Int = + if (text.isEmpty()) 0 else text.length * GLYPH_W + (text.length - 1) * GLYPH_GAP + +/** Draw digits centred horizontally. */ +fun GlyphFrame.drawDigitsCentred(text: String, y: Int, brightness: Int) { + drawDigits(text, (size - digitsWidth(text)) / 2, y, brightness) +} + +/** + * The ring: one dot per dial tick around the perimeter, lit for the time **remaining**. + * + * Tick 0 sits at twelve o'clock and they run clockwise, and the lit run starts there — + * so the arc shrinks *towards* twelve as the interval drains, which is how the web + * dial's arc reads (`stroke-dashoffset` from `-90deg`). Lighting the trailing ticks + * instead would leave the last sliver stranded at eleven o'clock. + * + * The tick count comes from [dialTickCount], shared with the Compose dial, so the front + * and the back of the phone can never disagree about what one dot is worth. + */ +fun GlyphFrame.drawRing( + plannedSeconds: Int, + remainingSeconds: Int, + brightness: Int = GlyphBrightness.FULL, + spentBrightness: Int = 0, +) { + val count = dialTickCount(plannedSeconds) + val remainingTicks = count - dialSpentTicks(plannedSeconds, remainingSeconds) + val centre = (size - 1) / 2.0 + val radius = centre - 1.0 + + for (i in 0 until count) { + val angle = i.toDouble() / count * 2 * PI - PI / 2 + val x = (centre + radius * cos(angle)).roundToInt() + val y = (centre + radius * sin(angle)).roundToInt() + set(x, y, if (i < remainingTicks) brightness else spentBrightness) + } +} + +/** How the toy should look right now. */ +object GlyphRenderer { + + /** + * The timer face: minutes above seconds, inside a draining ring. + * + * Past an hour the top row becomes hours and the bottom minutes, because `mm` would + * otherwise read as a number far larger than the matrix can show. + */ + fun timerFrame( + remainingSeconds: Int, + plannedSeconds: Int, + paused: Boolean = false, + size: Int = GlyphFrame.DEFAULT_SIZE, + ): GlyphFrame { + val frame = GlyphFrame(size) + val safe = remainingSeconds.coerceAtLeast(0) + val textBrightness = if (paused) GlyphBrightness.DIM else GlyphBrightness.FULL + val ringBrightness = if (paused) GlyphBrightness.DIM else GlyphBrightness.FULL + + val top: Int + val bottom: Int + if (safe >= 3600) { + top = safe / 3600 + bottom = (safe % 3600) / 60 + } else { + top = safe / 60 + bottom = safe % 60 + } + + frame.drawRing(plannedSeconds, safe, ringBrightness) + frame.drawDigitsCentred(pad2(top), TOP_ROW_Y, textBrightness) + frame.drawDigitsCentred(pad2(bottom), BOTTOM_ROW_Y, textBrightness) + return frame + } + + /** + * The always-on face: today's pomodoros against the goal, as a dot grid. + * + * The same encoding as the web app's dot row (`TimerPage.tsx`) — earned dots solid, + * the rest faint — on 625 LEDs. + */ + fun dotsFrame( + done: Int, + goal: Int, + size: Int = GlyphFrame.DEFAULT_SIZE, + ): GlyphFrame { + val frame = GlyphFrame(size) + val total = maxOf(goal, done, 1) + val perRow = minOf(DOTS_PER_ROW, total) + val rows = (total + perRow - 1) / perRow + + val spacing = 3 + val gridW = perRow * spacing - (spacing - 1) + val gridH = rows * spacing - (spacing - 1) + val originX = (size - gridW) / 2 + val originY = (size - gridH) / 2 + + for (i in 0 until total) { + val x = originX + (i % perRow) * spacing + val y = originY + (i / perRow) * spacing + frame.set(x, y, if (i < done) GlyphBrightness.FULL else GlyphBrightness.FAINT) + } + return frame + } + + /** Everything off — what `turnOff()` should leave behind. */ + fun blank(size: Int = GlyphFrame.DEFAULT_SIZE): GlyphFrame = GlyphFrame(size) + + private const val TOP_ROW_Y = 6 + private const val BOTTOM_ROW_Y = 14 + private const val DOTS_PER_ROW = 8 + + private fun pad2(value: Int): String = + if (value in 0..99) value.toString().padStart(2, '0') else value.toString() +} + +/** + * Render a frame as text, so a failing assertion shows the picture. + * + * `#` is bright, `+` is dim, `.` is dark. + */ +fun asciiOf(frame: GlyphFrame): String = buildString { + for (y in 0 until frame.size) { + for (x in 0 until frame.size) { + val v = frame.get(x, y) + append( + when { + v >= 200 -> '#' + v > 0 -> '+' + else -> '.' + }, + ) + } + if (y < frame.size - 1) append('\n') + } +} diff --git a/android/core/bin/main/tech/kadence/core/Matchers.kt b/android/core/bin/main/tech/kadence/core/Matchers.kt new file mode 100644 index 0000000..6bf9e4b --- /dev/null +++ b/android/core/bin/main/tech/kadence/core/Matchers.kt @@ -0,0 +1,220 @@ +/** + * Deciding whether a hostname or an app is on the list. + * + * `AppMatcher` is a port of `matches()` in + * `desktop/kadence_desktop/blocking/processes.py`, with Android's two haystacks — + * the package name and the app's visible label — standing in for the Linux process + * name and its command line. + * + * `DomainMatcher` goes one step further than a hosts file can. A hosts file has no + * wildcards, so the backend expands every mask into concrete hostnames and reports + * whatever it cannot express as `unexpandable`. A URL-bar matcher has no such limit: + * `*gram*` is matched here, and `*.youtube.com` covers every subdomain as soon as + * `*` is in `subdomain_expansions` — the same two things the desktop app now does + * through its local resolver stub. + */ +package tech.kadence.core + +import java.net.IDN + +/** What to do about a candidate, and why. */ +sealed interface BlockDecision { + data object Allow : BlockDecision + + /** + * @param target what gets reported to `POST /api/blocker/events` and grouped by + * `by_target` in the report — the hostname for a site, the matched pattern for an + * app, matching what the desktop enforcer sends. + * @param matchedRule the block-list entry responsible, for the overlay's copy. + */ + data class Block( + val target: String, + val matchedRule: String, + val action: String, + ) : BlockDecision +} + +/** `BlockEventRequest.action` — the server only fires `notify_on_block` for `killed`. */ +object BlockAction { + /** A site we turned away; the hosts-file equivalent. */ + const val BLOCKED = "blocked" + + /** An app we actually ejected the user from. The only value that notifies. */ + const val KILLED = "killed" + + /** Reported but not enforced — what `kill_blocked_apps = false` means. */ + const val WARNED = "warned" +} + +/** + * Reduce whatever a browser's URL bar exposed to a bare hostname, or null when the text + * is not a host at all. + * + * The URL bar is a lossy source: it may show `youtube.com`, a full URL, a search + * phrase, a `chrome://` page, or a placeholder. Anything that is not confidently a + * hostname must return null — a false positive here would block an innocent page. + */ +fun normalizeHost(raw: String?): String? { + var text = raw?.trim()?.lowercase().orEmpty() + if (text.isEmpty()) return null + + // A scheme we can never block, and the "about:" family. + if (text.startsWith("about:") || text.startsWith("chrome:") || + text.startsWith("chrome-native:") || text.startsWith("data:") || + text.startsWith("file:") || text.startsWith("content:") + ) { + return null + } + + // Strip the scheme. + val schemeAt = text.indexOf("://") + if (schemeAt >= 0) text = text.substring(schemeAt + 3) + + // A space means a search phrase, not a host — but only once the scheme is gone, + // since "https://example.com /path" pasted with a stray space is still a URL. + text = text.trim() + if (text.isEmpty()) return null + + // Authority ends at the first `/`, `?` or `#`. + text = text.takeWhile { it != '/' && it != '?' && it != '#' } + + // Drop userinfo and the port. + val at = text.lastIndexOf('@') + if (at >= 0) text = text.substring(at + 1) + val colon = text.indexOf(':') + if (colon >= 0) text = text.substring(0, colon) + + text = text.trim().trimEnd('.') + if (text.isEmpty() || ' ' in text) return null + + // Unicode domains are matched in their ASCII form, which is what a block list holds. + // + // ALLOW_UNASSIGNED on purpose: strict `toASCII` *throws* on a host built from + // unassigned code points (a homoglyph like `аррӏе.com`), and a throw here would + // return null, which means "allow". A blocker that silently waves through the one + // category of domain designed to look like something else has it backwards. + val ascii = runCatching { IDN.toASCII(text, IDN.ALLOW_UNASSIGNED) } + .getOrNull()?.lowercase() ?: return null + + return if (HOST_PATTERN.matches(ascii)) ascii else null +} + +private val HOST_PATTERN = + Regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$") + +/** + * Matches hostnames against the policy. + * + * @param expandedHosts the server's `all_domains` when online (already expanded and + * allow-list-subtracted), or [expandDomains]'s own output when offline. + */ +class DomainMatcher( + private val rules: BlockerRules, + expandedHosts: List = expandDomains(rules), +) { + private val exact: Set = expandedHosts.map { it.trim().lowercase() }.toSet() + private val allowMasks: List = allowMasksOf(rules) + private val expansion: Expansion = parseExpansions(rules.subdomainExpansions) + private val rawEntries: List = rules.blockedDomains + .filter { it.isNotBlank() } + .map { it.trim().lowercase().trimStart('.') } + + /** host -> the block-list entry that expanded into it; see [expandDomainsWithOrigin]. */ + private val origin: Map = expandDomainsWithOrigin(rules) + + /** @param candidate anything the URL bar gave us — a host, a URL, or junk. */ + fun decide(candidate: String?): BlockDecision { + val host = normalizeHost(candidate) ?: return BlockDecision.Allow + + // 1. The allow list wins outright: `*.google.com` blocked plus + // `*.docs.google.com` allowed does what you would expect. + if (isAllowed(host, allowMasks)) return BlockDecision.Allow + + // 2. The expanded set — exactly what a hosts file would have caught. Name the + // entry that produced the host, falling back to a pattern that matches it + // and finally to the host itself (the server-supplied-list case, where we + // have no provenance). + if (host in exact) { + val rule = origin[host] ?: rawEntries.firstOrNull { entryCovers(it, host) } ?: host + return BlockDecision.Block(host, rule, BlockAction.BLOCKED) + } + + // 3. The raw entries, as patterns. This is the step a hosts file cannot do — + // a `*gram*` mask, or every subdomain once `*` says so. + for (entry in rawEntries) { + if (entryCovers(entry, host)) { + return BlockDecision.Block(host, entry, BlockAction.BLOCKED) + } + } + + return BlockDecision.Allow + } + + /** + * How much of a hostname one block-list entry covers. + * + * * a plain entry covers itself and any subdomain of it, so `youtube.com` catches + * `music.youtube.com` even though no expansion listed it; + * * `*.youtube.com` is a wildcard only when `*` is in `subdomain_expansions` — + * otherwise it means exactly the configured labels, which is what the desktop + * app expands into a hosts file, and what the settings screens say; + * * any other mask (`*gram*`, `*gram.com`) is glob-matched, as it always was — + * the one place the phone stays stronger than a hosts file. + */ + private fun entryCovers(entry: String, host: String): Boolean { + if (entry.isEmpty()) return false + if (entry.startsWith("*.")) { + val base = entry.removePrefix("*.") + if (base.isNotEmpty() && '*' !in base) { + if (expansion.wildcard) return globMatches(entry, host) + return host == base || expansion.labels.any { host == "$it.$base" } + } + } + return if (isMask(entry)) { + globMatches(entry, host) + } else { + host == entry || host.endsWith(".$entry") + } + } + + /** The masks the server had to give up on, which this client does enforce. */ + fun enforcedBeyondHostsFile(): List = unexpandableMasks(rules) +} + +/** Matches installed apps against `blocker.blocked_apps`. */ +class AppMatcher(private val patterns: List) { + + /** + * @param packageName e.g. `com.instagram.android` + * @param label the user-visible app name, e.g. `Instagram` + * @return the pattern that matched, or null + */ + fun match(packageName: String, label: String = ""): String? { + val name = packageName.trim().lowercase() + val alt = label.trim().lowercase() + return patterns.firstOrNull { raw -> matches(raw, name, alt) } + } + + fun decide(packageName: String, label: String = "", kill: Boolean): BlockDecision { + val rule = match(packageName, label) ?: return BlockDecision.Allow + val action = if (kill) BlockAction.KILLED else BlockAction.WARNED + return BlockDecision.Block(rule, rule, action) + } + + private companion object { + /** + * The port of `processes.matches()`: a mask is glob-matched against either + * haystack, while a plain entry is a *substring* test — which is what lets one + * shared list (`discord`) work for both a Linux process and `com.discord`. + */ + fun matches(pattern: String, name: String, alt: String): Boolean { + val p = pattern.trim().lowercase() + if (p.isEmpty()) return false + return if (isMask(p)) { + globMatches(p, name) || globMatches(p, alt) + } else { + p in name || p in alt + } + } + } +} diff --git a/android/core/bin/main/tech/kadence/core/Outbox.kt b/android/core/bin/main/tech/kadence/core/Outbox.kt new file mode 100644 index 0000000..c22d598 --- /dev/null +++ b/android/core/bin/main/tech/kadence/core/Outbox.kt @@ -0,0 +1,245 @@ +/** + * The offline outbox. + * + * Not one of these endpoints is idempotent: `start` creates, `extend` adds, `interrupt` + * increments. So replay is **at-most-once with a post-condition probe**, never blind + * retry — if a send fails ambiguously (the socket died after the request went out, so + * we cannot know whether the server applied it), we re-read the state and ask "does the + * world look like my call landed?" instead of sending it again. + * + * Coalescing happens at enqueue, not at replay: the queue stays a faithful, minimal + * description of what the user asked for, which is far easier to reason about than a + * pile of intents collapsed at the last moment. + */ +package tech.kadence.core + +/** The intents that can be queued. There is no STOP — stopping is [ABANDON]. */ +enum class OutboxOp { + START, + PAUSE, + RESUME, + COMPLETE, + ABANDON, + EXTEND, + INTERRUPT, + ATTACH_TASK, +} + +/** + * One queued intent. + * + * @param anchorSessionId the interval this intent targeted; null for [OutboxOp.START], + * which by definition has no interval yet. + * @param repeat how many times to replay it — only [OutboxOp.INTERRUPT] coalesces this + * way, because the server increments a counter and the count *is* the data. + */ +data class OutboxEntry( + val seq: Long, + val op: OutboxOp, + val anchorSessionId: Long? = null, + val kind: String? = null, + val minutes: Int = 0, + val taskId: Long? = null, + val reason: String? = null, + val repeat: Int = 1, +) + +/** + * Fold [incoming] into [queue], returning the new queue. + * + * Every rule here exists because replaying the naive sequence would be wrong or + * wasteful, and each one is pinned by a test in `OutboxTest`. + */ +fun coalesce(queue: List, incoming: OutboxEntry): List { + val out = queue.toMutableList() + + when (incoming.op) { + // A start supersedes its predecessor on the server anyway (`pomodoro.start()` + // abandons the running interval as "superseded"), so every intent aimed at that + // interval is moot. Task and settings intents are not timer intents and stay. + OutboxOp.START -> { + out.removeAll { it.anchorSessionId != null && it.anchorSessionId == incoming.anchorSessionId } + out.removeAll { it.op == OutboxOp.START } + out.add(incoming) + } + + // Pause/resume annihilate: the user changed their mind before we had signal. + OutboxOp.RESUME -> { + val last = out.lastOrNull { it.isTimerOpFor(incoming.anchorSessionId) } + if (last?.op == OutboxOp.PAUSE) out.remove(last) else out.add(incoming) + } + + OutboxOp.PAUSE -> { + val last = out.lastOrNull { it.isTimerOpFor(incoming.anchorSessionId) } + if (last?.op == OutboxOp.RESUME) out.remove(last) else out.add(incoming) + } + + // "+5 min" twice is "+10 min" once. + OutboxOp.EXTEND -> { + val existing = out.lastOrNull { + it.op == OutboxOp.EXTEND && it.anchorSessionId == incoming.anchorSessionId + } + if (existing != null) { + out[out.indexOf(existing)] = + existing.copy(minutes = existing.minutes + incoming.minutes) + } else { + out.add(incoming) + } + } + + // Counted, not collapsed — three interruptions must become three increments. + OutboxOp.INTERRUPT -> { + val existing = out.lastOrNull { + it.op == OutboxOp.INTERRUPT && it.anchorSessionId == incoming.anchorSessionId + } + if (existing != null) { + out[out.indexOf(existing)] = existing.copy(repeat = existing.repeat + incoming.repeat) + } else { + out.add(incoming) + } + } + + // Only the final choice of task matters. + OutboxOp.ATTACH_TASK -> { + out.removeAll { + it.op == OutboxOp.ATTACH_TASK && it.anchorSessionId == incoming.anchorSessionId + } + out.add(incoming) + } + + // A terminal intent ends the interval: anything still queued for it would be + // rejected with "No interval is running." + OutboxOp.COMPLETE, OutboxOp.ABANDON -> { + out.removeAll { it.isTimerOpFor(incoming.anchorSessionId) } + out.add(incoming) + } + } + + return out +} + +private fun OutboxEntry.isTimerOpFor(sessionId: Long?): Boolean = + sessionId != null && anchorSessionId == sessionId && op != OutboxOp.START + +/** Whether the queue holds anything the reconciler should treat as our own doing. */ +fun pendingOpsOf(queue: List): PendingOps = PendingOps( + hasAny = queue.isNotEmpty(), + hasStart = queue.any { it.op == OutboxOp.START }, + hasTerminal = queue.any { it.op == OutboxOp.COMPLETE || it.op == OutboxOp.ABANDON }, +) + +/** What to do with a queued entry after an attempt. */ +sealed interface ReplayOutcome { + /** It landed. Drop the row. */ + data object Done : ReplayOutcome + + /** The server rejected it on its merits. Drop the row; never retry a 4xx. */ + data class Rejected(val detail: String) : ReplayOutcome + + /** Try again later, with backoff. */ + data class Retry(val attempt: Int, val delaySeconds: Int) : ReplayOutcome + + /** Out of attempts — surface it and let the user retry or discard. */ + data object GiveUp : ReplayOutcome +} + +/** Eight attempts, 1s doubling to a 60s ceiling. */ +fun backoffSeconds(attempt: Int): Int = + if (attempt <= 0) 1 else (1 shl (attempt - 1).coerceAtMost(6)).coerceAtMost(60) + +const val MAX_REPLAY_ATTEMPTS: Int = 8 + +fun replayOutcomeFor(attempt: Int): ReplayOutcome = + if (attempt >= MAX_REPLAY_ATTEMPTS) { + ReplayOutcome.GiveUp + } else { + ReplayOutcome.Retry(attempt + 1, backoffSeconds(attempt + 1)) + } + +/** + * Did an ambiguously-failed send actually land? + * + * Compared against the state read back *after* the failure. Each probe is the weakest + * claim that is still conclusive — `>=` rather than `==`, because another client may + * have moved the same number further along in the meantime. + */ +fun postconditionHolds( + entry: OutboxEntry, + before: TimerSessionFacts?, + after: ServerTimerState, +): Boolean = when (entry.op) { + OutboxOp.START -> + after.current != null && + after.current.status == Status.RUNNING && + (entry.kind == null || after.current.kind == entry.kind) && + after.current.id != entry.anchorSessionId + + OutboxOp.EXTEND -> { + val base = before?.plannedSeconds ?: return false + val now = after.current ?: return false + now.id == entry.anchorSessionId && now.plannedSeconds >= base + entry.minutes * 60 + } + + OutboxOp.INTERRUPT -> { + val base = before?.interruptions ?: return false + val now = after.current ?: return false + now.id == entry.anchorSessionId && now.interruptions >= base + entry.repeat + } + + OutboxOp.PAUSE -> after.current?.takeIf { it.id == entry.anchorSessionId }?.isPaused == true + + OutboxOp.RESUME -> + after.current?.takeIf { it.id == entry.anchorSessionId }?.status == Status.RUNNING + + // The interval is gone, or has been replaced by a newer one. + OutboxOp.COMPLETE, OutboxOp.ABANDON -> + after.current == null || after.current.id != entry.anchorSessionId + + OutboxOp.ATTACH_TASK -> false // Nothing observable to probe; let it retry. +} + +/** + * An interval that both began *and* ended while offline. + * + * It cannot be filed honestly: `pomodoro.start()` stamps `started_at = utcnow()` and + * `focus_minutes` is derived from `actual_seconds`, so replaying start-then-complete on + * reconnect would record a pomodoro at the wrong hour with roughly zero duration, + * quietly corrupting the very statistics the app exists to produce. These are + * quarantined and surfaced, never replayed silently. + */ +data class UnfiledInterval( + val kind: String, + val startedAtWallMillis: Long, + val endedAtWallMillis: Long, + val plannedSeconds: Int, + val interruptions: Int = 0, +) { + val actualSeconds: Int get() = ((endedAtWallMillis - startedAtWallMillis) / 1000L).toInt() +} + +/** + * Split a drained queue into what can be replayed and what has to be quarantined. + * + * A START whose matching COMPLETE/ABANDON is also still queued never reached the + * server, so the whole interval happened offline — see [UnfiledInterval]. + */ +fun partitionForReplay(queue: List): Pair, List> { + val startSeqs = queue.filter { it.op == OutboxOp.START }.map { it.seq }.toSet() + if (startSeqs.isEmpty()) return queue to emptyList() + + // A terminal op with no anchor belongs to the queued start that precedes it. + val quarantined = mutableSetOf() + for (start in queue.filter { it.op == OutboxOp.START }) { + val terminal = queue.firstOrNull { + it.seq > start.seq && (it.op == OutboxOp.COMPLETE || it.op == OutboxOp.ABANDON) + } + if (terminal != null) { + quarantined += queue.filter { it.seq >= start.seq && it.seq <= terminal.seq } + .map { it.seq } + } + } + + val replayable = queue.filter { it.seq !in quarantined } + val unfiled = queue.filter { it.seq in quarantined } + return replayable to unfiled +} diff --git a/android/core/bin/main/tech/kadence/core/TimeWindow.kt b/android/core/bin/main/tech/kadence/core/TimeWindow.kt new file mode 100644 index 0000000..e7c2526 --- /dev/null +++ b/android/core/bin/main/tech/kadence/core/TimeWindow.kt @@ -0,0 +1,32 @@ +/** + * Clock windows, including the ones that wrap midnight. + * + * A port of `in_window()` in `backend/app/timeutil.py`, used for the blocker's + * `schedule[]` entries and for Telegram's quiet hours. Both ends are inclusive, and a + * window whose start is after its end (`23:00 -> 08:00`) spans midnight. + */ +package tech.kadence.core + +import java.time.LocalTime + +/** Whether `moment` falls inside `[start, end]`, wrapping across midnight if needed. */ +fun inWindow(moment: LocalTime, start: LocalTime, end: LocalTime): Boolean = + if (start <= end) moment >= start && moment <= end else moment >= start || moment <= end + +/** + * Parse the `"HH:MM"` the settings document stores. + * + * Returns null rather than throwing: these strings come from a server document a user + * can edit, and one malformed window must not take the whole policy down with it. + */ +fun parseClockTime(raw: String?): LocalTime? { + val text = raw?.trim().orEmpty() + if (text.isEmpty()) return null + val parts = text.split(':') + if (parts.size < 2) return null + val hour = parts[0].toIntOrNull() ?: return null + val minute = parts[1].toIntOrNull() ?: return null + val second = if (parts.size > 2) parts[2].toIntOrNull() ?: 0 else 0 + if (hour !in 0..23 || minute !in 0..59 || second !in 0..59) return null + return LocalTime.of(hour, minute, second) +} diff --git a/android/core/bin/main/tech/kadence/core/Timer.kt b/android/core/bin/main/tech/kadence/core/Timer.kt new file mode 100644 index 0000000..fa1690c --- /dev/null +++ b/android/core/bin/main/tech/kadence/core/Timer.kt @@ -0,0 +1,248 @@ +/** + * The timer, as a client is allowed to understand it. + * + * The server owns the clock (`backend/app/services/pomodoro.py`); a client renders + * `remaining_seconds` and ticks locally between polls. Everything here is pure: given + * what the server last said, what the phone believes, and what is still queued, work + * out what to show and whether to say anything about it. + * + * Three server behaviours this file has to respect: + * + * * `POST /api/timer/start` **abandons** whatever is running, with reason + * `"superseded"` — so a queued start is destructive, not additive. + * * `POST /api/timer/complete` may **auto-start the next interval**, so a reply can + * contain an interval nobody explicitly asked for. + * * There is **no stop endpoint**: stopping is `abandon`, and under `strict_mode` a + * work interval refuses to be abandoned without a reason. + */ +package tech.kadence.core + +/** The interval kinds, as the API spells them. */ +object Kind { + const val WORK = "work" + const val SHORT_BREAK = "short_break" + const val LONG_BREAK = "long_break" +} + +/** The interval statuses, as the API spells them. */ +object Status { + const val RUNNING = "running" + const val PAUSED = "paused" + const val COMPLETED = "completed" + const val ABANDONED = "abandoned" +} + +/** The parts of `TimerState.current` a client actually reasons about. */ +data class TimerSessionFacts( + val id: Long, + val kind: String, + val status: String, + val plannedSeconds: Int, + val remainingSeconds: Int, + val pausedSeconds: Int = 0, + val interruptions: Int = 0, +) { + val isWork: Boolean get() = kind == Kind.WORK + val isPaused: Boolean get() = status == Status.PAUSED + val isActive: Boolean get() = status == Status.RUNNING || status == Status.PAUSED +} + +/** `GET /api/timer/state`, reduced to what drives the UI. */ +data class ServerTimerState( + val current: TimerSessionFacts?, + val nextKind: String = Kind.WORK, + val completedToday: Int = 0, + val goal: Int = 8, + val cyclePosition: Int = 0, + val cyclesBeforeLongBreak: Int = 4, + /** **Minutes**, not seconds — `pomodoro.state()` sends minutes here. */ + val durationsMinutes: Map = emptyMap(), + val timerEnabled: Boolean = true, +) + +/** + * Where the local tick counts down from. + * + * Anchored on [elapsedNanos] — a monotonic clock, never wall time — so a timezone + * change or an NTP correction mid-interval cannot corrupt a countdown. + */ +data class TickAnchor( + val sessionId: Long, + val remainingAtAnchor: Int, + val elapsedNanos: Long, + val paused: Boolean, +) + +/** + * Seconds left at [nowNanos]. A paused interval is frozen at its anchor. + * + * The elapsed delta is floored at zero: a monotonic source should never go backwards, + * but if it ever appears to, time must not be *added* to the interval — a countdown + * that grows is worse than one that stalls. + */ +fun remainingAt(anchor: TickAnchor, nowNanos: Long): Int { + if (anchor.paused) return anchor.remainingAtAnchor.coerceAtLeast(0) + val elapsedSeconds = ((nowNanos - anchor.elapsedNanos) / 1_000_000_000L) + .coerceAtLeast(0L) + .toInt() + return (anchor.remainingAtAnchor - elapsedSeconds).coerceIn(0, anchor.remainingAtAnchor) +} + +/** `1 - remaining/planned`, clamped — the dial's fill, mirroring `controller.progress`. */ +fun progressOf(remainingSeconds: Int, plannedSeconds: Int): Float { + if (plannedSeconds <= 0) return 0f + val raw = 1f - remainingSeconds.toFloat() / plannedSeconds.toFloat() + return raw.coerceIn(0f, 1f) +} + +/** + * How many ticks the dial draws, and how many are spent. + * + * One per minute up to an hour, then one per five minutes — the rule shared by + * `frontend/src/components/TimerDial.tsx` and `qml/components/TimerDial.qml`, kept here + * so the Compose dial and the Glyph ring cannot drift apart. + */ +fun dialTickCount(plannedSeconds: Int): Int { + val minutes = Math.round(plannedSeconds / 60.0).toInt().coerceAtLeast(1) + return if (minutes <= 60) minutes else Math.round(minutes / 5.0).toInt().coerceAtLeast(1) +} + +fun dialSpentTicks(plannedSeconds: Int, remainingSeconds: Int): Int { + val count = dialTickCount(plannedSeconds) + val elapsed = progressOf(remainingSeconds, plannedSeconds) + return kotlin.math.floor(count * elapsed).toInt().coerceIn(0, count) +} + +/** + * What should run next, worked out locally for an offline start. + * + * A port of `pomodoro.next_kind()`: after every `cyclesBeforeLongBreak` completed work + * intervals today the next break is the long one, and the count resets with the day, so + * a long break is never owed from yesterday. Online, the server's `next_kind` wins. + */ +fun nextKindLocally( + lastKind: String?, + lastStatus: String?, + completedWorkToday: Int, + cyclesBeforeLongBreak: Int, +): String { + if (lastKind == null || lastKind != Kind.WORK || lastStatus == Status.ABANDONED) { + return Kind.WORK + } + val cycles = cyclesBeforeLongBreak.coerceAtLeast(1) + return if (completedWorkToday > 0 && completedWorkToday % cycles == 0) { + Kind.LONG_BREAK + } else { + Kind.SHORT_BREAK + } +} + +/** + * Whether `abandon` will be refused for want of a reason. + * + * `pomodoro.abandon()` raises under `strict_mode` for a **work** interval with a blank + * reason. Checking it client-side is what lets the offline "stop" affordance collect + * the reason up front instead of queueing a call that is certain to 400. + */ +fun abandonNeedsReason(strictMode: Boolean, kind: String, reason: String?): Boolean = + strictMode && kind == Kind.WORK && reason.isNullOrBlank() + +/** Six hours, matching `pomodoro.reap_stale()`'s auto-abandon threshold. */ +const val STALE_INTERVAL_SECONDS: Int = 6 * 60 * 60 + +/** + * The client-side mirror of `reap_stale`: stop believing in a local interval that has + * been ticking for six hours with no server contact, so a phone left offline overnight + * does not claim a fourteen-hour pomodoro. + */ +fun isLocallyStale(elapsedSeconds: Int): Boolean = elapsedSeconds > STALE_INTERVAL_SECONDS + +// --- reconciliation --------------------------------------------------------- + +/** What the phone currently believes it is showing. */ +data class LocalBelief( + val sessionId: Long?, + val paused: Boolean = false, +) + +/** What is still queued, which decides whether a difference is ours or somebody's. */ +data class PendingOps( + val hasAny: Boolean = false, + val hasStart: Boolean = false, + val hasTerminal: Boolean = false, +) + +/** The outcome of comparing the server's answer with what the phone was showing. */ +sealed interface Reconciliation { + /** Re-anchor to the server and say nothing. The overwhelmingly common case. */ + data object Adopt : Reconciliation + + /** Keep the optimistic local value: re-anchoring now would visibly bounce. */ + data object KeepOptimistic : Reconciliation + + /** Our own queued intent landed; adopt it without narrating it. */ + data object AdoptOwnReplay : Reconciliation + + /** Another client took over. Worth telling the user. */ + data class AdoptForeign(val kind: String) : Reconciliation + + /** The interval we were ticking is gone and we did not end it. */ + data object EndedElsewhere : Reconciliation + + /** Still the same interval, but somebody else paused it. */ + data object PausedElsewhere : Reconciliation + + /** `timer_enabled` went false: every queued timer intent is now certain to 400. */ + data object ViewOnlyPurge : Reconciliation + + /** No answer from the server; keep ticking from the anchor. */ + data object Offline : Reconciliation +} + +/** + * Decide what to do with a poll result. + * + * @param server the freshly polled state, or null when the backend is unreachable. + */ +fun reconcile( + server: ServerTimerState?, + local: LocalBelief, + pending: PendingOps = PendingOps(), +): Reconciliation { + if (server == null) return Reconciliation.Offline + + // A disabled timer refuses start/pause/resume outright, so anything queued is + // dead on arrival; dropping it is kinder than retrying it eight times. + if (!server.timerEnabled) return Reconciliation.ViewOnlyPurge + + val current = server.current + + if (current == null) { + return when { + pending.hasTerminal -> Reconciliation.AdoptOwnReplay + local.sessionId != null -> Reconciliation.EndedElsewhere + else -> Reconciliation.Adopt + } + } + + // Nothing local to contradict: a cold start, or we were idle and someone began an + // interval. Either way there is no conflict to report. + if (local.sessionId == null) { + return if (pending.hasStart) Reconciliation.AdoptOwnReplay else Reconciliation.Adopt + } + + if (local.sessionId != current.id) { + return if (pending.hasStart) { + Reconciliation.AdoptOwnReplay + } else { + Reconciliation.AdoptForeign(current.kind) + } + } + + // Same interval. + return when { + pending.hasAny -> Reconciliation.KeepOptimistic + current.isPaused && !local.paused -> Reconciliation.PausedElsewhere + else -> Reconciliation.Adopt + } +} diff --git a/android/core/bin/main/tech/kadence/core/Tokens.kt b/android/core/bin/main/tech/kadence/core/Tokens.kt new file mode 100644 index 0000000..e82fb17 --- /dev/null +++ b/android/core/bin/main/tech/kadence/core/Tokens.kt @@ -0,0 +1,203 @@ +/** + * The design tokens, as ARGB. + * + * A transcription of `frontend/src/tokens.ts` and `frontend/src/lib/viz.ts`, kept in the + * pure module so `TokenParityTest` can read those TypeScript files directly and fail the + * build when the two drift. The design brief is that all clients are "the same product"; + * this is the only way to actually enforce it rather than hope for it. + * + * Values are `0xAARRGGBB` longs because `androidx.compose.ui.graphics.Color` cannot be + * referenced from a module that deliberately has no Android dependency. The app module + * wraps each one in `Color(...)` and nothing else. + */ +package tech.kadence.core + +import kotlin.math.roundToLong + +/** `#RRGGBB`, fully opaque. */ +internal fun rgb(hex: Long): Long = 0xFF000000L or hex + +/** `rgba(r, g, b, a)` with the CSS alpha fraction, rounded half-up to 8 bits. */ +internal fun rgba(hex: Long, alpha: Double): Long = + ((alpha * 255.0).roundToLong() shl 24) or hex + +/** The semantic palette. One instance per theme; see [kadenceTokens]. */ +data class KadenceTokens( + val paper: Long, + val surface: Long, + val surfaceSunken: Long, + val ink: Long, + val inkMuted: Long, + val inkFaint: Long, + val rule: Long, + val ruleStrong: Long, + val ember: Long, + val emberSoft: Long, + val pine: Long, + val amber: Long, + val clay: Long, +) + +val KADENCE_LIGHT = KadenceTokens( + paper = rgb(0xFAF7F2), + surface = rgb(0xFFFFFF), + surfaceSunken = rgb(0xF3EFE8), + ink = rgb(0x17150F), + inkMuted = rgb(0x6E675B), + inkFaint = rgb(0x9A9388), + rule = rgba(0x17150F, 0.12), + ruleStrong = rgba(0x17150F, 0.26), + ember = rgb(0xB4441E), + emberSoft = rgba(0xB4441E, 0.10), + pine = rgb(0x2F6B55), + amber = rgb(0x8A6200), + clay = rgb(0xA33A2E), +) + +val KADENCE_DARK = KadenceTokens( + paper = rgb(0x121110), + surface = rgb(0x1A1917), + surfaceSunken = rgb(0x201E1B), + ink = rgb(0xF4F1EA), + inkMuted = rgb(0xA79F93), + inkFaint = rgb(0x776F64), + rule = rgba(0xF4F1EA, 0.14), + ruleStrong = rgba(0xF4F1EA, 0.30), + ember = rgb(0xE0724A), + emberSoft = rgba(0xE0724A, 0.14), + pine = rgb(0x6FB394), + amber = rgb(0xD9A441), + clay = rgb(0xE07A6E), +) + +fun kadenceTokens(dark: Boolean): KadenceTokens = if (dark) KADENCE_DARK else KADENCE_LIGHT + +/** + * Chart colours — deliberately **not** the brand accent, and deliberately **not** the + * system accent either. A chart has to stay readable for colour-blind readers in both + * themes, which is a property of this validated palette and not of whatever hue the + * wallpaper happens to produce. + */ +data class VizTokens( + val work: Long, + val shortBreak: Long, + val longBreak: Long, + val sequential: List, + val grid: Long, + val axis: Long, + val empty: Long, +) + +val VIZ_LIGHT = VizTokens( + work = rgb(0xEB6834), + shortBreak = rgb(0x2A78D6), + longBreak = rgb(0x1BAF7A), + // blue 100 -> 600: "more pomodoros" reads as "darker". + sequential = listOf( + rgb(0xCDE2FB), rgb(0x9EC5F4), rgb(0x6DA7EC), + rgb(0x3987E5), rgb(0x256ABF), rgb(0x184F95), + ), + grid = rgba(0x17150F, 0.12), + axis = rgba(0x17150F, 0.45), + empty = rgba(0x17150F, 0.07), +) + +val VIZ_DARK = VizTokens( + work = rgb(0xD95926), + shortBreak = rgb(0x3987E5), + longBreak = rgb(0x199E70), + // Reversed, so "more" still reads as "darker" against a dark ground. + sequential = listOf( + rgb(0x104281), rgb(0x184F95), rgb(0x256ABF), + rgb(0x3987E5), rgb(0x6DA7EC), rgb(0x9EC5F4), + ), + grid = rgba(0xF4F1EA, 0.14), + axis = rgba(0xF4F1EA, 0.55), + empty = rgba(0xF4F1EA, 0.08), +) + +fun vizTokens(dark: Boolean): VizTokens = if (dark) VIZ_DARK else VIZ_LIGHT + +/** Port of `kindColor()` in `viz.ts`. */ +fun kindColor(kind: String, viz: VizTokens): Long = when (kind) { + Kind.WORK -> viz.work + Kind.LONG_BREAK -> viz.longBreak + else -> viz.shortBreak +} + +/** Port of `rampStep()` in `viz.ts`. Null means "nothing happened". */ +fun rampStep(count: Int, goal: Int, viz: VizTokens): Long? { + if (count <= 0) return null + val steps = viz.sequential.size + val ratio = if (goal > 0) count.toDouble() / goal else count.toDouble() / 8.0 + val index = (ratio * (steps - 1)).roundToLong().toInt().coerceIn(0, steps - 1) + return viz.sequential[index] +} + +// --- the system accent ----------------------------------------------------- + +/** + * Where the accent comes from. + * + * The canvas stays Kadence's — warm paper, ink type, hairline rules — while the single + * accent is taken from the system's dynamic palette when Android offers one, falling + * back to the brand ember. That keeps the editorial design intact (which is the look + * that was accepted) while letting the app pick up the phone's own colour. + * + * Chart colours are explicitly excluded: see [VizTokens]. + */ +fun accentArgb(systemAccent: Long?, dark: Boolean): Long = + systemAccent ?: kadenceTokens(dark).ember + +/** + * WCAG relative luminance of an opaque colour. + * + * Alpha is ignored: these are used to choose label colours against a solid fill. + */ +fun relativeLuminance(argb: Long): Double { + fun channel(value: Long): Double { + val c = value / 255.0 + return if (c <= 0.03928) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4) + } + val r = channel((argb shr 16) and 0xFF) + val g = channel((argb shr 8) and 0xFF) + val b = channel(argb and 0xFF) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +/** WCAG contrast ratio, 1.0 (identical) to 21.0 (black on white). */ +fun contrastRatio(a: Long, b: Long): Double { + val la = relativeLuminance(a) + val lb = relativeLuminance(b) + val lighter = maxOf(la, lb) + val darker = minOf(la, lb) + return (lighter + 0.05) / (darker + 0.05) +} + +/** + * The label colour to put on top of [background]. + * + * The brand ember is dark enough for white (and `theme.ts` hard-codes exactly that), + * but a wallpaper-derived accent can be any lightness at all — Material You will + * happily hand back a pale yellow. Picking whichever of ink/paper actually contrasts is + * what stops "use the system colour" from quietly producing unreadable buttons. + */ +fun onAccentArgb(background: Long): Long { + // The same two candidates the other clients use: white, or the near-black ink that + // `theme.ts` names as the dark-theme contrast colour. + val onLight = rgb(0xFFFFFF) + val onDark = rgb(0x16140F) + return if (contrastRatio(background, onLight) >= contrastRatio(background, onDark)) { + onLight + } else { + onDark + } +} + +/** Whether a colour clears WCAG AA for large text / UI components (3:1). */ +fun meetsLargeTextContrast(foreground: Long, background: Long): Boolean = + contrastRatio(foreground, background) >= 3.0 + +/** Whether a colour clears WCAG AA for body text (4.5:1). */ +fun meetsBodyContrast(foreground: Long, background: Long): Boolean = + contrastRatio(foreground, background) >= 4.5 diff --git a/android/core/bin/test/tech/kadence/core/BlockerTest.kt b/android/core/bin/test/tech/kadence/core/BlockerTest.kt new file mode 100644 index 0000000..c4353af --- /dev/null +++ b/android/core/bin/test/tech/kadence/core/BlockerTest.kt @@ -0,0 +1,306 @@ +package tech.kadence.core + +import java.time.ZoneId +import java.time.ZonedDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Parity with `backend/app/services/blocker_service.py`. + * + * Every expectation in `expands like the backend` was produced by calling the real + * `expand_entry()` through the backend's own venv, not derived by reading the code — + * the phone enforces this list offline, so a divergence here means the guard behaves + * differently depending on whether you have signal. + */ +class BlockerTest { + + private val subs = BlockerRules.DEFAULT_SUBDOMAINS + + @Test + fun `expands like the backend`() { + assertEquals( + listOf("youtube.com", "www.youtube.com", "m.youtube.com") to null, + expandEntry("youtube.com", subs), + ) + assertEquals( + listOf( + "youtube.com", "www.youtube.com", "m.youtube.com", "mobile.youtube.com", + "app.youtube.com", "web.youtube.com", "i.youtube.com", "old.youtube.com", + "new.youtube.com", + ) to null, + expandEntry("*.youtube.com", subs), + ) + // A leading dot is stripped, and the entry is trimmed and lower-cased. + assertEquals( + listOf("reddit.com", "www.reddit.com", "m.reddit.com") to null, + expandEntry(".reddit.com", subs), + ) + assertEquals( + listOf("tiktok.com", "www.tiktok.com", "m.tiktok.com") to null, + expandEntry(" TikTok.com ", subs), + ) + } + + @Test + fun `patterns a hosts file cannot express come back as leftovers`() { + assertEquals(emptyList() to "*gram*", expandEntry("*gram*", subs)) + assertEquals(emptyList() to "*.", expandEntry("*.", subs)) + assertEquals(emptyList() to "**", expandEntry("**", subs)) + assertEquals(emptyList() to "*.a*.com", expandEntry("*.a*.com", subs)) + } + + @Test + fun `an empty entry is neither expanded nor reported`() { + assertEquals(emptyList() to null, expandEntry("", subs)) + assertEquals(emptyList() to null, expandEntry(" ", subs)) + assertEquals(emptyList() to null, expandEntry(".", subs)) + } + + @Test + fun `isAllowed matches the backend, mask by mask`() { + assertTrue(isAllowed("docs.google.com", listOf("docs.google.com"))) + assertTrue(isAllowed("www.youtube.com", listOf("*.youtube.com"))) + // Both of these are false in the backend too: a `*.` mask does not cover the + // bare domain, which is why `expand_entry` emits the base name separately. + assertFalse(isAllowed("youtube.com", listOf("*.youtube.com"))) + assertFalse(isAllowed("docs.google.com", listOf("*.docs.google.com"))) + } + + @Test + fun `expandDomains subtracts the allow list`() { + val rules = BlockerRules( + blockedDomains = listOf("*.youtube.com"), + allowedDomains = listOf("music.youtube.com", "*.i.youtube.com"), + subdomainExpansions = listOf("www", "m", "music", "i"), + ) + val hosts = expandDomains(rules) + assertTrue("youtube.com" in hosts) + assertTrue("www.youtube.com" in hosts) + assertTrue("m.youtube.com" in hosts) + assertFalse("music.youtube.com" in hosts, "an explicitly allowed host is removed") + assertTrue("i.youtube.com" in hosts, "the bare i. host is not covered by `*.i.`") + } + + @Test + fun `expandDomains de-duplicates overlapping entries`() { + val rules = BlockerRules( + blockedDomains = listOf("youtube.com", "youtube.com", "*.youtube.com"), + subdomainExpansions = listOf("www", "m"), + ) + val hosts = expandDomains(rules) + assertEquals(hosts.size, hosts.distinct().size, "no host appears twice") + assertEquals(listOf("youtube.com", "www.youtube.com", "m.youtube.com"), hosts) + } + + @Test + fun `unexpandableMasks collects the leftovers once each`() { + val rules = BlockerRules( + blockedDomains = listOf("youtube.com", "*gram*", "*gram*", "*tube*"), + ) + assertEquals(listOf("*gram*", "*tube*"), unexpandableMasks(rules)) + } + + // --- the policy decision ------------------------------------------------- + + private val zone = ZoneId.of("Europe/Moscow") + private fun at(day: String, time: String): ZonedDateTime = + ZonedDateTime.of(java.time.LocalDate.parse(day), java.time.LocalTime.parse(time), zone) + + private val working = IntervalFacts("work", "running") + private val breaking = IntervalFacts("short_break", "running") + + @Test + fun `disabled beats everything`() { + val rules = BlockerRules(enabled = false, mode = "always") + val verdict = evaluatePolicy(rules, working, at("2026-10-19", "12:00")) + assertFalse(verdict.active) + assertEquals("disabled", verdict.reason) + } + + @Test + fun `always on needs no interval`() { + val rules = BlockerRules(enabled = true, mode = "always") + val verdict = evaluatePolicy(rules, null, at("2026-10-19", "03:00")) + assertTrue(verdict.active) + assertEquals("always on", verdict.reason) + } + + @Test + fun `during work follows the interval`() { + val rules = BlockerRules(enabled = true, mode = "during_work") + val now = at("2026-10-19", "12:00") + + assertTrue(evaluatePolicy(rules, working, now).active) + assertEquals("a work interval is running", evaluatePolicy(rules, working, now).reason) + + // A paused work interval still blocks — you did not finish, you stepped away. + assertTrue(evaluatePolicy(rules, IntervalFacts("work", "paused"), now).active) + + // A finished or abandoned interval does not. + assertFalse(evaluatePolicy(rules, IntervalFacts("work", "completed"), now).active) + assertFalse(evaluatePolicy(rules, IntervalFacts("work", "abandoned"), now).active) + assertFalse(evaluatePolicy(rules, null, now).active) + assertEquals("no work interval is running", evaluatePolicy(rules, null, now).reason) + } + + @Test + fun `breaks block only when asked to`() { + val now = at("2026-10-19", "12:00") + val off = BlockerRules(enabled = true, mode = "during_work", blockDuringBreaks = false) + val on = BlockerRules(enabled = true, mode = "during_work", blockDuringBreaks = true) + + assertFalse(evaluatePolicy(off, breaking, now).active) + assertTrue(evaluatePolicy(on, breaking, now).active) + assertEquals("blocking stays on through breaks", evaluatePolicy(on, breaking, now).reason) + assertTrue(evaluatePolicy(on, IntervalFacts("long_break", "paused"), now).active) + // Still nothing to block through when no interval exists at all. + assertFalse(evaluatePolicy(on, null, now).active) + } + + @Test + fun `a schedule window is checked by weekday and clock`() { + val rules = BlockerRules( + enabled = true, + mode = "scheduled", + // Monday = 0; this is Mon-Fri, 09:00-18:00. + schedule = listOf(BlockWindow("Work hours", "09:00", "18:00", listOf(0, 1, 2, 3, 4))), + ) + // 2026-10-19 is a Monday. + assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "09:00")).active) + assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "13:37")).active) + assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "18:00")).active) + assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "08:59")).active) + assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "18:01")).active) + // Saturday is not in the list. + assertFalse(evaluatePolicy(rules, null, at("2026-10-24", "13:00")).active) + assertEquals( + "outside every window", + evaluatePolicy(rules, null, at("2026-10-24", "13:00")).reason, + ) + assertEquals( + "inside a blocking window", + evaluatePolicy(rules, null, at("2026-10-19", "13:00")).reason, + ) + } + + @Test + fun `a schedule window may wrap midnight`() { + val rules = BlockerRules( + enabled = true, + mode = "scheduled", + schedule = listOf(BlockWindow("Night", "22:00", "06:00", listOf(0))), + ) + assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "23:30")).active) + assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "02:00")).active) + assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "12:00")).active) + } + + @Test + fun `a malformed window is skipped, not fatal`() { + val rules = BlockerRules( + enabled = true, + mode = "scheduled", + schedule = listOf( + BlockWindow("Broken", "nope", "also nope", listOf(0)), + BlockWindow("Good", "09:00", "18:00", listOf(0)), + ), + ) + assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "10:00")).active) + assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "20:00")).active) + } + + @Test + fun `scheduled mode ignores the timer entirely`() { + val rules = BlockerRules( + enabled = true, + mode = "scheduled", + schedule = listOf(BlockWindow("Work hours", "09:00", "18:00", listOf(0))), + ) + // A running work interval does not rescue a moment outside every window: this + // is why enforcement cannot be tied to the timer's lifecycle. + assertFalse(evaluatePolicy(rules, working, at("2026-10-19", "20:00")).active) + } + + @Test + fun `an unknown mode falls back to during_work`() { + // The server's `else` branch; a settings document from a newer backend must not + // turn the guard permanently on or off. + val rules = BlockerRules(enabled = true, mode = "something_new") + assertTrue(evaluatePolicy(rules, working, at("2026-10-19", "12:00")).active) + assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "12:00")).active) + } + + @Test + fun `a wildcard is the user asking for every subdomain`() { + // `*` used to be treated as a subdomain label, so `*.telegram.org` expanded to + // the literal string `*.telegram.org` — which every client then dropped without + // a word. It is the wildcard now: the labels still expand into real hostnames, + // and the pattern comes back for the matcher to take the rest. + val rules = BlockerRules( + blockedDomains = listOf("*.telegram.org"), + subdomainExpansions = listOf("*", "web", "", "not a label"), + ) + assertEquals(listOf("web"), parseExpansions(rules.subdomainExpansions).labels) + assertTrue(parseExpansions(rules.subdomainExpansions).wildcard) + assertEquals(listOf("not a label"), invalidExpansions(rules)) + assertEquals(listOf("telegram.org", "web.telegram.org"), expandDomains(rules)) + assertTrue(expandDomains(rules).none { '*' in it }) + assertEquals(listOf("*.telegram.org"), unexpandableMasks(rules)) + } + + @Test + fun `without a wildcard the mask stays inside the expansion list`() { + // The documented meaning of `*.google.com` before `*` existed: the bare domain + // plus the labels the user configured, nothing more, and nothing for a hosts + // file to refuse. + val rules = BlockerRules( + blockedDomains = listOf("*.google.com"), + subdomainExpansions = listOf("www", "mail"), + ) + assertFalse(parseExpansions(rules.subdomainExpansions).wildcard) + assertEquals(emptyList(), unexpandableMasks(rules)) + assertEquals( + listOf("google.com", "www.google.com", "mail.google.com"), + expandDomains(rules), + ) + } + + @Test + fun `zone parsing falls back instead of throwing`() { + assertEquals(ZoneId.of("Europe/Moscow"), zoneOrDefault("Europe/Moscow")) + assertEquals(ZoneId.systemDefault(), zoneOrDefault("Not/AZone")) + assertEquals(ZoneId.systemDefault(), zoneOrDefault(null)) + assertEquals(ZoneId.systemDefault(), zoneOrDefault("")) + } + + @Test + fun `a plain exception covers its subdomains, like a plain block does`() { + // Parity with `blocker_service.is_allowed`. `youtube.com` blocked catches + // `music.youtube.com`; `youtube.com` allowed now spares it, instead of sparing + // only the literal string while reading exactly like the block entry. + assertTrue(isAllowed("music.youtube.com", listOf("youtube.com"))) + assertTrue(isAllowed("youtube.com", listOf("youtube.com"))) + assertFalse(isAllowed("notyoutube.com", listOf("youtube.com"))) + } + + @Test + fun `a masked exception is still a glob`() { + assertTrue(isAllowed("docs.google.com", listOf("docs.*"))) + assertTrue(isAllowed("a.docs.google.com", listOf("*.docs.google.com"))) + assertFalse(isAllowed("sheets.google.com", listOf("docs.*"))) + } + + @Test + fun `an exception subtracts its subtree from the expanded list`() { + val rules = BlockerRules( + blockedDomains = listOf("*.youtube.com", "reddit.com"), + allowedDomains = listOf("youtube.com"), + subdomainExpansions = listOf("www", "m", "music"), + ) + + assertEquals(listOf("reddit.com", "www.reddit.com", "m.reddit.com"), expandDomains(rules)) + } +} diff --git a/android/core/bin/test/tech/kadence/core/FormatTest.kt b/android/core/bin/test/tech/kadence/core/FormatTest.kt new file mode 100644 index 0000000..d63c79c --- /dev/null +++ b/android/core/bin/test/tech/kadence/core/FormatTest.kt @@ -0,0 +1,111 @@ +package tech.kadence.core + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Parity with `frontend/src/lib/format.ts`. The same interval rendered in a browser, + * in the desktop tray and on the phone has to produce the same string. + */ +class FormatTest { + + @Test + fun `clock pads minutes and seconds`() { + assertEquals("00:00", clock(0)) + assertEquals("00:09", clock(9)) + assertEquals("00:59", clock(59)) + assertEquals("01:00", clock(60)) + assertEquals("25:00", clock(1500)) + assertEquals("59:59", clock(3599)) + } + + @Test + fun `clock grows an hours segment only when needed`() { + assertEquals("1:00:00", clock(3600)) + assertEquals("2:03:04", clock(7384)) + // A 240-minute interval is the backend's documented maximum. + assertEquals("4:00:00", clock(14400)) + } + + @Test + fun `clock clamps negatives to zero`() { + // The local tick can overshoot by a fraction before the poll re-anchors. + assertEquals("00:00", clock(-1)) + assertEquals("00:00", clock(-500)) + } + + @Test + fun `clock rounds a fractional tick`() { + assertEquals("00:30", clock(30.4)) + assertEquals("00:31", clock(30.5)) + assertEquals("01:00", clock(59.6)) + } + + @Test + fun `humanMinutes matches the typescript`() { + assertEquals("0m", humanMinutes(0)) + assertEquals("5m", humanMinutes(5)) + assertEquals("59m", humanMinutes(59)) + assertEquals("1h", humanMinutes(60)) + assertEquals("1h 1m", humanMinutes(61)) + assertEquals("2h 5m", humanMinutes(125)) + assertEquals("2h", humanMinutes(120)) + // The README's worked example: "Focus: 2h 35m". + assertEquals("2h 35m", humanMinutes(155)) + } + + @Test + fun `humanMinutes rounds and clamps`() { + assertEquals("2h 35m", humanMinutes(154.6)) + assertEquals("0m", humanMinutes(-5)) + } + + @Test + fun `kind labels cover every interval kind`() { + assertEquals("Focus", KIND_LABEL["work"]) + assertEquals("Short break", KIND_LABEL["short_break"]) + assertEquals("Long break", KIND_LABEL["long_break"]) + assertEquals(3, KIND_LABEL.size) + } + + @Test + fun `bucket labels follow the web app, not the desktop`() { + // The desktop says "In progress" here; the web app is this client's reference. + assertEquals("Would be nice to start", BUCKET_LABEL["active"]) + assertEquals("Overdue", BUCKET_LABEL["overdue"]) + assertEquals("Due today", BUCKET_LABEL["today"]) + assertEquals("Coming up", BUCKET_LABEL["upcoming"]) + assertEquals("No deadline", BUCKET_LABEL["someday"]) + } + + @Test + fun `bucket order has a label for every bucket and no extras`() { + assertEquals(BUCKET_LABEL.keys, BUCKET_ORDER.toSet()) + assertEquals(BUCKET_ORDER.size, BUCKET_ORDER.distinct().size) + assertEquals("overdue", BUCKET_ORDER.first()) + assertEquals("someday", BUCKET_ORDER.last()) + } + + @Test + fun `dueLabel prefers the overdue count`() { + assertEquals("", dueLabel(null, 0)) + assertEquals("", dueLabel("", 3)) + assertEquals("2026-10-19", dueLabel("2026-10-19", 0)) + assertEquals("3d overdue", dueLabel("2026-10-16", 3)) + } + + @Test + fun `shortDate drops the year`() { + assertEquals("10/19", shortDate("2026-10-19")) + assertEquals("01/05", shortDate("2026-01-05")) + } + + @Test + fun `weekdayOf is monday zero`() { + // 2026-10-19 is a Monday. + assertEquals(0, weekdayOf("2026-10-19")) + assertEquals(1, weekdayOf("2026-10-20")) + assertEquals(5, weekdayOf("2026-10-24")) + assertEquals(6, weekdayOf("2026-10-25")) + } +} diff --git a/android/core/bin/test/tech/kadence/core/GlobTest.kt b/android/core/bin/test/tech/kadence/core/GlobTest.kt new file mode 100644 index 0000000..f767ff9 --- /dev/null +++ b/android/core/bin/test/tech/kadence/core/GlobTest.kt @@ -0,0 +1,156 @@ +package tech.kadence.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Parity with Python's `fnmatch`, which is what the backend and the desktop enforcer + * use. Every expectation in [pythonParity] was produced by running the same pattern + * and text through CPython's `fnmatch.fnmatch`, not reasoned about by hand — the block + * lists are shared across clients, so a disagreement here means one list silently + * means two different things. + */ +class GlobTest { + + @Test + fun `matches python fnmatch exactly`() { + for ((pattern, text, expected) in pythonParity) { + assertEquals( + expected, + globMatches(pattern, text), + "fnmatch(${text.quoted()}, ${pattern.quoted()}) should be $expected", + ) + } + } + + @Test + fun `star is anchored at both ends`() { + // `steam*` must not match `xsteam` — a mask is a whole-string match, which is + // what keeps `steam*` from catching every command that mentions steam. + assertTrue(globMatches("steam*", "steamwebhelper")) + assertFalse(globMatches("steam*", "xsteam")) + } + + @Test + fun `star crosses separators unlike shell globbing`() { + // This is the property the whole mask feature leans on: a hosts file cannot + // express `*gram*`, but a string match can. + assertTrue(globMatches("*gram*", "instagram.com")) + assertTrue(globMatches("*.com", "a.b.com")) + } + + @Test + fun `subdomain mask does not match the bare domain`() { + // Precisely why `expand_entry()` emits the base domain alongside the + // subdomains instead of relying on the mask. + assertTrue(globMatches("*.youtube.com", "music.youtube.com")) + assertFalse(globMatches("*.youtube.com", "youtube.com")) + } + + @Test + fun `question mark matches exactly one character`() { + assertTrue(globMatches("telegram-?", "telegram-1")) + assertFalse(globMatches("telegram-?", "telegram-12")) + assertFalse(globMatches("telegram-?", "telegram-")) + } + + @Test + fun `character classes and negation`() { + assertTrue(globMatches("[sv]potify", "spotify")) + assertTrue(globMatches("[sv]potify", "vpotify")) + assertFalse(globMatches("[sv]potify", "zpotify")) + assertTrue(globMatches("[!sv]potify", "zpotify")) + assertFalse(globMatches("[!sv]potify", "spotify")) + } + + @Test + fun `ranges inside a class`() { + assertTrue(globMatches("app[0-9]", "app7")) + assertFalse(globMatches("app[0-9]", "appx")) + } + + @Test + fun `an unterminated class is a literal bracket`() { + // CPython's translate() falls back to a literal `[`; so must we, or a typo in + // the block list becomes a crash instead of a harmless non-match. + assertTrue(globMatches("[", "[")) + assertTrue(globMatches("[abc", "[abc")) + assertTrue(globMatches("a[]b", "a[]b")) + assertFalse(globMatches("[abc", "a")) + } + + @Test + fun `dots are literal, not regex wildcards`() { + assertTrue(globMatches("a.b", "a.b")) + assertFalse(globMatches("a.b", "axb")) + } + + @Test + fun `regex metacharacters in a pattern are escaped`() { + // A user pasting a URL fragment must not accidentally write a regex. + assertTrue(globMatches("a+b(c)", "a+b(c)")) + assertFalse(globMatches("a+b(c)", "aab c")) + assertTrue(globMatches("price\$", "price\$")) + } + + @Test + fun `empty pattern matches only empty text`() { + assertTrue(globMatches("", "")) + assertFalse(globMatches("", "x")) + } + + @Test + fun `isMask only reports real wildcards`() { + assertTrue(isMask("steam*")) + assertTrue(isMask("a?b")) + assertTrue(isMask("[ab]c")) + assertFalse(isMask("discord")) + assertFalse(isMask("youtube.com")) + assertFalse(isMask("")) + } + + @Test + fun `compiled patterns are cached and still correct on reuse`() { + val first = globToRegex("*discord*") + val second = globToRegex("*discord*") + assertTrue(first === second, "the compiled pattern should be reused") + assertTrue(globMatches("*discord*", "/usr/bin/discord --flag")) + assertTrue(globMatches("*discord*", "discord")) + } + + private fun String.quoted() = "'" + this + "'" + + private companion object { + /** (pattern, text, expected) — generated from CPython's `fnmatch.fnmatch`. */ + val pythonParity = listOf( + Triple("steam*", "steam", true), + Triple("steam*", "steamwebhelper", true), + Triple("steam*", "xsteam", false), + Triple("*discord*", "/usr/bin/discord --flag", true), + Triple("*discord*", "discord", true), + Triple("telegram-?", "telegram-1", true), + Triple("telegram-?", "telegram-12", false), + Triple("[sv]potify", "spotify", true), + Triple("[sv]potify", "vpotify", true), + Triple("[sv]potify", "zpotify", false), + Triple("[!sv]potify", "zpotify", true), + Triple("[!sv]potify", "spotify", false), + Triple("*.youtube.com", "music.youtube.com", true), + Triple("*.youtube.com", "youtube.com", false), + Triple("*gram*", "instagram.com", true), + Triple("*gram*", "telegram", true), + Triple("*.google.com", "docs.google.com", true), + Triple("a.b", "a.b", true), + Triple("a.b", "axb", false), + Triple("[", "[", true), + Triple("[abc", "[abc", true), + Triple("a[]b", "a[]b", true), + Triple("*", "anything", true), + Triple("", "", true), + Triple("", "x", false), + Triple("*.com", "a.b.com", true), + ) + } +} diff --git a/android/core/bin/test/tech/kadence/core/GlyphFrameTest.kt b/android/core/bin/test/tech/kadence/core/GlyphFrameTest.kt new file mode 100644 index 0000000..7e57454 --- /dev/null +++ b/android/core/bin/test/tech/kadence/core/GlyphFrameTest.kt @@ -0,0 +1,301 @@ +package tech.kadence.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The rear Glyph Matrix, tested on a laptop. + * + * This is the whole point of keeping the renderer pure: every dot of the Nothing Phone + * (3)'s 25x25 back display is decided by arithmetic, so the entire feature can be built + * and regression-tested without the device, without the vendored `.aar`, and without + * Android. [asciiOf] prints the frame when an assertion fails, so a broken glyph is + * visible rather than a mismatched integer. + */ +class GlyphFrameBasicsTest { + + @Test + fun `phone 3 is twenty-five squared`() { + val frame = GlyphFrame() + assertEquals(25, frame.size) + assertEquals(625, frame.dots.size, "Common.getDeviceMatrixLength() on DEVICE_23112") + } + + @Test + fun `a different matrix length is honoured`() { + // Phone (4a) Pro is 13x13; nothing may hard-code 25. + val frame = GlyphFrame(13) + assertEquals(169, frame.dots.size) + } + + @Test + fun `brightness is clamped, never wrapped`() { + val frame = GlyphFrame() + frame.set(0, 0, 999) + frame.set(1, 0, -5) + assertEquals(255, frame.get(0, 0)) + assertEquals(0, frame.get(1, 0)) + } + + @Test + fun `out-of-bounds writes are dropped, not crashes`() { + val frame = GlyphFrame() + frame.set(-1, 0, 255) + frame.set(0, -1, 255) + frame.set(25, 0, 255) + frame.set(0, 25, 255) + assertEquals(0, frame.litCount) + assertEquals(0, frame.get(-1, -1)) + assertEquals(0, frame.get(99, 99)) + } + + @Test + fun `render hands out a copy, so the SDK cannot be handed live state`() { + val frame = GlyphFrame() + frame.set(0, 0, 255) + val rendered = frame.render() + frame.set(0, 0, 0) + assertEquals(255, rendered[0]) + } + + @Test + fun `a blank frame is entirely dark`() { + assertEquals(0, GlyphRenderer.blank().litCount) + } +} + +class GlyphDigitsTest { + + @Test + fun `digit width accounts for the gap`() { + assertEquals(0, digitsWidth("")) + assertEquals(3, digitsWidth("1")) + assertEquals(7, digitsWidth("18")) + assertEquals(11, digitsWidth("184")) + } + + @Test + fun `digits render as the three-by-five font`() { + val frame = GlyphFrame() + frame.drawDigitsCentred("18", 6, GlyphBrightness.FULL) + val rows = asciiOf(frame).lines().subList(6, 11) + assertEquals( + listOf( + "...........#.###.........", + "...........#.#.#.........", + "...........#.###.........", + "...........#.#.#.........", + "...........#.###.........", + ), + rows, + "the 3x5 font, centred: a right-aligned 1 beside an 8", + ) + } + + @Test + fun `every digit is five rows tall and lights something`() { + for (d in 0..9) { + val frame = GlyphFrame() + frame.drawDigits(d.toString(), 0, 0, GlyphBrightness.FULL) + assertTrue(frame.litCount > 0, "digit $d renders nothing") + for (y in 5 until 25) { + for (x in 0 until 25) { + assertEquals(0, frame.get(x, y), "digit $d spills past row 5") + } + } + } + } + + @Test + fun `an unknown character advances the cursor without drawing`() { + val frame = GlyphFrame() + frame.drawDigits(":", 0, 0, GlyphBrightness.FULL) + assertEquals(0, frame.litCount) + } +} + +class GlyphRingTest { + + private fun ringOnly(planned: Int, remaining: Int): GlyphFrame { + val frame = GlyphFrame() + frame.drawRing(planned, remaining) + return frame + } + + @Test + fun `the ring uses the same tick rule as the Compose dial`() { + // If these ever diverge, one dot means a different amount of time on the front + // of the phone than on the back. + val planned = 25 * 60 + assertEquals(dialTickCount(planned), ringOnly(planned, planned).litCount) + } + + @Test + fun `a fresh interval lights every tick`() { + val planned = 25 * 60 + assertEquals(25, ringOnly(planned, planned).litCount) + } + + @Test + fun `the ring drains as the interval runs`() { + val planned = 25 * 60 + val full = ringOnly(planned, planned).litCount + val half = ringOnly(planned, planned / 2).litCount + val nearly = ringOnly(planned, 60).litCount + + assertTrue(half < full, "half-spent must be dimmer than fresh") + assertTrue(nearly < half, "nearly-done must be dimmer than half") + assertEquals(13, half) + assertEquals(1, nearly) + } + + @Test + fun `an expired interval lights nothing`() { + assertEquals(0, ringOnly(25 * 60, 0).litCount) + } + + @Test + fun `the arc is anchored at twelve o'clock`() { + // It must shrink *towards* twelve, not drain away from it, so the last sliver is + // at the top where the eye expects it. + val frame = ringOnly(25 * 60, 60) + assertEquals(GlyphBrightness.FULL, frame.get(12, 1), "the top dot stays lit last") + } + + @Test + fun `a long interval still fits the ring`() { + // 85 minutes -> one dot per five minutes, so 17 ticks, not 85. + val planned = 85 * 60 + assertEquals(17, ringOnly(planned, planned).litCount) + } +} + +class GlyphTimerFrameTest { + + @Test + fun `minutes sit above seconds`() { + val frame = GlyphRenderer.timerFrame(18 * 60 + 24, 25 * 60) + val art = asciiOf(frame) + val lines = art.lines() + // Row 6 is the top of the minutes, row 14 the top of the seconds. + assertTrue(lines[6].contains("#"), "minutes row is drawn") + assertTrue(lines[14].contains("#"), "seconds row is drawn") + assertTrue(frame.litCount > 20, "digits plus ring") + } + + @Test + fun `past an hour the rows become hours and minutes`() { + // 3930s is 1h 05m 30s. Shown as mm:ss it would read "65" over "30", and a + // two-glyph row cannot say 65 minutes meaningfully — so past an hour the top + // row becomes hours and the bottom becomes minutes. + val frame = GlyphRenderer.timerFrame(3930, 4 * 3600) + + val expected = GlyphFrame() + expected.drawDigitsCentred("01", 6, GlyphBrightness.FULL) + expected.drawDigitsCentred("05", 14, GlyphBrightness.FULL) + + // Compare only the columns the digits occupy; the ring owns the perimeter. + val got = asciiOf(frame).lines() + val want = asciiOf(expected).lines() + for (y in listOf(6, 7, 8, 9, 10, 14, 15, 16, 17, 18)) { + assertEquals( + want[y].substring(9, 16), + got[y].substring(9, 16), + "row $y should read as hours over minutes", + ) + } + } + + @Test + fun `under an hour the rows are minutes and seconds`() { + val frame = GlyphRenderer.timerFrame(18 * 60 + 24, 25 * 60) + + val expected = GlyphFrame() + expected.drawDigitsCentred("18", 6, GlyphBrightness.FULL) + expected.drawDigitsCentred("24", 14, GlyphBrightness.FULL) + + val got = asciiOf(frame).lines() + val want = asciiOf(expected).lines() + for (y in listOf(6, 7, 8, 9, 10, 14, 15, 16, 17, 18)) { + assertEquals(want[y].substring(9, 16), got[y].substring(9, 16), "row $y") + } + } + + @Test + fun `pausing dims the whole face`() { + val running = GlyphRenderer.timerFrame(18 * 60, 25 * 60, paused = false) + val paused = GlyphRenderer.timerFrame(18 * 60, 25 * 60, paused = true) + + assertEquals(running.litCount, paused.litCount, "the same dots, dimmer") + assertTrue(paused.dots.none { it == GlyphBrightness.FULL }, "nothing stays at full") + assertTrue(paused.dots.any { it == GlyphBrightness.DIM }) + } + + @Test + fun `a negative remaining is clamped rather than drawn`() { + val frame = GlyphRenderer.timerFrame(-30, 25 * 60) + val art = asciiOf(frame) + assertTrue(art.lines().size == 25) + // 00:00, and the ring fully drained. + assertEquals( + GlyphRenderer.timerFrame(0, 25 * 60).let(::asciiOf), + art, + ) + } +} + +class GlyphDotsTest { + + private fun counts(frame: GlyphFrame): Pair = + frame.dots.count { it == GlyphBrightness.FULL } to + frame.dots.count { it == GlyphBrightness.FAINT } + + @Test + fun `earned dots are solid and the rest are faint`() { + val (full, faint) = counts(GlyphRenderer.dotsFrame(6, 8)) + assertEquals(6, full) + assertEquals(2, faint) + } + + @Test + fun `a goal met lights everything`() { + val (full, faint) = counts(GlyphRenderer.dotsFrame(8, 8)) + assertEquals(8, full) + assertEquals(0, faint) + } + + @Test + fun `beating the goal grows the row instead of truncating`() { + val (full, faint) = counts(GlyphRenderer.dotsFrame(10, 8)) + assertEquals(10, full, "a tenth pomodoro on an eight-goal day still shows") + assertEquals(0, faint) + } + + @Test + fun `a large goal wraps onto more rows`() { + val frame = GlyphRenderer.dotsFrame(3, 12) + val (full, faint) = counts(frame) + assertEquals(3, full) + assertEquals(9, faint) + val rowsWithDots = asciiOf(frame).lines().count { it.any { c -> c != '.' } } + assertEquals(2, rowsWithDots, "12 dots at 8 per row is two rows") + } + + @Test + fun `nothing yet today still renders one placeholder`() { + val (full, faint) = counts(GlyphRenderer.dotsFrame(0, 0)) + assertEquals(0, full) + assertEquals(1, faint, "an empty grid would look like a dead display") + } + + @Test + fun `the grid stays inside the matrix`() { + val frame = GlyphRenderer.dotsFrame(20, 24) + for (i in frame.dots.indices) { + // Nothing wrapped around an edge: every lit dot is within bounds by + // construction, so just assert the count survived. + } + assertEquals(24, frame.litCount) + } +} diff --git a/android/core/bin/test/tech/kadence/core/MatchersTest.kt b/android/core/bin/test/tech/kadence/core/MatchersTest.kt new file mode 100644 index 0000000..db19631 --- /dev/null +++ b/android/core/bin/test/tech/kadence/core/MatchersTest.kt @@ -0,0 +1,347 @@ +package tech.kadence.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NormalizeHostTest { + + @Test + fun `a bare host passes through`() { + assertEquals("youtube.com", normalizeHost("youtube.com")) + assertEquals("m.youtube.com", normalizeHost("m.youtube.com")) + assertEquals("youtube.com", normalizeHost(" YouTube.com ")) + } + + @Test + fun `a full url is reduced to its host`() { + assertEquals("youtube.com", normalizeHost("https://youtube.com")) + assertEquals("www.youtube.com", normalizeHost("https://www.youtube.com/watch?v=abc")) + assertEquals("youtube.com", normalizeHost("http://youtube.com/")) + assertEquals("youtube.com", normalizeHost("https://youtube.com#anchor")) + assertEquals("youtube.com", normalizeHost("https://youtube.com?q=1")) + } + + @Test + fun `userinfo and ports are dropped`() { + assertEquals("example.com", normalizeHost("https://user:pw@example.com:8443/x")) + assertEquals("example.com", normalizeHost("example.com:8000")) + } + + @Test + fun `a trailing root dot is dropped`() { + assertEquals("youtube.com", normalizeHost("youtube.com.")) + } + + @Test + fun `unicode hosts are matched in punycode`() { + // A block list holds ASCII; the URL bar may show the pretty form. + assertEquals("xn--e1afmkfd.xn--p1ai", normalizeHost("пример.рф")) + assertEquals("xn--mnchen-3ya.de", normalizeHost("münchen.de")) + assertEquals("xn--wgv71a.jp", normalizeHost("日本.jp")) + } + + @Test + fun `a homoglyph host still normalises instead of being waved through`() { + // `аррӏе.com` is Cyrillic lookalikes. Strict IDN.toASCII throws on it, which + // would have returned null — and null means "allow". ALLOW_UNASSIGNED keeps it + // matchable, so putting it on the block list actually blocks it. + assertEquals("xn--80ak6aa92e.com", normalizeHost("аррӏе.com")) + } + + @Test + fun `a search phrase is not a host`() { + assertNull(normalizeHost("Search or type URL")) + assertNull(normalizeHost("how to focus better")) + assertNull(normalizeHost("youtube cat videos")) + } + + @Test + fun `internal schemes are never blockable`() { + assertNull(normalizeHost("chrome://newtab")) + assertNull(normalizeHost("about:blank")) + assertNull(normalizeHost("chrome-native://bookmarks")) + assertNull(normalizeHost("file:///sdcard/x.html")) + assertNull(normalizeHost("data:text/html,hi")) + } + + @Test + fun `junk is null rather than a false positive`() { + assertNull(normalizeHost(null)) + assertNull(normalizeHost("")) + assertNull(normalizeHost(" ")) + assertNull(normalizeHost("localhost"), "a single label is not a host we block") + assertNull(normalizeHost("...")) + assertNull(normalizeHost("-bad-.com")) + assertNull(normalizeHost("https://")) + } + + @Test + fun `an ip address survives`() { + assertEquals("192.168.1.10", normalizeHost("http://192.168.1.10:8000/api")) + } +} + +class DomainMatcherTest { + + private fun matcher( + blocked: List = emptyList(), + allowed: List = emptyList(), + subs: List = listOf("www", "m"), + ): DomainMatcher { + val rules = BlockerRules( + enabled = true, + blockedDomains = blocked, + allowedDomains = allowed, + subdomainExpansions = subs, + ) + return DomainMatcher(rules) + } + + private fun assertBlocked(decision: BlockDecision, host: String, rule: String) { + val block = assertIs(decision, "expected $host to be blocked") + assertEquals(host, block.target) + assertEquals(rule, block.matchedRule) + assertEquals(BlockAction.BLOCKED, block.action) + } + + @Test + fun `a plain entry blocks the site and its subdomains`() { + val m = matcher(blocked = listOf("youtube.com")) + assertBlocked(m.decide("youtube.com"), "youtube.com", "youtube.com") + assertBlocked(m.decide("www.youtube.com"), "www.youtube.com", "youtube.com") + assertBlocked(m.decide("m.youtube.com"), "m.youtube.com", "youtube.com") + // Beyond the hosts file's reach: never expanded, still caught here. + assertBlocked(m.decide("music.youtube.com"), "music.youtube.com", "youtube.com") + } + + @Test + fun `a lookalike domain is not caught`() { + val m = matcher(blocked = listOf("youtube.com")) + assertEquals(BlockDecision.Allow, m.decide("myyoutube.com")) + assertEquals(BlockDecision.Allow, m.decide("youtube.com.evil.net")) + assertEquals(BlockDecision.Allow, m.decide("notyoutube.com")) + } + + @Test + fun `the allow list wins over the block list`() { + val m = matcher(blocked = listOf("*.google.com"), allowed = listOf("docs.google.com")) + assertEquals(BlockDecision.Allow, m.decide("https://docs.google.com/document/d/1")) + assertBlocked(m.decide("www.google.com"), "www.google.com", "*.google.com") + } + + @Test + fun `an allow mask covers a whole subtree`() { + val m = matcher(blocked = listOf("google.com"), allowed = listOf("*.docs.google.com")) + assertEquals(BlockDecision.Allow, m.decide("a.docs.google.com")) + // Consistent with the backend: `*.x` does not cover the bare `x`. + assertBlocked(m.decide("docs.google.com"), "docs.google.com", "google.com") + } + + @Test + fun `masks the hosts file cannot express are enforced here`() { + // This is the payoff: the backend returns `*gram*` as `unexpandable` because a + // hosts file has no wildcards. The phone matches the string instead. + val m = matcher(blocked = listOf("*gram*")) + assertBlocked(m.decide("instagram.com"), "instagram.com", "*gram*") + assertBlocked(m.decide("https://www.instagram.com/explore/"), "www.instagram.com", "*gram*") + assertEquals(BlockDecision.Allow, m.decide("youtube.com")) + assertEquals(listOf("*gram*"), m.enforcedBeyondHostsFile()) + } + + @Test + fun `a subdomain mask blocks the base domain too, via expansion`() { + val m = matcher(blocked = listOf("*.reddit.com"), subs = listOf("www", "old")) + assertBlocked(m.decide("reddit.com"), "reddit.com", "*.reddit.com") + assertBlocked(m.decide("www.reddit.com"), "www.reddit.com", "*.reddit.com") + assertBlocked(m.decide("old.reddit.com"), "old.reddit.com", "*.reddit.com") + // …and only those: without `*` in the expansion list, an unlisted label is out. + assertEquals(BlockDecision.Allow, m.decide("cdn.reddit.com")) + } + + @Test + fun `junk from the url bar is always allowed`() { + val m = matcher(blocked = listOf("youtube.com", "*gram*")) + assertEquals(BlockDecision.Allow, m.decide("Search or type URL")) + assertEquals(BlockDecision.Allow, m.decide("chrome://newtab")) + assertEquals(BlockDecision.Allow, m.decide(null)) + assertEquals(BlockDecision.Allow, m.decide("")) + } + + @Test + fun `an empty policy blocks nothing`() { + val m = matcher() + assertEquals(BlockDecision.Allow, m.decide("youtube.com")) + assertEquals(BlockDecision.Allow, m.decide("anything.at.all")) + } + + @Test + fun `the server's expanded list is used when supplied`() { + // Online, `all_domains` arrives already expanded; we must honour it even for an + // entry whose raw form we were not given. + val rules = BlockerRules(enabled = true, blockedDomains = emptyList()) + val m = DomainMatcher(rules, expandedHosts = listOf("tiktok.com", "www.tiktok.com")) + val decision = m.decide("https://tiktok.com/foryou") + val block = assertIs(decision) + assertEquals("tiktok.com", block.target) + assertEquals(BlockAction.BLOCKED, block.action) + } + + @Test + fun `the reported rule is the entry that produced the host`() { + // `*.reddit.com` expands to the bare `reddit.com`, but the mask does not match + // that bare name — so without provenance the overlay would blame the host + // instead of naming the rule the user actually wrote. + val m = matcher(blocked = listOf("*.reddit.com")) + val block = assertIs(m.decide("reddit.com")) + assertEquals("reddit.com", block.target, "the event target stays the hostname") + assertEquals("*.reddit.com", block.matchedRule, "but the rule is the user's entry") + } + + @Test + fun `the first entry to claim a host owns it`() { + val m = matcher(blocked = listOf("youtube.com", "*.youtube.com")) + val block = assertIs(m.decide("www.youtube.com")) + assertEquals("youtube.com", block.matchedRule) + } + + @Test + fun `without a wildcard a subdomain mask covers only the configured labels`() { + // What the settings screens promise for `*.google.com`: the bare domain plus + // the labels listed in `subdomain_expansions`, and nothing else. + val m = matcher(blocked = listOf("*.google.com"), subs = listOf("www", "mail")) + assertBlocked(m.decide("google.com"), "google.com", "*.google.com") + assertBlocked(m.decide("mail.google.com"), "mail.google.com", "*.google.com") + assertEquals(BlockDecision.Allow, m.decide("cdn.google.com")) + } + + @Test + fun `a wildcard in the expansion list makes the mask cover every label`() { + val m = matcher(blocked = listOf("*.google.com"), subs = listOf("*", "www")) + assertBlocked(m.decide("cdn.google.com"), "cdn.google.com", "*.google.com") + assertBlocked(m.decide("a.b.google.com"), "a.b.google.com", "*.google.com") + // The bare domain still belongs to the mask, via its expansion. + assertBlocked(m.decide("google.com"), "google.com", "*.google.com") + // And the allow list still wins over the wildcard. + val allowed = matcher( + blocked = listOf("*.google.com"), + allowed = listOf("docs.google.com"), + subs = listOf("*"), + ) + assertEquals(BlockDecision.Allow, allowed.decide("docs.google.com")) + } + + @Test + fun `case and whitespace in the list do not matter`() { + val m = matcher(blocked = listOf(" YouTube.COM ")) + assertBlocked(m.decide("YOUTUBE.com"), "youtube.com", "youtube.com") + } +} + +class AppMatcherTest { + + @Test + fun `a plain entry is a substring test, so one list serves both platforms`() { + // `discord` was written for a Linux process name; it must still catch the + // Android package. + val m = AppMatcher(listOf("discord")) + assertEquals("discord", m.match("com.discord", "Discord")) + assertEquals("discord", m.match("com.discord.canary")) + } + + @Test + fun `the visible label is the second haystack`() { + // Android's analogue of the desktop matcher's command line. + val m = AppMatcher(listOf("instagram")) + assertEquals("instagram", m.match("com.instagram.android", "Instagram")) + + val byLabelOnly = AppMatcher(listOf("telegram")) + assertEquals("telegram", byLabelOnly.match("org.thunderdog.challegram", "Telegram X")) + } + + @Test + fun `a mask is anchored, so it misses the package name`() { + // `steam*` was written for a Linux process called `steam`. An fnmatch mask is a + // whole-string match, so it does NOT match the Android package — this is the + // shared-list gotcha, documented rather than papered over. + val m = AppMatcher(listOf("steam*")) + assertEquals("steam*", m.match("steamwebhelper")) + assertNull(m.match("com.valvesoftware.android.steam.community")) + assertNull(m.match("com.discord", "Discord")) + } + + @Test + fun `the label rescues an anchored mask on Android`() { + // Checking the visible label as a second haystack is what makes the desktop's + // `steam*` still work here: the package misses, but the label "Steam" matches. + // This is the Android analogue of the desktop also trying the command line. + val m = AppMatcher(listOf("steam*")) + assertEquals( + "steam*", + m.match("com.valvesoftware.android.steam.community", "Steam"), + ) + } + + @Test + fun `a wrapped mask matches the package directly`() { + // What the in-app app picker should write, so an entry added on the phone also + // blocks the Linux process. + val wrapped = AppMatcher(listOf("*steam*")) + assertEquals( + "*steam*", + wrapped.match("com.valvesoftware.android.steam.community", ""), + ) + assertEquals("*steam*", wrapped.match("steamwebhelper")) + } + + @Test + fun `masks match either haystack`() { + val m = AppMatcher(listOf("*telegram*")) + assertEquals("*telegram*", m.match("org.telegram.messenger", "Telegram")) + assertEquals("*telegram*", m.match("com.example.app", "My Telegram Client")) + } + + @Test + fun `no match returns null`() { + val m = AppMatcher(listOf("discord", "*steam*")) + assertNull(m.match("com.android.chrome", "Chrome")) + assertNull(m.match("tech.kadence.android", "Kadence")) + } + + @Test + fun `blank patterns are ignored`() { + val m = AppMatcher(listOf("", " ", "discord")) + assertEquals("discord", m.match("com.discord")) + assertNull(m.match("com.android.chrome")) + } + + @Test + fun `the first matching pattern is the one reported`() { + // `by_target` in the report groups by this string, so it must be stable. + val m = AppMatcher(listOf("*gram*", "instagram")) + assertEquals("*gram*", m.match("com.instagram.android", "Instagram")) + } + + @Test + fun `kill_blocked_apps decides the reported action`() { + val m = AppMatcher(listOf("instagram")) + + val killed = m.decide("com.instagram.android", "Instagram", kill = true) + assertEquals(BlockAction.KILLED, assertIs(killed).action) + + // Warn-only. This matters: the server only fires `notify_on_block` for + // `killed`, so sending `warned` here is what keeps Telegram quiet. + val warned = m.decide("com.instagram.android", "Instagram", kill = false) + assertEquals(BlockAction.WARNED, assertIs(warned).action) + + assertEquals(BlockDecision.Allow, m.decide("com.android.chrome", "Chrome", kill = true)) + } + + @Test + fun `case is folded on both sides`() { + val m = AppMatcher(listOf("DISCORD")) + assertTrue(m.match("com.Discord", "Discord") != null) + } +} diff --git a/android/core/bin/test/tech/kadence/core/OutboxTest.kt b/android/core/bin/test/tech/kadence/core/OutboxTest.kt new file mode 100644 index 0000000..9c2269b --- /dev/null +++ b/android/core/bin/test/tech/kadence/core/OutboxTest.kt @@ -0,0 +1,361 @@ +package tech.kadence.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class OutboxCoalesceTest { + + private var seq = 0L + private fun next() = ++seq + + private fun entry(op: OutboxOp, session: Long? = 7L, minutes: Int = 0, taskId: Long? = null) = + OutboxEntry(seq = next(), op = op, anchorSessionId = session, minutes = minutes, taskId = taskId) + + private fun queueOf(vararg ops: OutboxEntry): List = + ops.fold(emptyList()) { acc, e -> coalesce(acc, e) } + + @Test + fun `an empty queue takes the first intent as-is`() { + val q = queueOf(entry(OutboxOp.PAUSE)) + assertEquals(1, q.size) + assertEquals(OutboxOp.PAUSE, q.single().op) + } + + @Test + fun `pause then resume annihilate`() { + // The user changed their mind before we had signal; sending both is pointless + // and would wrongly bump the interruption counter. + val q = queueOf(entry(OutboxOp.PAUSE), entry(OutboxOp.RESUME)) + assertTrue(q.isEmpty()) + } + + @Test + fun `resume then pause annihilate too`() { + val q = queueOf(entry(OutboxOp.RESUME), entry(OutboxOp.PAUSE)) + assertTrue(q.isEmpty()) + } + + @Test + fun `a pause that survives a round trip is kept`() { + val q = queueOf(entry(OutboxOp.PAUSE), entry(OutboxOp.RESUME), entry(OutboxOp.PAUSE)) + assertEquals(listOf(OutboxOp.PAUSE), q.map { it.op }) + } + + @Test + fun `extends add up`() { + val q = queueOf( + entry(OutboxOp.EXTEND, minutes = 5), + entry(OutboxOp.EXTEND, minutes = 5), + entry(OutboxOp.EXTEND, minutes = 10), + ) + assertEquals(1, q.size) + assertEquals(20, q.single().minutes) + } + + @Test + fun `extends for different intervals stay apart`() { + val q = queueOf( + entry(OutboxOp.EXTEND, session = 7L, minutes = 5), + entry(OutboxOp.EXTEND, session = 8L, minutes = 5), + ) + assertEquals(2, q.size) + } + + @Test + fun `interruptions are counted, not collapsed`() { + // The server increments a counter, so three taps must become three increments. + val q = queueOf( + entry(OutboxOp.INTERRUPT), + entry(OutboxOp.INTERRUPT), + entry(OutboxOp.INTERRUPT), + ) + assertEquals(1, q.size) + assertEquals(3, q.single().repeat) + } + + @Test + fun `only the last chosen task matters`() { + val q = queueOf( + entry(OutboxOp.ATTACH_TASK, taskId = 1L), + entry(OutboxOp.ATTACH_TASK, taskId = 2L), + entry(OutboxOp.ATTACH_TASK, taskId = 3L), + ) + assertEquals(1, q.size) + assertEquals(3L, q.single().taskId) + } + + @Test + fun `a start discards everything queued for the interval it supersedes`() { + // `pomodoro.start()` abandons the running interval as "superseded", so a queued + // pause or +5 for it is already moot. + val q = queueOf( + entry(OutboxOp.PAUSE, session = 7L), + entry(OutboxOp.EXTEND, session = 7L, minutes = 5), + entry(OutboxOp.INTERRUPT, session = 7L), + OutboxEntry(seq = next(), op = OutboxOp.START, anchorSessionId = 7L, kind = Kind.WORK), + ) + assertEquals(listOf(OutboxOp.START), q.map { it.op }) + } + + @Test + fun `only one start is ever queued`() { + val q = queueOf( + OutboxEntry(seq = next(), op = OutboxOp.START, kind = Kind.WORK), + OutboxEntry(seq = next(), op = OutboxOp.START, kind = Kind.SHORT_BREAK), + ) + assertEquals(1, q.size) + assertEquals(Kind.SHORT_BREAK, q.single().kind) + } + + @Test + fun `a terminal intent clears the interval's other intents`() { + val q = queueOf( + entry(OutboxOp.PAUSE, session = 7L), + entry(OutboxOp.EXTEND, session = 7L, minutes = 5), + entry(OutboxOp.COMPLETE, session = 7L), + ) + assertEquals(listOf(OutboxOp.COMPLETE), q.map { it.op }) + } + + @Test + fun `a terminal intent leaves another interval's intents alone`() { + val q = queueOf( + entry(OutboxOp.PAUSE, session = 8L), + entry(OutboxOp.COMPLETE, session = 7L), + ) + assertEquals(2, q.size) + } + + @Test + fun `pendingOps summarises the queue for the reconciler`() { + assertEquals(PendingOps(), pendingOpsOf(emptyList())) + + val withStart = queueOf(OutboxEntry(seq = next(), op = OutboxOp.START, kind = Kind.WORK)) + assertEquals(PendingOps(hasAny = true, hasStart = true), pendingOpsOf(withStart)) + + val withTerminal = queueOf(entry(OutboxOp.ABANDON)) + assertEquals(PendingOps(hasAny = true, hasTerminal = true), pendingOpsOf(withTerminal)) + + val plain = queueOf(entry(OutboxOp.INTERRUPT)) + assertEquals(PendingOps(hasAny = true), pendingOpsOf(plain)) + } +} + +class BackoffTest { + + @Test + fun `backoff doubles and then caps`() { + assertEquals(1, backoffSeconds(1)) + assertEquals(2, backoffSeconds(2)) + assertEquals(4, backoffSeconds(3)) + assertEquals(8, backoffSeconds(4)) + assertEquals(16, backoffSeconds(5)) + assertEquals(32, backoffSeconds(6)) + assertEquals(60, backoffSeconds(7), "capped at a minute") + assertEquals(60, backoffSeconds(99)) + } + + @Test + fun `a zeroth attempt still waits a moment`() { + assertEquals(1, backoffSeconds(0)) + } + + @Test + fun `retries give up after eight attempts`() { + val retry = assertIs(replayOutcomeFor(1)) + assertEquals(2, retry.attempt) + assertIs(replayOutcomeFor(MAX_REPLAY_ATTEMPTS - 1)) + assertEquals(ReplayOutcome.GiveUp, replayOutcomeFor(MAX_REPLAY_ATTEMPTS)) + assertEquals(ReplayOutcome.GiveUp, replayOutcomeFor(MAX_REPLAY_ATTEMPTS + 5)) + } +} + +/** + * The post-condition probes. None of these endpoints is idempotent, so after an + * ambiguous failure the only safe question is "does the world look like my call + * landed?" — asked once, against freshly-read state. + */ +class PostconditionTest { + + private fun session( + id: Long = 7L, + status: String = Status.RUNNING, + planned: Int = 1500, + interruptions: Int = 0, + kind: String = Kind.WORK, + ) = TimerSessionFacts( + id = id, + kind = kind, + status = status, + plannedSeconds = planned, + remainingSeconds = 900, + interruptions = interruptions, + ) + + private fun state(current: TimerSessionFacts?) = ServerTimerState(current = current) + + @Test + fun `extend landed when planned seconds grew by at least the request`() { + val before = session(planned = 1500) + val entry = OutboxEntry(1, OutboxOp.EXTEND, anchorSessionId = 7L, minutes = 5) + + assertTrue(postconditionHolds(entry, before, state(session(planned = 1800)))) + assertFalse(postconditionHolds(entry, before, state(session(planned = 1500)))) + // Another client may have extended further in the meantime; still landed. + assertTrue(postconditionHolds(entry, before, state(session(planned = 2400)))) + } + + @Test + fun `extend did not land if the interval was replaced`() { + val before = session(id = 7L, planned = 1500) + val entry = OutboxEntry(1, OutboxOp.EXTEND, anchorSessionId = 7L, minutes = 5) + assertFalse(postconditionHolds(entry, before, state(session(id = 9L, planned = 1800)))) + assertFalse(postconditionHolds(entry, before, state(null))) + } + + @Test + fun `interrupt landed when the counter advanced by the repeat count`() { + val before = session(interruptions = 2) + val entry = OutboxEntry(1, OutboxOp.INTERRUPT, anchorSessionId = 7L, repeat = 3) + + assertFalse(postconditionHolds(entry, before, state(session(interruptions = 4)))) + assertTrue(postconditionHolds(entry, before, state(session(interruptions = 5)))) + assertTrue(postconditionHolds(entry, before, state(session(interruptions = 6)))) + } + + @Test + fun `start landed when a new running interval of the right kind exists`() { + val entry = OutboxEntry(1, OutboxOp.START, anchorSessionId = 7L, kind = Kind.WORK) + + assertTrue(postconditionHolds(entry, null, state(session(id = 9L, kind = Kind.WORK)))) + // The old interval is still there, so nothing started. + assertFalse(postconditionHolds(entry, null, state(session(id = 7L)))) + // Wrong kind: somebody else's interval, not ours. + assertFalse( + postconditionHolds(entry, null, state(session(id = 9L, kind = Kind.SHORT_BREAK))), + ) + assertFalse(postconditionHolds(entry, null, state(null))) + // A paused new interval is not what `start` produces. + assertFalse( + postconditionHolds(entry, null, state(session(id = 9L, status = Status.PAUSED))), + ) + } + + @Test + fun `pause and resume probe the status`() { + val pause = OutboxEntry(1, OutboxOp.PAUSE, anchorSessionId = 7L) + assertTrue(postconditionHolds(pause, null, state(session(status = Status.PAUSED)))) + assertFalse(postconditionHolds(pause, null, state(session(status = Status.RUNNING)))) + assertFalse(postconditionHolds(pause, null, state(null))) + + val resume = OutboxEntry(2, OutboxOp.RESUME, anchorSessionId = 7L) + assertTrue(postconditionHolds(resume, null, state(session(status = Status.RUNNING)))) + assertFalse(postconditionHolds(resume, null, state(session(status = Status.PAUSED)))) + } + + @Test + fun `complete and abandon landed when the interval is gone or replaced`() { + val complete = OutboxEntry(1, OutboxOp.COMPLETE, anchorSessionId = 7L) + assertTrue(postconditionHolds(complete, null, state(null))) + // An auto-started follow-on counts: our interval is no longer current. + assertTrue(postconditionHolds(complete, null, state(session(id = 9L)))) + assertFalse(postconditionHolds(complete, null, state(session(id = 7L)))) + + val abandon = OutboxEntry(2, OutboxOp.ABANDON, anchorSessionId = 7L) + assertTrue(postconditionHolds(abandon, null, state(null))) + assertFalse(postconditionHolds(abandon, null, state(session(id = 7L)))) + } + + @Test + fun `attach task has nothing conclusive to probe`() { + // `TimerState.current.task` is not in the facts we keep, so we would be + // guessing. Returning false means "retry", which is safe: attaching the same + // task twice is a no-op. + val entry = OutboxEntry(1, OutboxOp.ATTACH_TASK, anchorSessionId = 7L, taskId = 3L) + assertFalse(postconditionHolds(entry, null, state(session()))) + } +} + +/** + * Intervals that both started and finished offline cannot be filed truthfully, because + * the server stamps `started_at` itself and `focus_minutes` comes from the wall-clock + * difference. Quarantining them is the honest option; replaying them would silently + * corrupt the statistics the whole app exists to produce. + */ +class PartitionForReplayTest { + + @Test + fun `a queue with no start replays entirely`() { + val queue = listOf( + OutboxEntry(1, OutboxOp.PAUSE, anchorSessionId = 7L), + OutboxEntry(2, OutboxOp.EXTEND, anchorSessionId = 7L, minutes = 5), + OutboxEntry(3, OutboxOp.COMPLETE, anchorSessionId = 7L), + ) + val (replay, unfiled) = partitionForReplay(queue) + assertEquals(3, replay.size) + assertTrue(unfiled.isEmpty()) + } + + @Test + fun `a start still running replays`() { + val queue = listOf( + OutboxEntry(1, OutboxOp.START, kind = Kind.WORK), + OutboxEntry(2, OutboxOp.INTERRUPT, anchorSessionId = null), + ) + val (replay, unfiled) = partitionForReplay(queue) + assertEquals(2, replay.size) + assertTrue(unfiled.isEmpty()) + } + + @Test + fun `a start and its completion are quarantined together`() { + val queue = listOf( + OutboxEntry(1, OutboxOp.START, kind = Kind.WORK), + OutboxEntry(2, OutboxOp.COMPLETE), + ) + val (replay, unfiled) = partitionForReplay(queue) + assertTrue(replay.isEmpty(), "replaying would file a pomodoro at the wrong hour") + assertEquals(listOf(1L, 2L), unfiled.map { it.seq }) + } + + @Test + fun `everything between the start and its end goes with it`() { + val queue = listOf( + OutboxEntry(1, OutboxOp.START, kind = Kind.WORK), + OutboxEntry(2, OutboxOp.INTERRUPT), + OutboxEntry(3, OutboxOp.EXTEND, minutes = 5), + OutboxEntry(4, OutboxOp.ABANDON, reason = "the phone rang"), + ) + val (replay, unfiled) = partitionForReplay(queue) + assertTrue(replay.isEmpty()) + assertEquals(4, unfiled.size) + } + + @Test + fun `work queued before the offline start is still replayed`() { + val queue = listOf( + OutboxEntry(1, OutboxOp.PAUSE, anchorSessionId = 7L), + OutboxEntry(2, OutboxOp.START, kind = Kind.WORK), + OutboxEntry(3, OutboxOp.COMPLETE), + ) + val (replay, unfiled) = partitionForReplay(queue) + assertEquals(listOf(1L), replay.map { it.seq }) + assertEquals(listOf(2L, 3L), unfiled.map { it.seq }) + } + + @Test + fun `an unfiled interval reports its real duration`() { + val start = 1_760_000_000_000L + val interval = UnfiledInterval( + kind = Kind.WORK, + startedAtWallMillis = start, + endedAtWallMillis = start + 25 * 60 * 1000L, + plannedSeconds = 1500, + ) + assertEquals(1500, interval.actualSeconds) + assertEquals("25m", humanMinutes(interval.actualSeconds / 60)) + } +} diff --git a/android/core/bin/test/tech/kadence/core/TimeWindowTest.kt b/android/core/bin/test/tech/kadence/core/TimeWindowTest.kt new file mode 100644 index 0000000..8f2b72e --- /dev/null +++ b/android/core/bin/test/tech/kadence/core/TimeWindowTest.kt @@ -0,0 +1,71 @@ +package tech.kadence.core + +import java.time.LocalTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Parity with `in_window()` in `backend/app/timeutil.py`. */ +class TimeWindowTest { + + private fun t(text: String) = LocalTime.parse(text) + + @Test + fun `a normal window is inclusive at both ends`() { + val start = t("10:00") + val end = t("20:00") + assertTrue(inWindow(t("10:00"), start, end), "the start minute is inside") + assertTrue(inWindow(t("15:30"), start, end)) + assertTrue(inWindow(t("20:00"), start, end), "the end minute is inside") + assertFalse(inWindow(t("09:59"), start, end)) + assertFalse(inWindow(t("20:01"), start, end)) + } + + @Test + fun `a window that wraps midnight`() { + // The quiet-hours default: 23:00 -> 08:00. + val start = t("23:00") + val end = t("08:00") + assertTrue(inWindow(t("23:00"), start, end)) + assertTrue(inWindow(t("23:59"), start, end)) + assertTrue(inWindow(t("00:00"), start, end)) + assertTrue(inWindow(t("07:59"), start, end)) + assertTrue(inWindow(t("08:00"), start, end)) + assertFalse(inWindow(t("08:01"), start, end)) + assertFalse(inWindow(t("22:59"), start, end)) + assertFalse(inWindow(t("12:00"), start, end)) + } + + @Test + fun `a degenerate window covers exactly one minute`() { + val at = t("09:00") + assertTrue(inWindow(at, at, at)) + assertFalse(inWindow(t("09:01"), at, at)) + } + + @Test + fun `parses the HH colon MM the settings document stores`() { + assertEquals(LocalTime.of(9, 0), parseClockTime("09:00")) + assertEquals(LocalTime.of(23, 5), parseClockTime("23:05")) + assertEquals(LocalTime.of(21, 0), parseClockTime(" 21:00 ")) + assertEquals(LocalTime.of(7, 30, 15), parseClockTime("07:30:15")) + assertEquals(LocalTime.of(9, 0), parseClockTime("9:00")) + } + + @Test + fun `a malformed time is null, never an exception`() { + // These strings come from a user-editable server document; one bad window must + // not take the whole blocking policy down. + assertNull(parseClockTime(null)) + assertNull(parseClockTime("")) + assertNull(parseClockTime(" ")) + assertNull(parseClockTime("nope")) + assertNull(parseClockTime("25:00")) + assertNull(parseClockTime("10:60")) + assertNull(parseClockTime("10")) + assertNull(parseClockTime("-1:00")) + assertNull(parseClockTime("ab:cd")) + } +} diff --git a/android/core/bin/test/tech/kadence/core/TimerTest.kt b/android/core/bin/test/tech/kadence/core/TimerTest.kt new file mode 100644 index 0000000..8551854 --- /dev/null +++ b/android/core/bin/test/tech/kadence/core/TimerTest.kt @@ -0,0 +1,322 @@ +package tech.kadence.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class TickMathTest { + + private fun anchor(remaining: Int, atNanos: Long = 0L, paused: Boolean = false) = + TickAnchor(sessionId = 1L, remainingAtAnchor = remaining, elapsedNanos = atNanos, paused = paused) + + private fun seconds(n: Long) = n * 1_000_000_000L + + @Test + fun `counts down from the anchor`() { + val a = anchor(remaining = 300) + assertEquals(300, remainingAt(a, seconds(0))) + assertEquals(299, remainingAt(a, seconds(1))) + assertEquals(240, remainingAt(a, seconds(60))) + assertEquals(0, remainingAt(a, seconds(300))) + } + + @Test + fun `never goes below zero`() { + val a = anchor(remaining = 5) + assertEquals(0, remainingAt(a, seconds(5))) + assertEquals(0, remainingAt(a, seconds(500))) + } + + @Test + fun `a paused interval is frozen`() { + val a = anchor(remaining = 300, paused = true) + assertEquals(300, remainingAt(a, seconds(0))) + assertEquals(300, remainingAt(a, seconds(120))) + } + + @Test + fun `partial seconds do not tick early`() { + val a = anchor(remaining = 300) + assertEquals(300, remainingAt(a, 999_999_999L)) + assertEquals(299, remainingAt(a, 1_000_000_000L)) + } + + @Test + fun `the anchor is monotonic, so a clock change cannot corrupt it`() { + // elapsedNanos comes from a monotonic source; a negative delta (which wall time + // could produce after an NTP correction) must not add time to the interval. + val a = anchor(remaining = 300, atNanos = seconds(100)) + assertEquals(300, remainingAt(a, seconds(100))) + assertEquals(300, remainingAt(a, seconds(90)), "going backwards must not add time") + } + + @Test + fun `progress is the dial's fill, clamped`() { + assertEquals(0f, progressOf(1500, 1500)) + assertEquals(0.5f, progressOf(750, 1500)) + assertEquals(1f, progressOf(0, 1500)) + assertEquals(1f, progressOf(-10, 1500), "an overshooting tick still reads as full") + assertEquals(0f, progressOf(100, 0), "a zero-length interval has no progress") + } +} + +class DialGeometryTest { + + @Test + fun `one tick per minute up to an hour`() { + assertEquals(25, dialTickCount(25 * 60)) + assertEquals(45, dialTickCount(45 * 60)) + assertEquals(60, dialTickCount(60 * 60)) + assertEquals(5, dialTickCount(5 * 60)) + } + + @Test + fun `past an hour it switches to one per five minutes`() { + assertEquals(17, dialTickCount(85 * 60), "the 'Пара' preset: 85 minutes") + assertEquals(24, dialTickCount(120 * 60)) + assertEquals(48, dialTickCount(240 * 60), "the backend's 240-minute maximum") + } + + @Test + fun `a degenerate interval still draws one tick`() { + assertEquals(1, dialTickCount(0)) + assertEquals(1, dialTickCount(30)) + } + + @Test + fun `spent ticks track elapsed time`() { + val planned = 25 * 60 + assertEquals(0, dialSpentTicks(planned, planned)) + assertEquals(12, dialSpentTicks(planned, planned / 2)) + assertEquals(25, dialSpentTicks(planned, 0)) + } + + @Test + fun `spent ticks stay inside the dial`() { + val planned = 25 * 60 + assertEquals(25, dialSpentTicks(planned, -60)) + assertEquals(0, dialSpentTicks(planned, planned + 60)) + } +} + +class NextKindTest { + + @Test + fun `work follows anything that is not a completed work interval`() { + assertEquals(Kind.WORK, nextKindLocally(null, null, 0, 4)) + assertEquals(Kind.WORK, nextKindLocally(Kind.SHORT_BREAK, Status.COMPLETED, 2, 4)) + assertEquals(Kind.WORK, nextKindLocally(Kind.LONG_BREAK, Status.COMPLETED, 4, 4)) + } + + @Test + fun `an abandoned work interval does not earn a break`() { + assertEquals(Kind.WORK, nextKindLocally(Kind.WORK, Status.ABANDONED, 3, 4)) + } + + @Test + fun `a short break follows work mid-cycle`() { + assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 1, 4)) + assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 3, 4)) + assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 5, 4)) + } + + @Test + fun `a long break lands on the cycle boundary`() { + assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 4, 4)) + assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 8, 4)) + assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 2, 2)) + } + + @Test + fun `a long break is never owed from yesterday`() { + // The count resets with the day, so zero completed means no boundary. + assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 0, 4)) + } + + @Test + fun `a nonsense cycle length does not divide by zero`() { + assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 3, 0)) + } +} + +class StrictModeTest { + + @Test + fun `strict mode demands a reason for abandoning work`() { + assertTrue(abandonNeedsReason(true, Kind.WORK, null)) + assertTrue(abandonNeedsReason(true, Kind.WORK, "")) + assertTrue(abandonNeedsReason(true, Kind.WORK, " ")) + assertFalse(abandonNeedsReason(true, Kind.WORK, "the phone rang")) + } + + @Test + fun `breaks and non-strict mode need no reason`() { + assertFalse(abandonNeedsReason(true, Kind.SHORT_BREAK, null)) + assertFalse(abandonNeedsReason(true, Kind.LONG_BREAK, null)) + assertFalse(abandonNeedsReason(false, Kind.WORK, null)) + } +} + +class StaleIntervalTest { + + @Test + fun `six hours is the client-side reap threshold`() { + assertFalse(isLocallyStale(0)) + assertFalse(isLocallyStale(STALE_INTERVAL_SECONDS)) + assertTrue(isLocallyStale(STALE_INTERVAL_SECONDS + 1)) + assertTrue(isLocallyStale(14 * 3600), "a phone offline overnight claims nothing") + } +} + +/** + * One named test per row of the conflict matrix. This is the logic that decides whether + * a difference between the phone and the server is our own replay landing, another + * device taking over, or something worth interrupting the user about — and it is + * impossible to exercise by hand on a device. + */ +class ReconcileTest { + + private fun session(id: Long, kind: String = Kind.WORK, status: String = Status.RUNNING) = + TimerSessionFacts( + id = id, + kind = kind, + status = status, + plannedSeconds = 1500, + remainingSeconds = 900, + ) + + private fun state(current: TimerSessionFacts?, enabled: Boolean = true) = + ServerTimerState(current = current, timerEnabled = enabled) + + @Test + fun `no answer means keep ticking`() { + assertEquals( + Reconciliation.Offline, + reconcile(null, LocalBelief(sessionId = 7L)), + ) + } + + @Test + fun `same interval, nothing queued, re-anchor silently`() { + assertEquals( + Reconciliation.Adopt, + reconcile(state(session(7L)), LocalBelief(sessionId = 7L)), + ) + } + + @Test + fun `same interval with a queue keeps the optimistic value`() { + // Re-anchoring here would make a pending +5 visibly bounce back and forth. + assertEquals( + Reconciliation.KeepOptimistic, + reconcile( + state(session(7L)), + LocalBelief(sessionId = 7L), + PendingOps(hasAny = true), + ), + ) + } + + @Test + fun `a different interval is another device taking over`() { + val result = reconcile( + state(session(9L, kind = Kind.SHORT_BREAK)), + LocalBelief(sessionId = 7L), + ) + assertEquals(Kind.SHORT_BREAK, assertIs(result).kind) + } + + @Test + fun `a different interval we queued ourselves is adopted quietly`() { + assertEquals( + Reconciliation.AdoptOwnReplay, + reconcile( + state(session(9L)), + LocalBelief(sessionId = 7L), + PendingOps(hasAny = true, hasStart = true), + ), + ) + } + + @Test + fun `a vanished interval we did not end is reported`() { + assertEquals( + Reconciliation.EndedElsewhere, + reconcile(state(null), LocalBelief(sessionId = 7L)), + ) + } + + @Test + fun `a vanished interval we completed ourselves is not`() { + assertEquals( + Reconciliation.AdoptOwnReplay, + reconcile( + state(null), + LocalBelief(sessionId = 7L), + PendingOps(hasAny = true, hasTerminal = true), + ), + ) + } + + @Test + fun `a pause we did not perform is reported`() { + assertEquals( + Reconciliation.PausedElsewhere, + reconcile( + state(session(7L, status = Status.PAUSED)), + LocalBelief(sessionId = 7L, paused = false), + ), + ) + } + + @Test + fun `a pause we already knew about is not news`() { + assertEquals( + Reconciliation.Adopt, + reconcile( + state(session(7L, status = Status.PAUSED)), + LocalBelief(sessionId = 7L, paused = true), + ), + ) + } + + @Test + fun `a cold start simply adopts whatever is running`() { + // Local knows nothing yet; an interval on the server is not a conflict. + assertEquals( + Reconciliation.Adopt, + reconcile(state(session(7L)), LocalBelief(sessionId = null)), + ) + } + + @Test + fun `idle on both sides is quiet`() { + assertEquals( + Reconciliation.Adopt, + reconcile(state(null), LocalBelief(sessionId = null)), + ) + } + + @Test + fun `view-only mode purges the queue`() { + // Every timer intent would 400 with "Timer is disabled (view-only mode)". + assertEquals( + Reconciliation.ViewOnlyPurge, + reconcile( + state(session(7L), enabled = false), + LocalBelief(sessionId = 7L), + PendingOps(hasAny = true), + ), + ) + } + + @Test + fun `view-only wins even when idle and empty`() { + assertEquals( + Reconciliation.ViewOnlyPurge, + reconcile(state(null, enabled = false), LocalBelief(sessionId = null)), + ) + } +} diff --git a/android/core/bin/test/tech/kadence/core/TokenParityTest.kt b/android/core/bin/test/tech/kadence/core/TokenParityTest.kt new file mode 100644 index 0000000..e6c2837 --- /dev/null +++ b/android/core/bin/test/tech/kadence/core/TokenParityTest.kt @@ -0,0 +1,261 @@ +package tech.kadence.core + +import java.io.File +import kotlin.math.roundToLong +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The design system, checked against the web app's own source. + * + * The brief is that every client is "the same product", and the README says the desktop + * `Theme` singleton "carries the same tokens as the web app's `tokens.ts`". That has + * only ever been true by hand. This test reads the actual TypeScript and fails if a + * single channel drifts, which is the only version of that promise a build can keep. + * + * If `frontend/` is ever absent (a checkout of this module alone), the parity tests skip + * rather than fail — they are a guard, not a dependency. + */ +class TokenParityTest { + + private val repoRoot: File? = generateSequence(File("").absoluteFile) { it.parentFile } + .firstOrNull { File(it, "frontend/src/tokens.ts").isFile } + + private val tokensTs: File? = repoRoot?.let { File(it, "frontend/src/tokens.ts") } + private val vizTs: File? = repoRoot?.let { File(it, "frontend/src/lib/viz.ts") } + + /** `"#FAF7F2"` or `"rgba(23, 21, 15, 0.12)"` -> `0xAARRGGBB`. */ + private fun parseCss(value: String): Long { + val hex = Regex("^#([0-9a-fA-F]{6})$").find(value.trim()) + if (hex != null) return rgb(hex.groupValues[1].toLong(16)) + + val rgba = Regex( + """^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9.]+)\s*\)$""", + ).find(value.trim()) ?: error("unrecognised colour: $value") + + val (r, g, b, a) = rgba.destructured + val packed = (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong() + return ((a.toDouble() * 255.0).roundToLong() shl 24) or packed + } + + /** Pull `name: "value",` pairs out of the named `export const` block. */ + private fun block(source: String, name: String): Map { + val start = source.indexOf("$name: Tokens = {") + .takeIf { it >= 0 } + ?: source.indexOf("$name: VizPalette = {") + require(start >= 0) { "could not find the `$name` block" } + val open = source.indexOf('{', start) + val close = source.indexOf("};", open) + val body = source.substring(open + 1, close) + return Regex("""(\w+)\s*:\s*"([^"]+)"""") + .findAll(body) + .associate { it.groupValues[1] to it.groupValues[2] } + } + + private fun sequentialOf(source: String, name: String): List { + val start = source.indexOf("$name: VizPalette = {") + require(start >= 0) + val seqAt = source.indexOf("sequential:", start) + val open = source.indexOf('[', seqAt) + val close = source.indexOf(']', open) + return Regex(""""(#[0-9a-fA-F]{6})"""") + .findAll(source.substring(open, close)) + .map { it.groupValues[1] } + .toList() + } + + private fun assertPalette(expected: Map, actual: KadenceTokens, mode: String) { + val ours = mapOf( + "paper" to actual.paper, + "surface" to actual.surface, + "surfaceSunken" to actual.surfaceSunken, + "ink" to actual.ink, + "inkMuted" to actual.inkMuted, + "inkFaint" to actual.inkFaint, + "rule" to actual.rule, + "ruleStrong" to actual.ruleStrong, + "ember" to actual.ember, + "emberSoft" to actual.emberSoft, + "pine" to actual.pine, + "amber" to actual.amber, + "clay" to actual.clay, + ) + assertEquals( + expected.keys.sorted(), + ours.keys.sorted(), + "$mode: the Kotlin palette and tokens.ts must declare the same tokens", + ) + for ((key, css) in expected) { + assertEquals( + parseCss(css).toString(16), + ours.getValue(key).toString(16), + "$mode token `$key` drifted from tokens.ts ($css)", + ) + } + } + + @Test + fun `the light palette matches tokens ts`() { + val source = tokensTs?.readText() ?: return + assertPalette(block(source, "light"), KADENCE_LIGHT, "light") + } + + @Test + fun `the dark palette matches tokens ts`() { + val source = tokensTs?.readText() ?: return + assertPalette(block(source, "dark"), KADENCE_DARK, "dark") + } + + @Test + fun `the chart palette matches viz ts`() { + val source = vizTs?.readText() ?: return + for ((name, ours) in listOf("LIGHT" to VIZ_LIGHT, "DARK" to VIZ_DARK)) { + val expected = block(source, name) + assertEquals(parseCss(expected.getValue("work")), ours.work, "$name work") + assertEquals( + parseCss(expected.getValue("shortBreak")), ours.shortBreak, "$name shortBreak", + ) + assertEquals( + parseCss(expected.getValue("longBreak")), ours.longBreak, "$name longBreak", + ) + assertEquals(parseCss(expected.getValue("grid")), ours.grid, "$name grid") + assertEquals(parseCss(expected.getValue("axis")), ours.axis, "$name axis") + assertEquals(parseCss(expected.getValue("empty")), ours.empty, "$name empty") + + val seq = sequentialOf(source, name).map(::parseCss) + assertEquals(seq, ours.sequential, "$name sequential ramp") + } + } + + @Test + fun `the dark ramp is the light ramp reversed and shifted one step darker`() { + // Not a plain reversal, which is the easy thing to assume. The dark ramp drops + // the palest light step and prepends a darker blue (#104281), so that against a + // dark ground "more" still reads as "darker" without the top step glowing. + assertEquals( + VIZ_LIGHT.sequential.reversed().dropLast(1), + VIZ_DARK.sequential.drop(1), + ) + assertEquals(rgb(0x104281), VIZ_DARK.sequential.first()) + assertEquals(6, VIZ_DARK.sequential.size) + } + + @Test + fun `the parity guard actually found the web app`() { + // Guards against the tests above silently skipping forever. + assertTrue( + tokensTs?.isFile == true, + "frontend/src/tokens.ts not found from ${File("").absolutePath} — " + + "the parity tests would skip", + ) + } +} + +class RampStepTest { + + @Test + fun `nothing happened means no colour`() { + assertEquals(null, rampStep(0, 8, VIZ_LIGHT)) + assertEquals(null, rampStep(-1, 8, VIZ_LIGHT)) + } + + @Test + fun `a met goal lands on the darkest step`() { + assertEquals(VIZ_LIGHT.sequential.last(), rampStep(8, 8, VIZ_LIGHT)) + assertEquals(VIZ_LIGHT.sequential.last(), rampStep(16, 8, VIZ_LIGHT)) + } + + @Test + fun `steps climb with the ratio`() { + val steps = (1..8).map { rampStep(it, 8, VIZ_LIGHT) } + assertEquals(steps, steps.sortedBy { VIZ_LIGHT.sequential.indexOf(it) }) + assertEquals(VIZ_LIGHT.sequential[3], rampStep(4, 8, VIZ_LIGHT)) + } + + @Test + fun `a zero goal falls back to a goal of eight`() { + assertEquals(rampStep(4, 8, VIZ_LIGHT), rampStep(4, 0, VIZ_LIGHT)) + } + + @Test + fun `kindColor maps every interval kind`() { + assertEquals(VIZ_LIGHT.work, kindColor(Kind.WORK, VIZ_LIGHT)) + assertEquals(VIZ_LIGHT.shortBreak, kindColor(Kind.SHORT_BREAK, VIZ_LIGHT)) + assertEquals(VIZ_LIGHT.longBreak, kindColor(Kind.LONG_BREAK, VIZ_LIGHT)) + assertEquals(VIZ_LIGHT.shortBreak, kindColor("something_new", VIZ_LIGHT)) + } +} + +/** + * The system accent. You asked for the phone's own colour; these tests pin down how far + * it reaches and what stops it producing an unreadable button. + */ +class AccentTest { + + @Test + fun `the system accent is used when Android offers one`() { + val wallpaperBlue = rgb(0x3F5AA6) + assertEquals(wallpaperBlue, accentArgb(wallpaperBlue, dark = false)) + } + + @Test + fun `ember is the fallback when there is no dynamic colour`() { + assertEquals(KADENCE_LIGHT.ember, accentArgb(null, dark = false)) + assertEquals(KADENCE_DARK.ember, accentArgb(null, dark = true)) + } + + @Test + fun `contrast ratio is the WCAG formula`() { + assertEquals(21.0, contrastRatio(rgb(0x000000), rgb(0xFFFFFF)), 0.01) + assertEquals(1.0, contrastRatio(rgb(0x808080), rgb(0x808080)), 0.001) + } + + @Test + fun `white sits on the brand ember, as the other clients hard-code`() { + assertEquals(rgb(0xFFFFFF), onAccentArgb(KADENCE_LIGHT.ember)) + assertTrue(meetsBodyContrast(rgb(0xFFFFFF), KADENCE_LIGHT.ember)) + } + + @Test + fun `a pale system accent gets dark text instead of unreadable white`() { + // Material You will happily return a pale yellow; white-on-pale is the failure + // mode this guard exists for. + val paleYellow = rgb(0xFFE08A) + assertEquals(rgb(0x16140F), onAccentArgb(paleYellow)) + assertTrue(meetsBodyContrast(onAccentArgb(paleYellow), paleYellow)) + } + + @Test + fun `whatever the accent, its label is readable`() { + // Sweep the hue/lightness space the system could hand us and assert the chosen + // label always clears AA for large text. + var checked = 0 + for (r in 0..255 step 51) { + for (g in 0..255 step 51) { + for (b in 0..255 step 51) { + val accent = rgb((r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong()) + val label = onAccentArgb(accent) + assertTrue( + meetsLargeTextContrast(label, accent), + "label on #%06x only reaches %.2f:1".format( + accent and 0xFFFFFF, + contrastRatio(label, accent), + ), + ) + checked++ + } + } + } + assertTrue(checked > 200, "the sweep should cover the colour space") + } + + @Test + fun `the chart palette never follows the accent`() { + // A chart has to stay colour-blind readable, which is a property of the + // validated palette, not of the wallpaper. + val wallpaper = rgb(0x3F5AA6) + assertTrue(VIZ_LIGHT.work != wallpaper) + assertEquals(rgb(0xEB6834), VIZ_LIGHT.work, "work keeps its validated orange") + } +}