diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..cc08d86
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,23 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{kt,kts}]
+ktlint_code_style = android_studio
+max_line_length = 140
+ij_kotlin_allow_trailing_comma = true
+ij_kotlin_allow_trailing_comma_on_call_site = true
+ktlint_function_naming_ignore_when_annotated_with = Composable
+ktlint_standard_blank-line-between-when-conditions = disabled
+
+[*.{yml,yaml,json}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml
new file mode 100644
index 0000000..4d47c8b
--- /dev/null
+++ b/.github/workflows/pr-checks.yml
@@ -0,0 +1,99 @@
+name: Checks
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+# A new push to a pull request cancels its previous run; runs on main always finish.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
+
+jobs:
+ spotless:
+ name: Spotless
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 25
+ - uses: gradle/actions/setup-gradle@v5
+ - run: ./gradlew spotlessCheck
+
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 25
+ - uses: gradle/actions/setup-gradle@v5
+ - run: ./gradlew assembleDebug
+
+ unit-tests:
+ name: Unit tests and coverage
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 25
+ - uses: gradle/actions/setup-gradle@v5
+ - run: ./gradlew :koverXmlReportCoverage :koverHtmlReportCoverage
+ - if: failure()
+ uses: actions/upload-artifact@v5
+ with:
+ name: unit-test-reports
+ path: "**/build/reports/tests/testDebugUnitTest/"
+ # Only comments on the PR; the threshold itself is enforced by koverVerifyCoverage in build.gradle.kts.
+ - if: github.event_name == 'pull_request'
+ uses: mi-kas/kover-report@v2.7
+ with:
+ path: ${{ github.workspace }}/build/reports/kover/reportCoverage.xml
+ title: Code coverage
+ update-comment: true
+ min-coverage-overall: 80
+ coverage-counter-type: LINE
+ - run: ./gradlew :koverVerifyCoverage
+ - if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v5
+ with:
+ name: coverage-reports
+ path: build/reports/kover/
+
+ # Reference images are recorded on macOS; layoutlib font rendering differs on Linux.
+ screenshot-tests:
+ name: Screenshot tests
+ runs-on: macos-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 25
+ - uses: gradle/actions/setup-gradle@v5
+ - run: ./gradlew validateDebugScreenshotTest
+ - if: failure()
+ uses: actions/upload-artifact@v5
+ with:
+ name: screenshot-test-reports
+ path: |
+ **/build/reports/screenshotTest/
+ **/build/outputs/screenshotTest-results/
diff --git a/.gitignore b/.gitignore
index b7fb64c..59f8ae4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ captures/
.cxx/
*.aab
*.apk
+!apk/debug.apk
output-metadata.json
# IntelliJ
diff --git a/README.md b/README.md
index 6c57fae..fc8b8a1 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,230 @@
# SpeedReader
-Check your connection speed with native Android app.
+
+[](https://github.com/doudasek/SpeedReader/actions/workflows/pr-checks.yml?query=branch%3Amain)
+
+A native Android speed test against Ubiquiti's measurement servers. It fetches the server list from
+`sp-dir.uwn.com`, picks the five closest servers, pings them with real ICMP, and measures download
+and upload against the one with the lowest round-trip time.
+
+*Why the name? It sounds similar to CashReader, the app I currently work on, so I picked it.* 🙂
+
+The project is written as production code, not a prototype:
+
+- **Stack:** Kotlin, Jetpack Compose (Material 3), Coroutines and StateFlow, Hilt, Retrofit +
+ kotlinx.serialization, OkHttp, Timber.
+- **Architecture:** clean architecture with `data` / `domain` / `ui` layers inside a multi-module
+ build (`:app`, `:core:common`, `:core:network`, `:core:designsystem`, `:feature:speedtest`), with
+ Gradle convention plugins in `build-logic`. A single screen, so no navigation library. The
+ screen is state-hoisted and can become a navigation destination without touching anything below
+ it.
+- **Architecture tests:** Konsist rules enforce the layering, naming and Compose conventions that
+ module boundaries alone cannot express.
+- **Tests:** unit tests with fakes (Given-When-Then), MockK where a fake would add nothing, Turbine
+ for Flow assertions, MockWebServer for the HTTP layer, screenshot tests of the adaptive layouts
+ (phone, landscape, foldable, tablet, dark theme, 2× font), coverage gated at 80 % with Kover.
+- **Code quality:** Spotless + ktlint (Android Studio style), all of it run by GitHub Actions on
+ every pull request.
+- **Release build:** R8 with ProGuard rules shrinks, optimises and obfuscates the code, repackages
+ all classes into one package, strips debug logging, and keeps line numbers so crash stack traces
+ can be retraced with `mapping.txt`.
+- **Error handling:** failures are sealed types wrapped in `Outcome`, so the UI handles every case
+ exhaustively.
+- **Security:** HTTPS only, with cleartext traffic blocked also on API 26 and 27, where Android
+ still allows it by default.
+- **UI:** adaptive layouts for phones, foldables and tablets via window size classes, edge-to-edge,
+ light and dark theme, English and Czech localisation.
+
+This is the setup I currently use in production apps.
+
+Module and layer dependencies, where an arrow means "depends on". The Gradle module graph guards
+the modules, Konsist guards the layers inside the feature.
+
+```mermaid
+flowchart TD
+ app[":app"]
+
+ subgraph feature[":feature:speedtest"]
+ ui["ui
SpeedTestScreen, SpeedTestViewModel"]
+ domain["domain
use cases, repository interfaces, models"]
+ data["data
repositories, data sources, Retrofit APIs"]
+ end
+
+ designsystem[":core:designsystem
theme, components, adaptive layout"]
+ network[":core:network
OkHttp, JSON, safe API calls"]
+ common[":core:common
dispatchers, Outcome, errors"]
+
+ app --> feature
+ app --> designsystem
+ app --> common
+ ui --> domain
+ data --> domain
+ ui --> designsystem
+ data --> network
+ feature --> common
+ network --> common
+```
+
+The current debug build is ready to download and install: [apk/debug.apk](apk/debug.apk). To build
+it yourself:
+
+```bash
+./gradlew installDebug
+```
+
+The same checks the pull request runs, locally:
+
+```bash
+./gradlew spotlessCheck # formatting
+./gradlew testDebugUnitTest # unit tests, including the Konsist rules
+./gradlew validateDebugScreenshotTest # screenshot tests
+./gradlew :koverVerifyCoverage # coverage of at least 80 %
+```
+
+Minimum SDK is 26, because only from Android 8.0 does `InetAddress.isReachable()` send a real ICMP
+echo instead of a TCP connect to port 7 (see 4).
+
+[Pull request #1](https://github.com/doudasek/SpeedReader/pull/1) is a sample that shows the GitHub
+Actions checks passing (Spotless, build, unit tests with coverage, screenshot tests). It is only an
+example, a real pull request should never be this big. For small pull requests I normally prefer
+squash merging, which keeps the history linear with references to JIRA tickets. Here I chose rebase
+to keep the individual commits in the history.
+
+## How a speed test runs
+
+1. Get a token from the directory (`POST /api/v1/tokens`, valid for 80 s).
+2. Fetch the server list (`GET /api/v2/servers`).
+3. Get the location of the public IP (`GET /api/v1/ip`).
+4. Pick the five servers closest to that location.
+5. Ping each of them with ICMP and pick the one with the lowest round-trip time.
+6. Measure download for 7.5 s, then upload for 7.5 s, over six parallel connections or one. The
+ screen shows the live speed during the run and the average at the end.
+
+## Decisions I had to make myself
+
+The assignment left several things open, and the API is not documented at all. These are the
+places where I made a call and would like to know whether it matches the intent.
+
+### 1. The API is reverse-engineered
+
+There is no public documentation for `sp-dir.uwn.com`. Every endpoint, parameter and limit was
+derived from the wifiman.com web client and then verified by hand with `curl`, watching how the
+servers behave: the 80 s token TTL (checked only when a request starts, not during a stream), the
+minimum download size, that servers cut a download off after roughly 30 s and an upload after
+about 20 s, that only HTTP/1.1 is served. The client is built to tolerate what I observed (fresh
+token per run, one retry on `401`, server entries decoded one by one so a malformed entry does not
+drop the whole list), but anything here may be imprecise. With the official API documentation I
+would revisit these assumptions first.
+
+Two things in the server list surprised me. It is already filtered by the client's IP on the server
+side: a Czech IP gets nine servers from Czechia and Austria, not the whole fleet. And the `city`
+labels sometimes contradict the coordinates. An excerpt of the response, trimmed to the relevant
+fields:
+
+```json
+[
+ { "provider": "Lunoxia", "city": "Berlin", "countryCode": "DE", "latitude": 48.2048, "longitude": 16.3801 },
+ { "provider": "schoebel-online.net", "city": null, "countryCode": "AT", "latitude": 48.2048, "longitude": 16.3801 },
+ { "provider": "Indeno GmbH", "city": "Graz", "countryCode": "AT", "latitude": 48.2202, "longitude": 16.3529 }
+]
+```
+
+The server labelled *Berlin, DE* has exactly the same coordinates as an Austrian one, and both are
+in Vienna. *Graz* is in Vienna too, about 145 km from Graz. The coordinates look like the real
+hosting location and the labels like registration metadata, so distance is computed from the
+coordinates only, and the UI leads with the provider name rather than the city.
+
+### 2. Location comes from the IP, not from the phone
+
+The assignment asks for the servers closest to the *current location of the phone*. I read that
+as "closest to where the traffic actually leaves", and use the IP geolocation endpoint the
+directory already offers (`GET /api/v1/ip`) instead of the device location:
+
+- Asking for a location permission just to run a speed test is hard to justify to a user.
+- The measured path starts at the network's gateway, not at the phone. On mobile networks the
+ gateway can be hundreds of kilometres away, so a server close to the phone's GPS fix can be
+ further away in network terms than one close to the public IP.
+- The candidate list is IP-filtered anyway, so ranking it by the same IP is consistent.
+- In practice it barely matters: moving the origin by 175 km changed one of the five candidates,
+ and it swapped two servers at identical coordinates. The winner is picked by ICMP latency, not
+ distance.
+
+If device location was really meant, it is one more `LocationRepository` implementation plus a
+runtime location permission, though I would not recommend it for the reasons above.
+
+### 3. One connection or six
+
+The assignment does not say how many connections to measure with, and speed tests differ: some
+measure over a single connection, others over several in parallel. The wifiman web client uses six
+parallel connections when it measures against a single server, and twelve spread over up to four
+servers in its normal run. This app measures one server at a time, so six is the default here, and
+a **"Single connection only"** checkbox switches to one. The two answer different questions, what
+the link can carry versus what one transfer achieves, so I left the choice to the user rather than
+hiding it behind one number. Both modes are the same code path with a different connection count.
+
+### 4. Real ICMP ping, with a fallback
+
+The assignment forbids wifiman's HTTP pseudo-ping. The app uses `InetAddress.isReachable()`, which
+on Android 8.0+ sends a real ICMP echo over an unprivileged ping socket and falls back to TCP
+port 7 only when ICMP gets no answer. Such slower answers are discarded. Each server gets up to
+three 500 ms probes and the minimum RTT wins, since queueing only ever adds delay. Some networks
+block ICMP entirely. Instead of failing a test that HTTP could still run, the app then measures
+against the closest candidate and shows the ping as "—". Emulator RTTs are meaningless, so ping
+must be checked on a real device.
+
+### 5. Test duration
+
+I read the assignment's 15 s as the length of the whole test. The app measures both download
+and upload, so I split it in half: 7.5 s download, then 7.5 s upload. TCP slow start at the
+beginning of each transfer weighs a bit more in the shorter window. Restoring a full 15 s download
+is a one-constant change.
+
+## What is missing or should be improved
+
+- **Accessibility pass.** No TalkBack walk-through yet: live speed and phase changes are not
+ announced (`liveRegion`), metric cards are not merged into one focusable unit, the title is not
+ marked as a heading.
+- **UI tests.** The screen is covered by ViewModel tests and screenshot tests, but there are no
+ Compose UI tests yet. Next would be tests that click through a run (Start, Stop, the
+ single-connection checkbox, Back and Share on the result) against fake repositories injected
+ with Hilt, and assert the semantics tree, which also guards the accessibility fixes above.
+- **Integration tests against the real API.** The HTTP layer is tested with MockWebServer only. A
+ small scheduled CI job calling the live directory and one measurement server would show early
+ when the undocumented API changes.
+- **Baseline Profiles.** There is no Baseline Profile or startup profile yet, so on first launch
+ Compose and the rest of the code run interpreted or JIT compiled until ART has collected its own
+ runtime profile. A `:baselineprofile` module with a Macrobenchmark journey (launch, start a test,
+ show the result) would ship the profile with the app, and the same module would measure startup
+ and frame timing.
+- **Retries.** Apart from the token refresh, nothing retries and a failed run is restarted with
+ Start. Transient directory failures deserve a short, time-boxed backoff. A dropped transfer
+ stream is not reopened. The others carry on, and the test fails only when all of them fail.
+- **Average speed.** It is plain total bytes over total time, so TCP slow start counts and the result
+ reads a few percent lower than wifiman's, which trims the samples. Trimming is worth revisiting
+ with more measurements.
+- **Metered network warning.** Six streams on fast mobile data can use a few hundred MB per run.
+ The app should ask first on a metered network and show how much data a run used.
+- **Remote config.** Base URL, test length, connection count and ping parameters are compile-time
+ constants. Against an undocumented API that can change at any time, they belong in remote config
+ together with a kill switch.
+- **Crash reporting and analytics.** Timber logs only in debug builds. Release needs a Timber tree
+ forwarding warnings and errors to Crashlytics or Sentry (with the R8 mapping uploaded), and
+ failure-rate analytics per error type would catch the API breaking.
+- **Release signing and distribution.** There is no signing config and no pipeline to Google Play
+ (signed AAB, service account, internal track).
+- **Richer UI.** A gauge or live throughput graph, details of each candidate server with its RTT,
+ jitter, and a history of past runs. Foldable postures (tabletop, book) are handled only through
+ the window size, not through `FoldingFeature`.
+
+## Screenshots
+
+Phone: start, measuring, result.
+
+
+
+
+
+
+
+Tablet, result:
+
+
diff --git a/apk/debug.apk b/apk/debug.apk
new file mode 100644
index 0000000..115d867
Binary files /dev/null and b/apk/debug.apk differ
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..785a02b
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,47 @@
+plugins {
+ id("speedreader.android.application")
+ id("speedreader.android.compose")
+ id("speedreader.hilt")
+}
+
+// Semantic version; minor and patch stay below 100 so that versionCode keeps growing.
+val versionMajor = 0
+val versionMinor = 0
+val versionPatch = 1
+
+android {
+ namespace = "com.martindoudera.speedreader"
+
+ defaultConfig {
+ applicationId = "com.martindoudera.speedreader"
+ versionCode = versionMajor * 10_000 + versionMinor * 100 + versionPatch
+ versionName = "$versionMajor.$versionMinor.$versionPatch"
+ }
+
+ buildTypes {
+ debug {
+ applicationIdSuffix = ".debug"
+ versionNameSuffix = "-debug"
+ }
+ release {
+ optimization {
+ enable = true
+ }
+ }
+ }
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+dependencies {
+ implementation(project(":core:common"))
+ implementation(project(":core:designsystem"))
+ implementation(project(":feature:speedtest"))
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.timber)
+ testImplementation(libs.junit)
+ testImplementation(libs.konsist)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c1261d4
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/keepRules/rules.keep b/app/src/main/keepRules/rules.keep
new file mode 100644
index 0000000..78ab95a
--- /dev/null
+++ b/app/src/main/keepRules/rules.keep
@@ -0,0 +1,29 @@
+# Libraries ship their own rules (Retrofit, OkHttp, kotlinx.serialization, Hilt, Compose, coroutines),
+# so only the app's own reflection is covered here.
+#
+# https://d.android.com/r/tools/r8/keep-rules
+
+# Stack traces keep line numbers and can be retraced with mapping.txt.
+-keepattributes SourceFile,LineNumberTable
+-renamesourcefileattribute SourceFile
+
+# Collapse every class into a single root package to make the bytecode harder to read.
+# Relies on -allowaccessmodification from proguard-android-optimize.txt. Use
+# -flattenpackagehierarchy '' instead for the gentler variant that keeps the package shape.
+-repackageclasses ''
+
+# Release drops verbose, debug and info logs with their arguments; warnings and errors stay.
+# Kotlin callers go through the Timber.Forest companion, not the static bridges.
+-assumenosideeffects class timber.log.Timber$Forest {
+ public void v(...);
+ public void d(...);
+ # public void i(...); # depends on business logic
+}
+
+# TokenInterceptor reads @RequiresToken from the Retrofit method through reflection.
+-keep,allowobfuscation @interface com.martindoudera.speedreader.feature.speedtest.data.directory.RequiresToken {
+ *;
+}
+-keep,allowobfuscation enum com.martindoudera.speedreader.feature.speedtest.data.directory.TokenPlacement {
+ *;
+}
diff --git a/app/src/main/kotlin/com/martindoudera/speedreader/MainActivity.kt b/app/src/main/kotlin/com/martindoudera/speedreader/MainActivity.kt
new file mode 100644
index 0000000..fc660f2
--- /dev/null
+++ b/app/src/main/kotlin/com/martindoudera/speedreader/MainActivity.kt
@@ -0,0 +1,22 @@
+package com.martindoudera.speedreader
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+import com.martindoudera.speedreader.feature.speedtest.ui.SpeedTestScreen
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ SpeedReaderTheme {
+ SpeedTestScreen()
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/martindoudera/speedreader/SpeedReaderApplication.kt b/app/src/main/kotlin/com/martindoudera/speedreader/SpeedReaderApplication.kt
new file mode 100644
index 0000000..46f6495
--- /dev/null
+++ b/app/src/main/kotlin/com/martindoudera/speedreader/SpeedReaderApplication.kt
@@ -0,0 +1,15 @@
+package com.martindoudera.speedreader
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+import timber.log.Timber
+
+@HiltAndroidApp
+class SpeedReaderApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/martindoudera/speedreader/di/AppVersionModule.kt b/app/src/main/kotlin/com/martindoudera/speedreader/di/AppVersionModule.kt
new file mode 100644
index 0000000..ded4a0d
--- /dev/null
+++ b/app/src/main/kotlin/com/martindoudera/speedreader/di/AppVersionModule.kt
@@ -0,0 +1,15 @@
+package com.martindoudera.speedreader.di
+
+import com.martindoudera.speedreader.BuildConfig
+import com.martindoudera.speedreader.core.common.domain.model.AppVersion
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object AppVersionModule {
+ @Provides
+ fun providesAppVersion(): AppVersion = AppVersion(BuildConfig.VERSION_NAME)
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..0737e91
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..47df917
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..b3e26b4
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..b3e26b4
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..5deff2b
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,5 @@
+
+
+
+ #FF0D0F1A
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..11d730a
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..5c0d215
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+
+ #FFF5F6FB
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..5dfe011
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ SpeedReader
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..5250f9d
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..8427ae1
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..51bd560
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/test/kotlin/com/martindoudera/speedreader/konsist/ArchitectureTest.kt b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/ArchitectureTest.kt
new file mode 100644
index 0000000..7e35b79
--- /dev/null
+++ b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/ArchitectureTest.kt
@@ -0,0 +1,142 @@
+package com.martindoudera.speedreader.konsist
+
+import com.lemonappdev.konsist.api.architecture.KoArchitectureCreator.assertArchitecture
+import com.lemonappdev.konsist.api.architecture.Layer
+import com.lemonappdev.konsist.api.declaration.KoFileDeclaration
+import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
+import com.lemonappdev.konsist.api.ext.list.withPackage
+import com.lemonappdev.konsist.api.ext.list.withParent
+import com.lemonappdev.konsist.api.verify.assertFalse
+import com.lemonappdev.konsist.api.verify.assertTrue
+import org.junit.Test
+
+/**
+ * The build graph keeps `:core:*` away from `:feature:*`; these rules guard what Gradle cannot see, the
+ * `data` → `domain` ← `ui` layering inside a feature module.
+ */
+class ArchitectureTest {
+ @Test
+ fun `core modules depend only on core common`() {
+ productionScope.assertArchitecture {
+ val common = Layer("Common", "$BASE_PACKAGE.core.common..")
+ val network = Layer("Network", "$BASE_PACKAGE.core.network..")
+ val designSystem = Layer("DesignSystem", "$BASE_PACKAGE.core.designsystem..")
+ val feature = Layer("Feature", "$BASE_PACKAGE.feature..")
+
+ common.dependsOnNothing()
+ network.dependsOn(common)
+ designSystem.dependsOnNothing()
+ feature.dependsOn(common, network, designSystem)
+ }
+ }
+
+ @Test
+ fun `feature layers depend only on the domain`() {
+ featurePackages.forEach { feature ->
+ productionScope.assertArchitecture {
+ val domain = Layer("Domain", "$feature.domain..")
+ val data = Layer("Data", "$feature.data..")
+ val ui = Layer("Ui", "$feature.ui..")
+
+ domain.dependsOnNothing()
+ data.dependsOn(domain)
+ ui.dependsOn(domain)
+ }
+ }
+ }
+
+ @Test
+ fun `domain stays plain Kotlin`() {
+ productionScope.files
+ .withPackage("$BASE_PACKAGE.feature..domain..")
+ .assertFalse { it.importsAnyOf(FORBIDDEN_IN_DOMAIN) }
+ }
+
+ @Test
+ fun `ui knows nothing about the network stack`() {
+ productionScope.files
+ .withPackage("$BASE_PACKAGE.feature..ui..")
+ .assertFalse { it.importsAnyOf(NETWORK_STACK + "$BASE_PACKAGE.core.network.") }
+ }
+
+ @Test
+ fun `data knows nothing about Compose`() {
+ productionScope.files
+ .withPackage("$BASE_PACKAGE.feature..data..", "$BASE_PACKAGE.core.network..", "$BASE_PACKAGE.core.common..")
+ .assertFalse { it.importsAnyOf(listOf("androidx.compose.", "$BASE_PACKAGE.core.designsystem.")) }
+ }
+
+ @Test
+ fun `repository interfaces live in domain repository`() {
+ productionScope.interfaces()
+ .withNameEndingWith("Repository")
+ .assertTrue { it.resideInPackage("..domain.repository") }
+ }
+
+ @Test
+ fun `repository implementations are internal Default classes in data`() {
+ productionScope.classes()
+ .withParent { it.name.endsWith("Repository") }
+ .assertTrue { repository ->
+ val contract = repository.parents().first { it.name.endsWith("Repository") }
+ repository.name == "Default${contract.name}" &&
+ repository.resideInPackage("..data..") &&
+ repository.hasInternalModifier
+ }
+ }
+
+ @Test
+ fun `data sources are internal interfaces in data`() {
+ productionScope.interfaces()
+ .withNameEndingWith("DataSource")
+ .assertTrue { it.resideInPackage("..data..") && it.hasInternalModifier }
+ }
+
+ @Test
+ fun `data source implementations are internal Default classes`() {
+ productionScope.classes()
+ .withParent { it.name.endsWith("DataSource") }
+ .assertTrue { dataSource ->
+ val contract = dataSource.parents().first { it.name.endsWith("DataSource") }
+ dataSource.name == "Default${contract.name}" && dataSource.hasInternalModifier
+ }
+ }
+
+ @Test
+ fun `use cases live in domain usecase`() {
+ productionScope.classes()
+ .withNameEndingWith("UseCase")
+ .assertTrue { it.resideInPackage("..domain.usecase") }
+ }
+
+ @Test
+ fun `view models live in ui`() {
+ productionScope.classes()
+ .withNameEndingWith("ViewModel")
+ .assertTrue { it.resideInPackage("..ui..") }
+ }
+
+ @Test
+ fun `dtos and retrofit apis live in data`() {
+ productionScope.classes()
+ .withNameEndingWith("Dto")
+ .assertTrue { it.resideInPackage("..data..dto") }
+ productionScope.interfaces()
+ .withNameEndingWith("Api")
+ .assertTrue { it.resideInPackage("..data..") && it.hasInternalModifier }
+ }
+
+ private fun KoFileDeclaration.importsAnyOf(prefixes: List) = hasImport { import -> prefixes.any { import.name.startsWith(it) } }
+
+ private companion object {
+ val NETWORK_STACK = listOf("okhttp3.", "retrofit2.", "kotlinx.serialization.")
+
+ val FORBIDDEN_IN_DOMAIN = NETWORK_STACK + listOf(
+ "android.",
+ "androidx.",
+ "dagger.",
+ "$BASE_PACKAGE.core.network.",
+ "$BASE_PACKAGE.core.designsystem.",
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/martindoudera/speedreader/konsist/ComposeTest.kt b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/ComposeTest.kt
new file mode 100644
index 0000000..cf17965
--- /dev/null
+++ b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/ComposeTest.kt
@@ -0,0 +1,118 @@
+package com.martindoudera.speedreader.konsist
+
+import com.lemonappdev.konsist.api.declaration.KoFunctionDeclaration
+import com.lemonappdev.konsist.api.ext.list.withAnnotationNamed
+import com.lemonappdev.konsist.api.ext.list.withParent
+import com.lemonappdev.konsist.api.verify.assertFalse
+import com.lemonappdev.konsist.api.verify.assertTrue
+import org.junit.Test
+
+class ComposeTest {
+ @Test
+ fun `previews are private and end with Preview`() {
+ projectScope.functions()
+ .filter { it.isPreview() && !it.hasAnnotationWithName("PreviewTest") }
+ .assertTrue { it.name.endsWith("Preview") && it.hasPrivateModifier }
+ }
+
+ @Test
+ fun `multipreview annotations end with Previews`() {
+ projectScope.classes()
+ .filter { it.hasAnnotationModifier && it.hasAnnotationWithName("Preview") }
+ .assertTrue { it.name.endsWith("Previews") }
+ }
+
+ @Test
+ fun `preview parameter providers end with Provider`() {
+ projectScope.classes()
+ .withParent { it.name.startsWith("PreviewParameterProvider") }
+ .assertTrue { it.name.endsWith("Provider") }
+ }
+
+ @Test
+ fun `screenshot tests end with Screenshot and live in ScreenshotTests files`() {
+ projectScope.functions()
+ .withAnnotationNamed("PreviewTest")
+ .assertTrue { screenshot ->
+ screenshot.name.endsWith("Screenshot") &&
+ screenshot.containingFile.name.endsWith("ScreenshotTests") &&
+ screenshot.containingFile.path.contains("/src/screenshotTest/")
+ }
+ }
+
+ @Test
+ fun `composables are not suffixed Component`() {
+ composables().assertFalse { it.name.endsWith("Component") }
+ }
+
+ @Test
+ fun `composables emitting UI are PascalCase`() {
+ composables()
+ .filterNot { it.hasReturnType() }
+ .assertTrue { it.name.first().isUpperCase() }
+ }
+
+ @Test
+ fun `public design system composables are prefixed Sr`() {
+ composables()
+ .filter { it.resideInPackage("$BASE_PACKAGE.core.designsystem.component..") && it.hasPublicOrDefaultModifier }
+ .assertTrue { it.name.startsWith("Sr") }
+ }
+
+ @Test
+ fun `composables emitting UI take a modifier as their first optional parameter`() {
+ uiComposables().assertTrue { composable ->
+ val firstOptional = composable.parameters.firstOrNull { it.defaultValue != null }
+ firstOptional?.name == "modifier" && firstOptional.type.name == "Modifier" && firstOptional.defaultValue == "Modifier"
+ }
+ }
+
+ @Test
+ fun `composable parameters list required before optional ones, except a trailing content slot`() {
+ composables().assertTrue { composable ->
+ val parameters = composable.parameters.let { all ->
+ if (all.lastOrNull()?.type?.text?.startsWith("@Composable") == true) all.dropLast(1) else all
+ }
+ parameters.dropWhile { it.defaultValue == null }.all { it.defaultValue != null }
+ }
+ }
+
+ @Test
+ fun `composable event parameters are named in the present tense`() {
+ composables()
+ .flatMap { it.parameters }
+ .filter { it.name.startsWith("on") }
+ .assertFalse { PAST_TENSE_HANDLER.matches(it.name) }
+ }
+
+ @Test
+ fun `only screens receive a view model`() {
+ composables()
+ .filter { composable -> composable.parameters.any { it.type.name.endsWith("ViewModel") } }
+ .assertTrue { it.name.endsWith("Screen") }
+ }
+
+ private fun composables() = productionScope.functions()
+ .withAnnotationNamed("Composable")
+ .filterNot { it.isPreview() }
+
+ /** Public and internal composables that draw something; previews, their helpers and the theme only wrap content. */
+ private fun uiComposables() = composables().filter { composable ->
+ !composable.hasReturnType() &&
+ !composable.hasPrivateModifier &&
+ !composable.name.endsWith("Preview") &&
+ !composable.resideInPackage("..designsystem.theme..")
+ }
+
+ private fun KoFunctionDeclaration.isPreview() = annotations.any { it.name in previewAnnotations }
+
+ private companion object {
+ /** `@Preview` itself and every multipreview annotation built on it, such as `@ThemePreviews`. */
+ val previewAnnotations: Set by lazy {
+ projectScope.classes()
+ .filter { it.hasAnnotationModifier && it.hasAnnotationWithName("Preview") }
+ .map { it.name }
+ .toSet() + "Preview"
+ }
+ }
+}
diff --git a/app/src/test/kotlin/com/martindoudera/speedreader/konsist/DeclarationTest.kt b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/DeclarationTest.kt
new file mode 100644
index 0000000..2ac3734
--- /dev/null
+++ b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/DeclarationTest.kt
@@ -0,0 +1,91 @@
+package com.martindoudera.speedreader.konsist
+
+import com.lemonappdev.konsist.api.declaration.KoObjectDeclaration
+import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
+import com.lemonappdev.konsist.api.ext.list.withParent
+import com.lemonappdev.konsist.api.provider.KoDeclarationProvider
+import com.lemonappdev.konsist.api.verify.assertFalse
+import com.lemonappdev.konsist.api.verify.assertTrue
+import org.junit.Test
+
+class DeclarationTest {
+ @Test
+ fun `companion object is the last declaration`() {
+ projectScope.objects()
+ .filter { it.hasCompanionModifier }
+ .assertTrue { companion ->
+ val container = companion.containingDeclaration as? KoDeclarationProvider
+ val last = container?.declarations(includeNested = false, includeLocal = false)?.lastOrNull()
+ last is KoObjectDeclaration && last.hasCompanionModifier
+ }
+ }
+
+ @Test
+ fun `ui state classes are data classes`() {
+ productionScope.classes()
+ .withNameEndingWith("UiState")
+ .assertTrue { it.hasDataModifier && it.resideInPackage("..ui..") }
+ }
+
+ @Test
+ fun `dtos are internal serializable data classes`() {
+ productionScope.classes()
+ .withNameEndingWith("Dto")
+ .assertTrue { it.hasDataModifier && it.hasInternalModifier && it.hasAnnotationWithName("Serializable") }
+ }
+
+ @Test
+ fun `logging goes through Timber without hand-written tags`() {
+ productionScope.files.assertFalse { file ->
+ file.hasImport { it.name == "android.util.Log" } || file.text.contains("println(") || file.text.contains("Timber.tag(")
+ }
+ }
+
+ @Test
+ fun `production code does not block on coroutines`() {
+ productionScope.files.assertFalse { file -> file.hasImport { it.name == "kotlinx.coroutines.runBlocking" } }
+ }
+
+ @Test
+ fun `project types are imported, not fully qualified`() {
+ projectScope.files.assertFalse { file ->
+ file.text.lines()
+ .map { it.trim() }
+ .filterNot {
+ it.startsWith("import ") || it.startsWith("package ") || it.startsWith("*") || it.startsWith("//") ||
+ it.startsWith("/*")
+ }
+ .any { it.contains("$BASE_PACKAGE.") }
+ }
+ }
+
+ @Test
+ fun `test classes end with Test`() {
+ testScope.classes()
+ .filter { testClass -> testClass.functions(includeNested = false).any { it.hasAnnotationWithName("Test") } }
+ .assertTrue { it.name.endsWith("Test") }
+ }
+
+ @Test
+ fun `view model tests replace the main dispatcher`() {
+ testScope.classes()
+ .withNameEndingWith("ViewModelTest")
+ .assertTrue { test ->
+ test.properties(includeNested = false).any {
+ it.text.contains("MainDispatcherRule()") &&
+ it.text.contains("@get:Rule")
+ }
+ }
+ }
+
+ @Test
+ fun `fakes are named Fake and shared from the testing package`() {
+ testScope.classes()
+ .withParent { parent -> FAKED_SUFFIXES.any { parent.name.endsWith(it) } }
+ .assertTrue { it.name.startsWith("Fake") && it.resideInPackage("..testing..") }
+ }
+
+ private companion object {
+ val FAKED_SUFFIXES = listOf("Repository", "DataSource", "Api")
+ }
+}
diff --git a/app/src/test/kotlin/com/martindoudera/speedreader/konsist/KonsistScopes.kt b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/KonsistScopes.kt
new file mode 100644
index 0000000..56959bc
--- /dev/null
+++ b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/KonsistScopes.kt
@@ -0,0 +1,27 @@
+package com.martindoudera.speedreader.konsist
+
+import com.lemonappdev.konsist.api.Konsist
+import com.lemonappdev.konsist.api.container.KoScope
+
+internal const val BASE_PACKAGE = "com.martindoudera.speedreader"
+
+internal val PASCAL_CASE = Regex("^[A-Z][A-Za-z0-9]*$")
+
+/** `onClicked`, `onStartClicked`: handlers name the event in the present tense, `onStartClick`. */
+internal val PAST_TENSE_HANDLER = Regex("^on\\w*(Clicked|Changed|Selected|Checked|Pressed|Dismissed|Tapped)$")
+
+/** Parsing the project takes a few seconds, so every rule shares one scope per source-set kind. */
+internal val projectScope: KoScope by lazy { Konsist.scopeFromProject() }
+
+internal val productionScope: KoScope by lazy { Konsist.scopeFromProduction() }
+
+internal val testScope: KoScope by lazy { Konsist.scopeFromTest() }
+
+/** Root packages of every feature, such as `com.martindoudera.speedreader.feature.speedtest`. */
+internal val featurePackages: List by lazy {
+ productionScope.files
+ .mapNotNull { it.packagee?.name }
+ .filter { it.startsWith("$BASE_PACKAGE.feature.") }
+ .map { it.split('.').take(5).joinToString(".") }
+ .distinct()
+}
diff --git a/app/src/test/kotlin/com/martindoudera/speedreader/konsist/NamingTest.kt b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/NamingTest.kt
new file mode 100644
index 0000000..9bedc54
--- /dev/null
+++ b/app/src/test/kotlin/com/martindoudera/speedreader/konsist/NamingTest.kt
@@ -0,0 +1,127 @@
+package com.martindoudera.speedreader.konsist
+
+import com.lemonappdev.konsist.api.ext.list.modifierprovider.withEnumModifier
+import com.lemonappdev.konsist.api.ext.list.modifierprovider.withSealedModifier
+import com.lemonappdev.konsist.api.ext.list.withAnnotationNamed
+import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
+import com.lemonappdev.konsist.api.ext.list.withPackage
+import com.lemonappdev.konsist.api.ext.list.withParent
+import com.lemonappdev.konsist.api.verify.assertTrue
+import org.junit.Test
+
+class NamingTest {
+ @Test
+ fun `files in usecase packages end with UseCase`() {
+ productionScope.files
+ .withPackage("..usecase")
+ .assertTrue { it.name.endsWith("UseCase") }
+ }
+
+ @Test
+ fun `use cases have an injected constructor and a single operator invoke`() {
+ productionScope.classes()
+ .withNameEndingWith("UseCase")
+ .assertTrue { useCase ->
+ val publicFunctions = useCase.functions(includeNested = false).filter { it.hasPublicOrDefaultModifier }
+ useCase.primaryConstructor?.hasAnnotationWithName("Inject") == true &&
+ publicFunctions.singleOrNull()?.let { it.name == "invoke" && it.hasOperatorModifier } == true
+ }
+ }
+
+ @Test
+ fun `view models are named ViewModel`() {
+ productionScope.classes()
+ .withParent { it.name == "ViewModel" }
+ .assertTrue { it.name.endsWith("ViewModel") }
+ }
+
+ @Test
+ fun `classes named ViewModel are Hilt view models`() {
+ productionScope.classes()
+ .withNameEndingWith("ViewModel")
+ .assertTrue { viewModel ->
+ viewModel.parents().any { it.name == "ViewModel" } && viewModel.hasAnnotationWithName("HiltViewModel")
+ }
+ }
+
+ @Test
+ fun `view models expose only StateFlow`() {
+ productionScope.classes()
+ .withNameEndingWith("ViewModel")
+ .flatMap { it.properties(includeNested = false) }
+ .filter { it.hasPublicOrDefaultModifier }
+ .assertTrue { it.type?.name?.startsWith("StateFlow") == true }
+ }
+
+ @Test
+ fun `event handlers are named in the present tense`() {
+ productionScope.classes()
+ .withNameEndingWith("ViewModel")
+ .flatMap { it.functions(includeNested = false) }
+ .filter { it.hasPublicOrDefaultModifier }
+ .assertTrue { !PAST_TENSE_HANDLER.matches(it.name) }
+ }
+
+ @Test
+ fun `dependencies are named after their type`() {
+ productionScope.classes()
+ .flatMap { it.primaryConstructor?.parameters.orEmpty() }
+ .filter { parameter -> NAMED_BY_TYPE_SUFFIXES.any { parameter.type.name.endsWith(it) } }
+ .assertTrue { it.name == it.type.name.removeSuffix("UseCase").replaceFirstChar(Char::lowercase) }
+ }
+
+ @Test
+ fun `enum entries are PascalCase`() {
+ projectScope.classes()
+ .withEnumModifier()
+ .flatMap { it.enumConstants }
+ .assertTrue { PASCAL_CASE.matches(it.name) || it.name in ACRONYMS }
+ }
+
+ @Test
+ fun `sealed subtypes are PascalCase`() {
+ val sealedTypes = projectScope.classes().withSealedModifier() + projectScope.interfaces().withSealedModifier()
+ sealedTypes
+ .flatMap { it.classes(includeNested = false) + it.objects(includeNested = false) }
+ .assertTrue { PASCAL_CASE.matches(it.name) }
+ }
+
+ @Test
+ fun `hilt modules are internal, installed and live in di`() {
+ productionScope.objects()
+ .withAnnotationNamed("Module")
+ .assertTrue {
+ it.name.endsWith("Module") && it.resideInPackage("..di") && it.hasInternalModifier &&
+ it.hasAnnotationWithName("InstallIn")
+ }
+ productionScope.interfaces()
+ .withAnnotationNamed("Module")
+ .assertTrue {
+ it.name.endsWith("Module") && it.resideInPackage("..di") && it.hasInternalModifier &&
+ it.hasAnnotationWithName("InstallIn")
+ }
+ productionScope.classes()
+ .withAnnotationNamed("Module")
+ .assertTrue {
+ it.name.endsWith("Module") && it.resideInPackage("..di") && it.hasInternalModifier &&
+ it.hasAnnotationWithName("InstallIn")
+ }
+ }
+
+ @Test
+ fun `hilt bindings are named after the type they bind`() {
+ productionScope.functions()
+ .withAnnotationNamed("Binds")
+ .assertTrue { it.name == "binds${it.returnType?.name}" }
+ productionScope.functions()
+ .withAnnotationNamed("Provides")
+ .assertTrue { it.name.startsWith("provides") }
+ }
+
+ private companion object {
+ val NAMED_BY_TYPE_SUFFIXES = listOf("UseCase", "Repository", "DataSource")
+
+ /** Acronyms keep their established form, see notes on naming conventions. */
+ val ACRONYMS = setOf("IO")
+ }
+}
diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts
new file mode 100644
index 0000000..9f989f8
--- /dev/null
+++ b/build-logic/convention/build.gradle.kts
@@ -0,0 +1,39 @@
+plugins {
+ `kotlin-dsl`
+}
+
+group = "com.martindoudera.speedreader.buildlogic"
+
+dependencies {
+ compileOnly(libs.android.gradlePlugin)
+ compileOnly(libs.kover.gradlePlugin)
+}
+
+gradlePlugin {
+ plugins {
+ register("androidApplication") {
+ id = "speedreader.android.application"
+ implementationClass = "AndroidApplicationConventionPlugin"
+ }
+ register("androidLibrary") {
+ id = "speedreader.android.library"
+ implementationClass = "AndroidLibraryConventionPlugin"
+ }
+ register("androidCompose") {
+ id = "speedreader.android.compose"
+ implementationClass = "AndroidComposeConventionPlugin"
+ }
+ register("hilt") {
+ id = "speedreader.hilt"
+ implementationClass = "HiltConventionPlugin"
+ }
+ register("kover") {
+ id = "speedreader.kover"
+ implementationClass = "KoverConventionPlugin"
+ }
+ register("androidScreenshot") {
+ id = "speedreader.android.screenshot"
+ implementationClass = "AndroidScreenshotConventionPlugin"
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
new file mode 100644
index 0000000..fa63a57
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
@@ -0,0 +1,20 @@
+import com.android.build.api.dsl.ApplicationExtension
+import com.martindoudera.speedreader.buildlogic.TARGET_SDK
+import com.martindoudera.speedreader.buildlogic.configureAndroidCommon
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+
+class AndroidApplicationConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ pluginManager.apply("com.android.application")
+ pluginManager.apply("speedreader.kover")
+
+ extensions.configure {
+ configureAndroidCommon()
+ defaultConfig.targetSdk = TARGET_SDK
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt
new file mode 100644
index 0000000..30ea7dc
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt
@@ -0,0 +1,14 @@
+import com.android.build.api.dsl.CommonExtension
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+
+class AndroidComposeConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
+
+ val android = extensions.getByName("android") as CommonExtension
+ android.buildFeatures.compose = true
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
new file mode 100644
index 0000000..a465adb
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
@@ -0,0 +1,25 @@
+import com.android.build.api.dsl.LibraryExtension
+import com.martindoudera.speedreader.buildlogic.configureAndroidCommon
+import com.martindoudera.speedreader.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
+
+class AndroidLibraryConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ pluginManager.apply("com.android.library")
+ pluginManager.apply("speedreader.kover")
+
+ extensions.configure {
+ configureAndroidCommon()
+ }
+
+ dependencies {
+ add("testImplementation", libs.findLibrary("junit").get())
+ add("testImplementation", libs.findLibrary("kotlin-test").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt
new file mode 100644
index 0000000..0810351
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt
@@ -0,0 +1,22 @@
+import com.android.build.api.dsl.CommonExtension
+import com.martindoudera.speedreader.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+/** Compose Preview Screenshot Testing: `@PreviewTest` previews in `src/screenshotTest` rendered on the JVM by layoutlib. */
+class AndroidScreenshotConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ pluginManager.apply("com.android.compose.screenshot")
+
+ val android = extensions.getByName("android") as CommonExtension
+ android.experimentalProperties["android.experimental.enableScreenshotTest"] = true
+
+ dependencies {
+ add("screenshotTestImplementation", libs.findLibrary("screenshot-validation-api").get())
+ add("screenshotTestImplementation", libs.findLibrary("androidx-compose-ui-tooling").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt
new file mode 100644
index 0000000..b236ea5
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt
@@ -0,0 +1,18 @@
+import com.martindoudera.speedreader.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+class HiltConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ pluginManager.apply("com.google.devtools.ksp")
+ pluginManager.apply("com.google.dagger.hilt.android")
+
+ dependencies {
+ add("implementation", libs.findLibrary("hilt-android").get())
+ add("ksp", libs.findLibrary("hilt-compiler").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt
new file mode 100644
index 0000000..2a492e3
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt
@@ -0,0 +1,54 @@
+import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+
+private const val COVERAGE_VARIANT = "coverage"
+
+private val coverageExclusions = listOf(
+ // Hilt and Dagger
+ "*_Hilt*",
+ "*.Hilt_*",
+ "*.HiltWrapper_*",
+ "*_Factory",
+ "*_Factory$*",
+ "*_Provide*Factory*",
+ "*_MembersInjector",
+ "*_GeneratedInjector",
+ "*_ComponentTreeDeps",
+ "hilt_aggregated_deps.*",
+ "dagger.hilt.internal.*",
+ // kotlinx.serialization
+ "*$\$serializer",
+ // Compose UI and Android entry points: the screenshot tests render them on layoutlib, which Kover cannot measure
+ "*ComposableSingletons*",
+ "*Previews*",
+ "*.designsystem.component.*",
+ "*.designsystem.theme.*",
+ "*.ui.component.*",
+ "*.ui.*Screen*",
+ "*Activity",
+ "*Activity$*",
+ "*Application",
+)
+
+/** Kover code coverage of the unit tests, with generated code and the screenshot-tested UI left out. */
+class KoverConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ pluginManager.apply("org.jetbrains.kotlinx.kover")
+
+ extensions.configure {
+ // The root project merges the same-named variant of every module, so the debug variant needs a custom name.
+ currentProject.createVariant(COVERAGE_VARIANT) {
+ add("debug", optional = true)
+ }
+ reports.filters.excludes {
+ androidGeneratedClasses()
+ annotatedBy("dagger.Module")
+ classes(coverageExclusions)
+ }
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/martindoudera/speedreader/buildlogic/AndroidCommon.kt b/build-logic/convention/src/main/kotlin/com/martindoudera/speedreader/buildlogic/AndroidCommon.kt
new file mode 100644
index 0000000..f8fb176
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/martindoudera/speedreader/buildlogic/AndroidCommon.kt
@@ -0,0 +1,24 @@
+package com.martindoudera.speedreader.buildlogic
+
+import com.android.build.api.dsl.CommonExtension
+import org.gradle.api.JavaVersion
+import org.gradle.api.Project
+import org.gradle.api.artifacts.VersionCatalog
+import org.gradle.api.artifacts.VersionCatalogsExtension
+import org.gradle.kotlin.dsl.getByType
+
+internal const val COMPILE_SDK = 37
+internal const val MIN_SDK = 26
+internal const val TARGET_SDK = 37
+
+internal val Project.libs: VersionCatalog
+ get() = extensions.getByType().named("libs")
+
+internal fun CommonExtension.configureAndroidCommon() {
+ compileSdk {
+ version = release(COMPILE_SDK)
+ }
+ defaultConfig.minSdk = MIN_SDK
+ compileOptions.sourceCompatibility = JavaVersion.VERSION_11
+ compileOptions.targetCompatibility = JavaVersion.VERSION_11
+}
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
new file mode 100644
index 0000000..1b15085
--- /dev/null
+++ b/build-logic/settings.gradle.kts
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ versionCatalogs {
+ create("libs") {
+ from(files("../gradle/libs.versions.toml"))
+ }
+ }
+}
+
+rootProject.name = "build-logic"
+
+include(":convention")
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..3d65c75
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,57 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.kotlin.jvm) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.hilt) apply false
+ alias(libs.plugins.screenshot) apply false
+ alias(libs.plugins.spotless)
+ alias(libs.plugins.kover)
+ id("speedreader.kover")
+}
+
+dependencies {
+ kover(project(":app"))
+ kover(project(":core:common"))
+ kover(project(":core:designsystem"))
+ kover(project(":core:network"))
+ kover(project(":feature:speedtest"))
+}
+
+// Merges the debug unit test coverage of every module: `./gradlew :koverXmlReportCoverage :koverVerifyCoverage`.
+kover {
+ reports {
+ variant("coverage") {
+ verify {
+ rule {
+ minBound(80)
+ }
+ }
+ }
+ }
+}
+
+// Globs are recursive, so every module is covered without applying the plugin per project.
+spotless {
+ val ktlintVersion = libs.versions.ktlint.get()
+
+ kotlin {
+ target("**/*.kt")
+ targetExclude("**/build/**")
+ ktlint(ktlintVersion)
+ }
+ kotlinGradle {
+ target("**/*.gradle.kts")
+ targetExclude("**/build/**")
+ ktlint(ktlintVersion)
+ }
+ format("xml") {
+ target("**/*.xml")
+ targetExclude("**/build/**", "**/.idea/**")
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
+}
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
new file mode 100644
index 0000000..c37fbca
--- /dev/null
+++ b/core/common/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ id("speedreader.android.library")
+ id("speedreader.hilt")
+}
+
+android {
+ namespace = "com.martindoudera.speedreader.core.common"
+}
+
+dependencies {
+ api(libs.kotlinx.coroutines.android)
+ api(libs.timber)
+}
diff --git a/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/di/Dispatcher.kt b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/di/Dispatcher.kt
new file mode 100644
index 0000000..c05b216
--- /dev/null
+++ b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/di/Dispatcher.kt
@@ -0,0 +1,12 @@
+package com.martindoudera.speedreader.core.common.di
+
+import javax.inject.Qualifier
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Dispatcher(val dispatcher: SpeedReaderDispatcher)
+
+enum class SpeedReaderDispatcher {
+ Default,
+ IO,
+}
diff --git a/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/di/DispatchersModule.kt b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/di/DispatchersModule.kt
new file mode 100644
index 0000000..103ffbf
--- /dev/null
+++ b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/di/DispatchersModule.kt
@@ -0,0 +1,20 @@
+package com.martindoudera.speedreader.core.common.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object DispatchersModule {
+ @Provides
+ @Dispatcher(SpeedReaderDispatcher.Default)
+ fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+ @Provides
+ @Dispatcher(SpeedReaderDispatcher.IO)
+ fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
+}
diff --git a/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/di/TimeModule.kt b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/di/TimeModule.kt
new file mode 100644
index 0000000..fdbbc9f
--- /dev/null
+++ b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/di/TimeModule.kt
@@ -0,0 +1,14 @@
+package com.martindoudera.speedreader.core.common.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlin.time.TimeSource
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object TimeModule {
+ @Provides
+ fun providesTimeSource(): TimeSource = TimeSource.Monotonic
+}
diff --git a/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/domain/model/AppVersion.kt b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/domain/model/AppVersion.kt
new file mode 100644
index 0000000..96d30ac
--- /dev/null
+++ b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/domain/model/AppVersion.kt
@@ -0,0 +1,7 @@
+package com.martindoudera.speedreader.core.common.domain.model
+
+/**
+ * The installed app's version name, e.g. `0.0.1` or `0.0.1-debug`. A data class rather than a value class,
+ * because Dagger cannot inject value classes.
+ */
+data class AppVersion(val name: String)
diff --git a/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/error/NetworkError.kt b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/error/NetworkError.kt
new file mode 100644
index 0000000..24eeca8
--- /dev/null
+++ b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/error/NetworkError.kt
@@ -0,0 +1,21 @@
+package com.martindoudera.speedreader.core.common.error
+
+sealed interface NetworkError {
+ data class NoConnection(val cause: Throwable?) : NetworkError
+
+ data class Timeout(val cause: Throwable?) : NetworkError
+
+ data class Tls(val cause: Throwable?) : NetworkError
+
+ data class Http(val status: HttpStatusCode) : NetworkError
+
+ data class Malformed(val cause: Throwable?) : NetworkError
+
+ data class Unknown(val cause: Throwable?) : NetworkError
+}
+
+typealias NetworkOutcome = Outcome
+
+/** An HTTP response status code, e.g. 401 or 503. */
+@JvmInline
+value class HttpStatusCode(val value: Int)
diff --git a/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/error/Outcome.kt b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/error/Outcome.kt
new file mode 100644
index 0000000..6ea96d0
--- /dev/null
+++ b/core/common/src/main/kotlin/com/martindoudera/speedreader/core/common/error/Outcome.kt
@@ -0,0 +1,34 @@
+package com.martindoudera.speedreader.core.common.error
+
+sealed interface Outcome {
+ data class Success(val value: T) : Outcome
+
+ data class Failure(val error: E) : Outcome
+}
+
+inline fun Outcome.map(transform: (T) -> R): Outcome = when (this) {
+ is Outcome.Success -> Outcome.Success(transform(value))
+ is Outcome.Failure -> this
+}
+
+inline fun Outcome.mapError(transform: (E) -> F): Outcome = when (this) {
+ is Outcome.Success -> this
+ is Outcome.Failure -> Outcome.Failure(transform(error))
+}
+
+/** Runs [action] on the value of a success, e.g. to log it, and returns the outcome unchanged. */
+inline fun Outcome.onSuccess(action: (T) -> Unit): Outcome {
+ if (this is Outcome.Success) action(value)
+ return this
+}
+
+/** Runs [action] on the error of a failure, e.g. to log it, and returns the outcome unchanged. */
+inline fun Outcome.onFailure(action: (E) -> Unit): Outcome {
+ if (this is Outcome.Failure) action(error)
+ return this
+}
+
+fun Outcome.valueOrNull(): T? = when (this) {
+ is Outcome.Success -> value
+ is Outcome.Failure -> null
+}
diff --git a/core/common/src/test/kotlin/com/martindoudera/speedreader/core/common/error/OutcomeTest.kt b/core/common/src/test/kotlin/com/martindoudera/speedreader/core/common/error/OutcomeTest.kt
new file mode 100644
index 0000000..fffadec
--- /dev/null
+++ b/core/common/src/test/kotlin/com/martindoudera/speedreader/core/common/error/OutcomeTest.kt
@@ -0,0 +1,51 @@
+package com.martindoudera.speedreader.core.common.error
+
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import org.junit.Test
+
+class OutcomeTest {
+ @Test
+ fun onSuccessSeesTheValueAndPassesTheOutcomeOn() {
+ val success: Outcome = Outcome.Success(42)
+ var seen: Int? = null
+
+ val result = success.onSuccess { seen = it }
+
+ assertEquals(42, seen)
+ assertEquals(success, result)
+ }
+
+ @Test
+ fun onSuccessIsSkippedOnFailure() {
+ val failure: Outcome = Outcome.Failure("offline")
+ var seen: Int? = null
+
+ val result = failure.onSuccess { seen = it }
+
+ assertNull(seen)
+ assertEquals(failure, result)
+ }
+
+ @Test
+ fun onFailureSeesTheErrorAndPassesTheOutcomeOn() {
+ val failure: Outcome = Outcome.Failure("offline")
+ var seen: String? = null
+
+ val result = failure.onFailure { seen = it }
+
+ assertEquals("offline", seen)
+ assertEquals(failure, result)
+ }
+
+ @Test
+ fun onFailureIsSkippedOnSuccess() {
+ val success: Outcome = Outcome.Success(42)
+ var seen: String? = null
+
+ val result = success.onFailure { seen = it }
+
+ assertNull(seen)
+ assertEquals(success, result)
+ }
+}
diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts
new file mode 100644
index 0000000..93c0b7d
--- /dev/null
+++ b/core/designsystem/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins {
+ id("speedreader.android.library")
+ id("speedreader.android.compose")
+}
+
+android {
+ namespace = "com.martindoudera.speedreader.core.designsystem"
+ resourcePrefix = "core_designsystem_"
+}
+
+dependencies {
+ api(platform(libs.androidx.compose.bom))
+ api(libs.androidx.compose.material3)
+ api(libs.androidx.compose.material.iconsExtended)
+ api(libs.androidx.compose.material3.adaptive)
+ api(libs.androidx.compose.ui)
+ api(libs.androidx.compose.ui.graphics)
+ api(libs.androidx.compose.ui.tooling.preview)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/adaptive/WindowSizeClass.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/adaptive/WindowSizeClass.kt
new file mode 100644
index 0000000..b65159b
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/adaptive/WindowSizeClass.kt
@@ -0,0 +1,14 @@
+package com.martindoudera.speedreader.core.designsystem.adaptive
+
+import androidx.window.core.layout.WindowSizeClass
+
+/**
+ * Whether a screen should lay out two panes side by side rather than one column: from the expanded
+ * width (840 dp), or from the medium width (600 dp) when the window is also short, as a phone in
+ * landscape is.
+ */
+fun WindowSizeClass.prefersTwoPanes(): Boolean = isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) ||
+ (
+ isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) &&
+ !isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND)
+ )
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/Button.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/Button.kt
new file mode 100644
index 0000000..0632891
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/Button.kt
@@ -0,0 +1,69 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Share
+import androidx.compose.material.icons.rounded.Stop
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+/** The filled pill button for the main action of a screen state. */
+@Composable
+fun SrPrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, icon: ImageVector? = null) {
+ Button(
+ onClick = onClick,
+ modifier = modifier.heightIn(min = SpeedReaderTheme.dimens.buttonHeight),
+ contentPadding = if (icon != null) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.ContentPadding,
+ ) {
+ ButtonContent(text, icon)
+ }
+}
+
+/** The outlined pill button for secondary actions such as Stop or Back. */
+@Composable
+fun SrOutlinedButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, icon: ImageVector? = null) {
+ OutlinedButton(
+ onClick = onClick,
+ modifier = modifier.heightIn(min = SpeedReaderTheme.dimens.buttonHeight),
+ border = BorderStroke(1.5.dp, MaterialTheme.colorScheme.outlineVariant),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
+ contentPadding = if (icon != null) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.ContentPadding,
+ ) {
+ ButtonContent(text, icon)
+ }
+}
+
+@Composable
+private fun RowScope.ButtonContent(text: String, icon: ImageVector?) {
+ if (icon != null) {
+ Icon(icon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize))
+ Spacer(Modifier.width(ButtonDefaults.IconSpacing))
+ }
+ Text(text, style = MaterialTheme.typography.labelLarge)
+}
+
+@ThemePreviews
+@Composable
+private fun ButtonsPreview() {
+ ComponentPreview {
+ Column {
+ SrPrimaryButton(text = "Share", onClick = {}, icon = Icons.Rounded.Share)
+ SrOutlinedButton(text = "Stop", onClick = {}, icon = Icons.Rounded.Stop)
+ }
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/CheckboxRow.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/CheckboxRow.kt
new file mode 100644
index 0000000..ab66d8a
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/CheckboxRow.kt
@@ -0,0 +1,56 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+/** A whole-row checkbox with a title and a one-line explanation; tapping anywhere on the card toggles it. */
+@Composable
+fun SrCheckboxRow(checked: Boolean, onCheckedChange: (Boolean) -> Unit, title: String, description: String, modifier: Modifier = Modifier) {
+ val shape = MaterialTheme.shapes.medium
+ Row(
+ modifier = modifier
+ .clip(shape)
+ .background(MaterialTheme.colorScheme.surface)
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
+ .toggleable(value = checked, role = Role.Checkbox, onValueChange = onCheckedChange)
+ .padding(SpeedReaderTheme.spacing.xs),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(SpeedReaderTheme.spacing.md))
+ Spacer(Modifier.width(SpeedReaderTheme.spacing.xs))
+ Column(Modifier.weight(1f).padding(vertical = SpeedReaderTheme.spacing.sm)) {
+ Text(text = title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
+ Text(text = description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Spacer(Modifier.width(SpeedReaderTheme.spacing.lg))
+ }
+}
+
+@ThemePreviews
+@Composable
+private fun CheckboxRowPreview() {
+ ComponentPreview {
+ SrCheckboxRow(
+ checked = true,
+ onCheckedChange = {},
+ title = "Single connection only",
+ description = "Measure one transfer instead of 6 in parallel",
+ )
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/InfoChip.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/InfoChip.kt
new file mode 100644
index 0000000..1a49f6b
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/InfoChip.kt
@@ -0,0 +1,97 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Dns
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+/**
+ * A full-width pill with an icon, a title, a subtitle and an optional trailing tag.
+ * While `loading` is true the texts are replaced by shimmering placeholders of the same height.
+ */
+@Composable
+fun SrInfoChip(
+ icon: ImageVector,
+ title: String,
+ subtitle: String,
+ modifier: Modifier = Modifier,
+ tag: String? = null,
+ loading: Boolean = false,
+) {
+ val spacing = SpeedReaderTheme.spacing
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surface)
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
+ .padding(start = spacing.sm, top = spacing.sm, bottom = spacing.sm, end = spacing.lg),
+ horizontalArrangement = Arrangement.spacedBy(spacing.md),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(SpeedReaderTheme.dimens.chipIconContainer)
+ .background(MaterialTheme.colorScheme.surfaceContainer, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(SpeedReaderTheme.dimens.iconMedium),
+ )
+ }
+ if (loading) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
+ ShimmerLine(widthFraction = 1f)
+ ShimmerLine(widthFraction = 0.55f)
+ }
+ } else {
+ Column(Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
+ }
+ if (tag != null) {
+ Text(
+ text = tag,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surfaceContainer, CircleShape)
+ .padding(horizontal = spacing.sm, vertical = spacing.xs),
+ )
+ }
+ }
+ }
+}
+
+@ThemePreviews
+@Composable
+private fun InfoChipPreview() {
+ ComponentPreview {
+ Column(verticalArrangement = Arrangement.spacedBy(SpeedReaderTheme.spacing.md)) {
+ SrInfoChip(icon = Icons.Rounded.Dns, title = "CETIN a.s.", subtitle = "Prague, CZ", tag = "6 connections")
+ SrInfoChip(icon = Icons.Rounded.Dns, title = "", subtitle = "", loading = true)
+ }
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/LiveDot.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/LiveDot.kt
new file mode 100644
index 0000000..00bcc41
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/LiveDot.kt
@@ -0,0 +1,31 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.unit.dp
+
+private const val BLINK_DURATION_MILLIS = 600
+
+/** A small dot that fades in and out, marking a value that is still being measured. */
+@Composable
+internal fun LiveDot(color: Color, modifier: Modifier = Modifier) {
+ val alpha by rememberInfiniteTransition(label = "liveDot").animateFloat(
+ initialValue = 1f,
+ targetValue = 0.25f,
+ animationSpec = infiniteRepeatable(tween(BLINK_DURATION_MILLIS), RepeatMode.Reverse),
+ label = "liveDot",
+ )
+ Box(modifier.size(7.dp).graphicsLayer { this.alpha = alpha }.background(color, CircleShape))
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/Message.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/Message.kt
new file mode 100644
index 0000000..3689494
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/Message.kt
@@ -0,0 +1,53 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.MaterialTheme
+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.painter.Painter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.martindoudera.speedreader.core.designsystem.R
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+/** A centred illustration with a title and an optional explanation, for outcomes such as a finished test or an error. */
+@Composable
+fun SrMessage(illustration: Painter, title: String, modifier: Modifier = Modifier, text: String? = null) {
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ Image(painter = illustration, contentDescription = null, modifier = Modifier.width(SpeedReaderTheme.dimens.illustration))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = SpeedReaderTheme.spacing.lg),
+ )
+ if (text != null) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = SpeedReaderTheme.spacing.xs).widthIn(max = 280.dp),
+ )
+ }
+ }
+}
+
+@ThemePreviews
+@Composable
+private fun MessagePreview() {
+ ComponentPreview {
+ SrMessage(
+ illustration = painterResource(R.drawable.core_designsystem_illustration_disconnected),
+ title = "You're offline",
+ text = "Check your Wi-Fi or mobile data and try again.",
+ )
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/MetricCard.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/MetricCard.kt
new file mode 100644
index 0000000..88d71c7
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/MetricCard.kt
@@ -0,0 +1,174 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.TextAutoSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material.icons.rounded.Download
+import androidx.compose.material.icons.rounded.NetworkPing
+import androidx.compose.material.icons.rounded.Upload
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+/** Where a [SrMetricCard]'s value stands: not measured yet, being measured, or final. */
+enum class MetricStatus { Idle, Live, Done }
+
+/**
+ * A card with one measured value: an icon and label on top, the value with its unit, and a caption.
+ * A `Live` card is tinted with `accent` and its caption gets a blinking dot; a `Done` caption gets a check mark.
+ */
+@Composable
+fun SrMetricCard(
+ icon: ImageVector,
+ label: String,
+ value: String,
+ unit: String,
+ accent: Color,
+ modifier: Modifier = Modifier,
+ status: MetricStatus = MetricStatus.Idle,
+ caption: String? = null,
+ emphasized: Boolean = false,
+) {
+ val spacing = SpeedReaderTheme.spacing
+ val surface = MaterialTheme.colorScheme.surface
+ val live = status == MetricStatus.Live
+ val container by animateColorAsState(if (live) accent.copy(alpha = 0.1f).compositeOver(surface) else surface, label = "container")
+ val border by animateColorAsState(if (live) accent.copy(alpha = 0.55f) else MaterialTheme.colorScheme.outlineVariant, label = "border")
+ val shape = MaterialTheme.shapes.medium
+
+ Column(
+ modifier = modifier
+ .defaultMinSize(minHeight = SpeedReaderTheme.dimens.metricCardMinHeight)
+ .clip(shape)
+ .background(container)
+ .border(1.dp, border, shape)
+ .padding(spacing.md),
+ verticalArrangement = Arrangement.spacedBy(spacing.xxs),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing.xs + spacing.xxs)) {
+ Icon(icon, contentDescription = null, tint = accent, modifier = Modifier.size(SpeedReaderTheme.dimens.iconSmall))
+ FittedText(label, MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ ValueWithUnit(
+ value = value,
+ unit = unit,
+ valueStyle = if (emphasized) MaterialTheme.typography.headlineMedium else MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(top = spacing.xs),
+ )
+ Row(
+ modifier = Modifier.heightIn(min = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(spacing.xs),
+ ) {
+ when (status) {
+ MetricStatus.Live -> LiveDot(accent)
+ MetricStatus.Done -> Icon(Icons.Rounded.Check, contentDescription = null, tint = accent, modifier = Modifier.size(13.dp))
+ MetricStatus.Idle -> Unit
+ }
+ if (caption != null) {
+ FittedText(caption, MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+}
+
+/**
+ * The value and its smaller unit as one line of text, so that when a large font scale makes them
+ * too wide for the card they shrink together instead of the unit being cut off.
+ */
+@Composable
+private fun ValueWithUnit(value: String, unit: String, valueStyle: TextStyle, modifier: Modifier = Modifier) {
+ val unitStyle = MaterialTheme.typography.labelSmall
+ val unitSpan = SpanStyle(
+ fontSize = (unitStyle.fontSize.value / valueStyle.fontSize.value).em,
+ fontWeight = unitStyle.fontWeight,
+ letterSpacing = unitStyle.letterSpacing,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ val text = buildAnnotatedString {
+ append(value)
+ withStyle(unitSpan) {
+ append(UNIT_SEPARATOR)
+ append(unit)
+ }
+ }
+ FittedText(text, valueStyle, modifier = modifier)
+}
+
+/** One line of text in [style] that shrinks to fit the width rather than wrapping or being cut off. */
+@Composable
+private fun FittedText(text: AnnotatedString, style: TextStyle, modifier: Modifier = Modifier, color: Color = Color.Unspecified) {
+ Text(
+ text = text,
+ style = style,
+ color = color,
+ modifier = modifier,
+ softWrap = false,
+ maxLines = 1,
+ autoSize = TextAutoSize.StepBased(
+ minFontSize = with(LocalDensity.current) {
+ MIN_FITTED_TEXT_SIZE.toSp()
+ },
+ maxFontSize = style.fontSize,
+ ),
+ )
+}
+
+@Composable
+private fun FittedText(text: String, style: TextStyle, modifier: Modifier = Modifier, color: Color = Color.Unspecified) =
+ FittedText(AnnotatedString(text), style, modifier, color)
+
+/** A three-per-em space: at the unit's size, about as wide as the gap the separate texts used to have. */
+private const val UNIT_SEPARATOR = "\u2004"
+
+/** In dp, not sp, so that the floor holds at any font scale; an sp floor doubles at 2x and stops the shrinking. */
+val MIN_FITTED_TEXT_SIZE = 9.dp
+
+@ThemePreviews
+@Composable
+private fun MetricCardPreview() {
+ ComponentPreview {
+ val colors = SpeedReaderTheme.speedColors
+ Row(horizontalArrangement = Arrangement.spacedBy(SpeedReaderTheme.spacing.sm)) {
+ SrMetricCard(Icons.Rounded.NetworkPing, "Ping", "9", "ms", colors.highlight, Modifier.weight(1f), caption = "ICMP")
+ SrMetricCard(
+ Icons.Rounded.Download,
+ "Download",
+ "223",
+ "Mbps",
+ colors.download,
+ Modifier.weight(1f),
+ MetricStatus.Done,
+ "average",
+ )
+ SrMetricCard(Icons.Rounded.Upload, "Upload", "39.4", "Mbps", colors.upload, Modifier.weight(1f), MetricStatus.Live, "live avg")
+ }
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/ProgressBar.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/ProgressBar.kt
new file mode 100644
index 0000000..a7b96f4
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/ProgressBar.kt
@@ -0,0 +1,67 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.unit.dp
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+/** One part of a [SrSegmentedProgressBar]; `progress` runs from 0 to 1 within the segment. */
+@Immutable
+data class ProgressSegment(val progress: Float, val color: Color)
+
+/** A determinate progress bar split into equally wide segments, each filling in its own colour. */
+@Composable
+fun SrSegmentedProgressBar(segments: List, modifier: Modifier = Modifier) {
+ Row(
+ modifier = modifier.height(SpeedReaderTheme.dimens.progressBarHeight),
+ horizontalArrangement = Arrangement.spacedBy(SpeedReaderTheme.spacing.xs),
+ ) {
+ segments.forEach { segment ->
+ LinearProgressIndicator(
+ progress = { segment.progress },
+ modifier = Modifier.weight(1f).fillMaxHeight(),
+ color = segment.color,
+ trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ strokeCap = StrokeCap.Round,
+ gapSize = 0.dp,
+ drawStopIndicator = {},
+ )
+ }
+ }
+}
+
+/** The same bar while the length of the work is unknown. */
+@Composable
+fun SrIndeterminateProgressBar(modifier: Modifier = Modifier) {
+ LinearProgressIndicator(
+ modifier = modifier.height(SpeedReaderTheme.dimens.progressBarHeight),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ strokeCap = StrokeCap.Round,
+ gapSize = 0.dp,
+ )
+}
+
+@ThemePreviews
+@Composable
+private fun ProgressBarPreview() {
+ ComponentPreview {
+ val colors = SpeedReaderTheme.speedColors
+ val width = Modifier.width(SpeedReaderTheme.dimens.progressBarWidth)
+ Column(verticalArrangement = Arrangement.spacedBy(SpeedReaderTheme.spacing.lg)) {
+ SrSegmentedProgressBar(listOf(ProgressSegment(1f, colors.download), ProgressSegment(0.4f, colors.upload)), width)
+ SrIndeterminateProgressBar(width)
+ }
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/PulsingCircleButton.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/PulsingCircleButton.kt
new file mode 100644
index 0000000..c12edf3
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/PulsingCircleButton.kt
@@ -0,0 +1,82 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+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.draw.drawBehind
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.text.toUpperCase
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+private const val PULSE_DURATION_MILLIS = 2600
+private const val PULSE_GROWTH = 0.45f
+
+/** The large round call-to-action on the brand gradient, with two rings pulsing out of it to invite a tap. Shows `text` in capitals. */
+@Composable
+fun SrPulsingCircleButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
+ val ringColor = MaterialTheme.colorScheme.primary
+ val pulse by rememberInfiniteTransition(label = "pulse").animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(tween(PULSE_DURATION_MILLIS, easing = LinearOutSlowInEasing), RepeatMode.Restart),
+ label = "pulse",
+ )
+
+ Box(
+ modifier = modifier
+ .size(SpeedReaderTheme.dimens.startButton)
+ .drawBehind {
+ drawPulseRing(pulse, ringColor)
+ drawPulseRing((pulse + 0.5f) % 1f, ringColor)
+ }
+ .shadow(elevation = 16.dp, shape = CircleShape, spotColor = ringColor)
+ .background(Brush.linearGradient(SpeedReaderTheme.speedColors.brandGradient), CircleShape)
+ .clickable(role = Role.Button, onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = text.toUpperCase(Locale.current),
+ color = Color.White,
+ style = MaterialTheme.typography.headlineSmall.copy(letterSpacing = 2.sp),
+ )
+ }
+}
+
+private fun DrawScope.drawPulseRing(fraction: Float, color: Color) {
+ drawCircle(
+ color = color.copy(alpha = 0.45f * (1f - fraction)),
+ radius = size.minDimension / 2 * (1f + PULSE_GROWTH * fraction),
+ style = Stroke(width = 2.dp.toPx()),
+ )
+}
+
+@ThemePreviews
+@Composable
+private fun PulsingCircleButtonPreview() {
+ ComponentPreview {
+ SrPulsingCircleButton(text = "Start", onClick = {}, modifier = Modifier.padding(SpeedReaderTheme.spacing.xxxl))
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/Shimmer.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/Shimmer.kt
new file mode 100644
index 0000000..ade7d0d
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/Shimmer.kt
@@ -0,0 +1,46 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.unit.dp
+
+private const val SHIMMER_DURATION_MILLIS = 1300
+
+/** A rounded placeholder bar with a highlight sweeping across it, standing in for text that is still loading. */
+@Composable
+internal fun ShimmerLine(widthFraction: Float, modifier: Modifier = Modifier) {
+ val base = MaterialTheme.colorScheme.surfaceContainer
+ val highlight = MaterialTheme.colorScheme.surfaceContainerHigh
+ val progress by rememberInfiniteTransition(label = "shimmer").animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(tween(SHIMMER_DURATION_MILLIS, easing = LinearEasing)),
+ label = "shimmer",
+ )
+ Box(
+ modifier = modifier
+ .fillMaxWidth(widthFraction)
+ .height(10.dp)
+ .clip(MaterialTheme.shapes.extraSmall)
+ .drawBehind {
+ val start = (progress * 2f - 1f) * size.width
+ drawRect(
+ Brush.linearGradient(listOf(base, highlight, base), start = Offset(start, 0f), end = Offset(start + size.width, 0f)),
+ )
+ },
+ )
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/StatusPill.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/StatusPill.kt
new file mode 100644
index 0000000..c8070a5
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/StatusPill.kt
@@ -0,0 +1,46 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Download
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+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.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+/** A small tinted label with an icon, naming what is happening right now; changes of `color` animate. */
+@Composable
+fun SrStatusPill(icon: ImageVector, text: String, color: Color, modifier: Modifier = Modifier) {
+ val spacing = SpeedReaderTheme.spacing
+ val tint by animateColorAsState(color, label = "statusPill")
+ Row(
+ modifier = modifier
+ .background(tint.copy(alpha = 0.15f), CircleShape)
+ .padding(start = spacing.sm, end = spacing.md, top = spacing.xs, bottom = spacing.xs),
+ horizontalArrangement = Arrangement.spacedBy(spacing.xs + spacing.xxs),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(SpeedReaderTheme.dimens.iconSmall))
+ Text(text, style = MaterialTheme.typography.labelMedium, color = tint)
+ }
+}
+
+@ThemePreviews
+@Composable
+private fun StatusPillPreview() {
+ ComponentPreview {
+ SrStatusPill(icon = Icons.Rounded.Download, text = "Download", color = SpeedReaderTheme.speedColors.download)
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/ThemePreviews.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/ThemePreviews.kt
new file mode 100644
index 0000000..d395574
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/ThemePreviews.kt
@@ -0,0 +1,26 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+/** Renders a preview in both the light and the dark theme. */
+@Preview(name = "Light", uiMode = UI_MODE_NIGHT_NO)
+@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES)
+annotation class ThemePreviews
+
+@Composable
+internal fun ComponentPreview(content: @Composable () -> Unit) {
+ SpeedReaderTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ Box(Modifier.padding(SpeedReaderTheme.spacing.lg)) { content() }
+ }
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/TopBar.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/TopBar.kt
new file mode 100644
index 0000000..812041d
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/component/TopBar.kt
@@ -0,0 +1,45 @@
+package com.martindoudera.speedreader.core.designsystem.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import com.martindoudera.speedreader.core.designsystem.R
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+
+/** The app's top bar: logo, app name and optional [trailingContent] at the end; no actions. */
+@Composable
+fun SrTopBar(title: String, modifier: Modifier = Modifier, trailingContent: @Composable RowScope.() -> Unit = {}) {
+ Row(
+ modifier = modifier.fillMaxWidth().height(SpeedReaderTheme.dimens.topBarHeight),
+ horizontalArrangement = Arrangement.spacedBy(SpeedReaderTheme.spacing.md),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ painterResource(R.drawable.core_designsystem_logo),
+ contentDescription = null,
+ modifier = Modifier.size(SpeedReaderTheme.dimens.logo),
+ )
+ Text(title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f))
+ trailingContent()
+ }
+}
+
+@ThemePreviews
+@Composable
+private fun TopBarPreview() {
+ ComponentPreview {
+ SrTopBar(title = "SpeedReader") {
+ Text("v0.0.1", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Color.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Color.kt
new file mode 100644
index 0000000..db1ff90
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Color.kt
@@ -0,0 +1,34 @@
+package com.martindoudera.speedreader.core.designsystem.theme
+
+import androidx.compose.ui.graphics.Color
+
+internal val Indigo50 = Color(0xFF4B4EF0)
+internal val Indigo80 = Color(0xFF8B8DFF)
+
+internal val Teal50 = Color(0xFF0EA5A0)
+internal val Teal80 = Color(0xFF2DD4C4)
+
+internal val Violet60 = Color(0xFF8B5CF6)
+internal val Violet80 = Color(0xFFB197FC)
+
+internal val Amber60 = Color(0xFFF5A524)
+internal val Amber80 = Color(0xFFFFC14D)
+
+internal val Red50 = Color(0xFFE5484D)
+internal val Red70 = Color(0xFFFF6B6E)
+internal val Red20 = Color(0xFF46191F)
+internal val Red95 = Color(0xFFFDE4E4)
+
+internal val Slate5 = Color(0xFF0D0F1A)
+internal val Slate10 = Color(0xFF171927)
+internal val Slate12 = Color(0xFF161927)
+internal val Slate15 = Color(0xFF1D2133)
+internal val Slate20 = Color(0xFF272C42)
+internal val Slate25 = Color(0xFF2D3248)
+internal val Slate40 = Color(0xFF5D6178)
+internal val Slate70 = Color(0xFFA1A6BF)
+internal val Slate85 = Color(0xFFD9DCE8)
+internal val Slate90 = Color(0xFFE4E7F3)
+internal val Slate94 = Color(0xFFEEF0F8)
+internal val Slate95 = Color(0xFFECEEF7)
+internal val Slate98 = Color(0xFFF5F6FB)
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Dimens.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Dimens.kt
new file mode 100644
index 0000000..df4ebd1
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Dimens.kt
@@ -0,0 +1,27 @@
+package com.martindoudera.speedreader.core.designsystem.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** Fixed sizes of components and layout regions. */
+@Immutable
+data class Dimens(
+ val topBarHeight: Dp = 56.dp,
+ val logo: Dp = 28.dp,
+ val iconSmall: Dp = 16.dp,
+ val iconMedium: Dp = 20.dp,
+ val iconLarge: Dp = 72.dp,
+ val chipIconContainer: Dp = 40.dp,
+ val startButton: Dp = 184.dp,
+ val buttonHeight: Dp = 52.dp,
+ val metricCardMinHeight: Dp = 96.dp,
+ val progressBarWidth: Dp = 240.dp,
+ val progressBarHeight: Dp = 8.dp,
+ val illustration: Dp = 200.dp,
+ val contentMaxWidth: Dp = 480.dp,
+ val expandedSidePane: Dp = 380.dp,
+)
+
+val LocalDimens = staticCompositionLocalOf { Dimens() }
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Shape.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Shape.kt
new file mode 100644
index 0000000..1f0d57e
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Shape.kt
@@ -0,0 +1,12 @@
+package com.martindoudera.speedreader.core.designsystem.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.unit.dp
+
+internal val SpeedReaderShapes = Shapes(
+ small = RoundedCornerShape(8.dp),
+ medium = RoundedCornerShape(16.dp),
+ large = RoundedCornerShape(24.dp),
+ extraLarge = RoundedCornerShape(32.dp),
+)
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Spacing.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Spacing.kt
new file mode 100644
index 0000000..42ce860
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Spacing.kt
@@ -0,0 +1,21 @@
+package com.martindoudera.speedreader.core.designsystem.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** The spacing scale used for paddings and gaps; nothing in the UI should use a raw dp value for spacing. */
+@Immutable
+data class Spacing(
+ val xxs: Dp = 2.dp,
+ val xs: Dp = 4.dp,
+ val sm: Dp = 8.dp,
+ val md: Dp = 12.dp,
+ val lg: Dp = 16.dp,
+ val xl: Dp = 24.dp,
+ val xxl: Dp = 32.dp,
+ val xxxl: Dp = 48.dp,
+)
+
+val LocalSpacing = staticCompositionLocalOf { Spacing() }
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/SpeedColors.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/SpeedColors.kt
new file mode 100644
index 0000000..53728fe
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/SpeedColors.kt
@@ -0,0 +1,19 @@
+package com.martindoudera.speedreader.core.designsystem.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Colours Material 3 has no slot for: download and upload each keep one colour across the whole UI,
+ * and [brandGradient] is the same in both themes so white text on it stays readable.
+ */
+@Immutable
+data class SpeedColors(
+ val download: Color = Color.Unspecified,
+ val upload: Color = Color.Unspecified,
+ val highlight: Color = Color.Unspecified,
+ val brandGradient: List = emptyList(),
+)
+
+val LocalSpeedColors = staticCompositionLocalOf { SpeedColors() }
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Theme.kt
new file mode 100644
index 0000000..a3196b7
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Theme.kt
@@ -0,0 +1,114 @@
+package com.martindoudera.speedreader.core.designsystem.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalConfiguration
+import java.util.Locale
+
+private val LightColorScheme = lightColorScheme(
+ primary = Indigo50,
+ onPrimary = Color.White,
+ secondary = Teal50,
+ onSecondary = Color.White,
+ tertiary = Violet60,
+ onTertiary = Color.White,
+ error = Red50,
+ onError = Color.White,
+ errorContainer = Red95,
+ onErrorContainer = Red50,
+ background = Slate98,
+ onBackground = Slate10,
+ surface = Color.White,
+ onSurface = Slate10,
+ surfaceVariant = Slate94,
+ onSurfaceVariant = Slate40,
+ surfaceContainer = Slate94,
+ surfaceContainerHigh = Slate90,
+ outline = Slate40,
+ outlineVariant = Slate85,
+ inverseSurface = Slate10,
+ inverseOnSurface = Slate98,
+)
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Indigo80,
+ onPrimary = Slate5,
+ secondary = Teal80,
+ onSecondary = Slate5,
+ tertiary = Violet80,
+ onTertiary = Slate5,
+ error = Red70,
+ onError = Slate5,
+ errorContainer = Red20,
+ onErrorContainer = Red70,
+ background = Slate5,
+ onBackground = Slate95,
+ surface = Slate12,
+ onSurface = Slate95,
+ surfaceVariant = Slate15,
+ onSurfaceVariant = Slate70,
+ surfaceContainer = Slate15,
+ surfaceContainerHigh = Slate20,
+ outline = Slate70,
+ outlineVariant = Slate25,
+ inverseSurface = Slate95,
+ inverseOnSurface = Slate5,
+)
+
+private val BrandGradient = listOf(Teal50, Indigo50, Violet60)
+
+private val LightSpeedColors = SpeedColors(download = Teal50, upload = Violet60, highlight = Amber60, brandGradient = BrandGradient)
+
+private val DarkSpeedColors = SpeedColors(download = Teal80, upload = Violet80, highlight = Amber80, brandGradient = BrandGradient)
+
+private val DefaultSpacing = Spacing()
+
+private val DefaultDimens = Dimens()
+
+/**
+ * The app theme: Material 3 with the SpeedReader palette, plus [SpeedColors], [Spacing] and [Dimens].
+ *
+ * Dynamic colour is deliberately not supported, because download and upload are recognised by colour
+ * throughout the UI and a wallpaper-derived palette would erase that.
+ */
+@Composable
+fun SpeedReaderTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
+ CompositionLocalProvider(
+ LocalSpeedColors provides if (darkTheme) DarkSpeedColors else LightSpeedColors,
+ LocalSpacing provides DefaultSpacing,
+ LocalDimens provides DefaultDimens,
+ ) {
+ MaterialTheme(
+ colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
+ typography = SpeedReaderTypography,
+ shapes = SpeedReaderShapes,
+ content = content,
+ )
+ }
+}
+
+/** Access to the theme values Material 3 does not cover, alongside `MaterialTheme`. */
+object SpeedReaderTheme {
+ val speedColors: SpeedColors
+ @Composable @ReadOnlyComposable
+ get() = LocalSpeedColors.current
+
+ val spacing: Spacing
+ @Composable @ReadOnlyComposable
+ get() = LocalSpacing.current
+
+ val dimens: Dimens
+ @Composable @ReadOnlyComposable
+ get() = LocalDimens.current
+
+ /** The locale the UI is shown in; numbers and dates must be formatted in it, not in the JVM default. */
+ val locale: Locale
+ @Composable @ReadOnlyComposable
+ get() = LocalConfiguration.current.locales[0]
+}
diff --git a/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Type.kt b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Type.kt
new file mode 100644
index 0000000..05bf3fa
--- /dev/null
+++ b/core/designsystem/src/main/kotlin/com/martindoudera/speedreader/core/designsystem/theme/Type.kt
@@ -0,0 +1,46 @@
+package com.martindoudera.speedreader.core.designsystem.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+private const val TABULAR_FIGURES = "tnum"
+
+/**
+ * Material 3 type scale on the system font. The styles that show measured numbers use tabular figures,
+ * so a live value does not shift sideways as its digits change.
+ */
+internal val SpeedReaderTypography = Typography().run {
+ copy(
+ displayLarge = displayLarge.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 72.sp,
+ lineHeight = 72.sp,
+ letterSpacing = (-2).sp,
+ fontFeatureSettings = TABULAR_FIGURES,
+ ),
+ headlineMedium = headlineMedium.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 28.sp,
+ lineHeight = 34.sp,
+ fontFeatureSettings = TABULAR_FIGURES,
+ ),
+ headlineSmall = headlineSmall.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 24.sp,
+ lineHeight = 30.sp,
+ fontFeatureSettings = TABULAR_FIGURES,
+ ),
+ titleLarge = titleLarge.copy(fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp),
+ titleMedium = titleMedium.copy(fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 22.sp),
+ titleSmall = titleSmall.copy(fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp),
+ labelLarge = labelLarge.copy(fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp),
+ labelMedium = labelMedium.copy(
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ fontFeatureSettings = TABULAR_FIGURES,
+ ),
+ labelSmall = labelSmall.copy(fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp),
+ )
+}
diff --git a/core/designsystem/src/main/res/drawable/core_designsystem_illustration_disconnected.xml b/core/designsystem/src/main/res/drawable/core_designsystem_illustration_disconnected.xml
new file mode 100644
index 0000000..047a16a
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/core_designsystem_illustration_disconnected.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/core_designsystem_illustration_test_complete.xml b/core/designsystem/src/main/res/drawable/core_designsystem_illustration_test_complete.xml
new file mode 100644
index 0000000..bd47efb
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/core_designsystem_illustration_test_complete.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/core_designsystem_logo.xml b/core/designsystem/src/main/res/drawable/core_designsystem_logo.xml
new file mode 100644
index 0000000..ca6c3ef
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/core_designsystem_logo.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/test/kotlin/com/martindoudera/speedreader/core/designsystem/adaptive/WindowSizeClassTest.kt b/core/designsystem/src/test/kotlin/com/martindoudera/speedreader/core/designsystem/adaptive/WindowSizeClassTest.kt
new file mode 100644
index 0000000..4828339
--- /dev/null
+++ b/core/designsystem/src/test/kotlin/com/martindoudera/speedreader/core/designsystem/adaptive/WindowSizeClassTest.kt
@@ -0,0 +1,44 @@
+package com.martindoudera.speedreader.core.designsystem.adaptive
+
+import androidx.window.core.layout.WindowSizeClass
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+
+class WindowSizeClassTest {
+ @Test
+ fun phoneInPortraitUsesOneColumn() {
+ val window = WindowSizeClass.compute(412f, 915f)
+
+ val twoPanes = window.prefersTwoPanes()
+
+ assertFalse(twoPanes)
+ }
+
+ @Test
+ fun phoneInLandscapeUsesTwoPanes() {
+ val window = WindowSizeClass.compute(732f, 412f)
+
+ val twoPanes = window.prefersTwoPanes()
+
+ assertTrue(twoPanes)
+ }
+
+ @Test
+ fun unfoldedFoldableInPortraitUsesOneColumn() {
+ val window = WindowSizeClass.compute(673f, 841f)
+
+ val twoPanes = window.prefersTwoPanes()
+
+ assertFalse(twoPanes)
+ }
+
+ @Test
+ fun tabletInLandscapeUsesTwoPanes() {
+ val window = WindowSizeClass.compute(1280f, 800f)
+
+ val twoPanes = window.prefersTwoPanes()
+
+ assertTrue(twoPanes)
+ }
+}
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
new file mode 100644
index 0000000..a694d25
--- /dev/null
+++ b/core/network/build.gradle.kts
@@ -0,0 +1,18 @@
+plugins {
+ id("speedreader.android.library")
+ id("speedreader.hilt")
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ namespace = "com.martindoudera.speedreader.core.network"
+}
+
+dependencies {
+ api(libs.kotlinx.serialization.json)
+ api(libs.okhttp)
+ api(libs.retrofit)
+ api(libs.retrofit.converter.kotlinx.serialization)
+ implementation(project(":core:common"))
+ testImplementation(libs.kotlinx.coroutines.test)
+}
diff --git a/core/network/src/main/kotlin/com/martindoudera/speedreader/core/network/NetworkCall.kt b/core/network/src/main/kotlin/com/martindoudera/speedreader/core/network/NetworkCall.kt
new file mode 100644
index 0000000..3c2a50e
--- /dev/null
+++ b/core/network/src/main/kotlin/com/martindoudera/speedreader/core/network/NetworkCall.kt
@@ -0,0 +1,31 @@
+package com.martindoudera.speedreader.core.network
+
+import com.martindoudera.speedreader.core.common.error.HttpStatusCode
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.core.common.error.Outcome
+import java.io.IOException
+import java.io.InterruptedIOException
+import java.net.SocketTimeoutException
+import javax.net.ssl.SSLException
+import kotlinx.coroutines.CancellationException
+import kotlinx.serialization.SerializationException
+import retrofit2.HttpException
+
+suspend fun safeApiCall(block: suspend () -> T): NetworkOutcome = try {
+ Outcome.Success(block())
+} catch (e: CancellationException) {
+ throw e
+} catch (e: Exception) {
+ Outcome.Failure(e.toNetworkError())
+}
+
+private fun Throwable.toNetworkError(): NetworkError = when (this) {
+ is HttpException -> NetworkError.Http(HttpStatusCode(code()))
+ is SerializationException -> NetworkError.Malformed(this)
+ is SSLException -> NetworkError.Tls(this)
+ is SocketTimeoutException -> NetworkError.Timeout(this)
+ is InterruptedIOException -> NetworkError.Timeout(this)
+ is IOException -> NetworkError.NoConnection(this)
+ else -> NetworkError.Unknown(this)
+}
diff --git a/core/network/src/main/kotlin/com/martindoudera/speedreader/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/martindoudera/speedreader/core/network/di/NetworkModule.kt
new file mode 100644
index 0000000..42a3423
--- /dev/null
+++ b/core/network/src/main/kotlin/com/martindoudera/speedreader/core/network/di/NetworkModule.kt
@@ -0,0 +1,32 @@
+package com.martindoudera.speedreader.core.network.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+
+private const val TIMEOUT_SECONDS = 10L
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object NetworkModule {
+ @Provides
+ @Singleton
+ fun providesJson(): Json = Json {
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+ }
+
+ @Provides
+ @Singleton
+ fun providesOkHttpClient(): OkHttpClient = OkHttpClient
+ .Builder()
+ .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .build()
+}
diff --git a/core/network/src/test/kotlin/com/martindoudera/speedreader/core/network/NetworkCallTest.kt b/core/network/src/test/kotlin/com/martindoudera/speedreader/core/network/NetworkCallTest.kt
new file mode 100644
index 0000000..a003121
--- /dev/null
+++ b/core/network/src/test/kotlin/com/martindoudera/speedreader/core/network/NetworkCallTest.kt
@@ -0,0 +1,104 @@
+package com.martindoudera.speedreader.core.network
+
+import com.martindoudera.speedreader.core.common.error.HttpStatusCode
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.Outcome
+import java.io.EOFException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import javax.net.ssl.SSLHandshakeException
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.SerializationException
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Test
+import retrofit2.HttpException
+import retrofit2.Response
+
+class NetworkCallTest {
+ @Test
+ fun successfulCallIsWrapped() = runTest {
+ val outcome = safeApiCall { "payload" }
+
+ assertEquals(Outcome.Success("payload"), outcome)
+ }
+
+ @Test
+ fun unknownHostBecomesNoConnection() = runTest {
+ val failure = UnknownHostException("sp-dir.uwn.com")
+
+ val error = errorOf(failure)
+
+ assertIs(error)
+ }
+
+ @Test
+ fun socketTimeoutBecomesTimeout() = runTest {
+ val failure = SocketTimeoutException("read timed out")
+
+ val error = errorOf(failure)
+
+ assertIs(error)
+ }
+
+ @Test
+ fun handshakeFailureBecomesTls() = runTest {
+ val failure = SSLHandshakeException("trust anchor not found")
+
+ val error = errorOf(failure)
+
+ assertIs(error)
+ }
+
+ @Test
+ fun httpFailureKeepsTheStatusCode() = runTest {
+ val failure = HttpException(Response.error(401, """{"message":"No token"}""".toResponseBody()))
+
+ val error = errorOf(failure)
+
+ assertEquals(NetworkError.Http(HttpStatusCode(401)), error)
+ }
+
+ @Test
+ fun serializationFailureBecomesMalformed() = runTest {
+ val failure = SerializationException("unexpected token")
+
+ val error = errorOf(failure)
+
+ assertIs(error)
+ }
+
+ @Test
+ fun truncatedResponseIsTreatedAsConnectionLoss() = runTest {
+ val failure = EOFException("unexpected end of stream")
+
+ val error = errorOf(failure)
+
+ assertIs(error)
+ }
+
+ @Test
+ fun anythingElseBecomesUnknown() = runTest {
+ val failure = IllegalStateException("bug")
+
+ val error = errorOf(failure)
+
+ assertIs(error)
+ }
+
+ @Test
+ fun cancellationIsRethrownRatherThanReportedAsFailure() = runTest {
+ val cancellation = CancellationException("stopped by the user")
+
+ val result = runCatching { safeApiCall { throw cancellation } }
+
+ assertEquals(cancellation, result.exceptionOrNull())
+ }
+
+ private suspend fun errorOf(failure: Exception): NetworkError {
+ val outcome = safeApiCall { throw failure }
+ return assertIs>(outcome).error
+ }
+}
diff --git a/core/network/src/test/kotlin/com/martindoudera/speedreader/core/network/di/JsonConfigurationTest.kt b/core/network/src/test/kotlin/com/martindoudera/speedreader/core/network/di/JsonConfigurationTest.kt
new file mode 100644
index 0000000..542321a
--- /dev/null
+++ b/core/network/src/test/kotlin/com/martindoudera/speedreader/core/network/di/JsonConfigurationTest.kt
@@ -0,0 +1,98 @@
+package com.martindoudera.speedreader.core.network.di
+
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlinx.serialization.MissingFieldException
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import org.junit.Test
+
+enum class TestKind { Unknown, Residential, DataCentre }
+
+@Serializable
+data class ForwardCompatibleDto(
+ val url: String,
+ val kind: TestKind = TestKind.Unknown,
+ val label: String = "fallback",
+ val note: String? = null,
+)
+
+@Serializable
+data class StrictDto(val url: String, val kind: TestKind)
+
+class JsonConfigurationTest {
+ private val json = NetworkModule.providesJson()
+
+ @Test
+ fun unknownKeysAreIgnored() {
+ val withNewField = """{"url":"https://a","fieldAddedLater":42}"""
+
+ val decoded = json.decodeFromString(withNewField)
+
+ assertEquals("https://a", decoded.url)
+ }
+
+ @Test
+ fun unknownNestedObjectsAndArraysAreIgnored() {
+ val withNewBlock = """{"url":"https://a","meta":{"added":1,"nested":[1,2,{"deep":null}]},"tags":["x","y"]}"""
+
+ val decoded = json.decodeFromString(withNewBlock)
+
+ assertEquals("https://a", decoded.url)
+ }
+
+ @Test
+ fun oneMalformedEntryFailsTheWholeList() {
+ val list = """[{"url":"https://a"},{"url":42}]"""
+
+ val result = runCatching { json.decodeFromString>(list) }
+
+ assertIs(result.exceptionOrNull())
+ }
+
+ @Test
+ fun unknownEnumValueFallsBackToDeclaredDefault() {
+ val withNewEnumValue = """{"url":"https://a","kind":"Satellite"}"""
+
+ val decoded = json.decodeFromString(withNewEnumValue)
+
+ assertEquals(TestKind.Unknown, decoded.kind)
+ }
+
+ @Test
+ fun unknownEnumValueFailsWhenThePropertyHasNoDefault() {
+ val withNewEnumValue = """{"url":"https://a","kind":"Satellite"}"""
+
+ val result = runCatching { json.decodeFromString(withNewEnumValue) }
+
+ assertIs(result.exceptionOrNull())
+ }
+
+ @Test
+ fun explicitNullFallsBackToDefaultOnNonNullableProperty() {
+ val withNullLabel = """{"url":"https://a","label":null}"""
+
+ val decoded = json.decodeFromString(withNullLabel)
+
+ assertEquals("fallback", decoded.label)
+ }
+
+ @Test
+ fun missingNullablePropertyDecodesToNull() {
+ val withoutNote = """{"url":"https://a"}"""
+
+ val decoded = json.decodeFromString(withoutNote)
+
+ assertNull(decoded.note)
+ }
+
+ @Test
+ fun missingRequiredPropertyFails() {
+ val withoutUrl = """{"kind":"Residential"}"""
+
+ val result = runCatching { json.decodeFromString(withoutUrl) }
+
+ assertIs(result.exceptionOrNull())
+ }
+}
diff --git a/core/network/src/test/kotlin/com/martindoudera/speedreader/core/network/di/OkHttpClientConfigurationTest.kt b/core/network/src/test/kotlin/com/martindoudera/speedreader/core/network/di/OkHttpClientConfigurationTest.kt
new file mode 100644
index 0000000..099cf31
--- /dev/null
+++ b/core/network/src/test/kotlin/com/martindoudera/speedreader/core/network/di/OkHttpClientConfigurationTest.kt
@@ -0,0 +1,25 @@
+package com.martindoudera.speedreader.core.network.di
+
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
+import org.junit.Test
+
+class OkHttpClientConfigurationTest {
+ @Test
+ fun timeoutsAreTenSeconds() {
+ val expected = TimeUnit.SECONDS.toMillis(10).toInt()
+
+ val client = NetworkModule.providesOkHttpClient()
+
+ assertEquals(expected, client.connectTimeoutMillis)
+ assertEquals(expected, client.readTimeoutMillis)
+ assertEquals(expected, client.writeTimeoutMillis)
+ }
+
+ @Test
+ fun noCallTimeoutIsSetSoDerivedClientsCanRunLongTransfers() {
+ val client = NetworkModule.providesOkHttpClient()
+
+ assertEquals(0, client.callTimeoutMillis)
+ }
+}
diff --git a/docs/screenshots/download.png b/docs/screenshots/download.png
new file mode 100644
index 0000000..45dda0b
Binary files /dev/null and b/docs/screenshots/download.png differ
diff --git a/docs/screenshots/result.png b/docs/screenshots/result.png
new file mode 100644
index 0000000..efb73c8
Binary files /dev/null and b/docs/screenshots/result.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
new file mode 100644
index 0000000..811e9a3
Binary files /dev/null and b/docs/screenshots/start.png differ
diff --git a/docs/screenshots/tablet-result.png b/docs/screenshots/tablet-result.png
new file mode 100644
index 0000000..b9fdaf4
Binary files /dev/null and b/docs/screenshots/tablet-result.png differ
diff --git a/feature/speedtest/build.gradle.kts b/feature/speedtest/build.gradle.kts
new file mode 100644
index 0000000..3f38632
--- /dev/null
+++ b/feature/speedtest/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ id("speedreader.android.library")
+ id("speedreader.android.compose")
+ id("speedreader.android.screenshot")
+ id("speedreader.hilt")
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ namespace = "com.martindoudera.speedreader.feature.speedtest"
+ resourcePrefix = "feature_speedtest_"
+}
+
+dependencies {
+ implementation(project(":core:common"))
+ implementation(project(":core:designsystem"))
+ implementation(project(":core:network"))
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.timber)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.mockk)
+ testImplementation(libs.okhttp.mockwebserver3)
+ testImplementation(libs.turbine)
+}
diff --git a/feature/speedtest/src/main/AndroidManifest.xml b/feature/speedtest/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5e7684f
--- /dev/null
+++ b/feature/speedtest/src/main/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/di/DirectoryModule.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/di/DirectoryModule.kt
new file mode 100644
index 0000000..d012486
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/di/DirectoryModule.kt
@@ -0,0 +1,42 @@
+package com.martindoudera.speedreader.feature.speedtest.data.di
+
+import com.martindoudera.speedreader.feature.speedtest.data.directory.DirectoryTokenApi
+import com.martindoudera.speedreader.feature.speedtest.data.directory.SpeedTestDirectoryApi
+import com.martindoudera.speedreader.feature.speedtest.data.directory.TokenInterceptor
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+import kotlinx.serialization.json.Json
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.kotlinx.serialization.asConverterFactory
+import retrofit2.create
+
+internal val DIRECTORY_BASE_URL = "https://sp-dir.uwn.com/api/".toHttpUrl()
+
+/** The token API runs on the plain client, everything else on a client with [TokenInterceptor]. */
+@Module
+@InstallIn(SingletonComponent::class)
+internal object DirectoryModule {
+ @Provides
+ @Singleton
+ fun providesDirectoryTokenApi(okHttpClient: OkHttpClient, json: Json): DirectoryTokenApi =
+ retrofit(DIRECTORY_BASE_URL, okHttpClient, json).create()
+
+ @Provides
+ @Singleton
+ fun providesSpeedTestDirectoryApi(okHttpClient: OkHttpClient, tokenInterceptor: TokenInterceptor, json: Json): SpeedTestDirectoryApi =
+ retrofit(DIRECTORY_BASE_URL, okHttpClient.newBuilder().addInterceptor(tokenInterceptor).build(), json).create()
+
+ fun retrofit(baseUrl: HttpUrl, okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit
+ .Builder()
+ .baseUrl(baseUrl)
+ .client(okHttpClient)
+ .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
+ .build()
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/di/MeasurementModule.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/di/MeasurementModule.kt
new file mode 100644
index 0000000..080fe0a
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/di/MeasurementModule.kt
@@ -0,0 +1,29 @@
+package com.martindoudera.speedreader.feature.speedtest.data.di
+
+import com.martindoudera.speedreader.feature.speedtest.data.directory.TokenInterceptor
+import com.martindoudera.speedreader.feature.speedtest.data.throughput.MeasurementApi
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import retrofit2.create
+
+/**
+ * The measurement client pins HTTP/1.1, so N parallel requests are N TCP connections. The servers
+ * speak only HTTP/1.1 today; over HTTP/2 OkHttp would multiplex all of them onto one connection.
+ * Calls carry absolute URLs, so the base URL is a placeholder Retrofit requires.
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+internal object MeasurementModule {
+ @Provides
+ @Singleton
+ fun providesMeasurementApi(okHttpClient: OkHttpClient, tokenInterceptor: TokenInterceptor, json: Json): MeasurementApi {
+ val client = okHttpClient.newBuilder().protocols(listOf(Protocol.HTTP_1_1)).addInterceptor(tokenInterceptor).build()
+ return DirectoryModule.retrofit(DIRECTORY_BASE_URL, client, json).create()
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/di/SpeedTestDataModule.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/di/SpeedTestDataModule.kt
new file mode 100644
index 0000000..e24e63a
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/di/SpeedTestDataModule.kt
@@ -0,0 +1,57 @@
+package com.martindoudera.speedreader.feature.speedtest.data.di
+
+import com.martindoudera.speedreader.feature.speedtest.data.directory.DefaultTokenDataSource
+import com.martindoudera.speedreader.feature.speedtest.data.directory.TokenDataSource
+import com.martindoudera.speedreader.feature.speedtest.data.latency.DefaultLatencyRepository
+import com.martindoudera.speedreader.feature.speedtest.data.latency.DefaultPingDataSource
+import com.martindoudera.speedreader.feature.speedtest.data.latency.PingDataSource
+import com.martindoudera.speedreader.feature.speedtest.data.location.DefaultLocationRepository
+import com.martindoudera.speedreader.feature.speedtest.data.server.DefaultServerRepository
+import com.martindoudera.speedreader.feature.speedtest.data.throughput.DefaultDownloadDataSource
+import com.martindoudera.speedreader.feature.speedtest.data.throughput.DefaultThroughputRepository
+import com.martindoudera.speedreader.feature.speedtest.data.throughput.DefaultUploadDataSource
+import com.martindoudera.speedreader.feature.speedtest.data.throughput.DownloadDataSource
+import com.martindoudera.speedreader.feature.speedtest.data.throughput.UploadDataSource
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.LatencyRepository
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.LocationRepository
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.ServerRepository
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.ThroughputRepository
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.Dns
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal interface SpeedTestDataModule {
+ @Binds
+ fun bindsServerRepository(repository: DefaultServerRepository): ServerRepository
+
+ @Binds
+ fun bindsLocationRepository(repository: DefaultLocationRepository): LocationRepository
+
+ @Binds
+ fun bindsLatencyRepository(repository: DefaultLatencyRepository): LatencyRepository
+
+ @Binds
+ fun bindsThroughputRepository(repository: DefaultThroughputRepository): ThroughputRepository
+
+ @Binds
+ fun bindsTokenDataSource(dataSource: DefaultTokenDataSource): TokenDataSource
+
+ @Binds
+ fun bindsPingDataSource(dataSource: DefaultPingDataSource): PingDataSource
+
+ @Binds
+ fun bindsDownloadDataSource(dataSource: DefaultDownloadDataSource): DownloadDataSource
+
+ @Binds
+ fun bindsUploadDataSource(dataSource: DefaultUploadDataSource): UploadDataSource
+
+ companion object {
+ @Provides
+ fun providesDns(): Dns = Dns.SYSTEM
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DefaultTokenDataSource.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DefaultTokenDataSource.kt
new file mode 100644
index 0000000..9e8d9ea
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DefaultTokenDataSource.kt
@@ -0,0 +1,56 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeMark
+import kotlin.time.TimeSource
+import kotlinx.serialization.SerializationException
+import retrofit2.HttpException
+import timber.log.Timber
+
+/**
+ * Renew a token this long before its TTL runs out. Servers check the token only when a request
+ * starts, so the margin only has to cover the time until the next request starts, not a whole
+ * transfer.
+ */
+internal val TOKEN_RENEWAL_MARGIN = 15.seconds
+
+/**
+ * Caches the token and renews it [TOKEN_RENEWAL_MARGIN] before its TTL runs out, or halfway through
+ * a TTL too short for that margin. Concurrent callers share one request.
+ */
+@Singleton
+internal class DefaultTokenDataSource @Inject constructor(private val api: DirectoryTokenApi, private val timeSource: TimeSource) :
+ TokenDataSource {
+ private var cached: CachedToken? = null
+
+ @Synchronized
+ override fun token(): DirectoryToken {
+ val current = cached?.takeUnless { it.renewAt.hasPassedNow() }
+ if (current != null) Timber.v("Reusing the directory token")
+ return (current ?: issue().also { cached = it }).token
+ }
+
+ @Synchronized
+ override fun invalidate(token: DirectoryToken) {
+ if (cached?.token == token) cached = null
+ }
+
+ private fun issue(): CachedToken {
+ Timber.d("Requesting a directory token")
+ val response = try {
+ api.createToken().execute()
+ } catch (e: SerializationException) {
+ throw IOException("Malformed directory token", e)
+ }
+ if (!response.isSuccessful) throw HttpException(response)
+ val body = response.body() ?: throw IOException("Empty directory token response")
+ Timber.d("Directory token issued, valid for %d s", body.ttl)
+ val ttl = body.ttl.seconds
+ return CachedToken(DirectoryToken(body.token), timeSource.markNow() + ttl - minOf(TOKEN_RENEWAL_MARGIN, ttl / 2))
+ }
+
+ private class CachedToken(val token: DirectoryToken, val renewAt: TimeMark)
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DirectoryToken.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DirectoryToken.kt
new file mode 100644
index 0000000..a6afff4
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DirectoryToken.kt
@@ -0,0 +1,5 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+/** Access token issued by the directory, required by the server list and the measurement servers. */
+@JvmInline
+internal value class DirectoryToken(val value: String)
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DirectoryTokenApi.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DirectoryTokenApi.kt
new file mode 100644
index 0000000..30870e2
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DirectoryTokenApi.kt
@@ -0,0 +1,17 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+import com.martindoudera.speedreader.feature.speedtest.data.directory.dto.TokenDto
+import retrofit2.Call
+import retrofit2.http.POST
+
+/**
+ * Issues directory tokens: an empty `POST v1/tokens` answers `{"token": "", "ttl": 80}`.
+ *
+ * Verified with curl: `ttl` is in seconds, since a token still worked at 78 s and was refused at
+ * 82 s. Only the measurement servers check the token, and only when a request starts.
+ * Blocking, because it is called from inside an OkHttp interceptor.
+ */
+internal interface DirectoryTokenApi {
+ @POST("v1/tokens")
+ fun createToken(): Call
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/RequiresToken.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/RequiresToken.kt
new file mode 100644
index 0000000..4041d47
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/RequiresToken.kt
@@ -0,0 +1,15 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+/** Marks a Retrofit method whose request [TokenInterceptor] signs with the directory token. */
+@Target(AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+internal annotation class RequiresToken(val placement: TokenPlacement)
+
+/** The directory reads the token from a header, the measurement servers from a query parameter. */
+internal enum class TokenPlacement {
+ Header,
+ Query,
+}
+
+internal const val TOKEN_HEADER = "x-test-token"
+internal const val TOKEN_QUERY_PARAMETER = "token"
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/SpeedTestDirectoryApi.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/SpeedTestDirectoryApi.kt
new file mode 100644
index 0000000..7384fa2
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/SpeedTestDirectoryApi.kt
@@ -0,0 +1,30 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+import com.martindoudera.speedreader.feature.speedtest.data.directory.dto.IpLocationDto
+import kotlinx.serialization.json.JsonArray
+import retrofit2.http.GET
+
+/**
+ * The Ubiquiti speed test directory (`sp-dir.uwn.com/api`).
+ *
+ * The API is undocumented. Everything below comes from the wifiman.com web client and was checked
+ * with curl in October 2026:
+ * - The server list is already filtered by the client's IP, so it holds about ten nearby servers,
+ * not a world list.
+ * - `secured=only` limits it to HTTPS servers. Their ports vary (80, 81, 88, 800, …), never 443.
+ * - The directory no longer enforces the token on the server list: it answers 200 without the
+ * header. The web client still sends it, and so do we. An unknown query parameter is rejected
+ * with 400.
+ * - `v1/ip` needs no token. Its coordinates are an IP geolocation, so city-level at best.
+ *
+ * The server list comes back as a raw [JsonArray] so that one malformed entry can be dropped
+ * without failing the whole response.
+ */
+internal interface SpeedTestDirectoryApi {
+ @RequiresToken(TokenPlacement.Header)
+ @GET("v2/servers?secured=only")
+ suspend fun servers(): JsonArray
+
+ @GET("v1/ip")
+ suspend fun clientLocation(): IpLocationDto
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/TokenDataSource.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/TokenDataSource.kt
new file mode 100644
index 0000000..33da7e0
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/TokenDataSource.kt
@@ -0,0 +1,14 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+/**
+ * Holds the directory token shared by all requests.
+ *
+ * Blocking, because [TokenInterceptor] calls it on OkHttp threads. [token] throws `HttpException`
+ * when the directory refuses to issue a token, and `IOException` when it cannot be reached or its
+ * answer is malformed.
+ */
+internal interface TokenDataSource {
+ fun token(): DirectoryToken
+
+ fun invalidate(token: DirectoryToken)
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/TokenInterceptor.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/TokenInterceptor.kt
new file mode 100644
index 0000000..03baed2
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/TokenInterceptor.kt
@@ -0,0 +1,72 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+import java.io.IOException
+import java.net.HttpURLConnection.HTTP_UNAUTHORIZED
+import javax.inject.Inject
+import okhttp3.Interceptor
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import retrofit2.HttpException
+import retrofit2.Invocation
+import timber.log.Timber
+
+/**
+ * Signs requests of methods annotated with [RequiresToken] and leaves all others untouched.
+ *
+ * Measurement servers answer an expired, unknown or missing token with `401 Unauthorized`, so a
+ * request rejected with 401 is retried once with a fresh token. When no token can be issued,
+ * the directory's HTTP status is returned as the response, so the caller sees it as an HTTP error.
+ * Any other token failure leaves as an `IOException`, the only exception OkHttp lets an
+ * interceptor throw without crashing an asynchronous call.
+ */
+internal class TokenInterceptor @Inject constructor(private val tokenDataSource: TokenDataSource) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val placement = request.tag(Invocation::class.java)?.method()?.getAnnotation(RequiresToken::class.java)?.placement
+ ?: return chain.proceed(request)
+
+ return try {
+ chain.proceedSigned(request, placement)
+ } catch (e: HttpException) {
+ Timber.w(e, "Directory refused to issue a token")
+ tokenUnavailable(request, e.code())
+ }
+ }
+
+ private fun Interceptor.Chain.proceedSigned(request: Request, placement: TokenPlacement): Response {
+ val token = issueToken()
+ Timber.v("Signing %s with the directory token", request.url.encodedPath)
+ val response = proceed(request.signedWith(token, placement))
+ if (response.code != HTTP_UNAUTHORIZED) return response
+
+ Timber.w("%s rejected the token, renewing it and retrying once", request.url.encodedPath)
+ response.close()
+ tokenDataSource.invalidate(token)
+ return proceed(request.signedWith(issueToken(), placement))
+ }
+
+ /** Wraps only the token's own failures, so an exception from the request itself is not blamed on the token. */
+ private fun issueToken(): DirectoryToken = try {
+ tokenDataSource.token()
+ } catch (e: HttpException) {
+ throw e
+ } catch (e: RuntimeException) {
+ throw IOException("Directory token unavailable", e)
+ }
+
+ private fun Request.signedWith(token: DirectoryToken, placement: TokenPlacement): Request = when (placement) {
+ TokenPlacement.Header -> newBuilder().header(TOKEN_HEADER, token.value).build()
+ TokenPlacement.Query -> newBuilder().url(url.newBuilder().setQueryParameter(TOKEN_QUERY_PARAMETER, token.value).build()).build()
+ }
+
+ private fun tokenUnavailable(request: Request, status: Int): Response = Response
+ .Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(status)
+ .message("Directory token unavailable")
+ .body("".toResponseBody())
+ .build()
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/dto/IpLocationDto.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/dto/IpLocationDto.kt
new file mode 100644
index 0000000..0e7c4f7
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/dto/IpLocationDto.kt
@@ -0,0 +1,12 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory.dto
+
+import kotlinx.serialization.Serializable
+
+/**
+ * The directory's geolocation of the client's public IP, city-level at best.
+ *
+ * Only the coordinates are used, plus the country code for logs. The response also carries the
+ * IP, ISP and timezone, which are skipped: none of them may be logged.
+ */
+@Serializable
+internal data class IpLocationDto(val lat: Double, val lon: Double, val countryCode: String? = null)
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/dto/ServerDto.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/dto/ServerDto.kt
new file mode 100644
index 0000000..365c5f3
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/dto/ServerDto.kt
@@ -0,0 +1,21 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory.dto
+
+import kotlinx.serialization.Serializable
+
+/**
+ * One entry of the directory's server list.
+ *
+ * `url`, `latitude` and `longitude` were present in every entry seen. `provider`, `city` and
+ * `countryCode` can be missing (one server had no city), and the city labels sometimes
+ * contradict the coordinates. Fields we do not use (`providerUrl`, `country`, `speedMbps`,
+ * which is the server's link capacity rather than a measurement) are skipped.
+ */
+@Serializable
+internal data class ServerDto(
+ val url: String,
+ val latitude: Double,
+ val longitude: Double,
+ val provider: String? = null,
+ val city: String? = null,
+ val countryCode: String? = null,
+)
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/dto/TokenDto.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/dto/TokenDto.kt
new file mode 100644
index 0000000..863c634
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/dto/TokenDto.kt
@@ -0,0 +1,7 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory.dto
+
+import kotlinx.serialization.Serializable
+
+/** [ttl] is the token's lifetime in seconds; every response so far said 80. */
+@Serializable
+internal data class TokenDto(val token: String, val ttl: Long)
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/DefaultLatencyRepository.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/DefaultLatencyRepository.kt
new file mode 100644
index 0000000..c712565
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/DefaultLatencyRepository.kt
@@ -0,0 +1,87 @@
+package com.martindoudera.speedreader.feature.speedtest.data.latency
+
+import com.martindoudera.speedreader.core.common.di.Dispatcher
+import com.martindoudera.speedreader.core.common.di.SpeedReaderDispatcher
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestError
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestOutcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.LatencyRepository
+import java.io.IOException
+import java.net.Inet6Address
+import java.net.InetAddress
+import java.net.UnknownHostException
+import javax.inject.Inject
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.withContext
+import okhttp3.Dns
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import timber.log.Timber
+
+internal const val PING_PROBES = 3
+internal const val LOST_PROBES_IN_A_ROW = 2
+internal val PROBE_TIMEOUT = 500.milliseconds
+
+/**
+ * Pings a server up to [PING_PROBES] times, one probe after another, and reports the minimum
+ * round trip: queueing only ever adds delay, so the minimum is the best estimate of the path itself.
+ * A single lost probe is ordinary packet loss; [LOST_PROBES_IN_A_ROW] lost probes end the probing.
+ *
+ * The hostname is resolved first, so DNS time never counts as latency. The first address of each
+ * family is tried in the resolver's order, so a server whose IPv6 path is broken is still reached
+ * over IPv4, as OkHttp would reach it. A server whose name does not resolve or that answers no
+ * probe on any address is [SpeedTestError.ServerUnreachable].
+ */
+internal class DefaultLatencyRepository @Inject constructor(
+ private val pingDataSource: PingDataSource,
+ private val dns: Dns,
+ @param:Dispatcher(SpeedReaderDispatcher.IO) private val ioDispatcher: CoroutineDispatcher,
+) : LatencyRepository {
+ override suspend fun measureLatency(server: SpeedTestServer): SpeedTestOutcome = withContext(ioDispatcher) {
+ for (address in resolve(server)) {
+ val roundTrips = try {
+ probe(address)
+ } catch (e: IOException) {
+ Timber.e(e, "Pinging %s failed", address.hostAddress)
+ return@withContext Outcome.Failure(SpeedTestError.Unknown(e))
+ }
+
+ val latency = roundTrips.minOrNull()
+ Timber.d("%s answered %d of up to %d probes, latency %s", address.hostAddress, roundTrips.size, PING_PROBES, latency)
+ if (latency != null) return@withContext Outcome.Success(latency)
+ }
+ Timber.d("%s answered no probe", server.url.value)
+ Outcome.Failure(SpeedTestError.ServerUnreachable)
+ }
+
+ private suspend fun probe(address: InetAddress): List {
+ val roundTrips = mutableListOf()
+ var lostInARow = 0
+ repeat(PING_PROBES) {
+ currentCoroutineContext().ensureActive()
+ val roundTrip = pingDataSource.echo(address, PROBE_TIMEOUT)
+ Timber.v("Echo from %s: %s", address.hostAddress, roundTrip)
+ if (roundTrip != null) {
+ roundTrips += roundTrip
+ lostInARow = 0
+ } else if (++lostInARow == LOST_PROBES_IN_A_ROW) {
+ return roundTrips
+ }
+ }
+ return roundTrips
+ }
+
+ private fun resolve(server: SpeedTestServer): List {
+ val host = server.url.value.toHttpUrlOrNull()?.host ?: return emptyList()
+ return try {
+ dns.lookup(host).distinctBy { it is Inet6Address }
+ } catch (e: UnknownHostException) {
+ Timber.w(e, "Cannot resolve %s", host)
+ emptyList()
+ }
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/DefaultPingDataSource.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/DefaultPingDataSource.kt
new file mode 100644
index 0000000..a5266ad
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/DefaultPingDataSource.kt
@@ -0,0 +1,33 @@
+package com.martindoudera.speedreader.feature.speedtest.data.latency
+
+import java.net.InetAddress
+import javax.inject.Inject
+import kotlin.time.Duration
+import kotlin.time.TimeSource
+import timber.log.Timber
+
+/**
+ * Pings with [InetAddress.isReachable], timed around the call.
+ *
+ * On Android, unlike the JVM, `isReachable` sends a real ICMP echo request over an unprivileged
+ * ping socket (`SOCK_DGRAM` + `IPPROTO_ICMP`), with no root and no permission beyond `INTERNET`.
+ * This has been so since Android 8.0 (API 26, AOSP libcore `Inet6AddressImpl`, b/31926888).
+ * With a timeout of at most one second it sends exactly one request.
+ *
+ * Only when ICMP gets no answer does it fall back to a TCP connection to port 7, which would
+ * measure something else. A positive answer that took longer than [timeout] can only come from
+ * that fallback, so it is discarded as a lost probe. The measured time also includes opening the
+ * socket, a fraction of a millisecond.
+ */
+internal class DefaultPingDataSource @Inject constructor(private val timeSource: TimeSource) : PingDataSource {
+ override fun echo(address: InetAddress, timeout: Duration): Duration? {
+ val sentAt = timeSource.markNow()
+ val reachable = address.isReachable(timeout.inWholeMilliseconds.toInt())
+ val roundTrip = sentAt.elapsedNow()
+ if (reachable && roundTrip > timeout) {
+ Timber.v("Discarding a TCP fallback answer from %s after %s", address.hostAddress, roundTrip)
+ return null
+ }
+ return roundTrip.takeIf { reachable }
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/PingDataSource.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/PingDataSource.kt
new file mode 100644
index 0000000..37cb567
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/PingDataSource.kt
@@ -0,0 +1,13 @@
+package com.martindoudera.speedreader.feature.speedtest.data.latency
+
+import java.net.InetAddress
+import kotlin.time.Duration
+
+/** Sends one ICMP echo request and waits for its reply. Blocking. */
+internal interface PingDataSource {
+ /**
+ * Returns the round-trip time, or null when no reply arrived within [timeout]. Throws
+ * `IOException` when the address cannot be pinged at all.
+ */
+ fun echo(address: InetAddress, timeout: Duration): Duration?
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/location/DefaultLocationRepository.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/location/DefaultLocationRepository.kt
new file mode 100644
index 0000000..9d72762
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/location/DefaultLocationRepository.kt
@@ -0,0 +1,41 @@
+package com.martindoudera.speedreader.feature.speedtest.data.location
+
+import android.location.Location
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.core.common.error.onFailure
+import com.martindoudera.speedreader.core.network.safeApiCall
+import com.martindoudera.speedreader.feature.speedtest.data.directory.SpeedTestDirectoryApi
+import com.martindoudera.speedreader.feature.speedtest.data.directory.dto.IpLocationDto
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Distance
+import com.martindoudera.speedreader.feature.speedtest.domain.model.GeoPoint
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Latitude
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Longitude
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.LocationRepository
+import javax.inject.Inject
+import kotlinx.serialization.SerializationException
+import timber.log.Timber
+
+/**
+ * Locates the phone by the directory's geolocation of its public IP; no device location and no
+ * permission. Distances come from [Location.distanceBetween], on the WGS 84 ellipsoid.
+ */
+internal class DefaultLocationRepository @Inject constructor(private val api: SpeedTestDirectoryApi) : LocationRepository {
+ override suspend fun currentLocation(): NetworkOutcome {
+ Timber.d("Requesting the IP location")
+ return safeApiCall { api.clientLocation().toGeoPoint() }
+ .onFailure { error -> Timber.w("IP location unavailable: %s", error) }
+ }
+
+ override fun distance(from: GeoPoint, to: GeoPoint): Distance {
+ val results = FloatArray(1)
+ Location.distanceBetween(from.latitude.degrees, from.longitude.degrees, to.latitude.degrees, to.longitude.degrees, results)
+ return Distance(results[0].toDouble())
+ }
+
+ private fun IpLocationDto.toGeoPoint(): GeoPoint = try {
+ if (lat == 0.0 && lon == 0.0) throw SerializationException("IP not located, (0, 0) is a placeholder")
+ GeoPoint(Latitude(lat), Longitude(lon)).also { Timber.d("Located by IP in %s", countryCode) }
+ } catch (e: IllegalArgumentException) {
+ throw SerializationException("IP location out of range", e)
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/server/DefaultServerRepository.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/server/DefaultServerRepository.kt
new file mode 100644
index 0000000..79b4af2
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/server/DefaultServerRepository.kt
@@ -0,0 +1,58 @@
+package com.martindoudera.speedreader.feature.speedtest.data.server
+
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.core.common.error.map
+import com.martindoudera.speedreader.core.common.error.onFailure
+import com.martindoudera.speedreader.core.network.safeApiCall
+import com.martindoudera.speedreader.feature.speedtest.data.directory.SpeedTestDirectoryApi
+import com.martindoudera.speedreader.feature.speedtest.data.directory.dto.ServerDto
+import com.martindoudera.speedreader.feature.speedtest.domain.model.GeoPoint
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Latitude
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Longitude
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.ServerRepository
+import javax.inject.Inject
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import timber.log.Timber
+
+/**
+ * Loads the HTTPS servers the directory offers for this client.
+ *
+ * Entries are decoded one by one, so a malformed entry or one with impossible coordinates is
+ * dropped instead of failing the whole list.
+ */
+internal class DefaultServerRepository @Inject constructor(private val api: SpeedTestDirectoryApi, private val json: Json) :
+ ServerRepository {
+ override suspend fun servers(): NetworkOutcome> {
+ Timber.d("Loading the server list")
+ return safeApiCall { api.servers() }
+ .map { entries -> entries.decodeServers() }
+ .onFailure { error -> Timber.e("Loading the server list failed: %s", error) }
+ }
+
+ private fun JsonArray.decodeServers(): List {
+ val servers = mapNotNull { entry -> entry.toServerOrNull() }
+ Timber.i("Directory offered %d servers, %d unusable", servers.size, size - servers.size)
+ return servers
+ }
+
+ private fun JsonElement.toServerOrNull(): SpeedTestServer? = try {
+ json.decodeFromJsonElement(ServerDto.serializer(), this).toSpeedTestServer().also { server ->
+ Timber.v("Server %s by %s in %s", server.url.value, server.provider, server.countryCode)
+ }
+ } catch (e: IllegalArgumentException) {
+ Timber.w(e, "Dropped an unusable server entry")
+ null
+ }
+
+ private fun ServerDto.toSpeedTestServer() = SpeedTestServer(
+ url = ServerUrl(url),
+ location = GeoPoint(Latitude(latitude), Longitude(longitude)),
+ provider = provider,
+ city = city,
+ countryCode = countryCode,
+ )
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/CallExecution.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/CallExecution.kt
new file mode 100644
index 0000000..668ae26
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/CallExecution.kt
@@ -0,0 +1,45 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import java.io.IOException
+import kotlin.random.Random
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import retrofit2.Call
+import retrofit2.Response
+
+/**
+ * Executes this call on the current thread and cancels it as soon as the calling coroutine is
+ * cancelled. Cancelling the call aborts a read or write that is blocked on the socket, which
+ * coroutine cancellation alone cannot do. The `IOException` that abort causes is reported as the
+ * cancellation it is, not as a failure.
+ *
+ * The canceller starts undispatched, so it is waiting before the call blocks: a coroutine cancelled
+ * while still queued never runs, and its `finally` would never cancel the call. A caller that is
+ * already cancelled thus cancels the call before it is sent.
+ */
+internal suspend fun Call.executeCancellably(block: (Response) -> R): R = coroutineScope {
+ val call = this@executeCancellably
+ val canceller = launch(start = CoroutineStart.UNDISPATCHED) {
+ try {
+ awaitCancellation()
+ } finally {
+ call.cancel()
+ }
+ }
+ try {
+ block(call.execute())
+ } catch (e: IOException) {
+ ensureActive()
+ throw e
+ } finally {
+ canceller.cancel()
+ }
+}
+
+private const val CACHE_BUSTER_CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+/** Seven random characters, as the web client sends in `nc`. */
+internal fun cacheBuster(): String = String(CharArray(7) { CACHE_BUSTER_CHARACTERS[Random.nextInt(CACHE_BUSTER_CHARACTERS.length)] })
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultDownloadDataSource.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultDownloadDataSource.kt
new file mode 100644
index 0000000..ff142db
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultDownloadDataSource.kt
@@ -0,0 +1,40 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+import java.io.IOException
+import javax.inject.Inject
+import okio.Buffer
+import retrofit2.HttpException
+
+/**
+ * Size asked for per download request. Far more than one connection moves in a 7.5 s phase, so a
+ * single request normally lasts the whole phase; only the bytes actually read are transferred.
+ */
+internal const val DOWNLOAD_REQUEST_SIZE = 1_000_000_000L
+
+private const val READ_SIZE = 64L * 1024
+
+/** Reads the payload as it arrives and discards it; nothing is buffered beyond one read. */
+internal class DefaultDownloadDataSource @Inject constructor(private val api: MeasurementApi) : DownloadDataSource {
+ override suspend fun download(server: ServerUrl, onBytes: (Long) -> Unit) {
+ api.download(server.endpoint("download"), DOWNLOAD_REQUEST_SIZE, cacheBuster()).executeCancellably { response ->
+ if (!response.isSuccessful) {
+ response.errorBody()?.close()
+ throw HttpException(response)
+ }
+ val body = response.body() ?: throw IOException("Empty download response")
+ body.use {
+ val source = it.source()
+ val sink = Buffer()
+ while (true) {
+ val read = source.read(sink, READ_SIZE)
+ if (read == -1L) break
+ sink.clear()
+ onBytes(read)
+ }
+ }
+ }
+ }
+}
+
+internal fun ServerUrl.endpoint(path: String): String = "${value.trimEnd('/')}/$path"
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultThroughputRepository.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultThroughputRepository.kt
new file mode 100644
index 0000000..ec994e4
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultThroughputRepository.kt
@@ -0,0 +1,89 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import com.martindoudera.speedreader.core.common.di.Dispatcher
+import com.martindoudera.speedreader.core.common.di.SpeedReaderDispatcher
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.core.common.error.onFailure
+import com.martindoudera.speedreader.core.network.safeApiCall
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ByteCount
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.model.TransferSample
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.ThroughputRepository
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicLong
+import java.util.concurrent.atomic.AtomicReference
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.TimeSource
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+/** How often the transferred total is reported; 7.5 s is a whole number of these. */
+internal val TRANSFER_SAMPLE_INTERVAL = 250.milliseconds
+
+/**
+ * Runs `connections` parallel streams against one server, all adding into one byte counter, and
+ * reports the counter every [TRANSFER_SAMPLE_INTERVAL] until the collector stops.
+ *
+ * A stream whose request ends is restarted. A stream that fails stops, and the others carry on, so
+ * one dropped connection costs only its share of the speed. Only when every stream has failed is
+ * the last failure emitted as the final element. Elapsed time runs
+ * from the moment the streams start, so connection setup counts, as it does for the user.
+ */
+internal class DefaultThroughputRepository @Inject constructor(
+ private val downloadDataSource: DownloadDataSource,
+ private val uploadDataSource: UploadDataSource,
+ private val timeSource: TimeSource,
+ @param:Dispatcher(SpeedReaderDispatcher.IO) private val ioDispatcher: CoroutineDispatcher,
+) : ThroughputRepository {
+ override fun download(server: SpeedTestServer, connections: Int): Flow> =
+ transfer("Download", connections) { onBytes -> downloadDataSource.download(server.url, onBytes) }
+
+ override fun upload(server: SpeedTestServer, connections: Int): Flow> =
+ transfer("Upload", connections) { onBytes -> uploadDataSource.upload(server.url, onBytes) }
+
+ private fun transfer(
+ direction: String,
+ connections: Int,
+ request: suspend (onBytes: (Long) -> Unit) -> Unit,
+ ): Flow> = channelFlow {
+ val transferred = AtomicLong()
+ val failed = AtomicInteger()
+ val failure = AtomicReference()
+ val startedAt = timeSource.markNow()
+ Timber.d("%s started on %d connections", direction, connections)
+
+ repeat(connections) { stream ->
+ launch {
+ safeApiCall {
+ while (true) {
+ request { bytes -> transferred.addAndGet(bytes) }
+ Timber.v("%s stream %d: request ended, starting another", direction, stream)
+ }
+ }.onFailure { error ->
+ Timber.w("%s stream %d failed: %s", direction, stream, error)
+ if (failed.incrementAndGet() == connections) {
+ Timber.e("%s failed on every stream", direction)
+ failure.set(error)
+ }
+ }
+ }
+ }
+ try {
+ while (failure.get() == null) {
+ delay(TRANSFER_SAMPLE_INTERVAL)
+ send(Outcome.Success(TransferSample(startedAt.elapsedNow(), ByteCount(transferred.get()))))
+ }
+ send(Outcome.Failure(checkNotNull(failure.get())))
+ } finally {
+ Timber.d("%s ended after %s, %d bytes", direction, startedAt.elapsedNow(), transferred.get())
+ }
+ }.flowOn(ioDispatcher)
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultUploadDataSource.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultUploadDataSource.kt
new file mode 100644
index 0000000..ca81bea
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultUploadDataSource.kt
@@ -0,0 +1,79 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+import javax.inject.Inject
+import kotlin.random.Random
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.TimeSource
+import kotlinx.serialization.SerializationException
+import okhttp3.MediaType
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody
+import okio.BufferedSink
+import retrofit2.HttpException
+import timber.log.Timber
+
+internal const val UPLOAD_FIRST_REQUEST_SIZE = 10_000L
+internal const val UPLOAD_MAX_REQUEST_SIZE = 10_000_000L
+
+/** How long one upload request should take at the speed the previous one reached. */
+internal val UPLOAD_REQUEST_DURATION = 500.milliseconds
+
+/**
+ * Sends the payload as a series of fixed-size requests and counts only the bytes the server says
+ * it received. Bytes the socket accepts are no measure: Android lets each connection queue up to
+ * 4 MB in the kernel, which on a slow uplink is seconds of data that has not left the phone.
+ *
+ * Each request is sized to last [UPLOAD_REQUEST_DURATION] at the previous one's speed, as the
+ * wifiman web client does, so a slow link confirms its first bytes quickly and a fast one is not
+ * slowed down by request overhead. The request still in flight when the phase ends is not counted.
+ */
+internal class DefaultUploadDataSource @Inject constructor(private val api: MeasurementApi, private val timeSource: TimeSource) :
+ UploadDataSource {
+ override suspend fun upload(server: ServerUrl, onBytes: (Long) -> Unit) {
+ var size = UPLOAD_FIRST_REQUEST_SIZE
+ while (true) {
+ val startedAt = timeSource.markNow()
+ val received = send(server, size)
+ val elapsed = startedAt.elapsedNow()
+ Timber.v("Upload of %d B: server received %d B in %s", size, received, elapsed)
+ onBytes(received)
+ size = nextUploadSize(received, elapsed)
+ }
+ }
+
+ private suspend fun send(server: ServerUrl, size: Long): Long =
+ api.upload(server.endpoint("upload"), cacheBuster(), UploadBody(size)).executeCancellably { response ->
+ if (!response.isSuccessful) {
+ response.errorBody()?.close()
+ throw HttpException(response)
+ }
+ val answer = response.body()?.use { it.string() }
+ answer?.trim()?.toLongOrNull() ?: throw SerializationException("Unexpected upload answer: $answer")
+ }
+}
+
+internal fun nextUploadSize(received: Long, elapsed: Duration): Long =
+ (received * (UPLOAD_REQUEST_DURATION / elapsed)).toLong().coerceIn(UPLOAD_FIRST_REQUEST_SIZE, UPLOAD_MAX_REQUEST_SIZE)
+
+/** [size] random bytes, so that nothing on the path can compress them. */
+internal class UploadBody(private val size: Long) : RequestBody() {
+ override fun contentType(): MediaType = OCTET_STREAM
+
+ override fun contentLength(): Long = size
+
+ override fun writeTo(sink: BufferedSink) {
+ var remaining = size
+ while (remaining > 0) {
+ val chunk = minOf(remaining, PAYLOAD.size.toLong()).toInt()
+ sink.write(PAYLOAD, 0, chunk)
+ remaining -= chunk
+ }
+ }
+
+ private companion object {
+ val OCTET_STREAM = "application/octet-stream".toMediaType()
+ val PAYLOAD: ByteArray = Random.nextBytes(64 * 1024)
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DownloadDataSource.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DownloadDataSource.kt
new file mode 100644
index 0000000..8b2aa7a
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DownloadDataSource.kt
@@ -0,0 +1,12 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+
+/** Streams one download request from a measurement server. Blocks the calling thread while reading. */
+internal interface DownloadDataSource {
+ /**
+ * Reports every chunk of bytes as it arrives and returns when the server ends the response.
+ * Throws when the request fails, and stops promptly when the caller is cancelled.
+ */
+ suspend fun download(server: ServerUrl, onBytes: (Long) -> Unit)
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/MeasurementApi.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/MeasurementApi.kt
new file mode 100644
index 0000000..3cd7275
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/MeasurementApi.kt
@@ -0,0 +1,40 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import com.martindoudera.speedreader.feature.speedtest.data.directory.RequiresToken
+import com.martindoudera.speedreader.feature.speedtest.data.directory.TokenPlacement
+import okhttp3.RequestBody
+import okhttp3.ResponseBody
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.Headers
+import retrofit2.http.POST
+import retrofit2.http.Query
+import retrofit2.http.Streaming
+import retrofit2.http.Url
+
+/**
+ * The measurement servers' payload endpoints, on the host the directory listed.
+ *
+ * Verified with curl in October 2026, against what the wifiman.com web client sends:
+ * - `GET /download?size=` streams exactly `size` bytes (at least 200 000, up to at least
+ * 10 GB) as `application/octet-stream`. The server ends a request after roughly 27 to 40 s with
+ * status 200 and a body shorter than its `Content-Length`.
+ * - `POST /upload` accepts any body, chunked too, and answers with the number of bytes it received
+ * (`text/plain` when asked for it). It ends a request after about 20 s with `503 Timeouted`.
+ * - `nc` is a cache buster. The token goes into the query, as the web client sends it.
+ * - The servers speak HTTP/1.1 only.
+ *
+ * Blocking calls, because the payload is read and written on the calling thread.
+ */
+internal interface MeasurementApi {
+ @RequiresToken(TokenPlacement.Query)
+ @Streaming
+ @GET
+ fun download(@Url url: String, @Query("size") size: Long, @Query("nc") cacheBuster: String): Call
+
+ @RequiresToken(TokenPlacement.Query)
+ @Headers("Accept: text/plain")
+ @POST
+ fun upload(@Url url: String, @Query("nc") cacheBuster: String, @Body body: RequestBody): Call
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/UploadDataSource.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/UploadDataSource.kt
new file mode 100644
index 0000000..8b302ac
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/UploadDataSource.kt
@@ -0,0 +1,12 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+
+/** Uploads to a measurement server. Blocks the calling thread while a request is in flight. */
+internal interface UploadDataSource {
+ /**
+ * Sends requests one after another until the caller is cancelled, reporting after each request
+ * the bytes the server confirmed it received. Throws when a request fails.
+ */
+ suspend fun upload(server: ServerUrl, onBytes: (Long) -> Unit)
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/SpeedTestError.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/SpeedTestError.kt
new file mode 100644
index 0000000..8ae6ca6
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/SpeedTestError.kt
@@ -0,0 +1,23 @@
+package com.martindoudera.speedreader.feature.speedtest.domain
+
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.Outcome
+
+/**
+ * Everything that can go wrong in a speed test run, as the domain sees it.
+ *
+ * [ServerUnreachable] is recoverable: the use case skips that server, so it never ends a run on its own.
+ */
+sealed interface SpeedTestError {
+ data class Network(val error: NetworkError) : SpeedTestError
+
+ data object NoServersAvailable : SpeedTestError
+
+ data object ServerUnreachable : SpeedTestError
+
+ data object MeasurementFailed : SpeedTestError
+
+ data class Unknown(val cause: Throwable?) : SpeedTestError
+}
+
+typealias SpeedTestOutcome = Outcome
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/ThroughputMeter.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/ThroughputMeter.kt
new file mode 100644
index 0000000..528cdc0
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/ThroughputMeter.kt
@@ -0,0 +1,31 @@
+package com.martindoudera.speedreader.feature.speedtest.domain
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ByteCount
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Throughput
+import com.martindoudera.speedreader.feature.speedtest.domain.model.TransferSample
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Turns cumulative transfer samples of one test phase into speeds.
+ *
+ * The current speed covers roughly the last [window], so the live readout follows real changes
+ * without jumping on every sample. The average is total bytes over total elapsed time, untrimmed.
+ */
+internal class ThroughputMeter(private val window: Duration = 1.seconds) {
+ private val samples = ArrayDeque(listOf(TransferSample(Duration.ZERO, ByteCount.Zero)))
+
+ val transferred: ByteCount
+ get() = samples.last().transferred
+
+ fun record(sample: TransferSample): Throughput {
+ samples.addLast(sample)
+ while (samples.size > 2 && sample.elapsed - samples[1].elapsed >= window) {
+ samples.removeFirst()
+ }
+ val oldest = samples.first()
+ return Throughput.of(sample.transferred - oldest.transferred, sample.elapsed - oldest.elapsed)
+ }
+
+ fun average(): Throughput = samples.last().let { Throughput.of(it.transferred, it.elapsed) }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/ByteCount.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/ByteCount.kt
new file mode 100644
index 0000000..4068cf1
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/ByteCount.kt
@@ -0,0 +1,15 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+/** An amount of data in bytes. */
+@JvmInline
+value class ByteCount(val bytes: Long) : Comparable {
+ operator fun plus(other: ByteCount): ByteCount = ByteCount(bytes + other.bytes)
+
+ operator fun minus(other: ByteCount): ByteCount = ByteCount(bytes - other.bytes)
+
+ override fun compareTo(other: ByteCount): Int = bytes.compareTo(other.bytes)
+
+ companion object {
+ val Zero = ByteCount(0)
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/ConnectionMode.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/ConnectionMode.kt
new file mode 100644
index 0000000..23e3abc
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/ConnectionMode.kt
@@ -0,0 +1,11 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+/**
+ * How many parallel connections the download and upload tests open against the selected server.
+ *
+ * [Parallel] matches the six connections the wifiman.com web client opens to a single server.
+ */
+enum class ConnectionMode(val connections: Int) {
+ Single(1),
+ Parallel(6),
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/Distance.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/Distance.kt
new file mode 100644
index 0000000..133bbe6
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/Distance.kt
@@ -0,0 +1,7 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+/** A distance in metres. */
+@JvmInline
+value class Distance(val meters: Double) : Comparable {
+ override fun compareTo(other: Distance): Int = meters.compareTo(other.meters)
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/GeoPoint.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/GeoPoint.kt
new file mode 100644
index 0000000..2830a3b
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/GeoPoint.kt
@@ -0,0 +1,20 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+/** A point on Earth (WGS 84). */
+data class GeoPoint(val latitude: Latitude, val longitude: Longitude)
+
+/** Latitude in decimal degrees, from -90 (south pole) to 90 (north pole). */
+@JvmInline
+value class Latitude(val degrees: Double) {
+ init {
+ require(degrees in -90.0..90.0) { "Latitude out of range: $degrees" }
+ }
+}
+
+/** Longitude in decimal degrees, from -180 to 180. */
+@JvmInline
+value class Longitude(val degrees: Double) {
+ init {
+ require(degrees in -180.0..180.0) { "Longitude out of range: $degrees" }
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/Progress.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/Progress.kt
new file mode 100644
index 0000000..b985c40
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/Progress.kt
@@ -0,0 +1,9 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+/** How much of something is done, from 0 (nothing) to 1 (all of it). */
+@JvmInline
+value class Progress(val fraction: Float) {
+ init {
+ require(fraction in 0f..1f) { "Progress out of range: $fraction" }
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/SelectedServer.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/SelectedServer.kt
new file mode 100644
index 0000000..5658ab6
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/SelectedServer.kt
@@ -0,0 +1,9 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+import kotlin.time.Duration
+
+/**
+ * The server the transfer tests run against, with its ICMP round-trip time. The latency is null on
+ * a network that lets no ICMP echo through, where the closest server is used instead.
+ */
+data class SelectedServer(val server: SpeedTestServer, val latency: Duration?)
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/ServerUrl.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/ServerUrl.kt
new file mode 100644
index 0000000..dba47c4
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/ServerUrl.kt
@@ -0,0 +1,7 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+/** Base URL of a measurement server, as the directory reports it. Also the server's identity. */
+@JvmInline
+value class ServerUrl(val value: String) : Comparable {
+ override fun compareTo(other: ServerUrl): Int = value.compareTo(other.value)
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/SpeedTestProgress.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/SpeedTestProgress.kt
new file mode 100644
index 0000000..2e0ec60
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/SpeedTestProgress.kt
@@ -0,0 +1,38 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestError
+import kotlin.time.Duration
+
+/**
+ * The states a speed test run passes through, in order. [Finished] and [Failed] are terminal;
+ * stopping the run is cancellation, not a state of its own.
+ *
+ * While measuring, `progress` is the elapsed share of the whole test (download and upload together)
+ * and `remaining` is the time left until it finishes; `currentSpeed` covers roughly the last second and
+ * `averageSpeed` the phase so far. Finding the server comes before
+ * that clock starts and has no progress of its own, since its length cannot be known in advance.
+ */
+sealed interface SpeedTestProgress {
+ data object FindingServer : SpeedTestProgress
+
+ data class Downloading(
+ val server: SelectedServer,
+ val progress: Progress,
+ val remaining: Duration,
+ val currentSpeed: Throughput,
+ val averageSpeed: Throughput,
+ ) : SpeedTestProgress
+
+ data class Uploading(
+ val server: SelectedServer,
+ val downloadSpeed: Throughput,
+ val progress: Progress,
+ val remaining: Duration,
+ val currentSpeed: Throughput,
+ val averageSpeed: Throughput,
+ ) : SpeedTestProgress
+
+ data class Finished(val server: SelectedServer, val downloadSpeed: Throughput, val uploadSpeed: Throughput) : SpeedTestProgress
+
+ data class Failed(val error: SpeedTestError) : SpeedTestProgress
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/SpeedTestServer.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/SpeedTestServer.kt
new file mode 100644
index 0000000..aec0c53
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/SpeedTestServer.kt
@@ -0,0 +1,9 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+/**
+ * A measurement server from the Ubiquiti directory.
+ *
+ * [location] is where the server is hosted. The [city] and [countryCode] labels come from the
+ * directory as well and sometimes contradict it, so ranking uses [location] only.
+ */
+data class SpeedTestServer(val url: ServerUrl, val location: GeoPoint, val provider: String?, val city: String?, val countryCode: String?)
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/Throughput.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/Throughput.kt
new file mode 100644
index 0000000..eb04fe6
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/Throughput.kt
@@ -0,0 +1,26 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+import kotlin.time.Duration
+import kotlin.time.DurationUnit
+
+/**
+ * A transfer rate in bits per second.
+ *
+ * [megabitsPerSecond] uses the decimal mega (10^6), as every speed test does. Turning it into text
+ * is the UI's job.
+ */
+@JvmInline
+value class Throughput(val bitsPerSecond: Double) {
+ val megabitsPerSecond: Double
+ get() = bitsPerSecond / 1_000_000
+
+ companion object {
+ val Zero = Throughput(0.0)
+
+ fun of(transferred: ByteCount, duration: Duration): Throughput = if (duration.isPositive()) {
+ Throughput(transferred.bytes * 8 / duration.toDouble(DurationUnit.SECONDS))
+ } else {
+ Zero
+ }
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/TransferSample.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/TransferSample.kt
new file mode 100644
index 0000000..5cc360d
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/model/TransferSample.kt
@@ -0,0 +1,6 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.model
+
+import kotlin.time.Duration
+
+/** Data transferred so far, across all connections, at [elapsed] since the transfer started. */
+data class TransferSample(val elapsed: Duration, val transferred: ByteCount)
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/LatencyRepository.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/LatencyRepository.kt
new file mode 100644
index 0000000..d4ff55c
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/LatencyRepository.kt
@@ -0,0 +1,10 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.repository
+
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestOutcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import kotlin.time.Duration
+
+/** Measures the round-trip time to a server with real ICMP echo requests. */
+interface LatencyRepository {
+ suspend fun measureLatency(server: SpeedTestServer): SpeedTestOutcome
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/LocationRepository.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/LocationRepository.kt
new file mode 100644
index 0000000..845f870
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/LocationRepository.kt
@@ -0,0 +1,15 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.repository
+
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Distance
+import com.martindoudera.speedreader.feature.speedtest.domain.model.GeoPoint
+
+/**
+ * Locates the phone by the geolocation of its public IP address, as reported by the Ubiquiti
+ * directory, and measures distances between positions.
+ */
+interface LocationRepository {
+ suspend fun currentLocation(): NetworkOutcome
+
+ fun distance(from: GeoPoint, to: GeoPoint): Distance
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/ServerRepository.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/ServerRepository.kt
new file mode 100644
index 0000000..2f30277
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/ServerRepository.kt
@@ -0,0 +1,13 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.repository
+
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+
+/**
+ * Provides the measurement servers the Ubiquiti directory offers for this client.
+ *
+ * An empty list is a success; whether that ends the run is the use case's decision.
+ */
+interface ServerRepository {
+ suspend fun servers(): NetworkOutcome>
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/ThroughputRepository.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/ThroughputRepository.kt
new file mode 100644
index 0000000..a3cb2c7
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/repository/ThroughputRepository.kt
@@ -0,0 +1,19 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.repository
+
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.model.TransferSample
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Transfers data to and from a server over `connections` parallel streams and reports progress.
+ *
+ * Both flows emit cumulative samples at a steady cadence, also when no bytes arrived, until the
+ * collector cancels them; a failure is always the last element. Turning samples into speed is the
+ * domain's job, not the repository's.
+ */
+interface ThroughputRepository {
+ fun download(server: SpeedTestServer, connections: Int): Flow>
+
+ fun upload(server: SpeedTestServer, connections: Int): Flow>
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/RunSpeedTestUseCase.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/RunSpeedTestUseCase.kt
new file mode 100644
index 0000000..3c51bc0
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/RunSpeedTestUseCase.kt
@@ -0,0 +1,146 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.usecase
+
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.core.common.error.onFailure
+import com.martindoudera.speedreader.core.common.error.valueOrNull
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestError
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestOutcome
+import com.martindoudera.speedreader.feature.speedtest.domain.ThroughputMeter
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ByteCount
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ConnectionMode
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Progress
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SelectedServer
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestProgress
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Throughput
+import com.martindoudera.speedreader.feature.speedtest.domain.model.TransferSample
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.LatencyRepository
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.LocationRepository
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.ServerRepository
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.ThroughputRepository
+import javax.inject.Inject
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.transformWhile
+import timber.log.Timber
+
+val TEST_DURATION = 15.seconds
+val PHASE_DURATION = TEST_DURATION / 2
+
+/**
+ * Runs a whole speed test: finds the best server, then measures download and upload for
+ * [PHASE_DURATION] each, so both together take [TEST_DURATION]. Emits [SpeedTestProgress] along the way.
+ *
+ * The flow never throws; it ends with [SpeedTestProgress.Finished] or [SpeedTestProgress.Failed].
+ * An unexpected exception from a repository ends it as [SpeedTestError.Unknown]. Cancelling the
+ * collection stops the run.
+ */
+class RunSpeedTestUseCase @Inject constructor(
+ private val serverRepository: ServerRepository,
+ private val locationRepository: LocationRepository,
+ private val latencyRepository: LatencyRepository,
+ private val throughputRepository: ThroughputRepository,
+ private val selectClosestServers: SelectClosestServersUseCase,
+) {
+ operator fun invoke(mode: ConnectionMode): Flow = flow {
+ Timber.d("Speed test started in %s mode, %d connections", mode, mode.connections)
+ emit(SpeedTestProgress.FindingServer)
+ val server = valueOrFail(findServer()) ?: return@flow
+
+ val downloadSamples = throughputRepository.download(server.server, mode.connections)
+ val downloadOutcome = measure(downloadSamples, startsAt = Duration.ZERO) { progress, remaining, current, average ->
+ SpeedTestProgress.Downloading(server, progress, remaining, current, average)
+ }
+ val download = valueOrFail(downloadOutcome) ?: return@flow
+ Timber.d("Download average %.1f Mbit/s", download.megabitsPerSecond)
+
+ val uploadSamples = throughputRepository.upload(server.server, mode.connections)
+ val uploadOutcome = measure(uploadSamples, startsAt = PHASE_DURATION) { progress, remaining, current, average ->
+ SpeedTestProgress.Uploading(server, download, progress, remaining, current, average)
+ }
+ val upload = valueOrFail(uploadOutcome) ?: return@flow
+
+ Timber.i("Speed test finished: download %.1f Mbit/s, upload %.1f Mbit/s", download.megabitsPerSecond, upload.megabitsPerSecond)
+ emit(SpeedTestProgress.Finished(server, download, upload))
+ }.catch { e ->
+ Timber.e(e, "Speed test crashed")
+ emit(SpeedTestProgress.Failed(SpeedTestError.Unknown(e)))
+ }.onCompletion { cause ->
+ if (cause is CancellationException) Timber.d("Speed test stopped before it finished")
+ }
+
+ private suspend fun findServer(): SpeedTestOutcome = coroutineScope {
+ val location = async { locationRepository.currentLocation().valueOrNull() }
+ val servers = when (val fetched = serverRepository.servers()) {
+ is Outcome.Failure -> {
+ location.cancel()
+ return@coroutineScope Outcome.Failure(SpeedTestError.Network(fetched.error))
+ }
+ is Outcome.Success -> fetched.value
+ }
+ if (servers.isEmpty()) {
+ location.cancel()
+ return@coroutineScope Outcome.Failure(SpeedTestError.NoServersAvailable)
+ }
+
+ val candidates = selectClosestServers(servers, location.await())
+ val selected = candidates
+ .map { candidate -> async { candidate to latencyRepository.measureLatency(candidate).valueOrNull() } }
+ .awaitAll()
+ .mapNotNull { (candidate, latency) -> latency?.let { candidate to it } }
+ .minByOrNull { (_, latency) -> latency }
+ ?.let { (server, latency) -> SelectedServer(server, latency) }
+ ?: SelectedServer(candidates.first(), latency = null).also {
+ Timber.w("No server answered ICMP, falling back to the closest one")
+ }
+ Timber.d("Selected %s (%s), latency %s", selected.server.url.value, selected.server.provider, selected.latency)
+ Outcome.Success(selected)
+ }
+
+ private suspend fun FlowCollector.measure(
+ samples: Flow>,
+ startsAt: Duration,
+ progressOf: (progress: Progress, remaining: Duration, currentSpeed: Throughput, averageSpeed: Throughput) -> SpeedTestProgress,
+ ): SpeedTestOutcome {
+ val meter = ThroughputMeter()
+ var failure: SpeedTestError? = null
+ samples
+ .transformWhile { outcome ->
+ when (outcome) {
+ is Outcome.Failure -> {
+ failure = SpeedTestError.Network(outcome.error)
+ false
+ }
+ is Outcome.Success -> {
+ emit(outcome.value)
+ outcome.value.elapsed < PHASE_DURATION
+ }
+ }
+ }.collect { sample ->
+ val currentSpeed = meter.record(sample)
+ Timber.v("%s: %d B, current %.1f Mbit/s", sample.elapsed, sample.transferred.bytes, currentSpeed.megabitsPerSecond)
+ val testElapsed = startsAt + sample.elapsed.coerceAtMost(PHASE_DURATION)
+ val progress = Progress((testElapsed / TEST_DURATION).toFloat())
+ emit(progressOf(progress, TEST_DURATION - testElapsed, currentSpeed, meter.average()))
+ }
+
+ failure?.let { return Outcome.Failure(it) }
+ if (meter.transferred == ByteCount.Zero) return Outcome.Failure(SpeedTestError.MeasurementFailed)
+ return Outcome.Success(meter.average())
+ }
+
+ private suspend fun FlowCollector.valueOrFail(outcome: SpeedTestOutcome): T? = outcome
+ .onFailure { error ->
+ Timber.e("Speed test failed: %s", error)
+ emit(SpeedTestProgress.Failed(error))
+ }.valueOrNull()
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/SelectClosestServersUseCase.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/SelectClosestServersUseCase.kt
new file mode 100644
index 0000000..f48760a
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/SelectClosestServersUseCase.kt
@@ -0,0 +1,36 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.usecase
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.GeoPoint
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.LocationRepository
+import javax.inject.Inject
+import timber.log.Timber
+
+const val CLOSEST_SERVER_COUNT = 5
+
+/**
+ * Picks the `count` servers closest to `from`, or the first `count` in directory order when the
+ * location is unknown.
+ *
+ * Several servers share exact coordinates, so equal distances are ordered by URL to keep the
+ * selection stable between runs.
+ */
+class SelectClosestServersUseCase @Inject constructor(private val locationRepository: LocationRepository) {
+ operator fun invoke(servers: List, from: GeoPoint?, count: Int = CLOSEST_SERVER_COUNT): List {
+ if (from == null) {
+ Timber.w("Location unknown, using the first %d servers in directory order", count)
+ return servers.take(count)
+ }
+ val closest = servers
+ .map { server -> server to locationRepository.distance(from, server.location) }
+ .sortedWith(compareBy({ (_, distance) -> distance }, { (server, _) -> server.url }))
+ .take(count)
+ Timber.d(
+ "Closest servers: %s",
+ closest.joinToString { (server, distance) ->
+ "${server.provider} ${distance.meters.toInt() / 1000} km"
+ },
+ )
+ return closest.map { (server, _) -> server }
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentPreviews.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentPreviews.kt
new file mode 100644
index 0000000..7373f05
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentPreviews.kt
@@ -0,0 +1,59 @@
+package com.martindoudera.speedreader.feature.speedtest.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import com.martindoudera.speedreader.core.common.domain.model.AppVersion
+import com.martindoudera.speedreader.core.designsystem.component.ThemePreviews
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Progress
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Throughput
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+private val PreviewServer = ServerUiModel(name = "CETIN a.s.", location = "Prague, CZ", latency = 9.milliseconds)
+
+internal class SpeedTestPhaseProvider : PreviewParameterProvider {
+ override val values = sequenceOf(
+ SpeedTestPhase.Idle,
+ SpeedTestPhase.FindingServer,
+ SpeedTestPhase.Downloading(PreviewServer, Progress(0.3f), 10.5.seconds, Throughput(218_000_000.0), Throughput(212_400_000.0)),
+ SpeedTestPhase.Uploading(
+ server = PreviewServer,
+ downloadSpeed = Throughput(223_100_000.0),
+ progress = Progress(0.78f),
+ remaining = 3.3.seconds,
+ currentSpeed = Throughput(51_200_000.0),
+ averageSpeed = Throughput(39_400_000.0),
+ ),
+ SpeedTestPhase.Finished(PreviewServer, Throughput(223_100_000.0), Throughput(41_500_000.0)),
+ SpeedTestPhase.Failed(FailureReason.Offline),
+ )
+}
+
+@ThemePreviews
+@Composable
+private fun SpeedTestContentPreview(@PreviewParameter(SpeedTestPhaseProvider::class) phase: SpeedTestPhase) {
+ ContentPreview(phase)
+}
+
+@Preview(name = "Tablet", device = "spec:width=1280dp,height=800dp,dpi=240")
+@Composable
+private fun SpeedTestContentTabletPreview(@PreviewParameter(SpeedTestPhaseProvider::class) phase: SpeedTestPhase) {
+ ContentPreview(phase)
+}
+
+@Composable
+internal fun ContentPreview(phase: SpeedTestPhase) {
+ SpeedReaderTheme {
+ SpeedTestContent(
+ uiState = SpeedTestUiState(appVersion = AppVersion("0.0.1"), phase = phase),
+ onStartClick = {},
+ onStopClick = {},
+ onBackClick = {},
+ onShareClick = {},
+ onSingleConnectionChange = {},
+ )
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestFormat.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestFormat.kt
new file mode 100644
index 0000000..1beb8e0
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestFormat.kt
@@ -0,0 +1,25 @@
+package com.martindoudera.speedreader.feature.speedtest.ui
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Throughput
+import java.util.Locale
+import kotlin.math.ceil
+import kotlin.math.roundToLong
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.DurationUnit
+
+internal const val NO_VALUE = "—"
+
+/**
+ * Megabits per second, with one decimal below 100 and whole numbers from there on, e.g. "41.5" and
+ * "236". The threshold applies after rounding, so 99.96 reads "100", not "100.0".
+ */
+internal fun Throughput.formatMegabits(locale: Locale): String {
+ val tenths = (megabitsPerSecond * 10).roundToLong()
+ return if (tenths >= 1_000) megabitsPerSecond.roundToLong().toString() else String.format(locale, "%.1f", tenths / 10.0)
+}
+
+/** Whole milliseconds; a round trip under one millisecond reads "<1" rather than "0". */
+internal fun Duration.formatMilliseconds(): String = if (this < 1.milliseconds) "<1" else inWholeMilliseconds.toString()
+
+internal fun Duration.wholeSecondsLeft(): Int = ceil(toDouble(DurationUnit.SECONDS)).toInt()
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestScreen.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestScreen.kt
new file mode 100644
index 0000000..e1f9dfa
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestScreen.kt
@@ -0,0 +1,260 @@
+package com.martindoudera.speedreader.feature.speedtest.ui
+
+import android.content.Context
+import android.content.Intent
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.togetherWith
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Dns
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.keepScreenOn
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.martindoudera.speedreader.core.common.domain.model.AppVersion
+import com.martindoudera.speedreader.core.designsystem.R as DesignSystemR
+import com.martindoudera.speedreader.core.designsystem.adaptive.prefersTwoPanes
+import com.martindoudera.speedreader.core.designsystem.component.SrInfoChip
+import com.martindoudera.speedreader.core.designsystem.component.SrMessage
+import com.martindoudera.speedreader.core.designsystem.component.SrPulsingCircleButton
+import com.martindoudera.speedreader.core.designsystem.component.SrTopBar
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+import com.martindoudera.speedreader.feature.speedtest.R
+import com.martindoudera.speedreader.feature.speedtest.ui.component.MeasuringStage
+import com.martindoudera.speedreader.feature.speedtest.ui.component.MetricsRow
+import com.martindoudera.speedreader.feature.speedtest.ui.component.SpeedTestFooter
+import timber.log.Timber
+
+@Composable
+fun SpeedTestScreen(modifier: Modifier = Modifier, viewModel: SpeedTestViewModel = hiltViewModel()) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val phase = uiState.phase
+
+ BackHandler(enabled = phase is SpeedTestPhase.Finished || phase is SpeedTestPhase.Failed, onBack = viewModel::onBackClick)
+ val running = phase is SpeedTestPhase.FindingServer || phase is SpeedTestPhase.Downloading || phase is SpeedTestPhase.Uploading
+
+ SpeedTestContent(
+ uiState = uiState,
+ onStartClick = viewModel::onStartClick,
+ onStopClick = viewModel::onStopClick,
+ onBackClick = viewModel::onBackClick,
+ onShareClick = context::shareText,
+ onSingleConnectionChange = viewModel::onSingleConnectionChange,
+ modifier = if (running) modifier.keepScreenOn() else modifier,
+ )
+}
+
+/**
+ * The whole screen as a function of [SpeedTestUiState]. Every phase uses the same slots — server,
+ * stage, metrics, actions — and only their content changes, so the layout never jumps between phases.
+ * Wide windows, and short ones such as a phone in landscape, put the stage beside the rest.
+ */
+@Composable
+internal fun SpeedTestContent(
+ uiState: SpeedTestUiState,
+ onStartClick: () -> Unit,
+ onStopClick: () -> Unit,
+ onBackClick: () -> Unit,
+ onShareClick: (String) -> Unit,
+ onSingleConnectionChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val spacing = SpeedReaderTheme.spacing
+ val twoPane = currentWindowAdaptiveInfoV2().windowSizeClass.prefersTwoPanes()
+ val phase = uiState.phase
+ val shareText = (phase as? SpeedTestPhase.Finished)?.let { shareText(it) }
+
+ val chip = @Composable { chipModifier: Modifier -> ServerChip(uiState, chipModifier) }
+ val stage = @Composable { stageModifier: Modifier -> Stage(phase, onStartClick, stageModifier) }
+ val metrics = @Composable { metricsModifier: Modifier -> VisibleWhileTesting(phase, metricsModifier) { MetricsRow(phase, it) } }
+ val footer = @Composable { footerModifier: Modifier ->
+ SpeedTestFooter(
+ phase = phase,
+ connectionMode = uiState.connectionMode,
+ onStopClick = onStopClick,
+ onBackClick = onBackClick,
+ onShareClick = { shareText?.let(onShareClick) },
+ onSingleConnectionChange = onSingleConnectionChange,
+ modifier = footerModifier,
+ )
+ }
+
+ Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .safeDrawingPadding()
+ .padding(horizontal = if (twoPane) spacing.xxl else spacing.lg)
+ .padding(bottom = spacing.xl),
+ ) {
+ SrTopBar(title = stringResource(R.string.feature_speedtest_title)) {
+ AppInfoLabel(uiState.appVersion, visible = phase is SpeedTestPhase.Idle)
+ }
+ if (twoPane) {
+ Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(spacing.xxl)) {
+ stage(Modifier.weight(1f).fillMaxHeight())
+ Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween) {
+ chip(Modifier)
+ metrics(Modifier)
+ footer(Modifier)
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .widthIn(max = SpeedReaderTheme.dimens.contentMaxWidth)
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(spacing.sm),
+ ) {
+ chip(Modifier.padding(top = spacing.xs))
+ stage(Modifier.weight(1f).fillMaxWidth())
+ metrics(Modifier)
+ footer(Modifier)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Stage(phase: SpeedTestPhase, onStartClick: () -> Unit, modifier: Modifier = Modifier) {
+ AnimatedContent(
+ targetState = phase,
+ contentKey = { it.stageKey() },
+ transitionSpec = { (fadeIn(tween(400)) + scaleIn(initialScale = 0.94f)) togetherWith fadeOut(tween(200)) using null },
+ contentAlignment = Alignment.Center,
+ modifier = modifier,
+ label = "stage",
+ ) { target ->
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ when (target) {
+ SpeedTestPhase.Idle -> SrPulsingCircleButton(
+ text = stringResource(R.string.feature_speedtest_start),
+ onClick = onStartClick,
+ )
+ SpeedTestPhase.FindingServer, is SpeedTestPhase.Downloading, is SpeedTestPhase.Uploading -> MeasuringStage(target)
+ is SpeedTestPhase.Finished -> SrMessage(
+ illustration = painterResource(DesignSystemR.drawable.core_designsystem_illustration_test_complete),
+ title = stringResource(R.string.feature_speedtest_test_complete),
+ )
+ is SpeedTestPhase.Failed -> SrMessage(
+ illustration = painterResource(DesignSystemR.drawable.core_designsystem_illustration_disconnected),
+ title = stringResource(target.reason.titleRes()),
+ text = stringResource(target.reason.textRes()),
+ )
+ }
+ }
+ }
+}
+
+private enum class StageKey { Start, Measuring, Result, Error }
+
+private fun SpeedTestPhase.stageKey(): StageKey = when (this) {
+ SpeedTestPhase.Idle -> StageKey.Start
+ SpeedTestPhase.FindingServer, is SpeedTestPhase.Downloading, is SpeedTestPhase.Uploading -> StageKey.Measuring
+ is SpeedTestPhase.Finished -> StageKey.Result
+ is SpeedTestPhase.Failed -> StageKey.Error
+}
+
+@Composable
+private fun ServerChip(uiState: SpeedTestUiState, modifier: Modifier = Modifier) {
+ val server = uiState.phase.server
+ val connections = uiState.connectionMode.connections
+ VisibleWhileTesting(uiState.phase, modifier) { visibleModifier ->
+ SrInfoChip(
+ icon = Icons.Rounded.Dns,
+ title = server?.name.orEmpty(),
+ subtitle = server?.location.orEmpty(),
+ tag = pluralStringResource(R.plurals.feature_speedtest_connections, connections, connections),
+ loading = uiState.phase is SpeedTestPhase.FindingServer,
+ modifier = visibleModifier,
+ )
+ }
+}
+
+/** The app author and version in the top bar's corner, shown on the start screen only. */
+@Composable
+private fun AppInfoLabel(version: AppVersion, visible: Boolean, modifier: Modifier = Modifier) {
+ val alpha by animateFloatAsState(if (visible) 1f else 0f, label = "appInfoLabel")
+ Column(horizontalAlignment = Alignment.End, modifier = modifier.graphicsLayer { this.alpha = alpha }) {
+ Text(
+ text = stringResource(R.string.feature_speedtest_author),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = stringResource(R.string.feature_speedtest_version, version.name),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+/** Fades content out before a run and after an error while keeping its space, so the stage does not move. */
+@Composable
+private fun VisibleWhileTesting(phase: SpeedTestPhase, modifier: Modifier, content: @Composable (Modifier) -> Unit) {
+ val visible = phase !is SpeedTestPhase.Idle && phase !is SpeedTestPhase.Failed
+ val alpha by animateFloatAsState(if (visible) 1f else 0f, label = "visibleWhileTesting")
+ content(modifier.graphicsLayer { this.alpha = alpha })
+}
+
+@Composable
+private fun shareText(result: SpeedTestPhase.Finished): String {
+ val server = result.server
+ return stringResource(
+ R.string.feature_speedtest_share_text,
+ result.downloadSpeed.formatMegabits(SpeedReaderTheme.locale),
+ result.uploadSpeed.formatMegabits(SpeedReaderTheme.locale),
+ server.latency?.formatMilliseconds() ?: NO_VALUE,
+ listOfNotNull(server.name, server.location).joinToString(", "),
+ )
+}
+
+private fun Context.shareText(text: String) {
+ Timber.d("Sharing the test result")
+ val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text)
+ startActivity(Intent.createChooser(send, null))
+}
+
+private fun FailureReason.titleRes(): Int = when (this) {
+ FailureReason.Offline -> R.string.feature_speedtest_error_offline_title
+ FailureReason.NoServersAvailable -> R.string.feature_speedtest_error_no_servers_title
+ FailureReason.ConnectionLost -> R.string.feature_speedtest_error_connection_lost_title
+ FailureReason.Unexpected -> R.string.feature_speedtest_error_unexpected_title
+}
+
+private fun FailureReason.textRes(): Int = when (this) {
+ FailureReason.Offline -> R.string.feature_speedtest_error_offline_text
+ FailureReason.NoServersAvailable -> R.string.feature_speedtest_error_no_servers_text
+ FailureReason.ConnectionLost -> R.string.feature_speedtest_error_connection_lost_text
+ FailureReason.Unexpected -> R.string.feature_speedtest_error_unexpected_text
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestUiMapping.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestUiMapping.kt
new file mode 100644
index 0000000..6a6e527
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestUiMapping.kt
@@ -0,0 +1,35 @@
+package com.martindoudera.speedreader.feature.speedtest.ui
+
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestError
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SelectedServer
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestProgress
+import java.net.URI
+
+internal fun SpeedTestProgress.toUiPhase(): SpeedTestPhase = when (this) {
+ SpeedTestProgress.FindingServer -> SpeedTestPhase.FindingServer
+ is SpeedTestProgress.Downloading -> SpeedTestPhase.Downloading(server.toUiModel(), progress, remaining, currentSpeed, averageSpeed)
+ is SpeedTestProgress.Uploading ->
+ SpeedTestPhase.Uploading(server.toUiModel(), downloadSpeed, progress, remaining, currentSpeed, averageSpeed)
+ is SpeedTestProgress.Finished -> SpeedTestPhase.Finished(server.toUiModel(), downloadSpeed, uploadSpeed)
+ is SpeedTestProgress.Failed -> SpeedTestPhase.Failed(error.toFailureReason())
+}
+
+private fun SelectedServer.toUiModel(): ServerUiModel {
+ val name = server.provider?.takeIf { it.isNotBlank() }
+ ?: runCatching { URI(server.url.value).host }.getOrNull()
+ ?: server.url.value
+ val location = listOfNotNull(server.city, server.countryCode).filter { it.isNotBlank() }.joinToString(", ").ifEmpty { null }
+ return ServerUiModel(name, location, latency)
+}
+
+private fun SpeedTestError.toFailureReason(): FailureReason = when (this) {
+ is SpeedTestError.Network -> when (error) {
+ is NetworkError.NoConnection -> FailureReason.Offline
+ is NetworkError.Timeout -> FailureReason.ConnectionLost
+ else -> FailureReason.Unexpected
+ }
+ SpeedTestError.NoServersAvailable -> FailureReason.NoServersAvailable
+ SpeedTestError.MeasurementFailed -> FailureReason.ConnectionLost
+ SpeedTestError.ServerUnreachable, is SpeedTestError.Unknown -> FailureReason.Unexpected
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestUiState.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestUiState.kt
new file mode 100644
index 0000000..e58bffd
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestUiState.kt
@@ -0,0 +1,56 @@
+package com.martindoudera.speedreader.feature.speedtest.ui
+
+import com.martindoudera.speedreader.core.common.domain.model.AppVersion
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ConnectionMode
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Progress
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Throughput
+import kotlin.time.Duration
+
+/** Everything the speed test screen shows. `connectionMode` is the user's choice for the next run. */
+data class SpeedTestUiState(
+ val appVersion: AppVersion,
+ val connectionMode: ConnectionMode = ConnectionMode.Parallel,
+ val phase: SpeedTestPhase = SpeedTestPhase.Idle,
+)
+
+/** What the screen is doing: the domain's `SpeedTestProgress` plus `Idle` before and between runs. */
+sealed interface SpeedTestPhase {
+ data object Idle : SpeedTestPhase
+
+ data object FindingServer : SpeedTestPhase
+
+ data class Downloading(
+ val server: ServerUiModel,
+ val progress: Progress,
+ val remaining: Duration,
+ val currentSpeed: Throughput,
+ val averageSpeed: Throughput,
+ ) : SpeedTestPhase
+
+ data class Uploading(
+ val server: ServerUiModel,
+ val downloadSpeed: Throughput,
+ val progress: Progress,
+ val remaining: Duration,
+ val currentSpeed: Throughput,
+ val averageSpeed: Throughput,
+ ) : SpeedTestPhase
+
+ data class Finished(val server: ServerUiModel, val downloadSpeed: Throughput, val uploadSpeed: Throughput) : SpeedTestPhase
+
+ data class Failed(val reason: FailureReason) : SpeedTestPhase
+}
+
+internal val SpeedTestPhase.server: ServerUiModel?
+ get() = when (this) {
+ is SpeedTestPhase.Downloading -> server
+ is SpeedTestPhase.Uploading -> server
+ is SpeedTestPhase.Finished -> server
+ SpeedTestPhase.Idle, SpeedTestPhase.FindingServer, is SpeedTestPhase.Failed -> null
+ }
+
+/** The selected server as the screen labels it; `location` is null when the directory names no city or country. */
+data class ServerUiModel(val name: String, val location: String?, val latency: Duration?)
+
+/** Why a run failed, at the granularity the screen explains it to the user. */
+enum class FailureReason { Offline, NoServersAvailable, ConnectionLost, Unexpected }
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestViewModel.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestViewModel.kt
new file mode 100644
index 0000000..23a3991
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestViewModel.kt
@@ -0,0 +1,85 @@
+package com.martindoudera.speedreader.feature.speedtest.ui
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.martindoudera.speedreader.core.common.domain.model.AppVersion
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ConnectionMode
+import com.martindoudera.speedreader.feature.speedtest.domain.usecase.RunSpeedTestUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import timber.log.Timber
+
+private const val CONNECTION_MODE_KEY = "connectionMode"
+
+/**
+ * Runs speed tests for the screen. A running test lives in `viewModelScope`, so it survives configuration
+ * changes; the chosen connection mode also survives process death, a running test does not.
+ */
+@HiltViewModel
+class SpeedTestViewModel @Inject constructor(
+ private val runSpeedTest: RunSpeedTestUseCase,
+ private val savedStateHandle: SavedStateHandle,
+ appVersion: AppVersion,
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(
+ SpeedTestUiState(
+ appVersion = appVersion,
+ connectionMode = savedStateHandle[CONNECTION_MODE_KEY] ?: ConnectionMode.Parallel,
+ ),
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var testJob: Job? = null
+
+ fun onStartClick() {
+ if (testJob?.isActive == true) {
+ Timber.w("Start ignored, a speed test is already running")
+ return
+ }
+ val mode = _uiState.value.connectionMode
+ Timber.d("Start requested in %s mode", mode)
+ testJob = runSpeedTest(mode)
+ .onEach { progress -> showPhase(progress.toUiPhase()) }
+ .launchIn(viewModelScope)
+ }
+
+ fun onStopClick() {
+ Timber.d("Stop requested")
+ testJob?.cancel()
+ testJob = null
+ showPhase(SpeedTestPhase.Idle)
+ }
+
+ /** Leaves a result or an error for the start screen; ignored while a test runs, where Stop is the way out. */
+ fun onBackClick() {
+ val phase = _uiState.value.phase
+ if (phase !is SpeedTestPhase.Finished && phase !is SpeedTestPhase.Failed) {
+ Timber.w("Back ignored in %s", phase::class.simpleName)
+ return
+ }
+ showPhase(SpeedTestPhase.Idle)
+ }
+
+ fun onSingleConnectionChange(singleConnection: Boolean) {
+ val mode = if (singleConnection) ConnectionMode.Single else ConnectionMode.Parallel
+ Timber.d("Connection mode set to %s", mode)
+ savedStateHandle[CONNECTION_MODE_KEY] = mode
+ _uiState.update { it.copy(connectionMode = mode) }
+ }
+
+ private fun showPhase(phase: SpeedTestPhase) {
+ val previous = _uiState.value.phase
+ if (previous::class != phase::class) {
+ Timber.d("Screen phase %s -> %s", previous::class.simpleName, phase::class.simpleName)
+ }
+ _uiState.update { it.copy(phase = phase) }
+ }
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/component/MeasuringStage.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/component/MeasuringStage.kt
new file mode 100644
index 0000000..b967b82
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/component/MeasuringStage.kt
@@ -0,0 +1,142 @@
+package com.martindoudera.speedreader.feature.speedtest.ui.component
+
+import androidx.compose.animation.core.animateFloatAsState
+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.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.TextAutoSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Download
+import androidx.compose.material.icons.rounded.Public
+import androidx.compose.material.icons.rounded.Upload
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+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.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import com.martindoudera.speedreader.core.designsystem.component.MIN_FITTED_TEXT_SIZE
+import com.martindoudera.speedreader.core.designsystem.component.ProgressSegment
+import com.martindoudera.speedreader.core.designsystem.component.SrIndeterminateProgressBar
+import com.martindoudera.speedreader.core.designsystem.component.SrSegmentedProgressBar
+import com.martindoudera.speedreader.core.designsystem.component.SrStatusPill
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+import com.martindoudera.speedreader.feature.speedtest.R
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Throughput
+import com.martindoudera.speedreader.feature.speedtest.ui.SpeedTestPhase
+import com.martindoudera.speedreader.feature.speedtest.ui.formatMegabits
+import com.martindoudera.speedreader.feature.speedtest.ui.wholeSecondsLeft
+import kotlin.time.Duration
+
+/**
+ * The centre of the screen while a test runs: the current speed above a bar split into the download
+ * and upload halves of the test. While the server is being found the same slots show a globe and an
+ * indeterminate bar, so nothing moves when measuring starts.
+ */
+@Composable
+internal fun MeasuringStage(phase: SpeedTestPhase, modifier: Modifier = Modifier) {
+ val colors = SpeedReaderTheme.speedColors
+ val spacing = SpeedReaderTheme.spacing
+ val reading = phase.toReading()
+ val findingAlpha by animateFloatAsState(if (phase is SpeedTestPhase.FindingServer) 1f else 0f, label = "finding")
+
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(contentAlignment = Alignment.Center) {
+ Column(Modifier.graphicsLayer { alpha = findingAlpha }, horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ imageVector = Icons.Rounded.Public,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(SpeedReaderTheme.dimens.iconLarge),
+ )
+ Text(
+ text = stringResource(R.string.feature_speedtest_finding_server),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(top = spacing.md),
+ )
+ }
+ Column(Modifier.graphicsLayer { alpha = 1f - findingAlpha }, horizontalAlignment = Alignment.CenterHorizontally) {
+ SrStatusPill(
+ icon = if (reading.uploading) Icons.Rounded.Upload else Icons.Rounded.Download,
+ text = stringResource(
+ if (reading.uploading) R.string.feature_speedtest_upload else R.string.feature_speedtest_download,
+ ),
+ color = if (reading.uploading) colors.upload else colors.download,
+ )
+ Text(
+ text = reading.currentSpeed.formatMegabits(SpeedReaderTheme.locale),
+ style = MaterialTheme.typography.displayLarge,
+ modifier = Modifier.padding(top = spacing.md),
+ )
+ Text(
+ text = stringResource(R.string.feature_speedtest_unit_mbps),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ Column(Modifier.padding(top = spacing.xl).width(SpeedReaderTheme.dimens.progressBarWidth)) {
+ if (phase is SpeedTestPhase.FindingServer) {
+ SrIndeterminateProgressBar(Modifier.fillMaxWidth())
+ } else {
+ val doubled = reading.progress * 2
+ SrSegmentedProgressBar(
+ segments = listOf(
+ ProgressSegment(doubled.coerceAtMost(1f), colors.download),
+ ProgressSegment((doubled - 1f).coerceAtLeast(0f), colors.upload),
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = spacing.xs + spacing.xxs).graphicsLayer { alpha = 1f - findingAlpha },
+ horizontalArrangement = Arrangement.spacedBy(spacing.xs),
+ ) {
+ TimelineLabel(stringResource(R.string.feature_speedtest_download), TextAlign.Start)
+ TimelineLabel(stringResource(R.string.feature_speedtest_remaining, reading.remaining.wholeSecondsLeft()), TextAlign.Center)
+ TimelineLabel(stringResource(R.string.feature_speedtest_upload), TextAlign.End)
+ }
+ }
+ }
+}
+
+/** One of three equal slots under the bar; shrinks rather than running into its neighbour at large font scales. */
+@Composable
+private fun RowScope.TimelineLabel(text: String, align: TextAlign) {
+ val style = MaterialTheme.typography.labelMedium
+ Text(
+ text = text,
+ style = style,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = align,
+ softWrap = false,
+ maxLines = 1,
+ autoSize = TextAutoSize.StepBased(
+ minFontSize = with(LocalDensity.current) {
+ MIN_FITTED_TEXT_SIZE.toSp()
+ },
+ maxFontSize = style.fontSize,
+ ),
+ modifier = Modifier.weight(1f),
+ )
+}
+
+private data class Reading(val uploading: Boolean, val currentSpeed: Throughput, val progress: Float, val remaining: Duration)
+
+private fun SpeedTestPhase.toReading(): Reading = when (this) {
+ is SpeedTestPhase.Downloading -> Reading(uploading = false, currentSpeed, progress.fraction, remaining)
+ is SpeedTestPhase.Uploading -> Reading(uploading = true, currentSpeed, progress.fraction, remaining)
+ else -> Reading(uploading = false, Throughput.Zero, 0f, Duration.ZERO)
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/component/MetricsRow.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/component/MetricsRow.kt
new file mode 100644
index 0000000..b4f1d50
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/component/MetricsRow.kt
@@ -0,0 +1,102 @@
+package com.martindoudera.speedreader.feature.speedtest.ui.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Download
+import androidx.compose.material.icons.rounded.NetworkPing
+import androidx.compose.material.icons.rounded.Upload
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.martindoudera.speedreader.core.designsystem.component.MetricStatus
+import com.martindoudera.speedreader.core.designsystem.component.SrMetricCard
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+import com.martindoudera.speedreader.feature.speedtest.R
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Throughput
+import com.martindoudera.speedreader.feature.speedtest.ui.NO_VALUE
+import com.martindoudera.speedreader.feature.speedtest.ui.SpeedTestPhase
+import com.martindoudera.speedreader.feature.speedtest.ui.formatMegabits
+import com.martindoudera.speedreader.feature.speedtest.ui.formatMilliseconds
+import com.martindoudera.speedreader.feature.speedtest.ui.server
+
+/** Ping, download and upload side by side; download and upload show their average, live while measured. */
+@Composable
+internal fun MetricsRow(phase: SpeedTestPhase, modifier: Modifier = Modifier) {
+ val colors = SpeedReaderTheme.speedColors
+ val finished = phase is SpeedTestPhase.Finished
+ val server = phase.server
+ val download = phase.downloadMetric()
+ val upload = phase.uploadMetric()
+ val cardModifier = Modifier.fillMaxHeight()
+
+ Row(
+ modifier = modifier.fillMaxWidth().height(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.spacedBy(SpeedReaderTheme.spacing.sm),
+ ) {
+ SrMetricCard(
+ icon = Icons.Rounded.NetworkPing,
+ label = stringResource(R.string.feature_speedtest_ping),
+ value = server?.latency?.formatMilliseconds() ?: NO_VALUE,
+ unit = stringResource(R.string.feature_speedtest_unit_ms),
+ accent = colors.highlight,
+ modifier = cardModifier.weight(1f),
+ caption = server?.latency?.let { stringResource(R.string.feature_speedtest_caption_icmp) },
+ emphasized = finished,
+ )
+ SrMetricCard(
+ icon = Icons.Rounded.Download,
+ label = stringResource(R.string.feature_speedtest_download),
+ value = download.speed?.formatMegabits(SpeedReaderTheme.locale) ?: NO_VALUE,
+ unit = stringResource(R.string.feature_speedtest_unit_mbps),
+ accent = colors.download,
+ modifier = cardModifier.weight(1f),
+ status = download.status,
+ caption = download.caption(),
+ emphasized = finished,
+ )
+ SrMetricCard(
+ icon = Icons.Rounded.Upload,
+ label = stringResource(R.string.feature_speedtest_upload),
+ value = upload.speed?.formatMegabits(SpeedReaderTheme.locale) ?: NO_VALUE,
+ unit = stringResource(R.string.feature_speedtest_unit_mbps),
+ accent = colors.upload,
+ modifier = cardModifier.weight(1f),
+ status = upload.status,
+ caption = if (phase is SpeedTestPhase.Downloading) {
+ stringResource(
+ R.string.feature_speedtest_caption_up_next,
+ )
+ } else {
+ upload.caption()
+ },
+ emphasized = finished,
+ )
+ }
+}
+
+private data class SpeedMetric(val speed: Throughput?, val status: MetricStatus)
+
+@Composable
+private fun SpeedMetric.caption(): String? = when (status) {
+ MetricStatus.Live -> stringResource(R.string.feature_speedtest_caption_live_average)
+ MetricStatus.Done -> stringResource(R.string.feature_speedtest_caption_average)
+ MetricStatus.Idle -> null
+}
+
+private fun SpeedTestPhase.downloadMetric(): SpeedMetric = when (this) {
+ is SpeedTestPhase.Downloading -> SpeedMetric(averageSpeed, MetricStatus.Live)
+ is SpeedTestPhase.Uploading -> SpeedMetric(downloadSpeed, MetricStatus.Done)
+ is SpeedTestPhase.Finished -> SpeedMetric(downloadSpeed, MetricStatus.Done)
+ else -> SpeedMetric(null, MetricStatus.Idle)
+}
+
+private fun SpeedTestPhase.uploadMetric(): SpeedMetric = when (this) {
+ is SpeedTestPhase.Uploading -> SpeedMetric(averageSpeed, MetricStatus.Live)
+ is SpeedTestPhase.Finished -> SpeedMetric(uploadSpeed, MetricStatus.Done)
+ else -> SpeedMetric(null, MetricStatus.Idle)
+}
diff --git a/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/component/SpeedTestFooter.kt b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/component/SpeedTestFooter.kt
new file mode 100644
index 0000000..12af47b
--- /dev/null
+++ b/feature/speedtest/src/main/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/component/SpeedTestFooter.kt
@@ -0,0 +1,99 @@
+package com.martindoudera.speedreader.feature.speedtest.ui.component
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Share
+import androidx.compose.material.icons.rounded.Stop
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.martindoudera.speedreader.core.designsystem.component.SrCheckboxRow
+import com.martindoudera.speedreader.core.designsystem.component.SrOutlinedButton
+import com.martindoudera.speedreader.core.designsystem.component.SrPrimaryButton
+import com.martindoudera.speedreader.core.designsystem.theme.SpeedReaderTheme
+import com.martindoudera.speedreader.feature.speedtest.R
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ConnectionMode
+import com.martindoudera.speedreader.feature.speedtest.ui.SpeedTestPhase
+
+/**
+ * The actions under the metrics: the connection checkbox before a run, Stop during it, Back and Share
+ * with a result and Back alone after an error. Its minimum height fits two buttons, so swapping
+ * actions does not shift the rest of the screen.
+ */
+@Composable
+internal fun SpeedTestFooter(
+ phase: SpeedTestPhase,
+ connectionMode: ConnectionMode,
+ onStopClick: () -> Unit,
+ onBackClick: () -> Unit,
+ onShareClick: () -> Unit,
+ onSingleConnectionChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier.fillMaxWidth().heightIn(min = SpeedReaderTheme.dimens.buttonHeight * 2),
+ contentAlignment = Alignment.BottomCenter,
+ ) {
+ AnimatedContent(targetState = phase.footerActions(), transitionSpec = {
+ fadeIn() togetherWith fadeOut()
+ }, label = "footer") { actions ->
+ when (actions) {
+ FooterActions.ConnectionMode -> SrCheckboxRow(
+ checked = connectionMode == ConnectionMode.Single,
+ onCheckedChange = onSingleConnectionChange,
+ title = stringResource(R.string.feature_speedtest_single_connection_title),
+ description = stringResource(
+ R.string.feature_speedtest_single_connection_description,
+ ConnectionMode.Parallel.connections,
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ FooterActions.Stop -> SrOutlinedButton(
+ text = stringResource(R.string.feature_speedtest_stop),
+ onClick = onStopClick,
+ modifier = Modifier.fillMaxWidth(),
+ icon = Icons.Rounded.Stop,
+ )
+ FooterActions.BackAndShare -> Row(horizontalArrangement = Arrangement.spacedBy(SpeedReaderTheme.spacing.md)) {
+ SrOutlinedButton(
+ text = stringResource(R.string.feature_speedtest_back),
+ onClick = onBackClick,
+ modifier = Modifier.weight(1f),
+ icon = Icons.AutoMirrored.Rounded.ArrowBack,
+ )
+ SrPrimaryButton(
+ text = stringResource(R.string.feature_speedtest_share),
+ onClick = onShareClick,
+ modifier = Modifier.weight(1f),
+ icon = Icons.Rounded.Share,
+ )
+ }
+ FooterActions.Back -> SrOutlinedButton(
+ text = stringResource(R.string.feature_speedtest_back),
+ onClick = onBackClick,
+ modifier = Modifier.fillMaxWidth(),
+ icon = Icons.AutoMirrored.Rounded.ArrowBack,
+ )
+ }
+ }
+ }
+}
+
+private enum class FooterActions { ConnectionMode, Stop, BackAndShare, Back }
+
+private fun SpeedTestPhase.footerActions(): FooterActions = when (this) {
+ SpeedTestPhase.Idle -> FooterActions.ConnectionMode
+ SpeedTestPhase.FindingServer, is SpeedTestPhase.Downloading, is SpeedTestPhase.Uploading -> FooterActions.Stop
+ is SpeedTestPhase.Finished -> FooterActions.BackAndShare
+ is SpeedTestPhase.Failed -> FooterActions.Back
+}
diff --git a/feature/speedtest/src/main/res/values-cs/strings.xml b/feature/speedtest/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000..9ae068f
--- /dev/null
+++ b/feature/speedtest/src/main/res/values-cs/strings.xml
@@ -0,0 +1,40 @@
+
+
+ Spustit
+ Jen jedno spojení
+ Měřit jeden přenos místo %1$d souběžných
+
+ - %1$d spojení
+ - %1$d spojení
+ - %1$d spojení
+ - %1$d spojení
+
+
+ Hledám nejlepší server
+ Stahování
+ Odesílání
+ Ping
+ Mb/s
+ ms
+ zbývá %1$d s
+
+ ICMP
+ průběžný průměr
+ průměr
+ následuje
+
+ Zastavit
+ Zpět
+ Sdílet
+ Test dokončen
+ Výsledek SpeedReader\nStahování: %1$s Mb/s\nOdesílání: %2$s Mb/s\nPing: %3$s ms\nServer: %4$s
+
+ Jste offline
+ Zkontrolujte Wi-Fi nebo mobilní data a zkuste to znovu.
+ Žádné servery nejsou k dispozici
+ Seznam serverů je prázdný. Zkuste to později.
+ Test byl přerušen
+ Během měření se přerušilo spojení.
+ Něco se pokazilo
+ Test se nepodařilo dokončit. Zkuste to za chvíli znovu.
+
diff --git a/feature/speedtest/src/main/res/values/strings.xml b/feature/speedtest/src/main/res/values/strings.xml
new file mode 100644
index 0000000..bcf6ae7
--- /dev/null
+++ b/feature/speedtest/src/main/res/values/strings.xml
@@ -0,0 +1,41 @@
+
+
+ SpeedReader
+ v%1$s
+ Martin Douděra
+ Start
+ Single connection only
+ Measure one transfer instead of %1$d in parallel
+
+ - %1$d connection
+ - %1$d connections
+
+
+ Finding the best server
+ Download
+ Upload
+ Ping
+ Mbps
+ ms
+ %1$d s left
+
+ ICMP
+ live avg
+ average
+ up next
+
+ Stop
+ Back
+ Share
+ Test complete
+ SpeedReader result\nDownload: %1$s Mbps\nUpload: %2$s Mbps\nPing: %3$s ms\nServer: %4$s
+
+ You\'re offline
+ Check your Wi-Fi or mobile data and try again.
+ No servers available
+ The server list came back empty. Try again later.
+ The test was interrupted
+ The connection dropped while measuring.
+ Something went wrong
+ The test could not finish. Try again in a moment.
+
diff --git a/feature/speedtest/src/screenshotTest/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTests.kt b/feature/speedtest/src/screenshotTest/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTests.kt
new file mode 100644
index 0000000..87e1998
--- /dev/null
+++ b/feature/speedtest/src/screenshotTest/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTests.kt
@@ -0,0 +1,35 @@
+package com.martindoudera.speedreader.feature.speedtest.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import com.android.tools.screenshot.PreviewTest
+import com.martindoudera.speedreader.core.designsystem.component.ThemePreviews
+
+@PreviewTest
+@ThemePreviews
+@Composable
+private fun SpeedTestContentScreenshot(@PreviewParameter(SpeedTestPhaseProvider::class) phase: SpeedTestPhase) = ContentPreview(phase)
+
+@PreviewTest
+@Preview(name = "Tablet", device = "spec:width=1280dp,height=800dp,dpi=240")
+@Composable
+private fun SpeedTestContentTabletScreenshot(@PreviewParameter(SpeedTestPhaseProvider::class) phase: SpeedTestPhase) = ContentPreview(phase)
+
+@PreviewTest
+@Preview(name = "Phone landscape", device = "spec:width=891dp,height=411dp,dpi=420")
+@Composable
+private fun SpeedTestContentLandscapeScreenshot(@PreviewParameter(SpeedTestPhaseProvider::class) phase: SpeedTestPhase) =
+ ContentPreview(phase)
+
+@PreviewTest
+@Preview(name = "Foldable", device = "spec:width=673dp,height=841dp,dpi=420")
+@Composable
+private fun SpeedTestContentFoldableScreenshot(@PreviewParameter(SpeedTestPhaseProvider::class) phase: SpeedTestPhase) =
+ ContentPreview(phase)
+
+@PreviewTest
+@Preview(name = "Large font", device = "spec:width=411dp,height=891dp,dpi=420", fontScale = 2f)
+@Composable
+private fun SpeedTestContentLargeFontScreenshot(@PreviewParameter(SpeedTestPhaseProvider::class) phase: SpeedTestPhase) =
+ ContentPreview(phase)
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_0.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_0.png
new file mode 100644
index 0000000..4bacd62
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_0.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_1.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_1.png
new file mode 100644
index 0000000..a9839d8
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_1.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_2.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_2.png
new file mode 100644
index 0000000..0dc00df
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_2.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_3.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_3.png
new file mode 100644
index 0000000..46fe3bf
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_3.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_4.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_4.png
new file mode 100644
index 0000000..3af4446
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_4.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_5.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_5.png
new file mode 100644
index 0000000..2cb9dbd
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentFoldableScreenshot_Foldable_017436d1_ccb65037_5.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_0.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_0.png
new file mode 100644
index 0000000..5c0267e
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_0.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_1.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_1.png
new file mode 100644
index 0000000..c95a514
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_1.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_2.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_2.png
new file mode 100644
index 0000000..768d90f
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_2.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_3.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_3.png
new file mode 100644
index 0000000..a97cb3c
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_3.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_4.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_4.png
new file mode 100644
index 0000000..9a0ebbb
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_4.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_5.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_5.png
new file mode 100644
index 0000000..4756b46
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLandscapeScreenshot_Phone landscape_716177b6_ccb65037_5.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_0.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_0.png
new file mode 100644
index 0000000..47951a3
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_0.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_1.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_1.png
new file mode 100644
index 0000000..5b98ae9
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_1.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_2.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_2.png
new file mode 100644
index 0000000..d710328
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_2.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_3.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_3.png
new file mode 100644
index 0000000..b654912
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_3.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_4.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_4.png
new file mode 100644
index 0000000..2a6fc13
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_4.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_5.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_5.png
new file mode 100644
index 0000000..048d509
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentLargeFontScreenshot_Large font_977f2e66_ccb65037_5.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_0.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_0.png
new file mode 100644
index 0000000..5a1573d
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_0.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_1.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_1.png
new file mode 100644
index 0000000..9ab79f0
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_1.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_2.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_2.png
new file mode 100644
index 0000000..a155e18
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_2.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_3.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_3.png
new file mode 100644
index 0000000..1d3d18a
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_3.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_4.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_4.png
new file mode 100644
index 0000000..4f056d3
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_4.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_5.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_5.png
new file mode 100644
index 0000000..5e9f74d
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Dark_7fab2520_ccb65037_5.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_0.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_0.png
new file mode 100644
index 0000000..811e9a3
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_0.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_1.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_1.png
new file mode 100644
index 0000000..127110b
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_1.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_2.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_2.png
new file mode 100644
index 0000000..45dda0b
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_2.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_3.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_3.png
new file mode 100644
index 0000000..f4bdd71
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_3.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_4.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_4.png
new file mode 100644
index 0000000..efb73c8
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_4.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_5.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_5.png
new file mode 100644
index 0000000..36c153c
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentScreenshot_Light_bfef539f_ccb65037_5.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_0.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_0.png
new file mode 100644
index 0000000..2628c69
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_0.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_1.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_1.png
new file mode 100644
index 0000000..17e40aa
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_1.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_2.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_2.png
new file mode 100644
index 0000000..ecb10c9
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_2.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_3.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_3.png
new file mode 100644
index 0000000..c40ae9e
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_3.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_4.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_4.png
new file mode 100644
index 0000000..b9fdaf4
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_4.png differ
diff --git a/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_5.png b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_5.png
new file mode 100644
index 0000000..21e4ace
Binary files /dev/null and b/feature/speedtest/src/screenshotTestDebug/reference/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestContentScreenshotTestsKt/SpeedTestContentTabletScreenshot_Tablet_d6f36a9e_ccb65037_5.png differ
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DefaultTokenDataSourceTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DefaultTokenDataSourceTest.kt
new file mode 100644
index 0000000..d3be261
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/DefaultTokenDataSourceTest.kt
@@ -0,0 +1,142 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+import com.martindoudera.speedreader.feature.speedtest.data.di.DirectoryModule
+import com.martindoudera.speedreader.feature.speedtest.testing.data.testJson
+import java.io.IOException
+import java.util.concurrent.Callable
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TestTimeSource
+import kotlinx.serialization.SerializationException
+import mockwebserver3.MockResponse
+import mockwebserver3.MockWebServer
+import okhttp3.OkHttpClient
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import retrofit2.HttpException
+import retrofit2.create
+
+class DefaultTokenDataSourceTest {
+ private val server = MockWebServer()
+ private val timeSource = TestTimeSource()
+ private lateinit var dataSource: DefaultTokenDataSource
+
+ @Before
+ fun setUp() {
+ server.start()
+ val api = DirectoryModule.retrofit(server.url("/api/"), OkHttpClient(), testJson).create()
+ dataSource = DefaultTokenDataSource(api, timeSource)
+ }
+
+ @After
+ fun tearDown() {
+ server.close()
+ }
+
+ @Test
+ fun tokenIsRequestedWithAnEmptyPost() {
+ enqueueTokens("6fdd5148")
+
+ val token = dataSource.token()
+
+ val request = server.takeRequest()
+ assertEquals(DirectoryToken("6fdd5148"), token)
+ assertEquals("POST", request.method)
+ assertEquals("/api/v1/tokens", request.url.encodedPath)
+ assertEquals(0, request.bodySize)
+ }
+
+ @Test
+ fun tokenIsReusedWhileItIsValid() {
+ enqueueTokens("first", "second")
+ dataSource.token()
+ timeSource += 60.seconds
+
+ val token = dataSource.token()
+
+ assertEquals(DirectoryToken("first"), token)
+ assertEquals(1, server.requestCount)
+ }
+
+ @Test
+ fun tokenIsRenewedBeforeTheDirectoryExpiresIt() {
+ enqueueTokens("first", "second")
+ dataSource.token()
+ timeSource += 80.seconds - TOKEN_RENEWAL_MARGIN
+
+ val token = dataSource.token()
+
+ assertEquals(DirectoryToken("second"), token)
+ }
+
+ @Test
+ fun tokenWithATtlShorterThanTheMarginIsStillReused() {
+ enqueueTokens("first", "second", ttl = 10)
+ dataSource.token()
+ timeSource += 4.seconds
+
+ val token = dataSource.token()
+
+ assertEquals(DirectoryToken("first"), token)
+ }
+
+ @Test
+ fun invalidatedTokenIsReplaced() {
+ enqueueTokens("first", "second")
+ val rejected = dataSource.token()
+
+ dataSource.invalidate(rejected)
+
+ assertEquals(DirectoryToken("second"), dataSource.token())
+ }
+
+ @Test
+ fun invalidatingAnOlderTokenKeepsTheCurrentOne() {
+ enqueueTokens("first", "second", "third")
+ val stale = dataSource.token()
+ dataSource.invalidate(stale)
+ dataSource.token()
+
+ dataSource.invalidate(stale)
+
+ assertEquals(DirectoryToken("second"), dataSource.token())
+ }
+
+ @Test
+ fun refusalCarriesTheDirectoryStatus() {
+ server.enqueue(MockResponse(code = 503))
+
+ val failure = assertFailsWith { dataSource.token() }
+
+ assertEquals(503, failure.code())
+ }
+
+ @Test
+ fun malformedTokenIsAnIoFailure() {
+ server.enqueue(MockResponse(body = "Sign in to Wi-Fi"))
+
+ assertFailsWith { dataSource.token() }
+ }
+
+ @Test
+ fun concurrentCallersShareOneRequest() {
+ server.enqueue(MockResponse.Builder().body(tokenJson("shared", ttl = 80)).headersDelay(200, TimeUnit.MILLISECONDS).build())
+ enqueueTokens("second", "third", "fourth", "fifth", "sixth")
+ val executor = Executors.newFixedThreadPool(6)
+
+ val tokens = executor.invokeAll(List(6) { Callable { dataSource.token() } }).map { it.get() }
+
+ executor.shutdown()
+ assertEquals(setOf(DirectoryToken("shared")), tokens.toSet())
+ assertEquals(1, server.requestCount)
+ }
+
+ private fun enqueueTokens(vararg tokens: String, ttl: Int = 80) =
+ tokens.forEach { server.enqueue(MockResponse(body = tokenJson(it, ttl))) }
+
+ private fun tokenJson(token: String, ttl: Int) = """{"token":"$token","ttl":$ttl}"""
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/SpeedTestDirectoryApiTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/SpeedTestDirectoryApiTest.kt
new file mode 100644
index 0000000..5e9b5c9
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/SpeedTestDirectoryApiTest.kt
@@ -0,0 +1,91 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+import com.martindoudera.speedreader.feature.speedtest.data.di.DIRECTORY_BASE_URL
+import com.martindoudera.speedreader.feature.speedtest.data.di.DirectoryModule
+import com.martindoudera.speedreader.feature.speedtest.data.directory.dto.IpLocationDto
+import com.martindoudera.speedreader.feature.speedtest.testing.data.testJson
+import com.martindoudera.speedreader.feature.speedtest.testing.directory.FakeTokenDataSource
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.SerializationException
+import mockwebserver3.MockResponse
+import mockwebserver3.MockWebServer
+import okhttp3.OkHttpClient
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import retrofit2.create
+
+class SpeedTestDirectoryApiTest {
+ private val server = MockWebServer()
+ private lateinit var api: SpeedTestDirectoryApi
+
+ @Before
+ fun setUp() {
+ server.start()
+ val client = OkHttpClient.Builder().addInterceptor(TokenInterceptor(FakeTokenDataSource())).build()
+ api = DirectoryModule.retrofit(server.url("/api/"), client, testJson).create()
+ }
+
+ @After
+ fun tearDown() {
+ server.close()
+ }
+
+ @Test
+ fun endpointsResolveUnderTheApiPath() {
+ val tokens = DIRECTORY_BASE_URL.resolve("v1/tokens")
+
+ assertEquals("https://sp-dir.uwn.com/api/v1/tokens", tokens.toString())
+ }
+
+ @Test
+ fun serverListIsRequestedWithTheTokenHeaderAndHttpsOnly() = runTest {
+ server.enqueue(MockResponse(body = """[{"url":"https://breclav.wifiman.me:81","latitude":48.7,"longitude":16.8}]"""))
+
+ val servers = api.servers()
+
+ val request = server.takeRequest()
+ assertEquals(1, servers.size)
+ assertEquals("GET", request.method)
+ assertEquals("/api/v2/servers", request.url.encodedPath)
+ assertEquals("only", request.url.queryParameter("secured"))
+ assertEquals("token-1", request.headers[TOKEN_HEADER])
+ }
+
+ @Test
+ fun clientLocationIsRequestedWithoutTokenAndKeepsOnlyWhatWeUse() = runTest {
+ server.enqueue(MockResponse(body = IP_RESPONSE))
+
+ val location = api.clientLocation()
+
+ val request = server.takeRequest()
+ assertEquals(IpLocationDto(lat = 49.1953, lon = 16.6016, countryCode = "CZ"), location)
+ assertEquals("/api/v1/ip", request.url.encodedPath)
+ assertNull(request.headers[TOKEN_HEADER])
+ }
+
+ @Test
+ fun captivePortalPageFailsToDecode() = runTest {
+ server.enqueue(MockResponse(body = "Sign in to Wi-Fi"))
+
+ assertFailsWith { api.servers() }
+ }
+
+ private companion object {
+ const val IP_RESPONSE = """{
+ "ip": "203.0.113.7",
+ "isp": "Example ISP",
+ "ispImg": "https://static.ui.com/asn/example.png",
+ "org": "Example ISP",
+ "reverse": null,
+ "country": "Czechia",
+ "countryCode": "CZ",
+ "lat": 49.1953,
+ "lon": 16.6016,
+ "timezone": "Europe/Prague"
+ }"""
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/TokenInterceptorTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/TokenInterceptorTest.kt
new file mode 100644
index 0000000..a9d0335
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/directory/TokenInterceptorTest.kt
@@ -0,0 +1,158 @@
+package com.martindoudera.speedreader.feature.speedtest.data.directory
+
+import com.martindoudera.speedreader.feature.speedtest.data.di.DirectoryModule
+import com.martindoudera.speedreader.feature.speedtest.testing.data.httpError
+import com.martindoudera.speedreader.feature.speedtest.testing.data.testJson
+import com.martindoudera.speedreader.feature.speedtest.testing.directory.FakeTokenDataSource
+import java.io.IOException
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.SerializationException
+import mockwebserver3.MockResponse
+import mockwebserver3.MockWebServer
+import okhttp3.OkHttpClient
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import retrofit2.HttpException
+import retrofit2.create
+import retrofit2.http.GET
+
+class TokenInterceptorTest {
+ private val server = MockWebServer()
+ private val tokenDataSource = FakeTokenDataSource()
+ private lateinit var api: SignedApi
+
+ @Before
+ fun setUp() {
+ server.start()
+ val client = OkHttpClient.Builder().addInterceptor(TokenInterceptor(tokenDataSource)).build()
+ api = DirectoryModule.retrofit(server.url("/"), client, testJson).create()
+ }
+
+ @After
+ fun tearDown() {
+ server.close()
+ }
+
+ @Test
+ fun headerPlacementSendsTheTokenAsHeader() = runTest {
+ server.enqueue(MockResponse())
+
+ api.header()
+
+ val request = server.takeRequest()
+ assertEquals("token-1", request.headers[TOKEN_HEADER])
+ assertNull(request.url.queryParameter(TOKEN_QUERY_PARAMETER))
+ }
+
+ @Test
+ fun queryPlacementAddsTheTokenNextToExistingParameters() = runTest {
+ server.enqueue(MockResponse())
+
+ api.query()
+
+ val request = server.takeRequest()
+ assertEquals("token-1", request.url.queryParameter(TOKEN_QUERY_PARAMETER))
+ assertEquals("1000", request.url.queryParameter("size"))
+ assertNull(request.headers[TOKEN_HEADER])
+ }
+
+ @Test
+ fun unannotatedRequestIsLeftAlone() = runTest {
+ server.enqueue(MockResponse())
+
+ api.open()
+
+ val request = server.takeRequest()
+ assertNull(request.headers[TOKEN_HEADER])
+ assertEquals(0, tokenDataSource.issuedTokens)
+ }
+
+ @Test
+ fun rejectedTokenIsRenewedAndTheRequestRetriedOnce() = runTest {
+ server.enqueue(MockResponse(code = 401))
+ server.enqueue(MockResponse())
+
+ api.header()
+
+ assertEquals("token-1", server.takeRequest().headers[TOKEN_HEADER])
+ assertEquals("token-2", server.takeRequest().headers[TOKEN_HEADER])
+ assertEquals(listOf(DirectoryToken("token-1")), tokenDataSource.invalidated)
+ }
+
+ @Test
+ fun secondRejectionReachesTheCaller() = runTest {
+ server.enqueue(MockResponse(code = 401))
+ server.enqueue(MockResponse(code = 401))
+
+ val failure = assertFailsWith { api.header() }
+
+ assertEquals(401, failure.code())
+ assertEquals(2, server.requestCount)
+ }
+
+ @Test
+ fun otherHttpFailuresAreNotRetried() = runTest {
+ server.enqueue(MockResponse(code = 503))
+
+ assertFailsWith { api.header() }
+
+ assertEquals(1, server.requestCount)
+ assertEquals(emptyList(), tokenDataSource.invalidated)
+ }
+
+ @Test
+ fun refusedTokenIsReportedWithTheDirectoryStatus() = runTest {
+ tokenDataSource.failure = httpError(503)
+
+ val failure = assertFailsWith { api.header() }
+
+ assertEquals(503, failure.code())
+ assertEquals(0, server.requestCount)
+ }
+
+ @Test
+ fun refusedRenewalAfterRejectionIsReportedWithTheDirectoryStatus() = runTest {
+ tokenDataSource.renewalFailure = httpError(503)
+ server.enqueue(MockResponse(code = 401))
+
+ val failure = assertFailsWith { api.header() }
+
+ assertEquals(503, failure.code())
+ assertEquals(1, server.requestCount)
+ }
+
+ @Test
+ fun malformedTokenIsAnIoFailure() = runTest {
+ tokenDataSource.failure = SerializationException("unexpected token")
+
+ assertFailsWith { api.header() }
+
+ assertEquals(0, server.requestCount)
+ }
+
+ @Test
+ fun unreachableDirectoryIsAnIoFailure() = runTest {
+ tokenDataSource.failure = IOException("sp-dir.uwn.com unreachable")
+
+ assertFailsWith { api.header() }
+
+ assertEquals(0, server.requestCount)
+ }
+
+ private interface SignedApi {
+ @RequiresToken(TokenPlacement.Header)
+ @GET("header")
+ suspend fun header()
+
+ @RequiresToken(TokenPlacement.Query)
+ @GET("download?size=1000")
+ suspend fun query()
+
+ @GET("open")
+ suspend fun open()
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/DefaultLatencyRepositoryTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/DefaultLatencyRepositoryTest.kt
new file mode 100644
index 0000000..c7b68ce
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/latency/DefaultLatencyRepositoryTest.kt
@@ -0,0 +1,138 @@
+package com.martindoudera.speedreader.feature.speedtest.data.latency
+
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestError
+import com.martindoudera.speedreader.feature.speedtest.testing.data.testServer
+import com.martindoudera.speedreader.feature.speedtest.testing.latency.FakeDns
+import com.martindoudera.speedreader.feature.speedtest.testing.latency.FakePingDataSource
+import java.io.IOException
+import java.net.InetAddress
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class DefaultLatencyRepositoryTest {
+ private val pingDataSource = FakePingDataSource()
+ private val dns = FakeDns()
+ private val dispatcher = StandardTestDispatcher()
+ private val repository = DefaultLatencyRepository(pingDataSource, dns, dispatcher)
+ private val server = testServer("https://breclav.wifiman.me:81", 48.7569, 16.8854)
+ private val address = InetAddress.getByName("192.0.2.10")
+ private val ipv6Address = InetAddress.getByName("2001:db8::10")
+
+ @Before
+ fun setUp() {
+ dns.addresses = mapOf("breclav.wifiman.me" to listOf(address))
+ }
+
+ @Test
+ fun latencyIsTheFastestProbe() = runTest(dispatcher) {
+ pingDataSource.roundTrips = listOf(30, 22, 25).map { it.milliseconds }
+
+ val outcome = repository.measureLatency(server)
+
+ assertEquals(Outcome.Success(22.milliseconds), outcome)
+ }
+
+ @Test
+ fun oneLostProbeDoesNotEndProbing() = runTest(dispatcher) {
+ pingDataSource.roundTrips = listOf(null, 30.milliseconds, 10.milliseconds)
+
+ val outcome = repository.measureLatency(server)
+
+ assertEquals(Outcome.Success(10.milliseconds), outcome)
+ assertEquals(PING_PROBES, pingDataSource.pinged.size)
+ }
+
+ @Test
+ fun answersBeforeTwoLostProbesInARowCount() = runTest(dispatcher) {
+ pingDataSource.roundTrips = listOf(30.milliseconds, null, null)
+
+ val outcome = repository.measureLatency(server)
+
+ assertEquals(Outcome.Success(30.milliseconds), outcome)
+ }
+
+ @Test
+ fun everyProbeGoesToTheResolvedAddressWithTheProbeTimeout() = runTest(dispatcher) {
+ pingDataSource.roundTrips = List(PING_PROBES) { 20.milliseconds }
+
+ repository.measureLatency(server)
+
+ assertEquals(List(PING_PROBES) { address }, pingDataSource.pinged)
+ assertEquals(List(PING_PROBES) { PROBE_TIMEOUT }, pingDataSource.timeouts)
+ }
+
+ @Test
+ fun silentServerIsUnreachableAfterTwoLostProbesInARow() = runTest(dispatcher) {
+ pingDataSource.roundTrips = emptyList()
+
+ val outcome = repository.measureLatency(server)
+
+ assertEquals(Outcome.Failure(SpeedTestError.ServerUnreachable), outcome)
+ assertEquals(LOST_PROBES_IN_A_ROW, pingDataSource.pinged.size)
+ }
+
+ @Test
+ fun silentAddressFallsBackToTheOtherFamily() = runTest(dispatcher) {
+ dns.addresses = mapOf("breclav.wifiman.me" to listOf(ipv6Address, address))
+ pingDataSource.roundTrips = listOf(null, null, 20.milliseconds, 25.milliseconds, 22.milliseconds)
+
+ val outcome = repository.measureLatency(server)
+
+ assertEquals(Outcome.Success(20.milliseconds), outcome)
+ assertEquals(listOf(ipv6Address, ipv6Address, address, address, address), pingDataSource.pinged)
+ }
+
+ @Test
+ fun answeringAddressIsTheOnlyOnePinged() = runTest(dispatcher) {
+ dns.addresses = mapOf("breclav.wifiman.me" to listOf(ipv6Address, address))
+ pingDataSource.roundTrips = List(PING_PROBES) { 20.milliseconds }
+
+ repository.measureLatency(server)
+
+ assertEquals(List(PING_PROBES) { ipv6Address }, pingDataSource.pinged)
+ }
+
+ @Test
+ fun onlyTheFirstAddressOfEachFamilyIsPinged() = runTest(dispatcher) {
+ dns.addresses = mapOf("breclav.wifiman.me" to listOf(address, InetAddress.getByName("192.0.2.11")))
+
+ val outcome = repository.measureLatency(server)
+
+ assertEquals(Outcome.Failure(SpeedTestError.ServerUnreachable), outcome)
+ assertEquals(listOf(address, address), pingDataSource.pinged)
+ }
+
+ @Test
+ fun unresolvableHostIsUnreachableWithoutAnyProbe() = runTest(dispatcher) {
+ dns.addresses = emptyMap()
+
+ val outcome = repository.measureLatency(server)
+
+ assertEquals(Outcome.Failure(SpeedTestError.ServerUnreachable), outcome)
+ assertEquals(emptyList(), pingDataSource.pinged)
+ }
+
+ @Test
+ fun malformedServerUrlIsUnreachable() = runTest(dispatcher) {
+ val broken = testServer("not a url", 48.7569, 16.8854)
+
+ val outcome = repository.measureLatency(broken)
+
+ assertEquals(Outcome.Failure(SpeedTestError.ServerUnreachable), outcome)
+ }
+
+ @Test
+ fun pingFailureIsUnknownRatherThanUnreachable() = runTest(dispatcher) {
+ pingDataSource.failure = IOException("Network is unreachable")
+
+ val outcome = repository.measureLatency(server)
+
+ assertIs(assertIs>(outcome).error)
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/location/DefaultLocationRepositoryTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/location/DefaultLocationRepositoryTest.kt
new file mode 100644
index 0000000..b09a96d
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/location/DefaultLocationRepositoryTest.kt
@@ -0,0 +1,75 @@
+package com.martindoudera.speedreader.feature.speedtest.data.location
+
+import com.martindoudera.speedreader.core.common.error.HttpStatusCode
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.data.directory.dto.IpLocationDto
+import com.martindoudera.speedreader.feature.speedtest.testing.data.geoPoint
+import com.martindoudera.speedreader.feature.speedtest.testing.data.httpError
+import com.martindoudera.speedreader.feature.speedtest.testing.directory.FakeSpeedTestDirectoryApi
+import java.net.UnknownHostException
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultLocationRepositoryTest {
+ private val api = FakeSpeedTestDirectoryApi()
+ private val repository = DefaultLocationRepository(api)
+
+ @Test
+ fun ipLocationBecomesTheCurrentLocation() = runTest {
+ api.clientLocation = IpLocationDto(lat = 50.0755, lon = 14.4378, countryCode = "CZ")
+
+ val outcome = repository.currentLocation()
+
+ assertEquals(Outcome.Success(geoPoint(50.0755, 14.4378)), outcome)
+ }
+
+ @Test
+ fun missingCountryDoesNotMatter() = runTest {
+ api.clientLocation = IpLocationDto(lat = 50.0755, lon = 14.4378)
+
+ val outcome = repository.currentLocation()
+
+ assertEquals(Outcome.Success(geoPoint(50.0755, 14.4378)), outcome)
+ }
+
+ @Test
+ fun impossibleCoordinatesAreMalformed() = runTest {
+ api.clientLocation = IpLocationDto(lat = 120.0, lon = 14.4378)
+
+ val outcome = repository.currentLocation()
+
+ assertIs(outcome.error())
+ }
+
+ @Test
+ fun zeroCoordinatesAreAnUnlocatedIpRatherThanALocation() = runTest {
+ api.clientLocation = IpLocationDto(lat = 0.0, lon = 0.0)
+
+ val outcome = repository.currentLocation()
+
+ assertIs(outcome.error())
+ }
+
+ @Test
+ fun unreachableDirectoryIsNoConnection() = runTest {
+ api.clientLocationFailure = UnknownHostException("sp-dir.uwn.com")
+
+ val outcome = repository.currentLocation()
+
+ assertIs(outcome.error())
+ }
+
+ @Test
+ fun httpFailureKeepsTheStatusCode() = runTest {
+ api.clientLocationFailure = httpError(502)
+
+ val outcome = repository.currentLocation()
+
+ assertEquals(NetworkError.Http(HttpStatusCode(502)), outcome.error())
+ }
+
+ private fun Outcome.error() = assertIs>(this).error
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/server/DefaultServerRepositoryTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/server/DefaultServerRepositoryTest.kt
new file mode 100644
index 0000000..afd6a50
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/server/DefaultServerRepositoryTest.kt
@@ -0,0 +1,111 @@
+package com.martindoudera.speedreader.feature.speedtest.data.server
+
+import com.martindoudera.speedreader.core.common.error.HttpStatusCode
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.GeoPoint
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Latitude
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Longitude
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.testing.data.httpError
+import com.martindoudera.speedreader.feature.speedtest.testing.data.testJson
+import com.martindoudera.speedreader.feature.speedtest.testing.directory.FakeSpeedTestDirectoryApi
+import java.net.UnknownHostException
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultServerRepositoryTest {
+ private val api = FakeSpeedTestDirectoryApi()
+ private val repository = DefaultServerRepository(api, testJson)
+
+ @Test
+ fun serverIsMappedToTheDomainModel() = runTest {
+ api.serversJson = """[$BRECLAV]"""
+
+ val outcome = repository.servers()
+
+ val breclav = SpeedTestServer(
+ url = ServerUrl("https://breclav.wifiman.me:81"),
+ location = GeoPoint(Latitude(48.7569), Longitude(16.8854)),
+ provider = "MyOffice s.r.o.",
+ city = "Břeclav",
+ countryCode = "CZ",
+ )
+ assertEquals(Outcome.Success(listOf(breclav)), outcome)
+ }
+
+ @Test
+ fun missingLabelsBecomeNull() = runTest {
+ api.serversJson = """[{"url": "https://graz.wifiman.me:80", "latitude": 48.2, "longitude": 16.37}]"""
+
+ val server = repository.servers().servers().single()
+
+ assertEquals(null, server.provider)
+ assertEquals(null, server.city)
+ assertEquals(null, server.countryCode)
+ }
+
+ @Test
+ fun malformedEntryIsDroppedAndTheRestKept() = runTest {
+ api.serversJson = """[$BRECLAV, {"url": "https://broken.wifiman.me", "latitude": "north"}]"""
+
+ val servers = repository.servers().servers()
+
+ assertEquals(listOf(ServerUrl("https://breclav.wifiman.me:81")), servers.map { it.url })
+ }
+
+ @Test
+ fun entryWithImpossibleCoordinatesIsDropped() = runTest {
+ api.serversJson = """[$BRECLAV, {"url": "https://nowhere.wifiman.me", "latitude": 91.0, "longitude": 0.0}]"""
+
+ val servers = repository.servers().servers()
+
+ assertEquals(listOf(ServerUrl("https://breclav.wifiman.me:81")), servers.map { it.url })
+ }
+
+ @Test
+ fun emptyListIsSuccess() = runTest {
+ api.serversJson = "[]"
+
+ val outcome = repository.servers()
+
+ assertEquals(Outcome.Success(emptyList()), outcome)
+ }
+
+ @Test
+ fun httpFailureKeepsTheStatusCode() = runTest {
+ api.serversFailure = httpError(503)
+
+ val outcome = repository.servers()
+
+ assertEquals(Outcome.Failure(NetworkError.Http(HttpStatusCode(503))), outcome)
+ }
+
+ @Test
+ fun unreachableDirectoryIsNoConnection() = runTest {
+ api.serversFailure = UnknownHostException("sp-dir.uwn.com")
+
+ val outcome = repository.servers()
+
+ assertIs(assertIs>(outcome).error)
+ }
+
+ private fun Outcome, NetworkError>.servers() = assertIs>>(this).value
+
+ private companion object {
+ const val BRECLAV = """{
+ "url": "https://breclav.wifiman.me:81",
+ "latitude": 48.7569,
+ "longitude": 16.8854,
+ "provider": "MyOffice s.r.o.",
+ "providerUrl": "https://www.myoffice.cz",
+ "city": "Břeclav",
+ "country": "Czechia",
+ "countryCode": "CZ",
+ "speedMbps": 1000
+ }"""
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/CallExecutionTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/CallExecutionTest.kt
new file mode 100644
index 0000000..ff56434
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/CallExecutionTest.kt
@@ -0,0 +1,28 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import java.net.SocketException
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import retrofit2.Call
+
+/** Simulates a specific network error state on the call itself: the connection reset during `execute()`. */
+class CallExecutionTest {
+ private val call = mockk> {
+ every { cancel() } just Runs
+ }
+
+ @Test
+ fun connectionResetWhileTheCallerIsActiveIsAFailureNotACancellation() = runTest {
+ every { call.execute() } throws SocketException("Connection reset")
+
+ val failure = assertFailsWith { call.executeCancellably { it.body() } }
+
+ assertEquals("Connection reset", failure.message)
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultDownloadDataSourceTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultDownloadDataSourceTest.kt
new file mode 100644
index 0000000..8767797
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultDownloadDataSourceTest.kt
@@ -0,0 +1,104 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicLong
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.yield
+import mockwebserver3.MockResponse
+import mockwebserver3.MockWebServer
+import okio.Buffer
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import retrofit2.HttpException
+
+class DefaultDownloadDataSourceTest {
+ private val server = MockWebServer()
+ private lateinit var dataSource: DefaultDownloadDataSource
+
+ @Before
+ fun setUp() {
+ server.start()
+ dataSource = DefaultDownloadDataSource(measurementApi(server))
+ }
+
+ @After
+ fun tearDown() {
+ server.close()
+ }
+
+ @Test
+ fun everyByteOfTheResponseIsReported() = runTest {
+ server.enqueue(MockResponse.Builder().body(Buffer().write(ByteArray(300_000))).build())
+ var received = 0L
+
+ dataSource.download(server.serverUrl()) { received += it }
+
+ assertEquals(300_000, received)
+ }
+
+ @Test
+ fun requestAsksForALargePayloadWithCacheBusterAndToken() = runTest {
+ server.enqueue(MockResponse.Builder().body(Buffer().write(ByteArray(10))).build())
+
+ dataSource.download(server.serverUrl()) {}
+
+ val url = server.takeRequest().url
+ assertEquals("/download", url.encodedPath)
+ assertEquals(DOWNLOAD_REQUEST_SIZE.toString(), url.queryParameter("size"))
+ assertEquals(7, url.queryParameter("nc")?.length)
+ assertEquals("token-1", url.queryParameter("token"))
+ }
+
+ @Test
+ fun serverErrorThrowsWithItsStatus() = runTest {
+ server.enqueue(MockResponse(code = 503))
+
+ val failure = assertFailsWith { dataSource.download(server.serverUrl()) {} }
+
+ assertEquals(503, failure.code())
+ }
+
+ @Test
+ fun cancellationAbortsAReadBlockedOnTheSocket() = runTest {
+ server.enqueue(
+ MockResponse.Builder().body(Buffer().write(ByteArray(10_000_000))).throttleBody(1_024, 1, TimeUnit.SECONDS).build(),
+ )
+ val received = AtomicLong()
+
+ withContext(Dispatchers.IO) {
+ val download = launch { dataSource.download(server.serverUrl()) { received.addAndGet(it) } }
+ while (received.get() == 0L) yield()
+
+ withTimeout(2.seconds) { download.cancelAndJoin() }
+ }
+
+ assertTrue(received.get() < 10_000_000)
+ }
+
+ @Test
+ fun cancelledCallerSendsNoRequest() = runTest {
+ server.enqueue(MockResponse.Builder().body(Buffer().write(ByteArray(2_048))).throttleBody(1_024, 1, TimeUnit.SECONDS).build())
+ val received = AtomicLong()
+
+ withContext(Dispatchers.IO) {
+ launch {
+ cancel()
+ dataSource.download(server.serverUrl()) { received.addAndGet(it) }
+ }.join()
+ }
+
+ assertEquals(0, server.requestCount)
+ assertEquals(0, received.get())
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultThroughputRepositoryTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultThroughputRepositoryTest.kt
new file mode 100644
index 0000000..8236b70
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultThroughputRepositoryTest.kt
@@ -0,0 +1,100 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import com.martindoudera.speedreader.core.common.error.HttpStatusCode
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ByteCount
+import com.martindoudera.speedreader.feature.speedtest.domain.model.TransferSample
+import com.martindoudera.speedreader.feature.speedtest.testing.data.httpError
+import com.martindoudera.speedreader.feature.speedtest.testing.data.testServer
+import com.martindoudera.speedreader.feature.speedtest.testing.throughput.FakeTransferDataSource
+import kotlin.test.assertEquals
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultThroughputRepositoryTest {
+ private val downloads = FakeTransferDataSource()
+ private val uploads = FakeTransferDataSource()
+ private val dispatcher = StandardTestDispatcher()
+ private val repository = DefaultThroughputRepository(downloads, uploads, dispatcher.scheduler.timeSource, dispatcher)
+ private val server = testServer("https://breclav.wifiman.me:81", 48.7569, 16.8854)
+
+ @Test
+ fun downloadReportsTheRunningTotalEverySampleInterval() = runTest(dispatcher) {
+ val samples = repository.download(server, connections = 1).take(3).toList()
+
+ assertEquals(listOf(sample(250, 6_000), sample(500, 12_000), sample(750, 18_000)), samples)
+ }
+
+ @Test
+ fun allConnectionsAddIntoOneTotal() = runTest(dispatcher) {
+ val samples = repository.download(server, connections = 6).take(1).toList()
+
+ assertEquals(listOf(sample(250, 36_000)), samples)
+ assertEquals(List(6) { server.url }, downloads.requestedServers)
+ }
+
+ @Test
+ fun samplesKeepComingWhenNothingMoves() = runTest(dispatcher) {
+ downloads.bytesPerTick = 0
+
+ val samples = repository.download(server, connections = 2).take(3).toList()
+
+ assertEquals(listOf(sample(250, 0), sample(500, 0), sample(750, 0)), samples)
+ }
+
+ @Test
+ fun requestThatEndsIsReplacedByANewOne() = runTest(dispatcher) {
+ downloads.bytesPerRequest = 2_000
+
+ val samples = repository.download(server, connections = 1).take(2).toList()
+
+ assertEquals(listOf(sample(250, 6_000), sample(500, 12_000)), samples)
+ assertEquals(7, downloads.requestedServers.size)
+ }
+
+ @Test
+ fun oneFailingStreamLeavesTheOthersRunning() = runTest(dispatcher) {
+ downloads.failure = httpError(503)
+ downloads.failAfter = 300.milliseconds
+ downloads.failingRequests = 1
+
+ val samples = repository.download(server, connections = 2).take(3).toList()
+
+ assertEquals(listOf(sample(250, 12_000), sample(500, 19_000), sample(750, 25_000)), samples)
+ }
+
+ @Test
+ fun transferFailsOnlyWhenEveryStreamHasFailed() = runTest(dispatcher) {
+ downloads.failure = httpError(503)
+ downloads.failAfter = 300.milliseconds
+
+ val samples = repository.download(server, connections = 2).toList()
+
+ assertEquals(sample(250, 12_000), samples.first())
+ assertEquals(Outcome.Failure(NetworkError.Http(HttpStatusCode(503))), samples.last())
+ assertEquals(0, downloads.activeRequests)
+ }
+
+ @Test
+ fun stoppingTheCollectionCancelsEveryStream() = runTest(dispatcher) {
+ repository.download(server, connections = 6).take(2).toList()
+
+ assertEquals(0, downloads.activeRequests)
+ }
+
+ @Test
+ fun uploadRunsOnTheUploadDataSource() = runTest(dispatcher) {
+ val samples = repository.upload(server, connections = 2).take(1).toList()
+
+ assertEquals(listOf(sample(250, 12_000)), samples)
+ assertEquals(List(2) { server.url }, uploads.requestedServers)
+ assertEquals(emptyList(), downloads.requestedServers)
+ }
+
+ private fun sample(elapsedMillis: Int, bytes: Long) = Outcome.Success(TransferSample(elapsedMillis.milliseconds, ByteCount(bytes)))
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultUploadDataSourceTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultUploadDataSourceTest.kt
new file mode 100644
index 0000000..519cffe
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/DefaultUploadDataSourceTest.kt
@@ -0,0 +1,120 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TestTimeSource
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.yield
+import kotlinx.serialization.SerializationException
+import mockwebserver3.MockResponse
+import mockwebserver3.MockWebServer
+import okio.Buffer
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import retrofit2.HttpException
+
+class DefaultUploadDataSourceTest {
+ private val server = MockWebServer()
+ private lateinit var dataSource: DefaultUploadDataSource
+
+ @Before
+ fun setUp() {
+ server.start()
+ dataSource = DefaultUploadDataSource(measurementApi(server), TestTimeSource())
+ }
+
+ @After
+ fun tearDown() {
+ server.close()
+ }
+
+ @Test
+ fun uploadReportsWhatTheServerConfirmsNotWhatWasSent() = runTest {
+ server.enqueue(MockResponse(body = "4000"))
+ server.enqueue(MockResponse(body = "7000\n"))
+ val reported = CopyOnWriteArrayList()
+
+ withContext(Dispatchers.IO) {
+ val upload = launch { dataSource.upload(server.serverUrl()) { reported.add(it) } }
+ while (reported.size < 2) yield()
+
+ withTimeout(2.seconds) { upload.cancelAndJoin() }
+ }
+
+ assertEquals(listOf(4000L, 7000L), reported)
+ }
+
+ @Test
+ fun firstRequestIsASmallSizedPostWithToken() = runTest {
+ server.enqueue(MockResponse(body = "0"))
+
+ withContext(Dispatchers.IO) {
+ val upload = launch { dataSource.upload(server.serverUrl()) {} }
+ val request = checkNotNull(server.takeRequest(2, TimeUnit.SECONDS))
+ withTimeout(2.seconds) { upload.cancelAndJoin() }
+
+ assertEquals("POST", request.method)
+ assertEquals("/upload", request.url.encodedPath)
+ assertEquals("token-1", request.url.queryParameter("token"))
+ assertEquals("text/plain", request.headers["Accept"])
+ assertEquals(UPLOAD_FIRST_REQUEST_SIZE.toString(), request.headers["Content-Length"])
+ assertEquals(UPLOAD_FIRST_REQUEST_SIZE, request.bodySize)
+ }
+ }
+
+ @Test
+ fun failedRequestEndsTheUploadWithItsStatus() = runTest {
+ server.enqueue(MockResponse(code = 503, body = "Timeouted"))
+
+ val error = withContext(Dispatchers.IO) {
+ assertFailsWith { dataSource.upload(server.serverUrl()) {} }
+ }
+
+ assertEquals(503, error.code())
+ }
+
+ @Test
+ fun unreadableAnswerEndsTheUpload() = runTest {
+ server.enqueue(MockResponse(body = "Server is running"))
+
+ withContext(Dispatchers.IO) {
+ assertFailsWith { dataSource.upload(server.serverUrl()) {} }
+ }
+ }
+
+ @Test
+ fun nextSizeLastsTheTargetDurationAtThePreviousSpeed() {
+ val size = nextUploadSize(received = 1_000_000, elapsed = UPLOAD_REQUEST_DURATION * 4)
+
+ assertEquals(250_000, size)
+ }
+
+ @Test
+ fun nextSizeStaysWithinBounds() {
+ assertEquals(UPLOAD_FIRST_REQUEST_SIZE, nextUploadSize(received = 0, elapsed = 1.seconds))
+ assertEquals(UPLOAD_MAX_REQUEST_SIZE, nextUploadSize(received = 50_000_000, elapsed = 100.milliseconds))
+ assertEquals(UPLOAD_MAX_REQUEST_SIZE, nextUploadSize(received = 10_000, elapsed = Duration.ZERO))
+ }
+
+ @Test
+ fun bodyWritesExactlyItsDeclaredSize() {
+ val body = UploadBody(200_000)
+ val sink = Buffer()
+
+ body.writeTo(sink)
+
+ assertEquals(200_000, body.contentLength())
+ assertEquals(200_000, sink.size)
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/MeasurementServer.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/MeasurementServer.kt
new file mode 100644
index 0000000..4f38dbc
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/data/throughput/MeasurementServer.kt
@@ -0,0 +1,18 @@
+package com.martindoudera.speedreader.feature.speedtest.data.throughput
+
+import com.martindoudera.speedreader.feature.speedtest.data.di.DirectoryModule
+import com.martindoudera.speedreader.feature.speedtest.data.directory.TokenInterceptor
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+import com.martindoudera.speedreader.feature.speedtest.testing.data.testJson
+import com.martindoudera.speedreader.feature.speedtest.testing.directory.FakeTokenDataSource
+import mockwebserver3.MockWebServer
+import okhttp3.OkHttpClient
+import retrofit2.create
+
+/** A [MeasurementApi] wired like the app's, talking to [server]. */
+internal fun measurementApi(server: MockWebServer): MeasurementApi {
+ val client = OkHttpClient.Builder().addInterceptor(TokenInterceptor(FakeTokenDataSource())).build()
+ return DirectoryModule.retrofit(server.url("/"), client, testJson).create()
+}
+
+internal fun MockWebServer.serverUrl() = ServerUrl(url("/").toString().trimEnd('/'))
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/ThroughputMeterTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/ThroughputMeterTest.kt
new file mode 100644
index 0000000..a1c66aa
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/ThroughputMeterTest.kt
@@ -0,0 +1,97 @@
+package com.martindoudera.speedreader.feature.speedtest.domain
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ByteCount
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Throughput
+import com.martindoudera.speedreader.feature.speedtest.domain.model.TransferSample
+import com.martindoudera.speedreader.feature.speedtest.testing.data.SAMPLE_INTERVAL
+import kotlin.test.assertEquals
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import org.junit.Test
+
+class ThroughputMeterTest {
+ private val meter = ThroughputMeter()
+
+ @Test
+ fun firstSampleIsMeasuredFromTheStart() {
+ val sample = TransferSample(200.milliseconds, ByteCount(2_500_000))
+
+ val speed = meter.record(sample)
+
+ assertEquals(100_000_000.0, speed.bitsPerSecond, 1e-6)
+ }
+
+ @Test
+ fun currentSpeedFollowsTheLastSecondOnly() {
+ recordSteadyRate(bytesPerTick = 2_500_000, ticks = 10)
+
+ val speed = recordSteadyRate(bytesPerTick = 250_000, ticks = 5)
+
+ assertEquals(8_000_000.0, speed.bitsPerSecond, 1e-6)
+ }
+
+ @Test
+ fun averageIsTotalDataOverTotalTimeIncludingTheSlowPart() {
+ recordSteadyRate(bytesPerTick = 2_500_000, ticks = 10)
+ recordSteadyRate(bytesPerTick = 250_000, ticks = 10)
+
+ val average = meter.average()
+
+ assertEquals(44_000_000.0, average.bitsPerSecond, 1e-6)
+ }
+
+ @Test
+ fun stalledTransferReadsZero() {
+ recordSteadyRate(bytesPerTick = 2_500_000, ticks = 5)
+
+ val speed = recordSteadyRate(bytesPerTick = 0, ticks = 5)
+
+ assertEquals(Throughput.Zero, speed)
+ }
+
+ @Test
+ fun sampleWithoutElapsedTimeDoesNotDivideByZero() {
+ val sample = TransferSample(Duration.ZERO, ByteCount(1_000))
+
+ val speed = meter.record(sample)
+
+ assertEquals(Throughput.Zero, speed)
+ }
+
+ @Test
+ fun nothingRecordedAveragesToZero() {
+ val average = meter.average()
+
+ assertEquals(Throughput.Zero, average)
+ assertEquals(ByteCount.Zero, meter.transferred)
+ }
+
+ @Test
+ fun throughputConvertsBytesToBits() {
+ val throughput = Throughput.of(ByteCount(1), 1.seconds)
+
+ assertEquals(8.0, throughput.bitsPerSecond)
+ }
+
+ @Test
+ fun megabitsUseTheDecimalMega() {
+ val throughput = Throughput(163_500_000.0)
+
+ val megabits = throughput.megabitsPerSecond
+
+ assertEquals(163.5, megabits, 1e-9)
+ }
+
+ private var ticksRecorded = 0
+
+ private fun recordSteadyRate(bytesPerTick: Long, ticks: Int): Throughput {
+ var speed = Throughput.Zero
+ repeat(ticks) {
+ ticksRecorded++
+ val sample = TransferSample(SAMPLE_INTERVAL * ticksRecorded, meter.transferred + ByteCount(bytesPerTick))
+ speed = meter.record(sample)
+ }
+ return speed
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/RunSpeedTestUseCaseTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/RunSpeedTestUseCaseTest.kt
new file mode 100644
index 0000000..1275bba
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/RunSpeedTestUseCaseTest.kt
@@ -0,0 +1,270 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.usecase
+
+import app.cash.turbine.test
+import com.martindoudera.speedreader.core.common.error.HttpStatusCode
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestError
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ConnectionMode
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestProgress.Downloading
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestProgress.Failed
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestProgress.FindingServer
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestProgress.Finished
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestProgress.Uploading
+import com.martindoudera.speedreader.feature.speedtest.testing.data.geoPoint
+import com.martindoudera.speedreader.feature.speedtest.testing.data.steadyTransfer
+import com.martindoudera.speedreader.feature.speedtest.testing.data.testServer
+import com.martindoudera.speedreader.feature.speedtest.testing.data.transferSample
+import com.martindoudera.speedreader.feature.speedtest.testing.repository.FakeLatencyRepository
+import com.martindoudera.speedreader.feature.speedtest.testing.repository.FakeLocationRepository
+import com.martindoudera.speedreader.feature.speedtest.testing.repository.FakeServerRepository
+import com.martindoudera.speedreader.feature.speedtest.testing.repository.FakeThroughputRepository
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class RunSpeedTestUseCaseTest {
+ private val serverRepository = FakeServerRepository()
+ private val locationRepository = FakeLocationRepository()
+ private val latencyRepository = FakeLatencyRepository()
+ private val throughputRepository = FakeThroughputRepository()
+
+ private val runSpeedTest = RunSpeedTestUseCase(
+ serverRepository,
+ locationRepository,
+ latencyRepository,
+ throughputRepository,
+ SelectClosestServersUseCase(locationRepository),
+ )
+
+ @Before
+ fun givenAHealthyDirectoryAndServers() {
+ serverRepository.servers = Outcome.Success(DIRECTORY)
+ locationRepository.location = Outcome.Success(ORIGIN)
+ latencyRepository.latencies = DIRECTORY.associate { it.url to 20.milliseconds }
+ throughputRepository.downloadSamples = steadyTransfer(bytesPerTick = 1_000, until = PHASE_DURATION)
+ throughputRepository.uploadSamples = steadyTransfer(bytesPerTick = 1_000, until = PHASE_DURATION)
+ }
+
+ @Test
+ fun passesThroughEveryPhaseInOrder() = runTest {
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ val phases = states.map { it::class }.distinct()
+ assertEquals(listOf(FindingServer::class, Downloading::class, Uploading::class, Finished::class), phases)
+ }
+
+ @Test
+ fun finishesWithTheAverageOfEachPhase() = runTest {
+ throughputRepository.downloadSamples = steadyTransfer(bytesPerTick = 2_500_000, until = PHASE_DURATION)
+ throughputRepository.uploadSamples = steadyTransfer(bytesPerTick = 250_000, until = PHASE_DURATION)
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ val finished = assertIs(states.last())
+ assertEquals(80_000_000.0, finished.downloadSpeed.bitsPerSecond, 1e-6)
+ assertEquals(8_000_000.0, finished.uploadSpeed.bitsPerSecond, 1e-6)
+ }
+
+ @Test
+ fun uploadPhaseCarriesTheDownloadAverage() = runTest {
+ throughputRepository.downloadSamples = steadyTransfer(bytesPerTick = 2_500_000, until = PHASE_DURATION)
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ val uploading = states.filterIsInstance()
+ assertTrue(uploading.isNotEmpty())
+ assertTrue(uploading.all { it.downloadSpeed.bitsPerSecond == 80_000_000.0 })
+ }
+
+ @Test
+ fun measuringStatesCarryTheRunningAverageOfTheirPhase() = runTest {
+ throughputRepository.downloadSamples =
+ flowOf(transferSample(tick = 1, totalBytes = 250_000), transferSample(tick = 2, totalBytes = 1_000_000))
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ val downloading = states.filterIsInstance()
+ assertEquals(8_000_000.0, downloading[0].averageSpeed.bitsPerSecond, 1e-6)
+ assertEquals(16_000_000.0, downloading[1].averageSpeed.bitsPerSecond, 1e-6)
+ }
+
+ @Test
+ fun eachPhaseStopsAtHalfTheTestEvenIfTheServerKeepsSending() = runTest {
+ throughputRepository.downloadSamples = steadyTransfer(bytesPerTick = 1_000, until = 20.seconds)
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(PHASE_DURATION, throughputRepository.lastDownloadSampleConsumed)
+ assertEquals(30, states.filterIsInstance().size)
+ }
+
+ @Test
+ fun progressAndRemainingTimeSpanTheWholeTest() = runTest {
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ val downloading = states.filterIsInstance()
+ val uploading = states.filterIsInstance()
+ assertEquals(1f / 60, downloading.first().progress.fraction, 1e-6f)
+ assertEquals(14.75.seconds, downloading.first().remaining)
+ assertEquals(0.5f, downloading.last().progress.fraction)
+ assertEquals(7.5.seconds, downloading.last().remaining)
+ assertEquals(31f / 60, uploading.first().progress.fraction, 1e-6f)
+ assertEquals(1f, uploading.last().progress.fraction)
+ assertEquals(Duration.ZERO, uploading.last().remaining)
+ }
+
+ @Test
+ fun progressNeverPassesTheEndOfItsPhase() = runTest {
+ throughputRepository.downloadSamples = steadyTransfer(bytesPerTick = 1_000, until = 20.seconds)
+ throughputRepository.uploadSamples =
+ flowOf(transferSample(tick = 1, totalBytes = 1_000), transferSample(tick = 40, totalBytes = 2_000))
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(0.5f, states.filterIsInstance().maxOf { it.progress.fraction })
+ assertEquals(1f, states.filterIsInstance().last().progress.fraction)
+ assertEquals(Duration.ZERO, states.filterIsInstance().last().remaining)
+ }
+
+ @Test
+ fun picksTheReachableServerWithTheLowestLatency() = runTest {
+ latencyRepository.latencies = mapOf(NEAR.url to 30.milliseconds, MIDDLE.url to 12.milliseconds, FAR.url to 8.milliseconds)
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ val finished = assertIs(states.last())
+ assertEquals(FAR, finished.server.server)
+ assertEquals(8.milliseconds, finished.server.latency)
+ }
+
+ @Test
+ fun prefersTheClosestServerWhenLatenciesTie() = runTest {
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(NEAR, assertIs(states.last()).server.server)
+ }
+
+ @Test
+ fun skipsServersThatDoNotAnswer() = runTest {
+ latencyRepository.latencies = mapOf(MIDDLE.url to 20.milliseconds)
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(MIDDLE, assertIs(states.last()).server.server)
+ }
+
+ @Test
+ fun usesTheClosestServerWithoutLatencyWhenNoServerAnswers() = runTest {
+ latencyRepository.latencies = emptyMap()
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ val finished = assertIs(states.last())
+ assertEquals(NEAR, finished.server.server)
+ assertNull(finished.server.latency)
+ }
+
+ @Test
+ fun failsWithTheNetworkErrorWhenTheServerListCannotBeLoaded() = runTest {
+ val error = NetworkError.Http(HttpStatusCode(503))
+ serverRepository.servers = Outcome.Failure(error)
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(listOf(FindingServer, Failed(SpeedTestError.Network(error))), states)
+ }
+
+ @Test
+ fun failsWhenTheDirectoryOffersNoServers() = runTest {
+ serverRepository.servers = Outcome.Success(emptyList())
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(listOf(FindingServer, Failed(SpeedTestError.NoServersAvailable)), states)
+ }
+
+ @Test
+ fun withoutLocationUsesTheDirectoryOrder() = runTest {
+ locationRepository.location = Outcome.Failure(NetworkError.Timeout(null))
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(FAR, assertIs(states.last()).server.server)
+ }
+
+ @Test
+ fun downloadFailureEndsTheRunWithTheNetworkError() = runTest {
+ val error = NetworkError.NoConnection(null)
+ throughputRepository.downloadSamples = flowOf(transferSample(tick = 1, totalBytes = 1_000), Outcome.Failure(error))
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(Failed(SpeedTestError.Network(error)), states.last())
+ assertTrue(states.none { it is Uploading })
+ }
+
+ @Test
+ fun unexpectedExceptionEndsTheRunAsUnknownInsteadOfThrowing() = runTest {
+ val exception = IllegalStateException("Boom")
+ throughputRepository.downloadSamples = flow { throw exception }
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(Failed(SpeedTestError.Unknown(exception)), states.last())
+ }
+
+ @Test
+ fun waitingForTheFirstSampleNeitherFailsNorEnds() = runTest {
+ throughputRepository.downloadSamples = flow { awaitCancellation() }
+
+ runSpeedTest(ConnectionMode.Parallel).test {
+ assertEquals(FindingServer, awaitItem())
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun uploadWithoutASingleByteIsAMeasurementFailure() = runTest {
+ throughputRepository.uploadSamples = steadyTransfer(bytesPerTick = 0, until = PHASE_DURATION)
+
+ val states = runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(Failed(SpeedTestError.MeasurementFailed), states.last())
+ }
+
+ @Test
+ fun singleModeUsesOneConnectionInEachDirection() = runTest {
+ runSpeedTest(ConnectionMode.Single).toList()
+
+ assertEquals(listOf(1, 1), throughputRepository.requestedConnections)
+ }
+
+ @Test
+ fun parallelModeUsesSixConnectionsInEachDirection() = runTest {
+ runSpeedTest(ConnectionMode.Parallel).toList()
+
+ assertEquals(listOf(6, 6), throughputRepository.requestedConnections)
+ }
+
+ private companion object {
+ val ORIGIN = geoPoint(49.0, 16.0)
+
+ val FAR = testServer("https://far.example", 52.0, 16.0)
+ val NEAR = testServer("https://near.example", 49.5, 16.0)
+ val MIDDLE = testServer("https://middle.example", 50.0, 16.0)
+
+ val DIRECTORY = listOf(FAR, NEAR, MIDDLE)
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/SelectClosestServersUseCaseTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/SelectClosestServersUseCaseTest.kt
new file mode 100644
index 0000000..6019990
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/domain/usecase/SelectClosestServersUseCaseTest.kt
@@ -0,0 +1,75 @@
+package com.martindoudera.speedreader.feature.speedtest.domain.usecase
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.testing.data.geoPoint
+import com.martindoudera.speedreader.feature.speedtest.testing.data.testServer
+import com.martindoudera.speedreader.feature.speedtest.testing.repository.FakeLocationRepository
+import kotlin.random.Random
+import kotlin.test.assertEquals
+import org.junit.Test
+
+class SelectClosestServersUseCaseTest {
+ private val selectClosestServers = SelectClosestServersUseCase(FakeLocationRepository())
+
+ @Test
+ fun picksTheClosestServersInOrderOfDistance() {
+ val closest = selectClosestServers(directory, ORIGIN)
+
+ assertEquals(listOf(NEAREST, SECOND, CO_LOCATED_A, CO_LOCATED_B, FIFTH), closest)
+ }
+
+ @Test
+ fun coLocatedServersAreOrderedByUrl() {
+ val coLocated = listOf(CO_LOCATED_B, CO_LOCATED_A)
+
+ val ordered = selectClosestServers(coLocated, ORIGIN)
+
+ assertEquals(listOf(CO_LOCATED_A, CO_LOCATED_B), ordered)
+ }
+
+ @Test
+ fun inputOrderDoesNotChangeTheSelection() {
+ val shuffledDirectories = List(20) { seed -> directory.shuffled(Random(seed)) }
+
+ val selections = shuffledDirectories.map { selectClosestServers(it, ORIGIN) }
+
+ assertEquals(setOf(selectClosestServers(directory, ORIGIN)), selections.toSet())
+ }
+
+ @Test
+ fun withoutLocationKeepsTheDirectoryOrder() {
+ val closest = selectClosestServers(directory, from = null)
+
+ assertEquals(directory.take(5), closest)
+ }
+
+ @Test
+ fun returnsEveryServerWhenThereAreFewerThanRequested() {
+ val smallDirectory = directory.take(3)
+
+ val closest = selectClosestServers(smallDirectory, ORIGIN)
+
+ assertEquals(3, closest.size)
+ }
+
+ @Test
+ fun emptyDirectoryGivesNoServers() {
+ val closest = selectClosestServers(emptyList(), ORIGIN)
+
+ assertEquals(emptyList(), closest)
+ }
+
+ private companion object {
+ val ORIGIN = geoPoint(49.0, 16.0)
+
+ val NEAREST = testServer("https://nearest.example", 49.1, 16.0)
+ val SECOND = testServer("https://second.example", 49.3, 16.0)
+ val CO_LOCATED_A = testServer("https://co-located-a.example", 49.5, 16.0)
+ val CO_LOCATED_B = testServer("https://co-located-b.example", 49.5, 16.0)
+ val FIFTH = testServer("https://fifth.example", 49.7, 16.0)
+ val SIXTH = testServer("https://sixth.example", 49.9, 16.0)
+ val FARTHEST = testServer("https://farthest.example", 50.5, 16.0)
+
+ val directory = listOf(FARTHEST, CO_LOCATED_B, SECOND, SIXTH, NEAREST, CO_LOCATED_A, FIFTH)
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/MainDispatcherRule.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/MainDispatcherRule.kt
new file mode 100644
index 0000000..a65295b
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/MainDispatcherRule.kt
@@ -0,0 +1,16 @@
+package com.martindoudera.speedreader.feature.speedtest.testing
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+/** Replaces `Dispatchers.Main` for the duration of a test, so `viewModelScope` runs on the test dispatcher. */
+class MainDispatcherRule(private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() {
+ override fun starting(description: Description) = Dispatchers.setMain(testDispatcher)
+
+ override fun finished(description: Description) = Dispatchers.resetMain()
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/data/TestData.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/data/TestData.kt
new file mode 100644
index 0000000..af5de26
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/data/TestData.kt
@@ -0,0 +1,45 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.data
+
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ByteCount
+import com.martindoudera.speedreader.feature.speedtest.domain.model.GeoPoint
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Latitude
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Longitude
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.model.TransferSample
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.onEach
+import kotlinx.serialization.json.Json
+import okhttp3.ResponseBody.Companion.toResponseBody
+import retrofit2.HttpException
+import retrofit2.Response
+
+val SAMPLE_INTERVAL = 250.milliseconds
+
+fun geoPoint(latitude: Double, longitude: Double) = GeoPoint(Latitude(latitude), Longitude(longitude))
+
+fun testServer(url: String, latitude: Double, longitude: Double) =
+ SpeedTestServer(ServerUrl(url), geoPoint(latitude, longitude), provider = null, city = null, countryCode = null)
+
+fun transferSample(tick: Int, totalBytes: Long): NetworkOutcome =
+ Outcome.Success(TransferSample(SAMPLE_INTERVAL * tick, ByteCount(totalBytes)))
+
+/** A transfer moving [bytesPerTick] every [SAMPLE_INTERVAL] until [until]. */
+fun steadyTransfer(bytesPerTick: Long, until: Duration): Flow> {
+ val ticks = (until / SAMPLE_INTERVAL).toInt()
+ return (1..ticks).map { tick -> transferSample(tick, bytesPerTick * tick) }.asFlow()
+}
+
+/** [steadyTransfer] paced in virtual time, one sample every [SAMPLE_INTERVAL], as the real repository reports them. */
+fun pacedTransfer(bytesPerTick: Long, until: Duration): Flow> =
+ steadyTransfer(bytesPerTick, until).onEach { delay(SAMPLE_INTERVAL) }
+
+val testJson = Json { ignoreUnknownKeys = true }
+
+fun httpError(code: Int) = HttpException(Response.error(code, "".toResponseBody()))
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/directory/FakeSpeedTestDirectoryApi.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/directory/FakeSpeedTestDirectoryApi.kt
new file mode 100644
index 0000000..45851b6
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/directory/FakeSpeedTestDirectoryApi.kt
@@ -0,0 +1,24 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.directory
+
+import com.martindoudera.speedreader.feature.speedtest.data.directory.SpeedTestDirectoryApi
+import com.martindoudera.speedreader.feature.speedtest.data.directory.dto.IpLocationDto
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.jsonArray
+
+internal class FakeSpeedTestDirectoryApi : SpeedTestDirectoryApi {
+ var serversJson = "[]"
+ var serversFailure: Exception? = null
+ var clientLocation = IpLocationDto(lat = 49.1953, lon = 16.6016, countryCode = "CZ")
+ var clientLocationFailure: Exception? = null
+
+ override suspend fun servers(): JsonArray {
+ serversFailure?.let { throw it }
+ return Json.parseToJsonElement(serversJson).jsonArray
+ }
+
+ override suspend fun clientLocation(): IpLocationDto {
+ clientLocationFailure?.let { throw it }
+ return clientLocation
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/directory/FakeTokenDataSource.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/directory/FakeTokenDataSource.kt
new file mode 100644
index 0000000..2b0acb9
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/directory/FakeTokenDataSource.kt
@@ -0,0 +1,28 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.directory
+
+import com.martindoudera.speedreader.feature.speedtest.data.directory.DirectoryToken
+import com.martindoudera.speedreader.feature.speedtest.data.directory.TokenDataSource
+
+/** Issues `token-1`, `token-2`, … and a new one only after the current one is invalidated. */
+internal class FakeTokenDataSource : TokenDataSource {
+ var failure: Exception? = null
+ var renewalFailure: Exception? = null
+
+ var issuedTokens = 0
+ private set
+ val invalidated = mutableListOf()
+
+ private var current: DirectoryToken? = null
+
+ override fun token(): DirectoryToken {
+ failure?.let { throw it }
+ current?.let { return it }
+ if (issuedTokens > 0) renewalFailure?.let { throw it }
+ return DirectoryToken("token-${++issuedTokens}").also { current = it }
+ }
+
+ override fun invalidate(token: DirectoryToken) {
+ invalidated += token
+ if (current == token) current = null
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/latency/FakeDns.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/latency/FakeDns.kt
new file mode 100644
index 0000000..1eab49b
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/latency/FakeDns.kt
@@ -0,0 +1,12 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.latency
+
+import java.net.InetAddress
+import java.net.UnknownHostException
+import okhttp3.Dns
+
+/** Resolves only the hosts in [addresses], to their addresses in the given order. */
+internal class FakeDns : Dns {
+ var addresses: Map> = emptyMap()
+
+ override fun lookup(hostname: String): List = addresses[hostname] ?: throw UnknownHostException(hostname)
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/latency/FakePingDataSource.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/latency/FakePingDataSource.kt
new file mode 100644
index 0000000..dc23a05
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/latency/FakePingDataSource.kt
@@ -0,0 +1,22 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.latency
+
+import com.martindoudera.speedreader.feature.speedtest.data.latency.PingDataSource
+import java.io.IOException
+import java.net.InetAddress
+import kotlin.time.Duration
+
+/** Answers probe n with `roundTrips[n]`; a null entry or a missing one is a lost probe. */
+internal class FakePingDataSource : PingDataSource {
+ var roundTrips: List = emptyList()
+ var failure: IOException? = null
+
+ val pinged = mutableListOf()
+ val timeouts = mutableListOf()
+
+ override fun echo(address: InetAddress, timeout: Duration): Duration? {
+ failure?.let { throw it }
+ pinged += address
+ timeouts += timeout
+ return roundTrips.getOrNull(pinged.size - 1)
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeLatencyRepository.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeLatencyRepository.kt
new file mode 100644
index 0000000..b708830
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeLatencyRepository.kt
@@ -0,0 +1,17 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.repository
+
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestError
+import com.martindoudera.speedreader.feature.speedtest.domain.SpeedTestOutcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.LatencyRepository
+import kotlin.time.Duration
+
+/** Servers without a configured latency do not answer. */
+class FakeLatencyRepository : LatencyRepository {
+ var latencies: Map = emptyMap()
+
+ override suspend fun measureLatency(server: SpeedTestServer): SpeedTestOutcome =
+ latencies[server.url]?.let { Outcome.Success(it) } ?: Outcome.Failure(SpeedTestError.ServerUnreachable)
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeLocationRepository.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeLocationRepository.kt
new file mode 100644
index 0000000..6ba06df
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeLocationRepository.kt
@@ -0,0 +1,21 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.repository
+
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Distance
+import com.martindoudera.speedreader.feature.speedtest.domain.model.GeoPoint
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.LocationRepository
+import kotlin.math.hypot
+
+/** Distances are flat-plane approximations (1° = 111 km), enough to order points in tests. */
+class FakeLocationRepository : LocationRepository {
+ var location: NetworkOutcome = Outcome.Failure(NetworkError.NoConnection(null))
+
+ override suspend fun currentLocation(): NetworkOutcome = location
+
+ override fun distance(from: GeoPoint, to: GeoPoint): Distance {
+ val degrees = hypot(to.latitude.degrees - from.latitude.degrees, to.longitude.degrees - from.longitude.degrees)
+ return Distance(degrees * 111_000)
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeServerRepository.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeServerRepository.kt
new file mode 100644
index 0000000..e38cbb6
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeServerRepository.kt
@@ -0,0 +1,12 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.repository
+
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.ServerRepository
+
+class FakeServerRepository : ServerRepository {
+ var servers: NetworkOutcome> = Outcome.Success(emptyList())
+
+ override suspend fun servers(): NetworkOutcome> = servers
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeThroughputRepository.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeThroughputRepository.kt
new file mode 100644
index 0000000..e6acfc7
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/repository/FakeThroughputRepository.kt
@@ -0,0 +1,31 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.repository
+
+import com.martindoudera.speedreader.core.common.error.NetworkOutcome
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.model.TransferSample
+import com.martindoudera.speedreader.feature.speedtest.domain.repository.ThroughputRepository
+import kotlin.time.Duration
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.onEach
+
+/** Replays the configured samples and records what the caller asked for and consumed. */
+class FakeThroughputRepository : ThroughputRepository {
+ var downloadSamples: Flow> = emptyFlow()
+ var uploadSamples: Flow> = emptyFlow()
+
+ val requestedConnections = mutableListOf()
+ var lastDownloadSampleConsumed: Duration? = null
+ private set
+
+ override fun download(server: SpeedTestServer, connections: Int): Flow> {
+ requestedConnections += connections
+ return downloadSamples.onEach { if (it is Outcome.Success) lastDownloadSampleConsumed = it.value.elapsed }
+ }
+
+ override fun upload(server: SpeedTestServer, connections: Int): Flow> {
+ requestedConnections += connections
+ return uploadSamples
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/throughput/FakeTransferDataSource.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/throughput/FakeTransferDataSource.kt
new file mode 100644
index 0000000..ef3dfae
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/testing/throughput/FakeTransferDataSource.kt
@@ -0,0 +1,54 @@
+package com.martindoudera.speedreader.feature.speedtest.testing.throughput
+
+import com.martindoudera.speedreader.feature.speedtest.data.throughput.DownloadDataSource
+import com.martindoudera.speedreader.feature.speedtest.data.throughput.UploadDataSource
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+
+/**
+ * Moves [bytesPerTick] every [tick] of virtual time per request. A request ends after
+ * [bytesPerRequest] and, when [failure] is set, fails once it has run for [failAfter]; only the
+ * first [failingRequests] requests fail.
+ *
+ * The 40 ms tick never coincides with a 250 ms sample, so a test never depends on which of two
+ * coroutines due at the same virtual instant runs first.
+ */
+internal class FakeTransferDataSource :
+ DownloadDataSource,
+ UploadDataSource {
+ var bytesPerTick = 1_000L
+ var tick = 40.milliseconds
+ var bytesPerRequest = Long.MAX_VALUE
+ var failure: Exception? = null
+ var failAfter = Duration.ZERO
+ var failingRequests = Int.MAX_VALUE
+
+ val requestedServers = mutableListOf()
+ var activeRequests = 0
+ private set
+
+ override suspend fun download(server: ServerUrl, onBytes: (Long) -> Unit) = transfer(server, onBytes)
+
+ override suspend fun upload(server: ServerUrl, onBytes: (Long) -> Unit) = transfer(server, onBytes)
+
+ private suspend fun transfer(server: ServerUrl, onBytes: (Long) -> Unit) {
+ requestedServers += server
+ val failing = requestedServers.size <= failingRequests
+ activeRequests++
+ try {
+ var sent = 0L
+ var running = Duration.ZERO
+ while (sent < bytesPerRequest) {
+ delay(tick)
+ running += tick
+ failure?.takeIf { failing && running >= failAfter }?.let { throw it }
+ onBytes(bytesPerTick)
+ sent += bytesPerTick
+ }
+ } finally {
+ activeRequests--
+ }
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestFormatTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestFormatTest.kt
new file mode 100644
index 0000000..e00d408
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestFormatTest.kt
@@ -0,0 +1,73 @@
+package com.martindoudera.speedreader.feature.speedtest.ui
+
+import com.martindoudera.speedreader.feature.speedtest.domain.model.Throughput
+import java.util.Locale
+import kotlin.test.assertEquals
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import org.junit.Test
+
+class SpeedTestFormatTest {
+ @Test
+ fun speedsBelowOneHundredKeepOneDecimal() {
+ val speed = Throughput(41_549_000.0)
+
+ val text = speed.formatMegabits(Locale.US)
+
+ assertEquals("41.5", text)
+ }
+
+ @Test
+ fun speedsFromOneHundredAreWholeNumbers() {
+ val speed = Throughput(236_800_000.0)
+
+ val text = speed.formatMegabits(Locale.US)
+
+ assertEquals("237", text)
+ }
+
+ @Test
+ fun speedsThatRoundToOneHundredAreWholeNumbers() {
+ val speed = Throughput(99_960_000.0)
+
+ val text = speed.formatMegabits(Locale.US)
+
+ assertEquals("100", text)
+ }
+
+ @Test
+ fun theDecimalSeparatorFollowsTheLocale() {
+ val speed = Throughput(41_549_000.0)
+
+ val text = speed.formatMegabits(Locale.forLanguageTag("cs-CZ"))
+
+ assertEquals("41,5", text)
+ }
+
+ @Test
+ fun remainingTimeRoundsUpToWholeSeconds() {
+ val remaining = 10.5.seconds
+
+ val seconds = remaining.wholeSecondsLeft()
+
+ assertEquals(11, seconds)
+ }
+
+ @Test
+ fun latencyIsShownInWholeMilliseconds() {
+ val latency = 9.7.milliseconds
+
+ val text = latency.formatMilliseconds()
+
+ assertEquals("9", text)
+ }
+
+ @Test
+ fun subMillisecondLatencyIsNotShownAsZero() {
+ val latency = 0.4.milliseconds
+
+ val text = latency.formatMilliseconds()
+
+ assertEquals("<1", text)
+ }
+}
diff --git a/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestViewModelTest.kt b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestViewModelTest.kt
new file mode 100644
index 0000000..d09db7f
--- /dev/null
+++ b/feature/speedtest/src/test/kotlin/com/martindoudera/speedreader/feature/speedtest/ui/SpeedTestViewModelTest.kt
@@ -0,0 +1,313 @@
+package com.martindoudera.speedreader.feature.speedtest.ui
+
+import androidx.lifecycle.SavedStateHandle
+import app.cash.turbine.test
+import com.martindoudera.speedreader.core.common.domain.model.AppVersion
+import com.martindoudera.speedreader.core.common.error.NetworkError
+import com.martindoudera.speedreader.core.common.error.Outcome
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ConnectionMode
+import com.martindoudera.speedreader.feature.speedtest.domain.model.ServerUrl
+import com.martindoudera.speedreader.feature.speedtest.domain.model.SpeedTestServer
+import com.martindoudera.speedreader.feature.speedtest.domain.usecase.PHASE_DURATION
+import com.martindoudera.speedreader.feature.speedtest.domain.usecase.RunSpeedTestUseCase
+import com.martindoudera.speedreader.feature.speedtest.domain.usecase.SelectClosestServersUseCase
+import com.martindoudera.speedreader.feature.speedtest.testing.MainDispatcherRule
+import com.martindoudera.speedreader.feature.speedtest.testing.data.geoPoint
+import com.martindoudera.speedreader.feature.speedtest.testing.data.pacedTransfer
+import com.martindoudera.speedreader.feature.speedtest.testing.data.steadyTransfer
+import com.martindoudera.speedreader.feature.speedtest.testing.data.transferSample
+import com.martindoudera.speedreader.feature.speedtest.testing.repository.FakeLatencyRepository
+import com.martindoudera.speedreader.feature.speedtest.testing.repository.FakeLocationRepository
+import com.martindoudera.speedreader.feature.speedtest.testing.repository.FakeServerRepository
+import com.martindoudera.speedreader.feature.speedtest.testing.repository.FakeThroughputRepository
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class SpeedTestViewModelTest {
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private val serverRepository = FakeServerRepository()
+ private val locationRepository = FakeLocationRepository()
+ private val latencyRepository = FakeLatencyRepository()
+ private val throughputRepository = FakeThroughputRepository()
+
+ @Before
+ fun givenAHealthyServer() {
+ serverRepository.servers = Outcome.Success(listOf(SERVER))
+ locationRepository.location = Outcome.Success(geoPoint(49.0, 16.0))
+ latencyRepository.latencies = mapOf(SERVER.url to 9.milliseconds)
+ throughputRepository.downloadSamples = steadyTransfer(bytesPerTick = 2_500_000, until = PHASE_DURATION)
+ throughputRepository.uploadSamples = steadyTransfer(bytesPerTick = 250_000, until = PHASE_DURATION)
+ }
+
+ @Test
+ fun startsIdleWithParallelConnections() {
+ val viewModel = createViewModel()
+
+ assertEquals(SpeedTestUiState(APP_VERSION, ConnectionMode.Parallel, SpeedTestPhase.Idle), viewModel.uiState.value)
+ }
+
+ @Test
+ fun keepsTheChosenConnectionModeAcrossProcessDeath() {
+ val savedState = SavedStateHandle()
+ createViewModel(savedState).onSingleConnectionChange(true)
+
+ val restored = createViewModel(savedState)
+
+ assertEquals(ConnectionMode.Single, restored.uiState.value.connectionMode)
+ }
+
+ @Test
+ fun showsFindingServerUntilTheMeasurementStarts() {
+ throughputRepository.downloadSamples = flow { awaitCancellation() }
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ assertEquals(SpeedTestPhase.FindingServer, viewModel.uiState.value.phase)
+ }
+
+ @Test
+ fun showsTheCurrentAndTheAverageSpeedWhileDownloading() {
+ throughputRepository.downloadSamples = flow {
+ emit(transferSample(tick = 1, totalBytes = 250_000))
+ emit(transferSample(tick = 8, totalBytes = 1_250_000))
+ emit(transferSample(tick = 12, totalBytes = 5_250_000))
+ awaitCancellation()
+ }
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ val downloading = assertIs(viewModel.uiState.value.phase)
+ assertEquals(32_000_000.0, downloading.currentSpeed.bitsPerSecond, 1e-6)
+ assertEquals(14_000_000.0, downloading.averageSpeed.bitsPerSecond, 1e-6)
+ }
+
+ @Test
+ fun labelsTheServerWithItsProviderAndLocation() {
+ throughputRepository.downloadSamples = flow {
+ emit(transferSample(tick = 1, totalBytes = 1_000))
+ awaitCancellation()
+ }
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ val downloading = assertIs(viewModel.uiState.value.phase)
+ assertEquals(ServerUiModel(name = "CETIN a.s.", location = "Prague, CZ", latency = 9.milliseconds), downloading.server)
+ }
+
+ @Test
+ fun namesAServerWithoutProviderByItsHost() {
+ val anonymous = SERVER.copy(provider = null, city = null, countryCode = null)
+ serverRepository.servers = Outcome.Success(listOf(anonymous))
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ val finished = assertIs(viewModel.uiState.value.phase)
+ assertEquals(ServerUiModel(name = "speed.example.net", location = null, latency = 9.milliseconds), finished.server)
+ }
+
+ @Test
+ fun keepsTheDownloadAverageWhileUploading() {
+ throughputRepository.uploadSamples = flow {
+ emit(transferSample(tick = 1, totalBytes = 250_000))
+ awaitCancellation()
+ }
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ val uploading = assertIs(viewModel.uiState.value.phase)
+ assertEquals(80_000_000.0, uploading.downloadSpeed.bitsPerSecond, 1e-6)
+ assertEquals(8_000_000.0, uploading.averageSpeed.bitsPerSecond, 1e-6)
+ }
+
+ @Test
+ fun showsBothAveragesWhenTheTestFinishes() {
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ val finished = assertIs(viewModel.uiState.value.phase)
+ assertEquals(80_000_000.0, finished.downloadSpeed.bitsPerSecond, 1e-6)
+ assertEquals(8_000_000.0, finished.uploadSpeed.bitsPerSecond, 1e-6)
+ }
+
+ @Test
+ fun goesThroughEveryPhaseOfARunInOrder() = runTest {
+ throughputRepository.downloadSamples = pacedTransfer(bytesPerTick = 2_500_000, until = PHASE_DURATION)
+ throughputRepository.uploadSamples = pacedTransfer(bytesPerTick = 250_000, until = PHASE_DURATION)
+ val viewModel = createViewModel()
+
+ viewModel.uiState.phases().test {
+ viewModel.onStartClick()
+
+ assertEquals(
+ listOf(
+ SpeedTestPhase.Idle::class,
+ SpeedTestPhase.FindingServer::class,
+ SpeedTestPhase.Downloading::class,
+ SpeedTestPhase.Uploading::class,
+ SpeedTestPhase.Finished::class,
+ ),
+ List(5) { awaitItem() },
+ )
+ }
+ }
+
+ @Test
+ fun stopReturnsToIdleAndNothingFollows() = runTest {
+ throughputRepository.downloadSamples = flow {
+ emit(transferSample(tick = 1, totalBytes = 1_000))
+ awaitCancellation()
+ }
+ val viewModel = createViewModel()
+ viewModel.onStartClick()
+
+ viewModel.uiState.phases().test {
+ viewModel.onStopClick()
+
+ assertEquals(listOf(SpeedTestPhase.Downloading::class, SpeedTestPhase.Idle::class), List(2) { awaitItem() })
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun startIsIgnoredWhileATestIsRunning() {
+ throughputRepository.downloadSamples = flow { awaitCancellation() }
+ val viewModel = createViewModel()
+ viewModel.onStartClick()
+
+ viewModel.onStartClick()
+
+ assertEquals(listOf(6), throughputRepository.requestedConnections)
+ }
+
+ @Test
+ fun runsWithASingleConnectionWhenChosen() {
+ val viewModel = createViewModel()
+ viewModel.onSingleConnectionChange(true)
+
+ viewModel.onStartClick()
+
+ assertEquals(listOf(1, 1), throughputRepository.requestedConnections)
+ }
+
+ @Test
+ fun backFromTheResultReturnsToIdle() {
+ val viewModel = createViewModel()
+ viewModel.onStartClick()
+
+ viewModel.onBackClick()
+
+ assertEquals(SpeedTestPhase.Idle, viewModel.uiState.value.phase)
+ }
+
+ @Test
+ fun backIsIgnoredWhileATestIsRunning() {
+ throughputRepository.downloadSamples = flow { awaitCancellation() }
+ val viewModel = createViewModel()
+ viewModel.onStartClick()
+
+ viewModel.onBackClick()
+
+ assertEquals(SpeedTestPhase.FindingServer, viewModel.uiState.value.phase)
+ }
+
+ @Test
+ fun explainsAMissingConnectionAsOffline() {
+ serverRepository.servers = Outcome.Failure(NetworkError.NoConnection(null))
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ assertEquals(SpeedTestPhase.Failed(FailureReason.Offline), viewModel.uiState.value.phase)
+ }
+
+ @Test
+ fun showsNoLatencyWhenNoServerAnswersPing() {
+ latencyRepository.latencies = emptyMap()
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ val finished = assertIs(viewModel.uiState.value.phase)
+ assertEquals(ServerUiModel(name = "CETIN a.s.", location = "Prague, CZ", latency = null), finished.server)
+ }
+
+ @Test
+ fun explainsATimedOutTransferAsConnectionLost() {
+ throughputRepository.downloadSamples = flow {
+ emit(transferSample(tick = 1, totalBytes = 1_000))
+ emit(Outcome.Failure(NetworkError.Timeout(null)))
+ }
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ assertEquals(SpeedTestPhase.Failed(FailureReason.ConnectionLost), viewModel.uiState.value.phase)
+ }
+
+ @Test
+ fun explainsAnUnexpectedExceptionAsUnexpected() {
+ throughputRepository.downloadSamples = flow { throw IllegalStateException("Boom") }
+ val viewModel = createViewModel()
+
+ viewModel.onStartClick()
+
+ assertEquals(SpeedTestPhase.Failed(FailureReason.Unexpected), viewModel.uiState.value.phase)
+ }
+
+ @Test
+ fun startsAgainAfterAnUnexpectedException() {
+ throughputRepository.downloadSamples = flow { throw IllegalStateException("Boom") }
+ val viewModel = createViewModel()
+ viewModel.onStartClick()
+ throughputRepository.downloadSamples = flow { awaitCancellation() }
+
+ viewModel.onStartClick()
+
+ assertEquals(SpeedTestPhase.FindingServer, viewModel.uiState.value.phase)
+ }
+
+ /** The phase types the screen passes through; the measuring phases repeat with every sample. */
+ private fun StateFlow.phases() = map { it.phase::class }.distinctUntilChanged()
+
+ private fun createViewModel(savedState: SavedStateHandle = SavedStateHandle()) = SpeedTestViewModel(
+ RunSpeedTestUseCase(
+ serverRepository,
+ locationRepository,
+ latencyRepository,
+ throughputRepository,
+ SelectClosestServersUseCase(locationRepository),
+ ),
+ savedState,
+ APP_VERSION,
+ )
+
+ private companion object {
+ val APP_VERSION = AppVersion("0.0.1")
+
+ val SERVER = SpeedTestServer(
+ url = ServerUrl("https://speed.example.net:8080"),
+ location = geoPoint(49.5, 16.0),
+ provider = "CETIN a.s.",
+ city = "Prague",
+ countryCode = "CZ",
+ )
+ }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..4293fe6
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,21 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# When enabled, the Configuration Cache allows Gradle to skip the configuration
+# phase entirely if nothing that affects the build configuration (such as build scripts)
+# has changed. Additionally, Gradle applies performance optimizations to task execution.
+org.gradle.configuration-cache=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Compose Preview Screenshot Testing is experimental and has to be enabled project-wide.
+android.experimental.enableScreenshotTest=true
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..fa4ed51
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
+toolchainVersion=25
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..af2ab4a
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,68 @@
+[versions]
+agp = "9.4.1"
+kotlin = "2.3.0"
+ksp = "2.3.4"
+junit = "4.13.2"
+lifecycle = "2.11.0"
+activityCompose = "1.13.0"
+composeBom = "2026.09.00"
+material3Adaptive = "1.3.0"
+okhttp = "5.5.1"
+retrofit = "3.0.0"
+kotlinxSerialization = "1.11.0"
+coroutines = "1.11.0"
+hilt = "2.60.1"
+hiltViewModelCompose = "1.4.0"
+timber = "5.0.1"
+spotless = "8.10.4"
+ktlint = "1.8.0"
+screenshot = "0.0.1-alpha16"
+konsist = "0.18.1"
+mockk = "1.14.11"
+turbine = "1.2.1"
+kover = "0.9.11"
+
+[libraries]
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "material3Adaptive" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
+kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+okhttp-mockwebserver3 = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" }
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
+androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "hiltViewModelCompose" }
+timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
+konsist = { group = "com.lemonappdev", name = "konsist", version.ref = "konsist" }
+mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
+turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
+screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" }
+android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
+kover-gradlePlugin = { group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
+screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot" }
+kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..b1b8ef5
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..5907d39
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,10 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=21bd5da2de49b642904077f38c0efdc7778e92b3ed0889392dca61b97e389607
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..249efbb
--- /dev/null
+++ b/gradlew
@@ -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
+#
+
+##############################################################################
+#
+# gradlew 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 gradlew
+#
+# 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..a51ec4f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,82 @@
+@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 gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+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
+
+"%COMSPEC%" /c exit 1
+
+: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
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..ab1a889
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,33 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+includeBuild("build-logic")
+
+rootProject.name = "SpeedReader"
+
+include(":app")
+include(":core:common")
+include(":core:designsystem")
+include(":core:network")
+include(":feature:speedtest")