some backend fixes and add android app

This commit is contained in:
timka123 committed 2026-10-19 14:52:20 +03:00
1 parent 772fdbfded
commit 1e55144291
69 files changed
+7111 -7

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
# Kadence for Android
The fourth client, for the Nothing Phone (3). Same backend, same editorial design, plus
the three things only a native phone client can do: enforce the focus guard on the phone
itself, surface the running interval as an Android 16 Live Update, and draw the timer on
the rear Glyph Matrix.
## Two modules, and why
| Module | What it is | Needs the Android SDK? |
| --- | --- | --- |
| **`core`** | Plain Kotlin on the JVM: the matchers, the policy clock, the timer maths, the outbox reconciliation, the Glyph frame renderer, the design tokens | **No** |
| **`app`** | The Android client: Compose UI, services, Room, the HTTP client, the Glyph toy | Yes |
Everything with interesting behaviour lives in `core`, deliberately. The result is that
the hard parts — offline reconciliation, glob and mask matching, the blocking policy,
even the 25×25 rear display — are **testable on a laptop with nothing but a JDK**, with
no emulator, no SDK and no phone in the loop:
```bash
make android-test # both modules: 219 tests, no device, no backend
make android-test-core # just the pure-logic suite: needs only a JDK
```
`settings.gradle.kts` only includes `:app` when an SDK is actually present, so that
command works on a machine that has never seen the Android tooling.
## Running the tests
```
./gradlew :core:test :app:testDebugUnitTest
```
**219 tests, none of which need a device or a running backend.** The backend lives only
on the dev machine, so the suite feeds on committed JSON fixtures under
`app/src/test/resources/fixtures/` and a `MockWebServer`; there is no step that curls a
live server.
`:core` (186 tests):
| Suite | What it pins down |
| --- | --- |
| `GlobTest` | Python `fnmatch` parity — expectations generated from CPython, because the block lists are shared with the backend |
| `BlockerTest` | `blocker_service.py` parity: `expand_entry`, allow-list subtraction, the policy decision and its reason strings, schedule windows incl. midnight wrap |
| `DomainMatcherTest` / `AppMatcherTest` | hostname and app matching, allow-list precedence, and the masks a hosts file cannot express |
| `NormalizeHostTest` | what a browser URL bar actually hands over: bare hosts, full URLs, search phrases, `chrome://`, punycode |
| `ReconcileTest` | one test per row of the conflict matrix — another device taking over, our own replay landing, view-only mode |
| `OutboxCoalesceTest` / `PostconditionTest` / `PartitionForReplayTest` | the offline queue: coalescing, the at-most-once probes, and quarantining intervals that cannot be filed honestly |
| `TickMathTest` / `DialGeometryTest` | the countdown anchor and the dial's tick rule |
| `Glyph*Test` | the rear matrix, asserted as ASCII art |
| `TokenParityTest` | reads `frontend/src/tokens.ts` and `lib/viz.ts` and fails if a single colour channel drifts |
| `AccentTest` | the system-accent policy and its contrast guard |
`:app` (33 tests, on the JVM via Robolectric):
| Suite | What it pins down |
| --- | --- |
| `DtoToleranceTest` | every endpoint's fixture decodes — then decodes again with keys stripped, unknown keys added and nulls where values are declared. The backend has no response models, so this is the contract |
| `KadenceApiTest` | `X-API-Token`, the session cookie, 401 routing (and *not* routing on the login path), both FastAPI `detail` shapes, a 422 rendered as `field: message`, an unreachable server as a typed error |
| `ThemeTest` | the palette actually reaching a composable, and that the serif clock is genuinely fixed-advance |
## Toolchain
Gradle 9.6 (via the wrapper) on **JDK 21** — newer JDKs are ahead of what AGP
supports:
```bash
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
```
For the `app` module you also need an Android SDK. AGP 9.4 with Compose 1.12 wants
`compileSdk 37`, which is published under Android's new minor-version scheme as
`platforms;android-37.0` (plain `android-37` does not exist). `targetSdk` stays 36,
because Android 16 is what the phone runs. Point the build at the SDK with
`ANDROID_HOME`, or `android/local.properties`:
```
sdk.dir=/home/you/Android/Sdk
```
## The Glyph Matrix SDK
`glyph-matrix-sdk-2.0.aar` is **not on Maven** — it has to be downloaded from
[Nothing's developer kit](https://github.com/Nothing-Developer-Programme/GlyphMatrix-Developer-Kit)
and dropped into `glyph/libs/`. It is git-ignored, and the Glyph code sits behind an
interface with a no-op implementation, so a missing `.aar` disables the rear display and
breaks nothing else. The renderer itself is in `core` and needs none of this to be
tested.
## Design notes
* **Fraunces has no tabular figures.** Its digits are proportional — `1` is 1024 units
against `0` at 1461 — and the font ships no `tnum` feature, so the web app's global
`font-variant-numeric: tabular-nums` does nothing for the serif dial clock. Here the
clock goes through `TabularFigures`, which lays every glyph in a box as wide as the
widest digit; `ThemeTest` measures it. Inter *does* ship `tnum`, so the sans numbers
are fixed with a font feature instead.
* **The canvas is Kadence's, the accent is the phone's.** Warm paper, ink type, hairline
rules and Fraunces figures are fixed; the single accent comes from Android's dynamic
palette when available, falling back to the brand ember. `onAccentArgb()` picks the
label colour by WCAG contrast, because a wallpaper-derived accent can be any lightness
— that guard is what keeps "use the system colour" from producing unreadable buttons.
* **Chart colours never follow the accent.** They come from the validated, colour-blind
safe palette in `viz.ts`. A chart has to stay readable; that is a property of the
palette, not of the wallpaper.
* **No `VpnService`, ever.** Android allows exactly one, and it belongs to the user's
always-on VPN. Website blocking reads the browser URL bar through an accessibility
service instead; app blocking polls `UsageStatsManager`. Neither touches the VPN slot.
* **The server owns the clock.** The phone polls, ticks locally from a monotonic anchor,
and re-anchors on every reply. Offline writes go to an outbox and the server's answer
always wins the final word.
## Verifying on the device
See the plan at `~/.claude/plans/make-an-app-for-logical-russell.md` for the full manual
script. The short version, once the SDK is installed:
```bash
make up # backend + web UI on :8000
ip -4 addr show | grep inet # find the LAN address for the phone
make android-install # ./gradlew :app:installDebug
adb logcat -s Kadence:*
```
Note that an always-on VPN can swallow a LAN address; `adb shell curl -s
http://<lan-ip>:8000/api/health` is the first thing to check if onboarding cannot reach
the backend.
+85
View File
@@ -0,0 +1,85 @@
plugins {
// AGP 9 has built-in Kotlin support, so `org.jetbrains.kotlin.android` is gone;
// only the Compose compiler plugin is still applied explicitly.
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "tech.kadence.android"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = "tech.kadence.android"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
// So a debug build can sit beside a release one on the same phone.
applicationIdSuffix = ".debug"
}
release {
isMinifyEnabled = false
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
packaging {
resources.excludes += setOf("/META-INF/{AL2.0,LGPL2.1}")
}
}
dependencies {
// Every piece of logic worth testing lives here, and it has no Android dependency.
implementation(project(":core"))
implementation(libs.serialization.json)
implementation(libs.okhttp)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.activity.compose)
implementation(libs.navigation.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
// Robolectric runs these on the JVM, so the UI layer is verifiable without a
// device or an emulator — the same reason the logic lives in :core.
testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.robolectric)
testImplementation(libs.coroutines.test)
testImplementation(libs.mockwebserver)
testImplementation(platform(libs.compose.bom))
testImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
Kadence never declares VpnService. Android allows exactly one active VPN, and on
this phone it belongs to the user's always-on connection. Website blocking reads
the browser's URL bar through an accessibility service instead; app blocking polls
usage stats. Neither takes the VPN slot. Please keep it that way.
-->
<application
android:name=".KadenceApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Kadence">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Kadence">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,5 @@
package tech.kadence.android
import android.app.Application
class KadenceApplication : Application()
@@ -0,0 +1,176 @@
package tech.kadence.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import tech.kadence.android.ui.components.GhostButton
import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.components.PageHeader
import tech.kadence.android.ui.components.Panel
import tech.kadence.android.ui.components.PresetChip
import tech.kadence.android.ui.components.PrimaryButton
import tech.kadence.android.ui.components.SectionTitle
import tech.kadence.android.ui.components.Stat
import tech.kadence.android.ui.components.StatSize
import tech.kadence.android.ui.components.TimerDial
import tech.kadence.android.ui.components.VerticalRule
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.Kind
import tech.kadence.core.KIND_LABEL
import tech.kadence.core.humanMinutes
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
KadenceTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = KadenceTheme.colors.paper,
) {
DesignGallery()
}
}
}
}
}
/**
* A scratch screen for the design system, standing in until the real Timer screen
* lands. It exists so the tokens, the type and the dial can be checked against the web
* app side by side on a device.
*/
@Composable
private fun DesignGallery() {
val colors = KadenceTheme.colors
val planned = 25 * 60
// A live countdown, so the dial and the fixed-advance clock can be watched ticking.
var remaining by remember { mutableIntStateOf(18 * 60 + 24) }
var paused by remember { mutableStateOf(false) }
var preset by remember { mutableIntStateOf(0) }
LaunchedEffect(paused) {
while (!paused) {
delay(1000)
remaining = if (remaining > 0) remaining - 1 else planned
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(22.dp),
) {
PageHeader(
eyebrow = "Monday, 19 October",
title = KIND_LABEL.getValue(Kind.WORK),
caption = "Tracking \"Fix the flaky importer\"",
) {
GhostButton(text = if (paused) "Resume" else "Pause", onClick = { paused = !paused })
PrimaryButton(text = "Finish", onClick = { remaining = planned })
}
Panel(padding = 24.dp) {
TimerDial(
label = KIND_LABEL.getValue(Kind.WORK),
remaining = { remaining },
plannedSeconds = planned,
accent = KadenceTheme.viz.work,
sublabel = "Cycle 2 of 4 before a long break",
paused = paused,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(14.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
listOf("Classic", "Пара", "Custom").forEachIndexed { index, name ->
PresetChip(
text = name,
selected = preset == index,
onClick = { preset = index },
)
}
}
}
SectionTitle("Today", trailing = "6 of 8")
Panel(padding = 0.dp) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Stat(
label = "Focus today",
value = humanMinutes(155),
caption = "35m resting",
modifier = Modifier.weight(1f).padding(16.dp),
)
VerticalRule(height = 70.dp)
Stat(
label = "Interruptions",
value = "3",
caption = "counted, not stopped",
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
}
HorizontalRule()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Stat(
label = "Score",
value = "72",
caption = "out of 100",
size = StatSize.Large,
accent = colors.accent,
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
VerticalRule(height = 84.dp)
Stat(
label = "Streak",
value = "4d",
caption = "best 11",
size = StatSize.Large,
modifier = Modifier.weight(1f).padding(16.dp),
)
}
}
SectionTitle("Palette")
Panel {
Text(
text = "The accent is taken from the system palette; paper, ink and the " +
"hairlines stay Kadence's. Chart colours never follow the accent.",
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
)
}
}
}
@@ -0,0 +1,310 @@
/**
* The wire shapes.
*
* The backend has **no response models** — `backend/app/api/schemas.py` only declares
* request bodies, and every response is a plain dict built by a service function. So
* the only contract is the service code plus `frontend/src/lib/types.ts`, and these DTOs
* are reverse-engineered from both.
*
* That means tolerance is the design, not a nicety: **every field has a default and
* every nested object is nullable**, so a backend that grows a key, drops one, or sends
* null where a value is declared can never crash the phone. `DtoToleranceTest` asserts
* exactly that by mutating committed fixtures.
*
* Two details worth not re-learning the hard way:
* * `TimerStateDto.durations` is in **minutes** (`pomodoro.state()`), not seconds.
* * `paused_seconds` exists on the wire but is missing from `types.ts`; the outbox
* reconciliation wants it, so it is declared here.
*/
package tech.kadence.android.data.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TaskRefDto(
val source: String = "",
@SerialName("external_id") val externalId: String = "",
val title: String = "",
)
@Serializable
data class TimerSessionDto(
val id: Long = 0,
val kind: String = "work",
val status: String = "running",
@SerialName("started_at") val startedAt: String? = null,
@SerialName("ended_at") val endedAt: String? = null,
@SerialName("planned_seconds") val plannedSeconds: Int = 0,
@SerialName("elapsed_seconds") val elapsedSeconds: Int = 0,
@SerialName("remaining_seconds") val remainingSeconds: Int = 0,
@SerialName("paused_seconds") val pausedSeconds: Int = 0,
val interruptions: Int = 0,
@SerialName("cycle_index") val cycleIndex: Int = 0,
val task: TaskRefDto? = null,
val note: String? = null,
)
@Serializable
data class TimerStateDto(
val current: TimerSessionDto? = null,
@SerialName("next_kind") val nextKind: String = "work",
@SerialName("completed_today") val completedToday: Int = 0,
val goal: Int = 8,
@SerialName("cycle_position") val cyclePosition: Int = 0,
@SerialName("cycles_before_long_break") val cyclesBeforeLongBreak: Int = 4,
/** Minutes per kind, not seconds. */
val durations: Map<String, Int> = emptyMap(),
@SerialName("server_time") val serverTime: String? = null,
/** Only present on a `complete` reply. */
val finished: TimerSessionDto? = null,
@SerialName("auto_started") val autoStarted: TimerSessionDto? = null,
val celebration: String? = null,
@SerialName("timer_enabled") val timerEnabled: Boolean = true,
)
@Serializable
data class TaskDto(
val id: Long = 0,
val source: String = "",
@SerialName("source_label") val sourceLabel: String = "",
@SerialName("external_id") val externalId: String = "",
val title: String = "",
val description: String? = null,
val url: String? = null,
val project: String? = null,
val status: String? = null,
val priority: String? = null,
@SerialName("due_date") val dueDate: String? = null,
@SerialName("start_date") val startDate: String? = null,
val labels: List<String> = emptyList(),
@SerialName("is_done") val isDone: Boolean = false,
val pinned: Boolean = false,
val stale: Boolean = false,
@SerialName("estimated_pomodoros") val estimatedPomodoros: Int? = null,
@SerialName("days_overdue") val daysOverdue: Int = 0,
val bucket: String = "someday",
)
@Serializable
data class TaskListDto(
val date: String = "",
val tasks: List<TaskDto> = emptyList(),
val counts: Map<String, Int> = emptyMap(),
val sources: List<String> = emptyList(),
)
@Serializable
data class RefreshSourceDto(
val source: String = "",
val label: String = "",
val ok: Boolean = true,
val error: String? = null,
val count: Int = 0,
val closed: Int = 0,
)
@Serializable
data class RefreshResultDto(
@SerialName("refreshed_at") val refreshedAt: String? = null,
val date: String = "",
val total: Int = 0,
val closed: Int = 0,
val sources: List<RefreshSourceDto> = emptyList(),
)
@Serializable
data class BlockerPolicyDto(
val active: Boolean = false,
val reason: String = "",
val mode: String = "during_work",
val enabled: Boolean = false,
val strict: Boolean = false,
@SerialName("redirect_ip") val redirectIp: String = "127.0.0.1",
/** Empty whenever `active` is false — read `all_domains` instead. */
val domains: List<String> = emptyList(),
/** Likewise empty when inactive. */
val apps: List<String> = emptyList(),
val unexpandable: List<String> = emptyList(),
@SerialName("kill_apps") val killApps: Boolean = true,
@SerialName("notify_on_block") val notifyOnBlock: Boolean = true,
@SerialName("poll_seconds") val pollSeconds: Int = 5,
@SerialName("session_id") val sessionId: Long? = null,
/** Always populated, whatever `active` says. */
@SerialName("all_domains") val allDomains: List<String> = emptyList(),
@SerialName("all_apps") val allApps: List<String> = emptyList(),
)
@Serializable
data class BlockEventDto(
val id: Long = 0,
val kind: String = "app",
val target: String = "",
val action: String = "blocked",
val at: String? = null,
val details: String? = null,
)
@Serializable
data class TargetCountDto(val target: String = "", val count: Int = 0)
@Serializable
data class BlockEventsDto(
val date: String = "",
val total: Int = 0,
@SerialName("by_target") val byTarget: List<TargetCountDto> = emptyList(),
val events: List<BlockEventDto> = emptyList(),
)
@Serializable
data class HourCountDto(val hour: Int = 0, val count: Int = 0)
@Serializable
data class CompletedTaskDto(
val id: Long = 0,
val title: String = "",
val source: String = "",
val project: String? = null,
val url: String? = null,
)
@Serializable
data class TaskMinutesDto(val title: String = "", val minutes: Int = 0)
@Serializable
data class DaySessionDto(
val id: Long = 0,
val kind: String = "work",
val status: String = "completed",
@SerialName("started_at") val startedAt: String? = null,
val minutes: Int = 0,
val task: String? = null,
val interruptions: Int = 0,
)
@Serializable
data class DayMetricsDto(
val date: String = "",
val pomodoros: Int = 0,
val goal: Int = 8,
@SerialName("goal_pct") val goalPct: Double = 0.0,
@SerialName("goal_met") val goalMet: Boolean = false,
@SerialName("focus_minutes") val focusMinutes: Int = 0,
@SerialName("break_minutes") val breakMinutes: Int = 0,
@SerialName("wasted_minutes") val wastedMinutes: Int = 0,
val abandoned: Int = 0,
@SerialName("breaks_taken") val breaksTaken: Int = 0,
val interruptions: Int = 0,
@SerialName("completion_rate") val completionRate: Double = 0.0,
@SerialName("longest_stretch_minutes") val longestStretchMinutes: Int = 0,
@SerialName("first_start") val firstStart: String? = null,
@SerialName("last_end") val lastEnd: String? = null,
@SerialName("by_hour") val byHour: List<HourCountDto> = emptyList(),
@SerialName("peak_hour") val peakHour: Int? = null,
@SerialName("tasks_completed") val tasksCompleted: List<CompletedTaskDto> = emptyList(),
@SerialName("tasks_completed_count") val tasksCompletedCount: Int = 0,
@SerialName("time_by_task") val timeByTask: List<TaskMinutesDto> = emptyList(),
@SerialName("blocked_attempts") val blockedAttempts: Int = 0,
@SerialName("blocked_top") val blockedTop: List<TargetCountDto> = emptyList(),
val sessions: List<DaySessionDto> = emptyList(),
)
@Serializable
data class StreakDto(
val current: Int = 0,
val best: Int = 0,
@SerialName("goal_streak") val goalStreak: Int = 0,
@SerialName("total_days") val totalDays: Int = 0,
)
@Serializable
data class LifetimeDto(
val pomodoros: Int = 0,
@SerialName("focus_minutes") val focusMinutes: Int = 0,
@SerialName("focus_hours") val focusHours: Double = 0.0,
@SerialName("tasks_completed") val tasksCompleted: Int = 0,
@SerialName("blocked_attempts") val blockedAttempts: Int = 0,
)
@Serializable
data class AverageDto(
val pomodoros: Double = 0.0,
@SerialName("focus_minutes") val focusMinutes: Double = 0.0,
)
@Serializable
data class ComparisonDto(
val yesterday: Int = 0,
@SerialName("previous_best") val previousBest: Int = 0,
)
@Serializable
data class AchievementDto(
val code: String = "",
val emoji: String = "",
val title: String = "",
val detail: String = "",
)
@Serializable
data class DailyReportDto(
val date: String = "",
val metrics: DayMetricsDto = DayMetricsDto(),
val streak: StreakDto = StreakDto(),
@SerialName("average_7d") val average7d: AverageDto = AverageDto(),
val lifetime: LifetimeDto = LifetimeDto(),
val comparison: ComparisonDto = ComparisonDto(),
val achievements: List<AchievementDto> = emptyList(),
@SerialName("fun_facts") val funFacts: List<String> = emptyList(),
val verdict: String = "",
@SerialName("open_tasks") val openTasks: Map<String, Int> = emptyMap(),
val score: Double = 0.0,
)
@Serializable
data class DailyReportEnvelopeDto(
val report: DailyReportDto = DailyReportDto(),
@SerialName("sent_at") val sentAt: String? = null,
@SerialName("telegram_preview") val telegramPreview: String = "",
)
@Serializable
data class RangeDayDto(
val date: String = "",
val pomodoros: Int = 0,
@SerialName("focus_minutes") val focusMinutes: Int = 0,
@SerialName("goal_met") val goalMet: Boolean = false,
)
@Serializable
data class StatsSummaryDto(
val today: DayMetricsDto = DayMetricsDto(),
val streak: StreakDto = StreakDto(),
val lifetime: LifetimeDto = LifetimeDto(),
@SerialName("average_7d") val average7d: AverageDto = AverageDto(),
val range: List<RangeDayDto> = emptyList(),
val heatmap: List<RangeDayDto> = emptyList(),
)
@Serializable
data class AuthMeDto(
val authenticated: Boolean = false,
@SerialName("login_required") val loginRequired: Boolean = false,
val username: String = "",
@SerialName("password_login_enabled") val passwordLoginEnabled: Boolean = false,
@SerialName("telegram_login_enabled") val telegramLoginEnabled: Boolean = false,
@SerialName("telegram_bot_username") val telegramBotUsername: String = "",
)
@Serializable
data class HealthDto(
val status: String = "",
val version: String = "",
)
@Serializable
data class SessionsResponseDto(
val date: String = "",
val sessions: List<DaySessionDto> = emptyList(),
val metrics: DayMetricsDto = DayMetricsDto(),
)
@@ -0,0 +1,62 @@
/**
* Error shapes, and what a call can come back as.
*
* FastAPI returns `{"detail": "..."}` for an `HTTPException` and
* `{"detail": [{loc, msg, ...}]}` for a 422. Both existing clients unwrap both forms —
* `frontend/src/lib/api.ts` and `desktop/kadence_desktop/api.py` — so this does too,
* and renders a validation error as `field: message` rather than raw JSON.
*/
package tech.kadence.android.data.net
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** The outcome of one call. */
sealed interface ApiResult<out T> {
data class Ok<T>(val value: T) : ApiResult<T>
/** A 4xx/5xx the server explained. Never retried blindly. */
data class HttpError(val status: Int, val detail: String) : ApiResult<Nothing>
/** The session or token is no longer good; the UI drops to the login gate. */
data class Unauthorized(val detail: String) : ApiResult<Nothing>
/**
* Nothing came back. **Ambiguous for a write**: the request may or may not have
* been applied, which is why the outbox probes a post-condition instead of
* resending. See `Outbox.kt` in `:core`.
*/
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
}
/** Pull a human message out of either `detail` shape; fall back to the status line. */
fun parseErrorDetail(body: String?, fallback: String): String {
if (body.isNullOrBlank()) return fallback
val element = runCatching { KadenceJson.parseToJsonElement(body) }.getOrNull() ?: return fallback
val detail = (element as? JsonObject)?.get("detail") ?: return fallback
// The simple case: {"detail": "No interval is running."}
(detail as? JsonPrimitive)?.contentOrNull?.let { return it }
// The 422 case: {"detail": [{"loc": ["body", "minutes"], "msg": "..."}]}
if (detail is JsonArray) {
val parts = detail.jsonArray.mapNotNull { item ->
val obj = item as? JsonObject ?: return@mapNotNull null
val loc = (obj["loc"] as? JsonArray)
?.drop(1)
?.mapNotNull { it.jsonPrimitive.contentOrNull }
?.joinToString(".")
.orEmpty()
val msg = (obj["msg"] as? JsonPrimitive)?.contentOrNull.orEmpty()
listOf(loc, msg).filter { it.isNotBlank() }.joinToString(": ").ifBlank { null }
}
if (parts.isNotEmpty()) return parts.joinToString("; ")
}
return fallback
}
@@ -0,0 +1,23 @@
/**
* The one JSON configuration.
*
* This object *is* the tolerance strategy for a backend with no response models:
*
* * `ignoreUnknownKeys` — the server may grow a field at any time.
* * `coerceInputValues` — a declared-non-null field arriving as `null` falls back to
* its default instead of throwing.
* * `explicitNulls = false` — we never send `null` for an absent value, which matters
* for the settings PUT: the backend deep-merges, so an explicit null would clobber.
* * `isLenient` — survives the odd unquoted value.
*/
package tech.kadence.android.data.net
import kotlinx.serialization.json.Json
val KadenceJson: Json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
explicitNulls = false
isLenient = true
encodeDefaults = false
}
@@ -0,0 +1,175 @@
/**
* The one place that talks to the backend.
*
* Hand-written over OkHttp rather than generated, for two reasons specific to this
* server: the base URL is configured by the user at runtime (so a client that freezes
* it at construction is the wrong shape), and there are no response models to generate
* from anyway. `desktop/kadence_desktop/api.py` does the same job in 150 stdlib lines;
* keeping this file shaped like it means the two clients stay diff-able.
*
* Auth mirrors the other clients: `X-API-Token` when a token is configured, plus a
* cookie jar for the `kadence_session` the browser login sets. Either is sufficient —
* `backend/app/api/deps.py` accepts either.
*/
package tech.kadence.android.data.net
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
/** Where the backend is and how we prove who we are. Both user-supplied at runtime. */
interface AuthConfig {
val baseUrl: String
val apiToken: String?
}
/**
* Holds `kadence_session` across calls and process restarts.
*
* In-memory here; the real implementation persists through the Keystore-wrapped store.
* Kept behind an interface so the HTTP tests can use a plain map.
*/
class SimpleCookieJar : CookieJar {
private val cookies = mutableMapOf<String, MutableList<Cookie>>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val host = url.host
val existing = this.cookies.getOrPut(host) { mutableListOf() }
for (cookie in cookies) {
existing.removeAll { it.name == cookie.name }
existing.add(cookie)
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
cookies[url.host]?.filter { it.expiresAt > System.currentTimeMillis() } ?: emptyList()
fun clear() = cookies.clear()
fun named(name: String): Cookie? = cookies.values.flatten().firstOrNull { it.name == name }
}
/** Adds the shared-secret header the desktop client also sends. */
class AuthInterceptor(private val config: () -> AuthConfig) : Interceptor {
override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
val builder = chain.request().newBuilder()
.header("Accept", "application/json")
config().apiToken?.takeIf { it.isNotBlank() }?.let {
builder.header("X-API-Token", it)
}
return chain.proceed(builder.build())
}
}
/**
* A 401 on anything but an `/api/auth/` path means the session died mid-use.
*
* The web app does the same (`api.ts`'s `onUnauthorized`) so the UI can drop to the
* login screen instead of showing a generic "backend unreachable".
*/
class UnauthorizedBus {
private val listeners = mutableSetOf<() -> Unit>()
fun subscribe(listener: () -> Unit): () -> Unit {
listeners += listener
return { listeners -= listener }
}
fun publish() = listeners.toList().forEach { it() }
}
class KadenceApi(
private val config: () -> AuthConfig,
val cookieJar: SimpleCookieJar = SimpleCookieJar(),
val unauthorized: UnauthorizedBus = UnauthorizedBus(),
client: OkHttpClient? = null,
) {
private val http: OkHttpClient = client ?: OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.cookieJar(cookieJar)
.addInterceptor(AuthInterceptor(config))
.build()
/** Raw request; every typed call below funnels through here. */
suspend fun request(
method: String,
path: String,
body: String? = null,
): ApiResult<String> = withContext(Dispatchers.IO) {
val base = config().baseUrl.trimEnd('/')
val url = "$base$path".toHttpUrlOrNull()
?: return@withContext ApiResult.NetworkError(
IOException("not a usable backend URL: $base"),
)
val requestBody = when {
body != null -> body.toRequestBody(JSON_MEDIA)
method == "POST" || method == "PUT" || method == "PATCH" ->
"{}".toRequestBody(JSON_MEDIA)
else -> null
}
val request = Request.Builder().url(url).method(method, requestBody).build()
try {
http.newCall(request).execute().use { response ->
val text = response.body?.string()
if (response.code == 401 && !path.startsWith("/api/auth/")) {
unauthorized.publish()
return@withContext ApiResult.Unauthorized(
parseErrorDetail(text, "not authorised"),
)
}
if (!response.isSuccessful) {
return@withContext ApiResult.HttpError(
response.code,
parseErrorDetail(text, "${response.code} ${response.message}"),
)
}
ApiResult.Ok(text.orEmpty())
}
} catch (e: IOException) {
// Deliberately not retried here: for a write this is ambiguous, and the
// outbox resolves it with a post-condition probe.
ApiResult.NetworkError(e)
}
}
suspend inline fun <reified T> get(path: String): ApiResult<T> = decode(request("GET", path))
suspend inline fun <reified T> post(path: String, body: String? = null): ApiResult<T> =
decode(request("POST", path, body))
suspend inline fun <reified T> put(path: String, body: String): ApiResult<T> =
decode(request("PUT", path, body))
suspend inline fun <reified T> patch(path: String, body: String): ApiResult<T> =
decode(request("PATCH", path, body))
suspend inline fun <reified T> delete(path: String): ApiResult<T> =
decode(request("DELETE", path))
/** Decode a successful body, turning a malformed payload into an HttpError. */
inline fun <reified T> decode(result: ApiResult<String>): ApiResult<T> = when (result) {
is ApiResult.Ok -> runCatching {
ApiResult.Ok(KadenceJson.decodeFromString<T>(result.value))
}.getOrElse { ApiResult.HttpError(200, "unreadable response: ${it.message}") }
is ApiResult.HttpError -> result
is ApiResult.Unauthorized -> result
is ApiResult.NetworkError -> result
}
}
@@ -0,0 +1,265 @@
/**
* The shared page furniture, ported from `frontend/src/components/Layout.tsx` and the
* QML `components/` set so all clients read as one product.
*
* The rule throughout: a hairline border does the separating. No shadows, no tonal
* elevation, no cards floating over the paper.
*/
package tech.kadence.android.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import tech.kadence.android.ui.theme.KadenceTheme
/** One hairline, defined once so no call site hand-rolls a 1px box. */
@Composable
fun HorizontalRule(modifier: Modifier = Modifier, strong: Boolean = false) {
val colors = KadenceTheme.colors
Box(
modifier
.fillMaxWidth()
.height(1.dp)
.background(if (strong) colors.ruleStrong else colors.rule),
)
}
@Composable
fun VerticalRule(modifier: Modifier = Modifier, height: Dp) {
Box(modifier.width(1.dp).height(height).background(KadenceTheme.colors.rule))
}
/** The small-caps letterspaced label that sits above a serif title. */
@Composable
fun Eyebrow(text: String, modifier: Modifier = Modifier, color: Color? = null) {
Text(
text = text.uppercase(),
style = KadenceTheme.type.eyebrow,
color = color ?: KadenceTheme.colors.inkMuted,
modifier = modifier,
)
}
/** Eyebrow, serif title, optional caption, then a rule. */
@Composable
fun PageHeader(
eyebrow: String,
title: String,
caption: String? = null,
modifier: Modifier = Modifier,
actions: (@Composable () -> Unit)? = null,
) {
val colors = KadenceTheme.colors
Column(modifier.fillMaxWidth()) {
Eyebrow(eyebrow)
Column(Modifier.padding(top = 2.dp)) {
Text(text = title, style = KadenceTheme.type.pageTitle, color = colors.ink)
}
if (caption != null) {
Text(
text = caption,
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
modifier = Modifier.padding(top = 4.dp),
)
}
if (actions != null) {
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) { actions() }
}
HorizontalRule(Modifier.padding(top = 14.dp))
}
}
/** An eyebrow, a rule filling the rest of the row, and an optional trailing note. */
@Composable
fun SectionTitle(
label: String,
modifier: Modifier = Modifier,
trailing: String? = null,
) {
val colors = KadenceTheme.colors
Row(
modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Eyebrow(label)
HorizontalRule(Modifier.weight(1f))
if (trailing != null) {
Text(text = trailing, style = KadenceTheme.type.caption, color = colors.inkFaint)
}
}
}
/** A bordered surface. No shadow, no tint — the border does the separating. */
@Composable
fun Panel(
modifier: Modifier = Modifier,
padding: Dp = 16.dp,
content: @Composable () -> Unit,
) {
val colors = KadenceTheme.colors
Surface(
modifier = modifier,
shape = RoundedCornerShape(14.dp),
color = colors.surface,
border = BorderStroke(1.dp, colors.rule),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Column(Modifier.padding(padding)) { content() }
}
}
enum class StatSize { Small, Medium, Large }
/** Label, serif figure, caption — the web app's `Stat`. */
@Composable
fun Stat(
label: String,
value: String,
modifier: Modifier = Modifier,
caption: String? = null,
size: StatSize = StatSize.Medium,
accent: Color? = null,
tabular: Boolean = false,
) {
val colors = KadenceTheme.colors
val style = when (size) {
StatSize.Small -> KadenceTheme.type.statSmall
StatSize.Medium -> KadenceTheme.type.statMedium
StatSize.Large -> KadenceTheme.type.statLarge
}
Column(modifier) {
Eyebrow(label)
if (tabular) {
TabularFigures(
text = value,
style = style,
color = accent ?: colors.ink,
modifier = Modifier.padding(top = 2.dp),
)
} else {
Text(
text = value,
style = style,
color = accent ?: colors.ink,
modifier = Modifier.padding(top = 2.dp),
)
}
if (caption != null) {
Text(
text = caption,
style = KadenceTheme.type.caption,
color = colors.inkFaint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** The filled accent button. Exactly one per view. */
@Composable
fun PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val colors = KadenceTheme.colors
Button(
onClick = onClick,
modifier = modifier.heightIn(min = 40.dp),
enabled = enabled,
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.onAccent,
disabledContainerColor = colors.rule,
disabledContentColor = colors.inkFaint,
),
elevation = null,
) {
Text(text = text, style = KadenceTheme.type.button)
}
}
/** The outlined one. Everything that is not the single primary action. */
@Composable
fun GhostButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val colors = KadenceTheme.colors
OutlinedButton(
onClick = onClick,
modifier = modifier.heightIn(min = 40.dp),
enabled = enabled,
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, colors.ruleStrong),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = colors.ink,
disabledContentColor = colors.inkFaint,
),
) {
Text(text = text, style = KadenceTheme.type.button)
}
}
/** The pill used for timer presets. */
@Composable
fun PresetChip(
text: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = KadenceTheme.colors
Surface(
modifier = modifier.heightIn(min = 32.dp),
shape = RoundedCornerShape(16.dp),
color = if (selected) colors.accent else Color.Transparent,
border = BorderStroke(1.dp, if (selected) colors.accent else colors.rule),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
onClick = onClick,
) {
Box(
Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = KadenceTheme.type.button,
color = if (selected) colors.onAccent else colors.ink,
)
}
}
}
@@ -0,0 +1,68 @@
/**
* A serif number that does not twitch.
*
* Fraunces has no `tnum` feature and its digits are proportional — "1" is 1024 units
* where "0" is 1461 — so a clock redrawn every second would visibly jump as the digits
* change. This lays each character out in a box as wide as the widest digit in the
* font at that style, which is the only way to get tabular behaviour out of a font that
* does not provide it.
*
* Only worth using for numbers that change in place: the dial clock, the Live Update
* text, a stat figure that ticks. Static figures can use a plain `Text`.
*/
package tech.kadence.android.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.width
import androidx.compose.ui.text.style.TextAlign
@Composable
fun TabularFigures(
text: String,
style: TextStyle,
color: Color,
modifier: Modifier = Modifier,
) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
// The widest digit at this exact style, measured once per style change.
val digitWidth: Dp = remember(style, density) {
val widest = (0..9).maxOf { d ->
measurer.measure(AnnotatedString(d.toString()), style = style).size.width
}
with(density) { widest.toDp() }
}
Row(
modifier = modifier.clearAndSetSemantics { },
verticalAlignment = Alignment.CenterVertically,
) {
for (ch in text) {
if (ch.isDigit()) {
Text(
text = ch.toString(),
style = style.copy(textAlign = TextAlign.Center),
color = color,
modifier = Modifier.width(digitWidth),
)
} else {
// Separators keep their natural width; only digits need a fixed box.
Text(text = ch.toString(), style = style, color = color)
}
}
}
}
@@ -0,0 +1,166 @@
/**
* The dial.
*
* Same geometry as `frontend/src/components/TimerDial.tsx` and
* `desktop/.../qml/components/TimerDial.qml`, with the tick arithmetic coming from
* `:core` (`dialTickCount` / `dialSpentTicks`) so the dial, the Glyph ring and the
* tests can never disagree about what one tick is worth.
*
* `remaining` is passed as a lambda on purpose: the engine emits a value every second,
* and reading it inside the draw scope keeps the 1 Hz tick in the draw phase instead of
* recomposing the whole dial sixty times a minute.
*/
package tech.kadence.android.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.clock
import tech.kadence.core.dialSpentTicks
import tech.kadence.core.dialTickCount
import tech.kadence.core.progressOf
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun TimerDial(
label: String,
remaining: () -> Int,
plannedSeconds: Int,
accent: Color,
modifier: Modifier = Modifier,
sublabel: String? = null,
paused: Boolean = false,
idle: Boolean = false,
) {
val colors = KadenceTheme.colors
val remainingNow = remaining()
val elapsed = progressOf(remainingNow, plannedSeconds)
// Glide the arc over the second rather than stepping it, matching the web app's
// `transition: stroke-dashoffset 1s linear`.
val animatedElapsed by animateFloatAsState(
targetValue = elapsed,
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
label = "dialElapsed",
)
val clockAlpha by animateFloatAsState(
targetValue = if (paused) 0.55f else 1f,
animationSpec = tween(durationMillis = 180),
label = "clockAlpha",
)
Box(
modifier = modifier
.aspectRatio(1f)
.semantics {
contentDescription = "$label: ${clock(remainingNow)} remaining"
},
contentAlignment = Alignment.Center,
) {
val density = LocalDensity.current
Canvas(Modifier.fillMaxWidth().aspectRatio(1f)) {
val side = minOf(size.width, size.height)
// Keep the web app's 26:300 inset ratio so the dial scales rather than
// assuming a 300px box.
val inset = side * (26f / 300f)
val radius = side / 2f - inset
val centre = Offset(size.width / 2f, size.height / 2f)
val count = dialTickCount(plannedSeconds)
val spent = dialSpentTicks(plannedSeconds, remainingNow)
// The base ring.
drawCircle(
color = colors.rule,
radius = radius,
center = centre,
style = Stroke(width = with(density) { 1.5.dp.toPx() }),
)
// One tick per minute, every fifth one major, spent ticks tinted.
val tickOuter = radius - side * (7f / 300f)
for (i in 0 until count) {
val major = i % 5 == 0
val lengthPx = side * (if (major) 9f else 5f) / 300f
val angle = i.toFloat() / count * 2f * PI.toFloat() - PI.toFloat() / 2f
val cosA = cos(angle)
val sinA = sin(angle)
val inner = tickOuter - lengthPx
val isSpent = i < spent && !idle
drawLine(
color = if (isSpent) accent else colors.rule,
start = Offset(centre.x + tickOuter * cosA, centre.y + tickOuter * sinA),
end = Offset(centre.x + inner * cosA, centre.y + inner * sinA),
strokeWidth = with(density) { (if (major) 1.4.dp else 1.dp).toPx() },
cap = StrokeCap.Round,
alpha = if (idle) 0.5f else 1f,
)
}
// The remaining arc, draining clockwise from twelve.
if (!idle) {
val stroke = with(density) { 2.5.dp.toPx() }
drawArc(
color = accent,
startAngle = -90f,
sweepAngle = (1f - animatedElapsed) * 360f,
useCenter = false,
topLeft = Offset(centre.x - radius, centre.y - radius),
size = Size(radius * 2, radius * 2),
style = Stroke(width = stroke, cap = StrokeCap.Round),
alpha = if (paused) 0.4f else 1f,
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(0.7f),
) {
Eyebrow(label, color = if (idle) colors.inkMuted else accent)
// Fraunces has no tabular figures, so the clock gets fixed-advance boxes.
TabularFigures(
text = clock(remainingNow),
style = KadenceTheme.type.clock,
color = colors.ink.copy(alpha = clockAlpha),
modifier = Modifier.padding(top = 6.dp),
)
if (sublabel != null) {
Text(
text = sublabel,
style = KadenceTheme.type.caption,
color = colors.inkMuted,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -0,0 +1,179 @@
/**
* The theme.
*
* Colour values are not defined here — they live in `:core` as ARGB longs, checked
* against `frontend/src/tokens.ts` by `TokenParityTest`. This file only wraps them in
* Compose types and decides the accent.
*
* The palette is semantic (paper / ink / rule / ember), not Material roles, so it rides
* on its own CompositionLocals. A minimal `ColorScheme` is derived from it purely so
* that `Switch`, `TextField` and the rest of M3 behave like Android components instead
* of being restyled from scratch.
*/
package tech.kadence.android.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import tech.kadence.core.KadenceTokens
import tech.kadence.core.VizTokens
import tech.kadence.core.accentArgb
import tech.kadence.core.kadenceTokens
import tech.kadence.core.onAccentArgb
import tech.kadence.core.vizTokens
/** An `0xAARRGGBB` long from `:core` as a Compose colour. */
private fun Long.toColor(): Color = Color(this.toInt())
/** The semantic palette, as Compose colours. */
@Immutable
data class KadenceColors(
val paper: Color,
val surface: Color,
val surfaceSunken: Color,
val ink: Color,
val inkMuted: Color,
val inkFaint: Color,
val rule: Color,
val ruleStrong: Color,
val accent: Color,
val onAccent: Color,
val accentSoft: Color,
val pine: Color,
val amber: Color,
val clay: Color,
)
/** The chart palette. Separate on purpose — see `VizTokens` in `:core`. */
@Immutable
data class KadenceViz(
val work: Color,
val shortBreak: Color,
val longBreak: Color,
val sequential: List<Color>,
val grid: Color,
val axis: Color,
val empty: Color,
)
private fun colorsFrom(tokens: KadenceTokens, accent: Long, accentSoft: Long): KadenceColors =
KadenceColors(
paper = tokens.paper.toColor(),
surface = tokens.surface.toColor(),
surfaceSunken = tokens.surfaceSunken.toColor(),
ink = tokens.ink.toColor(),
inkMuted = tokens.inkMuted.toColor(),
inkFaint = tokens.inkFaint.toColor(),
rule = tokens.rule.toColor(),
ruleStrong = tokens.ruleStrong.toColor(),
accent = accent.toColor(),
onAccent = onAccentArgb(accent).toColor(),
accentSoft = accentSoft.toColor(),
pine = tokens.pine.toColor(),
amber = tokens.amber.toColor(),
clay = tokens.clay.toColor(),
)
private fun vizFrom(tokens: VizTokens): KadenceViz = KadenceViz(
work = tokens.work.toColor(),
shortBreak = tokens.shortBreak.toColor(),
longBreak = tokens.longBreak.toColor(),
sequential = tokens.sequential.map { it.toColor() },
grid = tokens.grid.toColor(),
axis = tokens.axis.toColor(),
empty = tokens.empty.toColor(),
)
val LocalKadenceColors = staticCompositionLocalOf {
colorsFrom(kadenceTokens(false), kadenceTokens(false).ember, kadenceTokens(false).emberSoft)
}
val LocalKadenceViz = staticCompositionLocalOf { vizFrom(vizTokens(false)) }
val LocalKadenceTypography = staticCompositionLocalOf { kadenceTypography() }
/** Reads as `KadenceTheme.colors.ink` at a call site. */
object KadenceTheme {
val colors: KadenceColors
@Composable get() = LocalKadenceColors.current
val viz: KadenceViz
@Composable get() = LocalKadenceViz.current
val type: KadenceTypography
@Composable get() = LocalKadenceTypography.current
}
/**
* @param dynamicAccent take the accent from the system's wallpaper-derived palette.
* The canvas stays Kadence's either way; only the single accent changes. See
* `accentArgb` / `onAccentArgb` in `:core` for the policy and its contrast guard.
*/
@Composable
fun KadenceTheme(
dark: Boolean = isSystemInDarkTheme(),
dynamicAccent: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val tokens = kadenceTokens(dark)
val systemAccent: Long? = if (dynamicAccent && Build.VERSION.SDK_INT >= 31) {
val scheme = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
scheme.primary.toArgb().toLong() and 0xFFFFFFFFL
} else {
null
}
val accent = accentArgb(systemAccent, dark)
// The soft wash keeps the accent's hue; derive it rather than reusing ember's.
val accentSoft = (accent and 0x00FFFFFFL) or (if (dark) 0x24000000L else 0x1A000000L)
val colors = colorsFrom(tokens, accent, accentSoft)
val viz = vizFrom(vizTokens(dark))
// M3 exists only to make stock components behave; nothing reads these roles for
// layout, and tonal elevation is never used.
val base = if (dark) darkColorScheme() else lightColorScheme()
val scheme = base.copy(
primary = colors.accent,
onPrimary = colors.onAccent,
secondary = colors.pine,
tertiary = colors.amber,
error = colors.clay,
background = colors.paper,
onBackground = colors.ink,
surface = colors.surface,
onSurface = colors.ink,
surfaceVariant = colors.surfaceSunken,
onSurfaceVariant = colors.inkMuted,
outline = colors.ruleStrong,
outlineVariant = colors.rule,
)
CompositionLocalProvider(
LocalKadenceColors provides colors,
LocalKadenceViz provides viz,
LocalKadenceTypography provides kadenceTypography(),
) {
MaterialTheme(
colorScheme = scheme,
shapes = Shapes(
extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(6.dp),
small = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
medium = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
large = androidx.compose.foundation.shape.RoundedCornerShape(14.dp),
),
content = content,
)
}
}
@@ -0,0 +1,153 @@
/**
* Type.
*
* Fraunces for figures and titles, Inter for everything else — the same pairing as the
* web app and the desktop app, bundled as variable fonts (both SIL OFL; the licences
* ship in `res/raw`).
*
* **The tabular-figures problem.** The web app sets `font-variant-numeric: tabular-nums`
* globally, but Fraunces has no `tnum` feature *and* its digits are proportional — a
* "1" is 1024 units against a "0" at 1461, a 30% difference. So that CSS does nothing
* for the serif clock, and a 1 Hz countdown in Fraunces visibly jiggles. Inter does ship
* `tnum`, so the sans numbers are fixed with a font feature; the serif ones are fixed by
* [tech.kadence.android.ui.components.TabularFigures], which lays every glyph in a box
* the width of the widest digit.
*/
package tech.kadence.android.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import tech.kadence.android.R
private fun fraunces(weight: Int, opsz: Float) = Font(
resId = R.font.fraunces,
weight = FontWeight(weight),
variationSettings = FontVariation.Settings(
FontVariation.weight(weight),
FontVariation.opticalSizing(opsz.sp),
),
)
private fun inter(weight: Int) = Font(
resId = R.font.inter,
weight = FontWeight(weight),
variationSettings = FontVariation.Settings(FontVariation.weight(weight)),
)
/** Display serif. Optical sizing is set per weight, as the web app's `opsz` axis does. */
val Fraunces = FontFamily(
fraunces(400, 72f),
fraunces(500, 72f),
fraunces(600, 72f),
)
val Inter = FontFamily(
inter(400),
inter(500),
inter(600),
)
/** Inter ships `tnum`; this is what keeps the sans numbers from twitching. */
private const val TABULAR = "tnum"
@Immutable
data class KadenceTypography(
/** Small-caps-ish eyebrow above a title. Uppercase at the call site, not here. */
val eyebrow: TextStyle,
val pageTitle: TextStyle,
val statSmall: TextStyle,
val statMedium: TextStyle,
val statLarge: TextStyle,
/** The dial clock; its size is supplied by the dial (~0.21 x its width). */
val clock: TextStyle,
val body: TextStyle,
val bodySmall: TextStyle,
val caption: TextStyle,
val button: TextStyle,
val mono: TextStyle,
)
fun kadenceTypography(): KadenceTypography = KadenceTypography(
eyebrow = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.SemiBold,
fontSize = 10.5.sp,
letterSpacing = 0.12.em,
lineHeight = 16.sp,
),
pageTitle = TextStyle(
fontFamily = Fraunces,
fontWeight = FontWeight.Medium,
fontSize = 27.sp,
letterSpacing = (-0.02).em,
lineHeight = 32.sp,
),
statSmall = TextStyle(
fontFamily = Fraunces,
fontWeight = FontWeight.Medium,
fontSize = 24.sp,
letterSpacing = (-0.02).em,
lineHeight = 27.sp,
),
statMedium = TextStyle(
fontFamily = Fraunces,
fontWeight = FontWeight.Medium,
fontSize = 30.sp,
letterSpacing = (-0.02).em,
lineHeight = 33.sp,
),
statLarge = TextStyle(
fontFamily = Fraunces,
fontWeight = FontWeight.Medium,
fontSize = 38.sp,
letterSpacing = (-0.02).em,
lineHeight = 42.sp,
),
clock = TextStyle(
fontFamily = Fraunces,
fontWeight = FontWeight.Normal,
fontSize = 56.sp,
letterSpacing = (-0.03).em,
lineHeight = 60.sp,
textAlign = TextAlign.Center,
),
body = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
lineHeight = 23.sp,
fontFeatureSettings = TABULAR,
),
bodySmall = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 13.5.sp,
lineHeight = 21.sp,
fontFeatureSettings = TABULAR,
),
caption = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 18.sp,
fontFeatureSettings = TABULAR,
),
button = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
letterSpacing = 0.em,
),
mono = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
lineHeight = 20.sp,
),
)
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The tomato and leaf from desktop/packaging/kadence.svg, in the adaptive-icon
safe zone. The white arc is the timer ring, draining clockwise from the top. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#E8502F"
android:pathData="M54,60 m-25,0 a25,25 0 1,0 50,0 a25,25 0 1,0 -50,0" />
<path
android:pathData="M54,43 A17,17 0 0 1 71,60"
android:strokeColor="#FFFFFF"
android:strokeWidth="5"
android:strokeLineCap="round" />
<path
android:fillColor="#3E8E41"
android:pathData="M54,35 C46,33 42,27 43,22 C50,22 55,27 54,35 Z" />
<path
android:strokeColor="#2E7D32"
android:strokeWidth="3"
android:strokeLineCap="round"
android:pathData="M54,35 L54,27" />
</vector>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/icon_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,93 @@
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- tokens.ts `dark.paper`. -->
<color name="paper">#121110</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Mirrors tokens.ts `light.paper`; only used for the pre-Compose window frame. -->
<color name="paper">#FAF7F2</color>
<!-- The launcher icon's ground: paper, so the icon reads as the same product. -->
<color name="icon_background">#FAF7F2</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kadence</string>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
A bare platform theme: the paper ground and every colour come from Compose, not
from Android resources, so there is exactly one source of truth for the palette.
windowBackground is set so the cold-start frame is paper rather than white.
-->
<style name="Theme.Kadence" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/paper</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>
@@ -0,0 +1,256 @@
package tech.kadence.android
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import org.junit.Test
import tech.kadence.android.data.dto.AuthMeDto
import tech.kadence.android.data.dto.BlockEventsDto
import tech.kadence.android.data.dto.BlockerPolicyDto
import tech.kadence.android.data.dto.DailyReportEnvelopeDto
import tech.kadence.android.data.dto.StatsSummaryDto
import tech.kadence.android.data.dto.TaskListDto
import tech.kadence.android.data.dto.TimerStateDto
import tech.kadence.android.data.net.KadenceJson
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The contract test, against committed mock data.
*
* The backend declares no response models, so there is nothing to generate DTOs from
* and nothing to validate them against — the only defence is fixtures plus deliberate
* corruption. Each DTO is decoded from a realistic fixture, then from the same fixture
* with every optional key stripped, with unknown keys added, and with nulls where a
* value is declared. None of that may throw, because the alternative is a phone that
* crashes when the server grows a field.
*
* These tests never touch a network or a running backend.
*/
class DtoToleranceTest {
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) {
"missing fixture: $name"
}.bufferedReader().readText()
private inline fun <reified T> decode(name: String): T =
KadenceJson.decodeFromString<T>(fixture(name))
/** The same payload with three keys the client has never heard of. */
private fun withUnknownKeys(raw: String): String {
val obj = KadenceJson.parseToJsonElement(raw).jsonObject
val grown = buildJsonObject {
obj.forEach { (k, v) -> put(k, v) }
put("some_new_field", JsonPrimitive("surprise"))
put("another_one", JsonPrimitive(42))
put("nested_novelty", buildJsonObject { put("x", JsonPrimitive(1)) })
}
return grown.toString()
}
/** Every top-level value replaced with null, as a sloppy serialiser might. */
private fun allNulls(raw: String): String {
val obj = KadenceJson.parseToJsonElement(raw).jsonObject
val nulled = buildJsonObject {
obj.keys.forEach { k -> put(k, kotlinx.serialization.json.JsonNull) }
}
return nulled.toString()
}
// --- the happy path, asserting the values that matter ---------------------
@Test
fun `a running interval decodes`() {
val state = decode<TimerStateDto>("timer_state_running.json")
val current = assertNotNull(state.current)
assertEquals(412L, current.id)
assertEquals("work", current.kind)
assertEquals(1104, current.remainingSeconds)
assertEquals(1500, current.plannedSeconds)
assertEquals(1, current.interruptions)
assertEquals("PLAT-184", current.task?.externalId)
assertEquals(6, state.completedToday)
assertEquals("short_break", state.nextKind)
assertTrue(state.timerEnabled)
}
@Test
fun `durations are minutes, not seconds`() {
// The trap: `pomodoro.state()` sends minutes. Treating them as seconds would
// make every interval 25 seconds long.
val state = decode<TimerStateDto>("timer_state_running.json")
assertEquals(25, state.durations["work"])
assertEquals(5, state.durations["short_break"])
assertEquals(15, state.durations["long_break"])
}
@Test
fun `an idle state has no current interval`() {
val state = decode<TimerStateDto>("timer_state_idle.json")
assertNull(state.current)
assertEquals("work", state.nextKind)
assertEquals(45, state.durations["work"], "the Classic preset")
}
@Test
fun `a complete reply carries finished, auto_started and the celebration`() {
val state = decode<TimerStateDto>("timer_complete_reply.json")
assertEquals(412L, state.finished?.id)
assertEquals("completed", state.finished?.status)
assertEquals(413L, state.autoStarted?.id)
assertEquals("short_break", state.autoStarted?.kind)
assertNotNull(state.celebration)
assertEquals(413L, state.current?.id, "current is the follow-on interval")
}
@Test
fun `an inactive policy hides its lists but keeps all_domains`() {
// The single most important API detail for the focus guard: `domains` and `apps`
// are empty whenever `active` is false, so a client that reads them would think
// the block list was empty and enforce nothing.
val policy = decode<BlockerPolicyDto>("blocker_policy_inactive.json")
assertTrue(!policy.active)
assertTrue(policy.domains.isEmpty())
assertTrue(policy.apps.isEmpty())
assertEquals(3, policy.allDomains.size)
assertEquals(2, policy.allApps.size)
assertNull(policy.sessionId)
}
@Test
fun `an active policy populates both`() {
val policy = decode<BlockerPolicyDto>("blocker_policy_active.json")
assertTrue(policy.active)
assertEquals(policy.allDomains, policy.domains)
assertEquals(listOf("*gram*"), policy.unexpandable)
assertEquals(412L, policy.sessionId)
}
@Test
fun `tasks decode with their buckets and sources`() {
val list = decode<TaskListDto>("tasks.json")
assertEquals(3, list.tasks.size)
assertEquals("overdue", list.tasks[0].bucket)
assertEquals(3, list.tasks[0].daysOverdue)
assertTrue(list.tasks[0].pinned)
assertEquals("local", list.tasks[2].source)
assertEquals(1, list.counts["overdue"])
}
@Test
fun `the daily report decodes whole`() {
val envelope = decode<DailyReportEnvelopeDto>("daily_report.json")
val report = envelope.report
assertEquals(6, report.metrics.pomodoros)
assertEquals(155, report.metrics.focusMinutes)
assertEquals(80, report.metrics.longestStretchMinutes)
assertEquals(2, report.achievements.size)
assertEquals("deep_work", report.achievements[0].code)
assertEquals(2, report.funFacts.size)
assertEquals(72.4, report.score)
assertEquals(4, report.streak.current)
assertEquals(16, report.metrics.blockedAttempts)
assertNull(envelope.sentAt)
}
@Test
fun `stats summary decodes its ranges`() {
val stats = decode<StatsSummaryDto>("stats_summary.json")
assertEquals(3, stats.range.size)
assertEquals(2, stats.heatmap.size)
assertEquals(162.5, stats.lifetime.focusHours)
assertEquals(5.4, stats.average7d.pomodoros)
}
@Test
fun `block events decode both kinds`() {
val events = decode<BlockEventsDto>("block_events.json")
assertEquals(16, events.total)
assertEquals("domain", events.events[0].kind)
assertEquals("killed", events.events[1].action)
assertEquals("youtube.com", events.byTarget[0].target)
}
@Test
fun `auth me decodes`() {
val me = decode<AuthMeDto>("auth_me.json")
assertTrue(me.authenticated)
assertTrue(me.loginRequired)
assertEquals("timka", me.username)
}
// --- the corruption passes ------------------------------------------------
@Test
fun `an empty object decodes into every DTO`() {
// A stripped-down or errored response must still produce usable defaults.
assertNull(KadenceJson.decodeFromString<TimerStateDto>("{}").current)
assertEquals(8, KadenceJson.decodeFromString<TimerStateDto>("{}").goal)
assertTrue(KadenceJson.decodeFromString<TaskListDto>("{}").tasks.isEmpty())
assertTrue(!KadenceJson.decodeFromString<BlockerPolicyDto>("{}").active)
assertEquals(0.0, KadenceJson.decodeFromString<DailyReportEnvelopeDto>("{}").report.score)
assertEquals(0, KadenceJson.decodeFromString<StatsSummaryDto>("{}").range.size)
assertEquals(0, KadenceJson.decodeFromString<BlockEventsDto>("{}").total)
assertTrue(!KadenceJson.decodeFromString<AuthMeDto>("{}").authenticated)
}
@Test
fun `unknown keys are ignored everywhere`() {
val grown = withUnknownKeys(fixture("timer_state_running.json"))
assertEquals(412L, KadenceJson.decodeFromString<TimerStateDto>(grown).current?.id)
val policy = withUnknownKeys(fixture("blocker_policy_active.json"))
assertTrue(KadenceJson.decodeFromString<BlockerPolicyDto>(policy).active)
val tasks = withUnknownKeys(fixture("tasks.json"))
assertEquals(3, KadenceJson.decodeFromString<TaskListDto>(tasks).tasks.size)
}
@Test
fun `nulls where a value is declared fall back to defaults`() {
// `coerceInputValues` is what saves us here; without it this throws.
val nulled = allNulls(fixture("timer_state_running.json"))
val state = KadenceJson.decodeFromString<TimerStateDto>(nulled)
assertNull(state.current)
assertEquals(8, state.goal, "the default survives a null")
assertEquals("work", state.nextKind)
assertTrue(state.timerEnabled)
val policy = KadenceJson.decodeFromString<BlockerPolicyDto>(
allNulls(fixture("blocker_policy_active.json")),
)
assertTrue(!policy.active)
assertTrue(policy.allDomains.isEmpty())
}
@Test
fun `a nested object arriving as the wrong shape does not take the page down`() {
// A task row missing almost everything still decodes, because every field has
// a default — the list renders with blanks instead of crashing.
val json = """{"date":"2026-10-19","tasks":[{"id":9}],"counts":{},"sources":[]}"""
val list = KadenceJson.decodeFromString<TaskListDto>(json)
assertEquals(1, list.tasks.size)
assertEquals(9L, list.tasks[0].id)
assertEquals("", list.tasks[0].title)
assertEquals("someday", list.tasks[0].bucket)
}
@Test
fun `every fixture round-trips through the parser`() {
// Cheap guard that no fixture has drifted into invalid JSON.
val names = listOf(
"timer_state_running.json", "timer_state_idle.json", "timer_complete_reply.json",
"blocker_policy_active.json", "blocker_policy_inactive.json", "tasks.json",
"daily_report.json", "stats_summary.json", "block_events.json", "auth_me.json",
)
for (name in names) {
val parsed = KadenceJson.parseToJsonElement(fixture(name))
assertTrue(parsed is JsonObject, "$name should be a JSON object")
}
}
}
@@ -0,0 +1,225 @@
package tech.kadence.android
import kotlinx.coroutines.runBlocking
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import tech.kadence.android.data.dto.TimerStateDto
import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.data.net.AuthConfig
import tech.kadence.android.data.net.KadenceApi
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The HTTP layer, against a mock server.
*
* Nothing here needs the real backend running — the backend lives only on the dev
* machine, so a suite that required it would be unrunnable anywhere else. MockWebServer
* serves the committed fixtures instead.
*/
class KadenceApiTest {
private lateinit var server: MockWebServer
private var token: String? = "s3cret-token"
private val api: KadenceApi by lazy {
KadenceApi(config = {
object : AuthConfig {
override val baseUrl = server.url("/").toString().trimEnd('/')
override val apiToken = token
}
})
}
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name"))
.bufferedReader().readText()
private fun json(code: Int, body: String) =
MockResponse.Builder()
.code(code)
.addHeader("Content-Type", "application/json")
.body(body)
.build()
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.close()
}
@Test
fun `the api token goes out as X-API-Token, like the desktop client`() = runBlocking {
server.enqueue(json(200, fixture("timer_state_running.json")))
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.Ok<TimerStateDto>>(result)
assertEquals(412L, result.value.current?.id)
val recorded = server.takeRequest()
assertEquals("s3cret-token", recorded.headers["X-API-Token"])
assertEquals("application/json", recorded.headers["Accept"])
assertEquals("/api/timer/state", recorded.target)
}
@Test
fun `no token means no header, for a backend that wants none`() = runBlocking {
token = null
server.enqueue(json(200, fixture("timer_state_idle.json")))
api.get<TimerStateDto>("/api/timer/state")
assertNull(server.takeRequest().headers["X-API-Token"])
token = "s3cret-token"
}
@Test
fun `the session cookie is kept and sent back`() = runBlocking {
server.enqueue(
MockResponse.Builder()
.code(200)
.addHeader("Content-Type", "application/json")
.addHeader("Set-Cookie", "kadence_session=abc123; Path=/; HttpOnly; Max-Age=2592000")
.body("""{"ok":true}""")
.build(),
)
server.enqueue(json(200, fixture("timer_state_running.json")))
api.post<Map<String, Boolean>>("/api/auth/login", """{"username":"timka","password":"x"}""")
server.takeRequest()
api.get<TimerStateDto>("/api/timer/state")
val second = server.takeRequest()
assertTrue(
second.headers["Cookie"]?.contains("kadence_session=abc123") == true,
"the session cookie should ride along: ${second.headers["Cookie"]}",
)
assertEquals("abc123", api.cookieJar.named("kadence_session")?.value)
}
@Test
fun `a 401 on a normal path notifies so the UI can drop to login`() = runBlocking {
var notified = 0
api.unauthorized.subscribe { notified++ }
server.enqueue(json(401, """{"detail":"not authenticated"}"""))
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.Unauthorized>(result)
assertEquals("not authenticated", result.detail)
assertEquals(1, notified)
}
@Test
fun `a 401 on the login path does not, or signing in would loop`() = runBlocking {
var notified = 0
api.unauthorized.subscribe { notified++ }
server.enqueue(json(401, """{"detail":"wrong password"}"""))
val result = api.post<Map<String, Boolean>>("/api/auth/login", """{"username":"a"}""")
assertIs<ApiResult.HttpError>(result)
assertEquals(401, result.status)
assertEquals("wrong password", result.detail)
assertEquals(0, notified, "a failed sign-in must not trigger the re-auth bus")
}
@Test
fun `a plain detail string is surfaced as the message`() = runBlocking {
// What `TimerError` produces, e.g. refusing to pause when pausing is off.
server.enqueue(json(400, """{"detail":"Pausing is switched off in your settings."}"""))
val result = api.post<TimerStateDto>("/api/timer/pause")
assertIs<ApiResult.HttpError>(result)
assertEquals(400, result.status)
assertEquals("Pausing is switched off in your settings.", result.detail)
}
@Test
fun `a 422 validation body renders as field colon message`() = runBlocking {
server.enqueue(
json(
422,
"""{"detail":[
{"loc":["body","minutes"],"msg":"Input should be less than or equal to 240"},
{"loc":["body","kind"],"msg":"Input should be 'work', 'short_break' or 'long_break'"}
]}""",
),
)
val result = api.post<TimerStateDto>("/api/timer/start", """{"minutes":9999}""")
assertIs<ApiResult.HttpError>(result)
assertEquals(422, result.status)
assertEquals(
"minutes: Input should be less than or equal to 240; " +
"kind: Input should be 'work', 'short_break' or 'long_break'",
result.detail,
)
}
@Test
fun `a non-JSON error body falls back to the status line`() = runBlocking {
server.enqueue(
MockResponse.Builder().code(502).body("<html>Bad Gateway</html>").build(),
)
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.HttpError>(result)
assertEquals(502, result.status)
assertTrue(result.detail.startsWith("502"), "got: ${result.detail}")
}
@Test
fun `an unreachable backend is a NetworkError, never a crash`() = runBlocking {
server.close() // nothing listening any more
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.NetworkError>(result)
// Restart so tearDown has something to close.
server = MockWebServer().also { it.start() }
}
@Test
fun `a successful but unreadable body does not crash the caller`() = runBlocking {
server.enqueue(json(200, """{"current": "this should be an object"}"""))
val result = api.get<TimerStateDto>("/api/timer/state")
assertIs<ApiResult.HttpError>(result)
assertTrue(result.detail.startsWith("unreadable response"), "got: ${result.detail}")
}
@Test
fun `a POST with no body still sends an empty object`() = runBlocking {
// FastAPI's request models default every field, but an absent body on a POST
// that declares one is a 422; the desktop client sends `{}` for the same reason.
server.enqueue(json(200, fixture("timer_state_running.json")))
api.post<TimerStateDto>("/api/timer/resume")
val recorded = server.takeRequest()
assertEquals("POST", recorded.method)
assertEquals("{}", recorded.body?.utf8())
assertTrue(recorded.headers["Content-Type"]?.startsWith("application/json") == true)
}
@Test
fun `trailing slashes in the configured URL do not produce a double slash`() = runBlocking {
server.enqueue(json(200, fixture("timer_state_idle.json")))
api.get<TimerStateDto>("/api/timer/state")
assertEquals("/api/timer/state", server.takeRequest().target)
}
}
@@ -0,0 +1,135 @@
package tech.kadence.android
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.Modifier
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import tech.kadence.android.ui.components.TabularFigures
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.KADENCE_DARK
import tech.kadence.core.KADENCE_LIGHT
import kotlin.math.abs
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The theme and the type, on the JVM.
*
* Robolectric means the Compose layer is checked here rather than on the phone: the
* palette actually reaching a composable, and — the one that matters — that the serif
* clock really is fixed-advance despite Fraunces having no `tnum`.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ThemeTest {
@get:Rule
val compose = createComposeRule()
private fun argb(color: Color): Long = color.value.toLong() ushr 32 and 0xFFFFFFFFL
@Test
fun `the light palette reaching a composable is the core palette`() {
var paper: Color? = null
var ink: Color? = null
var rule: Color? = null
compose.setContent {
KadenceTheme(dark = false, dynamicAccent = false) {
paper = KadenceTheme.colors.paper
ink = KadenceTheme.colors.ink
rule = KadenceTheme.colors.rule
}
}
assertEquals(KADENCE_LIGHT.paper, argb(paper!!), "paper")
assertEquals(KADENCE_LIGHT.ink, argb(ink!!), "ink")
assertEquals(KADENCE_LIGHT.rule, argb(rule!!), "rule keeps its 12% alpha")
}
@Test
fun `the dark palette is the dark one, not a tinted light`() {
var paper: Color? = null
compose.setContent {
KadenceTheme(dark = true, dynamicAccent = false) {
paper = KadenceTheme.colors.paper
}
}
assertEquals(KADENCE_DARK.paper, argb(paper!!))
}
@Test
fun `without dynamic colour the accent is the brand ember`() {
var accent: Color? = null
var onAccent: Color? = null
compose.setContent {
KadenceTheme(dark = false, dynamicAccent = false) {
accent = KadenceTheme.colors.accent
onAccent = KadenceTheme.colors.onAccent
}
}
assertEquals(KADENCE_LIGHT.ember, argb(accent!!))
assertEquals(0xFFFFFFFFL, argb(onAccent!!), "white sits on ember")
}
@Test
fun `the chart palette does not follow the accent`() {
var work: Color? = null
var accent: Color? = null
compose.setContent {
KadenceTheme(dark = false, dynamicAccent = true) {
work = KadenceTheme.viz.work
accent = KadenceTheme.colors.accent
}
}
assertEquals(0xFFEB6834L, argb(work!!), "work keeps its validated orange")
assertTrue(work != accent || argb(accent!!) == 0xFFEB6834L)
}
@Test
fun `the serif clock is fixed-advance despite Fraunces having no tnum`() {
// The whole point of TabularFigures. "11:11" is the narrowest possible set of
// digits and "00:00" among the widest; with proportional figures these would
// differ by roughly 30% and the dial would visibly jiggle every second.
compose.setContent {
KadenceTheme(dark = false, dynamicAccent = false) {
Row {
TabularFigures(
text = "11:11",
style = KadenceTheme.type.clock,
color = KadenceTheme.colors.ink,
modifier = Modifier.testTag("ones"),
)
TabularFigures(
text = "00:00",
style = KadenceTheme.type.clock,
color = KadenceTheme.colors.ink,
modifier = Modifier.testTag("zeros"),
)
}
}
}
val ones = compose.onNodeWithTag("ones").getUnclippedBoundsInRoot()
val zeros = compose.onNodeWithTag("zeros").getUnclippedBoundsInRoot()
val onesWidth = (ones.right - ones.left).value
val zerosWidth = (zeros.right - zeros.left).value
// Guard against a stubbed text measurer: if both came back zero the comparison
// below would pass without proving anything.
assertTrue(onesWidth > 0f, "the figures did not actually measure")
assertTrue(
abs(onesWidth - zerosWidth) < 0.5f,
"11:11 measured $onesWidth dp and 00:00 measured $zerosWidth dp — " +
"the clock would jiggle every second",
)
}
}
@@ -0,0 +1,8 @@
{
"authenticated": true,
"login_required": true,
"username": "timka",
"password_login_enabled": true,
"telegram_login_enabled": false,
"telegram_bot_username": ""
}
@@ -0,0 +1,9 @@
{
"date": "2026-10-19",
"total": 16,
"by_target": [{"target": "youtube.com", "count": 12}, {"target": "reddit.com", "count": 4}],
"events": [
{"id": 90, "kind": "domain", "target": "youtube.com", "action": "blocked", "at": "2026-10-19T10:04:00+00:00", "details": "chrome"},
{"id": 91, "kind": "app", "target": "*discord*", "action": "killed", "at": "2026-10-19T10:06:00+00:00", "details": "Discord (com.discord)"}
]
}
@@ -0,0 +1,17 @@
{
"active": true,
"reason": "a work interval is running",
"mode": "during_work",
"enabled": true,
"strict": false,
"redirect_ip": "127.0.0.1",
"domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
"apps": ["steam*", "*discord*"],
"unexpandable": ["*gram*"],
"kill_apps": true,
"notify_on_block": true,
"poll_seconds": 5,
"session_id": 412,
"all_domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
"all_apps": ["steam*", "*discord*"]
}
@@ -0,0 +1,17 @@
{
"active": false,
"reason": "no work interval is running",
"mode": "during_work",
"enabled": true,
"strict": false,
"redirect_ip": "127.0.0.1",
"domains": [],
"apps": [],
"unexpandable": ["*gram*"],
"kill_apps": true,
"notify_on_block": true,
"poll_seconds": 5,
"session_id": null,
"all_domains": ["youtube.com", "www.youtube.com", "m.youtube.com"],
"all_apps": ["steam*", "*discord*"]
}
@@ -0,0 +1,41 @@
{
"report": {
"date": "2026-10-19",
"metrics": {
"date": "2026-10-19", "pomodoros": 6, "goal": 8, "goal_pct": 75.0, "goal_met": false,
"focus_minutes": 155, "break_minutes": 35, "wasted_minutes": 12, "abandoned": 1,
"breaks_taken": 4, "interruptions": 3, "completion_rate": 0.86,
"longest_stretch_minutes": 80, "first_start": "2026-10-19T09:12:00+00:00",
"last_end": "2026-10-19T18:40:00+00:00",
"by_hour": [{"hour": 9, "count": 1}, {"hour": 10, "count": 2}, {"hour": 14, "count": 3}],
"peak_hour": 10,
"tasks_completed": [
{"id": 1, "title": "Fix the flaky importer", "source": "jira", "project": "Platform", "url": "https://example.atlassian.net/browse/PLAT-184"}
],
"tasks_completed_count": 1,
"time_by_task": [{"title": "Fix the flaky importer", "minutes": 75}],
"blocked_attempts": 16,
"blocked_top": [{"target": "youtube.com", "count": 12}, {"target": "reddit.com", "count": 4}],
"sessions": [
{"id": 412, "kind": "work", "status": "completed", "started_at": "2026-10-19T09:12:00+00:00", "minutes": 25, "task": "Fix the flaky importer", "interruptions": 1}
]
},
"streak": {"current": 4, "best": 11, "goal_streak": 2, "total_days": 63},
"average_7d": {"pomodoros": 5.4, "focus_minutes": 142.3},
"lifetime": {"pomodoros": 390, "focus_minutes": 9750, "focus_hours": 162.5, "tasks_completed": 88, "blocked_attempts": 512},
"comparison": {"yesterday": 7, "previous_best": 11},
"achievements": [
{"code": "deep_work", "emoji": "🧘", "title": "Deep work", "detail": "80 minutes without breaking the chain."},
{"code": "gatekeeper", "emoji": "🛡️", "title": "Gatekeeper", "detail": "The blocker turned you away 16 times. It won."}
],
"fun_facts": [
"155 minutes of focus — that is 7.0 sitcom episodes you did not watch.",
"Your peak hour was 10:00. Defend it."
],
"verdict": "Target hit. Now protect the streak. 💪",
"open_tasks": {"overdue": 1, "today": 1, "someday": 1},
"score": 72.4
},
"sent_at": null,
"telegram_preview": "🍅 Daily report — 2026-10-19"
}
@@ -0,0 +1,20 @@
{
"today": {
"date": "2026-10-19", "pomodoros": 6, "goal": 8, "goal_pct": 75.0, "goal_met": false,
"focus_minutes": 155, "break_minutes": 35, "interruptions": 3,
"by_hour": [{"hour": 10, "count": 2}], "peak_hour": 10,
"blocked_attempts": 16, "sessions": []
},
"streak": {"current": 4, "best": 11, "goal_streak": 2, "total_days": 63},
"lifetime": {"pomodoros": 390, "focus_minutes": 9750, "focus_hours": 162.5, "tasks_completed": 88, "blocked_attempts": 512},
"average_7d": {"pomodoros": 5.4, "focus_minutes": 142.3},
"range": [
{"date": "2026-10-17", "pomodoros": 5, "focus_minutes": 125, "goal_met": false},
{"date": "2026-10-18", "pomodoros": 7, "focus_minutes": 175, "goal_met": false},
{"date": "2026-10-19", "pomodoros": 6, "focus_minutes": 155, "goal_met": false}
],
"heatmap": [
{"date": "2026-10-13", "pomodoros": 8, "focus_minutes": 200, "goal_met": true},
{"date": "2026-10-14", "pomodoros": 0, "focus_minutes": 0, "goal_met": false}
]
}
@@ -0,0 +1,32 @@
{
"date": "2026-10-19",
"tasks": [
{
"id": 1, "source": "jira", "source_label": "Jira", "external_id": "PLAT-184",
"title": "Fix the flaky importer", "description": null,
"url": "https://example.atlassian.net/browse/PLAT-184",
"project": "Platform", "status": "In Progress", "priority": "High",
"due_date": "2026-10-16", "start_date": null, "labels": ["backend"],
"is_done": false, "pinned": true, "stale": false,
"estimated_pomodoros": 3, "days_overdue": 3, "bucket": "overdue"
},
{
"id": 2, "source": "todoist", "source_label": "Todoist", "external_id": "7788",
"title": "Write the design doc", "description": null, "url": null,
"project": "Work", "status": null, "priority": "p2",
"due_date": "2026-10-19", "start_date": null, "labels": [],
"is_done": false, "pinned": false, "stale": false,
"estimated_pomodoros": null, "days_overdue": 0, "bucket": "today"
},
{
"id": 3, "source": "local", "source_label": "Local", "external_id": "local-3",
"title": "And this too", "description": null, "url": null,
"project": null, "status": null, "priority": null,
"due_date": null, "start_date": null, "labels": [],
"is_done": false, "pinned": false, "stale": false,
"estimated_pomodoros": null, "days_overdue": 0, "bucket": "someday"
}
],
"counts": {"overdue": 1, "today": 1, "active": 0, "upcoming": 0, "someday": 1},
"sources": ["jira", "todoist", "local"]
}
@@ -0,0 +1,52 @@
{
"current": {
"id": 413,
"kind": "short_break",
"status": "running",
"started_at": "2026-10-19T09:37:04+00:00",
"ended_at": null,
"planned_seconds": 300,
"elapsed_seconds": 0,
"remaining_seconds": 300,
"paused_seconds": 0,
"interruptions": 0,
"cycle_index": 3,
"task": null,
"note": null
},
"finished": {
"id": 412,
"kind": "work",
"status": "completed",
"started_at": "2026-10-19T09:12:04+00:00",
"ended_at": "2026-10-19T09:37:04+00:00",
"planned_seconds": 1500,
"elapsed_seconds": 1500,
"remaining_seconds": 0,
"paused_seconds": 0,
"interruptions": 1,
"cycle_index": 2,
"task": {"source": "jira", "external_id": "PLAT-184", "title": "Fix the flaky importer"},
"note": null
},
"auto_started": {
"id": 413,
"kind": "short_break",
"status": "running",
"started_at": "2026-10-19T09:37:04+00:00",
"planned_seconds": 300,
"remaining_seconds": 300,
"interruptions": 0,
"cycle_index": 3,
"task": null
},
"celebration": "Seven down. The afternoon is yours to lose.",
"next_kind": "work",
"completed_today": 7,
"goal": 8,
"cycle_position": 3,
"cycles_before_long_break": 4,
"durations": {"work": 25, "short_break": 5, "long_break": 15},
"server_time": "2026-10-19T09:37:04+00:00",
"timer_enabled": true
}
@@ -0,0 +1,11 @@
{
"current": null,
"next_kind": "work",
"completed_today": 0,
"goal": 8,
"cycle_position": 0,
"cycles_before_long_break": 4,
"durations": {"work": 45, "short_break": 15, "long_break": 30},
"server_time": "2026-10-19T08:00:00+00:00",
"timer_enabled": true
}
@@ -0,0 +1,29 @@
{
"current": {
"id": 412,
"kind": "work",
"status": "running",
"started_at": "2026-10-19T09:12:04+00:00",
"ended_at": null,
"planned_seconds": 1500,
"elapsed_seconds": 396,
"remaining_seconds": 1104,
"paused_seconds": 0,
"interruptions": 1,
"cycle_index": 2,
"task": {
"source": "jira",
"external_id": "PLAT-184",
"title": "Fix the flaky importer"
},
"note": null
},
"next_kind": "short_break",
"completed_today": 6,
"goal": 8,
"cycle_position": 2,
"cycles_before_long_break": 4,
"durations": {"work": 25, "short_break": 5, "long_break": 15},
"server_time": "2026-10-19T09:18:40+00:00",
"timer_enabled": true
}
+8
View File
@@ -0,0 +1,8 @@
// Plugins are applied per module; this file only declares them so the version
// catalog resolves them once for the whole build.
plugins {
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
+17
View File
@@ -0,0 +1,17 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(21)
}
dependencies {
testImplementation(libs.kotlin.test)
}
tasks.test {
testLogging {
events("passed", "failed", "skipped")
}
}
@@ -0,0 +1,191 @@
/**
* 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<Int> = 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<String> = emptyList(),
val allowedDomains: List<String> = emptyList(),
val subdomainExpansions: List<String> = DEFAULT_SUBDOMAINS,
val blockedApps: List<String> = emptyList(),
val killBlockedApps: Boolean = true,
val schedule: List<BlockWindow> = 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)`.
*/
fun expandEntry(raw: String, subdomains: List<String>): Pair<List<String>, String?> {
val entry = raw.trim().lowercase().trimStart('.')
if (entry.isEmpty()) return emptyList<String>() to null
if (entry.startsWith("*.")) {
val base = entry.removePrefix("*.")
if (base.isEmpty() || '*' in base) return emptyList<String>() to entry
return (listOf(base) + subdomains.map { "$it.$base" }) to null
}
if ('*' in entry) return emptyList<String>() to entry
return listOf(entry, "www.$entry", "m.$entry") to null
}
/** Whether an allow-list entry — itself maskable — covers this hostname. */
fun isAllowed(host: String, allowMasks: List<String>): Boolean =
allowMasks.any { globMatches(it, host) }
/** Normalised, de-duplicated allow masks, the way `expand_domains()` prepares them. */
fun allowMasksOf(rules: BlockerRules): List<String> = rules.allowedDomains
.filter { it.isNotBlank() }
.map { it.trim().lowercase().trimStart('.') }
private fun subdomainsOf(rules: BlockerRules): List<String> = rules.subdomainExpansions
.filter { it.isNotBlank() }
.map { it.trim().lowercase() }
/**
* 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<String> = 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<String, String> {
val allowMasks = allowMasksOf(rules)
val subdomains = subdomainsOf(rules)
val origin = LinkedHashMap<String, String>()
for (raw in rules.blockedDomains) {
val entry = raw.trim().lowercase().trimStart('.')
val (expanded, _) = expandEntry(raw, subdomains)
for (host in expanded) {
if (!isAllowed(host, allowMasks)) origin.putIfAbsent(host, entry)
}
}
return origin
}
/** Block-list entries a hosts file cannot express, so the UI can flag them. */
fun unexpandableMasks(rules: BlockerRules): List<String> {
val subdomains = subdomainsOf(rules)
val leftovers = LinkedHashSet<String>()
for (raw in rules.blockedDomains) {
val (_, pattern) = expandEntry(raw, subdomains)
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() }
@@ -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<String, String> = 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<String, String> = 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<String> = 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')
@@ -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<String, Regex>()
@@ -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<Char, Array<String>> = 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')
}
}
@@ -0,0 +1,200 @@
/**
* 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 the desktop *can* go. A hosts file has no
* wildcards, so the backend has to expand every mask into concrete hostnames and
* reports whatever it cannot express as `unexpandable`. A URL-bar matcher has no such
* limit, so patterns like `*gram*` — which the desktop apologises for — are enforced
* here. That is the one place the phone is strictly stronger than the laptop.
*/
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<String> = expandDomains(rules),
) {
private val exact: Set<String> = expandedHosts.map { it.trim().lowercase() }.toSet()
private val allowMasks: List<String> = allowMasksOf(rules)
private val rawEntries: List<String> = 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<String, String> = 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 the desktop cannot do.
for (entry in rawEntries) {
if (entryCovers(entry, host)) {
return BlockDecision.Block(host, entry, BlockAction.BLOCKED)
}
}
return BlockDecision.Allow
}
/**
* A mask is glob-matched; a plain entry covers itself and any subdomain of it, so
* `youtube.com` catches `music.youtube.com` even though no expansion listed it.
*/
private fun entryCovers(entry: String, host: String): Boolean = when {
entry.isEmpty() -> false
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<String> = unexpandableMasks(rules)
}
/** Matches installed apps against `blocker.blocked_apps`. */
class AppMatcher(private val patterns: List<String>) {
/**
* @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
}
}
}
}
@@ -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<OutboxEntry>, incoming: OutboxEntry): List<OutboxEntry> {
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<OutboxEntry>): 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<OutboxEntry>): Pair<List<OutboxEntry>, List<OutboxEntry>> {
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<Long>()
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
}
@@ -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)
}
@@ -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<String, Int> = 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
}
}
@@ -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<Long>,
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
@@ -0,0 +1,243 @@
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<String>() to "*gram*", expandEntry("*gram*", subs))
assertEquals(emptyList<String>() to "*.", expandEntry("*.", subs))
assertEquals(emptyList<String>() to "**", expandEntry("**", subs))
assertEquals(emptyList<String>() to "*.a*.com", expandEntry("*.a*.com", subs))
}
@Test
fun `an empty entry is neither expanded nor reported`() {
assertEquals(emptyList<String>() to null, expandEntry("", subs))
assertEquals(emptyList<String>() to null, expandEntry(" ", subs))
assertEquals(emptyList<String>() 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 `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(""))
}
}
@@ -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"))
}
}
@@ -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),
)
}
}
@@ -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<Int, Int> =
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)
}
}
@@ -0,0 +1,319 @@
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<String> = emptyList(),
allowed: List<String> = emptyList(),
subs: List<String> = 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<BlockDecision.Block>(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"))
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")
}
@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<BlockDecision.Block>(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<BlockDecision.Block>(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<BlockDecision.Block>(m.decide("www.youtube.com"))
assertEquals("youtube.com", block.matchedRule)
}
@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<BlockDecision.Block>(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<BlockDecision.Block>(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)
}
}
@@ -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<OutboxEntry> =
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<ReplayOutcome.Retry>(replayOutcomeFor(1))
assertEquals(2, retry.attempt)
assertIs<ReplayOutcome.Retry>(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))
}
}
@@ -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"))
}
}
@@ -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<Reconciliation.AdoptForeign>(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)),
)
}
}
@@ -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<String, String> {
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<String> {
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<String, String>, 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")
}
}
+9
View File
@@ -0,0 +1,9 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=true
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true
+49
View File
@@ -0,0 +1,49 @@
[versions]
agp = "9.4.1"
kotlin = "2.4.21"
# compileSdk 37 because Compose 1.12 (BOM 2026.09.00) requires it; the platform is
# published under the new minor-version scheme as `platforms;android-37.0`.
# targetSdk stays 36 — Android 16 is what the Nothing Phone (3) runs, and opting into
# a newer runtime's behaviour changes is a separate decision from compiling against it.
compileSdk = "37"
targetSdk = "36"
minSdk = "34"
composeBom = "2026.09.00"
activityCompose = "1.13.0"
navigationCompose = "2.10.2"
lifecycle = "2.11.0"
coroutines = "1.10.2"
robolectric = "4.14.1"
serialization = "1.11.0"
okhttp = "5.5.0"
junit = "4.13.2"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3-junit4", version.ref = "okhttp" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-material3 = { module = "androidx.compose.material3:material3" }
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+52
View File
@@ -0,0 +1,52 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "kadence-android"
// `core` is plain Kotlin on the JVM: the matchers, the policy clock, the outbox
// reconciliation, the Glyph frame and the formatting. No Android, no device, no SDK —
// which is the whole point. `./gradlew :core:test` runs the logic suite on a machine
// that has nothing but a JDK, so almost none of this app needs a phone to verify.
include(":core")
// `app` is the Android client and genuinely needs an SDK. Including it
// unconditionally would make every Gradle invocation fail on a machine without one,
// taking `:core:test` down with it — so it joins the build only when an SDK exists.
val androidSdk: java.io.File? = sequenceOf(
System.getenv("ANDROID_HOME"),
System.getenv("ANDROID_SDK_ROOT"),
file("local.properties")
.takeIf { it.isFile }
?.readLines()
?.firstOrNull { it.startsWith("sdk.dir=") }
?.substringAfter('=')
?.trim(),
).filterNotNull()
.filter { it.isNotBlank() }
.map { file(it) }
.firstOrNull { it.isDirectory }
val appModule = file("app/build.gradle.kts")
if (androidSdk != null && appModule.isFile) {
include(":app")
} else if (androidSdk != null) {
logger.lifecycle("SDK found, but app/ is not built out yet — :app excluded.")
} else {
logger.lifecycle(
"Android SDK not found (set ANDROID_HOME or local.properties' sdk.dir) — " +
":app is excluded from this build. :core still compiles and tests.",
)
}