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. + +[![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
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. + +

+ Start screen + Download in progress + Finished test with results +

+ +Tablet, result: + +Finished test on a tablet, two-pane layout 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")