Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
a750bf6
AS project init
doudasek Oct 22, 2026
b06ead6
Add libs, update versions
doudasek Oct 22, 2026
667e2ae
Setup Spotless, run formatting
doudasek Oct 22, 2026
f105935
Add Timber, custom Application class
doudasek Oct 22, 2026
c815eb5
Create modules
doudasek Oct 22, 2026
62aa8d1
Implement common module
doudasek Oct 22, 2026
d977ffb
Base Network and Json setup
doudasek Oct 22, 2026
2510613
Error handling with sealed error class
doudasek Oct 22, 2026
e5e7856
Implement domain layer
doudasek Oct 22, 2026
1e0eb9d
Unify unit tests with fakes
doudasek Oct 22, 2026
11d3dbb
Implement ServerRepository with TokenInterceptor
doudasek Oct 22, 2026
f5cc5e6
Add more logs and comments
doudasek Oct 22, 2026
3d2863e
Implement LocationRepository
doudasek Oct 22, 2026
d023a07
Implement LatencyRepository
doudasek Oct 22, 2026
af8ee98
Implement ThroughtputRepository
doudasek Oct 22, 2026
38aedc6
Define design system, components
doudasek Oct 22, 2026
a60a2f8
Implement ViewModel
doudasek Oct 22, 2026
cba82d6
Implement the screen
doudasek Oct 22, 2026
50142f9
Add screenshot testing
doudasek Oct 22, 2026
756fc4b
Czech localization
doudasek Oct 22, 2026
0520ac8
Fix upload measurement
doudasek Oct 22, 2026
1b118dc
Create Github action to check PR
doudasek Oct 22, 2026
114f060
UI fixes and improvements
doudasek Oct 22, 2026
447a5ed
Add Konsist tests
doudasek Oct 22, 2026
25ccc82
Avoid clear text for networks
doudasek Oct 22, 2026
c9504c9
Do not use backup feature
doudasek Oct 22, 2026
8502d20
Add ProGuard
doudasek Oct 22, 2026
e4877c0
Ensure UseCase cannot throw
doudasek Oct 22, 2026
c37d299
Add app version to the init screen
doudasek Oct 22, 2026
c4c8b8a
Add code coverage - Jacoco
doudasek Oct 22, 2026
0a27ace
Improve CallExecution.kt
doudasek Oct 23, 2026
347a00c
Use Kover instead of Jacoco
doudasek Oct 23, 2026
33dc23a
If ping fails, it takes server anyway
doudasek Oct 23, 2026
66d16e3
Ensure if one stream fails, other can run
doudasek Oct 23, 2026
d1ecdf0
Remove module classes from test coverage
doudasek Oct 23, 2026
61b996f
Improve UI on bigger screens
doudasek Oct 23, 2026
510ee88
Final improvements and polishing
doudasek Oct 23, 2026
9144646
Add also Turbine and Mockk to have entire tech stack
doudasek Oct 23, 2026
872db5d
Add author
doudasek Oct 23, 2026
5f5e8ea
Add README.md with APK ready
doudasek Oct 23, 2026
2c1fb22
Add screenshots to README.md
doudasek Oct 23, 2026
2af98a7
Add main branch check
doudasek Oct 23, 2026
234607d
Extend README.md
doudasek Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
99 changes: 99 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
@@ -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/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ captures/
.cxx/
*.aab
*.apk
!apk/debug.apk
output-metadata.json

# IntelliJ
Expand Down
230 changes: 229 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,230 @@
# SpeedReader
Check your connection speed with native Android app.

[![Checks](https://github.com/doudasek/SpeedReader/actions/workflows/pr-checks.yml/badge.svg?branch=main)](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<br/>SpeedTestScreen, SpeedTestViewModel"]
domain["domain<br/>use cases, repository interfaces, models"]
data["data<br/>repositories, data sources, Retrofit APIs"]
end

designsystem[":core:designsystem<br/>theme, components, adaptive layout"]
network[":core:network<br/>OkHttp, JSON, safe API calls"]
common[":core:common<br/>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.

<p>
<img src="docs/screenshots/start.png" width="32%" alt="Start screen">
<img src="docs/screenshots/download.png" width="32%" alt="Download in progress">
<img src="docs/screenshots/result.png" width="32%" alt="Finished test with results">
</p>

Tablet, result:

<img src="docs/screenshots/tablet-result.png" width="100%" alt="Finished test on a tablet, two-pane layout">
Binary file added apk/debug.apk
Binary file not shown.
1 change: 1 addition & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
Loading
Loading