diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..c6624f7
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,10 @@
+version: 2
+updates:
+ - package-ecosystem: gradle
+ directory: /Speedtest-Android
+ schedule:
+ interval: weekly
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
diff --git a/.github/logo-dark.png b/.github/logo-dark.png
new file mode 100644
index 0000000..d31dd5a
Binary files /dev/null and b/.github/logo-dark.png differ
diff --git a/.github/logo-light.png b/.github/logo-light.png
new file mode 100644
index 0000000..437b10c
Binary files /dev/null and b/.github/logo-light.png differ
diff --git a/.github/screenshot-dark.png b/.github/screenshot-dark.png
new file mode 100644
index 0000000..f4446c2
Binary files /dev/null and b/.github/screenshot-dark.png differ
diff --git a/.github/screenshot-light.png b/.github/screenshot-light.png
new file mode 100644
index 0000000..9c66721
Binary files /dev/null and b/.github/screenshot-light.png differ
diff --git a/.github/screenshot-settings.png b/.github/screenshot-settings.png
new file mode 100644
index 0000000..7698863
Binary files /dev/null and b/.github/screenshot-settings.png differ
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..9c3afdd
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,108 @@
+name: CI
+
+on:
+ push:
+ branches: [ "**" ]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ working-directory: Speedtest-Android
+
+jobs:
+ build:
+ name: Lint, tests & debug build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "21"
+ - uses: gradle/actions/setup-gradle@v6
+ - name: Lint, unit and screenshot tests
+ run: ./gradlew :app:lintDebug :app:testDebugUnitTest -Proborazzi.test.verify=true
+ - name: Upload screenshot diffs
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: roborazzi-diffs
+ path: |
+ Speedtest-Android/app/src/test/snapshots/*_compare.png
+ Speedtest-Android/app/build/outputs/roborazzi/
+ if-no-files-found: ignore
+ - name: Build debug APK
+ run: ./gradlew :app:assembleDebug
+ - uses: actions/upload-artifact@v7
+ with:
+ name: librespeed-debug-apk
+ path: Speedtest-Android/app/build/outputs/apk/debug/*.apk
+ - name: Upload Gradle problems report
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: gradle-problems-report
+ path: Speedtest-Android/build/reports/problems/
+ if-no-files-found: ignore
+
+ integration:
+ name: Integration test against LibreSpeed
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "21"
+ - uses: gradle/actions/setup-gradle@v6
+ - name: Start a LibreSpeed backend
+ run: |
+ # the image serves on port 8080 inside the container
+ docker run -d --name librespeed -p 8080:8080 ghcr.io/librespeed/speedtest:latest
+ for i in $(seq 1 30); do
+ curl -fs http://localhost:8080/backend/empty.php > /dev/null && break
+ sleep 2
+ done
+ curl -fs http://localhost:8080/backend/empty.php > /dev/null || { docker logs librespeed; exit 1; }
+ - name: Run the engine against the real backend
+ env:
+ LIBRESPEED_URL: http://localhost:8080
+ run: ./gradlew :app:testDebugUnitTest --tests "org.librespeed.speedtest.engine.LibrespeedIntegrationTest"
+
+ instrumentation:
+ name: Instrumentation (API ${{ matrix.api-level }})
+ if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ # minSdk, per-app language boundary, current stable, targetSdk
+ api-level: [ 26, 33, 35, 36 ]
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "21"
+ - uses: gradle/actions/setup-gradle@v6
+ - name: Enable KVM
+ working-directory: .
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+ - uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
+ with:
+ api-level: ${{ matrix.api-level }}
+ arch: x86_64
+ target: ${{ matrix.api-level >= 30 && 'google_apis' || 'default' }}
+ working-directory: Speedtest-Android
+ script: ./gradlew :app:connectedDebugAndroidTest
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..237f2c9
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,30 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+ schedule:
+ - cron: "30 4 * * 1"
+ workflow_dispatch:
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ contents: read
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "21"
+ - uses: github/codeql-action/init@v4
+ with:
+ languages: java-kotlin
+ # buildless analysis; also enables incremental overlay databases
+ build-mode: none
+ - uses: github/codeql-action/analyze@v4
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..bbdb4f5
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,55 @@
+name: Publish to Google Play
+
+# Manual only — uploads the signed AAB to the chosen track, never to production.
+on:
+ workflow_dispatch:
+ inputs:
+ track:
+ description: Play track
+ type: choice
+ default: internal
+ options: [ internal, alpha, beta ]
+
+permissions:
+ contents: read
+
+defaults:
+ run:
+ working-directory: Speedtest-Android
+
+jobs:
+ publish:
+ name: Upload AAB to Play (${{ inputs.track }})
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "21"
+ - uses: gradle/actions/setup-gradle@v6
+ - name: Check required secrets
+ run: |
+ if [ -z "${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}" ] || [ -z "${{ secrets.SIGNING_KEYSTORE_BASE64 }}" ]; then
+ echo "PLAY_SERVICE_ACCOUNT_JSON and SIGNING_* secrets are required" >&2
+ exit 1
+ fi
+ - name: Prepare signing keystore
+ run: |
+ echo "${{ secrets.SIGNING_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/keystore.jks"
+ {
+ echo "SIGNING_KEYSTORE=$RUNNER_TEMP/keystore.jks"
+ echo "SIGNING_STORE_PASSWORD=${{ secrets.SIGNING_STORE_PASSWORD }}"
+ echo "SIGNING_KEY_ALIAS=${{ secrets.SIGNING_KEY_ALIAS }}"
+ echo "SIGNING_KEY_PASSWORD=${{ secrets.SIGNING_KEY_PASSWORD }}"
+ } >> "$GITHUB_ENV"
+ - name: Tests and release bundle
+ run: ./gradlew :app:testReleaseUnitTest :app:bundleRelease
+ - uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
+ with:
+ serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
+ packageName: org.librespeed.speedtest
+ releaseFiles: Speedtest-Android/app/build/outputs/bundle/release/*.aab
+ track: ${{ inputs.track }}
+ status: draft
+ mappingFile: Speedtest-Android/app/build/outputs/mapping/release/mapping.txt
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..972d050
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,65 @@
+name: Release
+
+on:
+ push:
+ tags: [ "v*" ]
+
+permissions:
+ contents: read
+
+defaults:
+ run:
+ working-directory: Speedtest-Android
+
+jobs:
+ release:
+ name: Release build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "21"
+ - uses: gradle/actions/setup-gradle@v6
+ - name: Check version against the tag
+ run: |
+ VERSION=$(sed -nE 's/.*versionName = "(.*)"/\1/p' app/build.gradle.kts)
+ if [[ "$GITHUB_REF_NAME" != "v$VERSION" ]]; then
+ echo "Tag $GITHUB_REF_NAME does not match versionName $VERSION" >&2
+ exit 1
+ fi
+ - name: Prepare signing keystore
+ run: |
+ if [ -z "${{ secrets.SIGNING_KEYSTORE_BASE64 }}" ]; then
+ echo "Release builds require the SIGNING_* secrets; refusing to ship a debug-signed release" >&2
+ exit 1
+ fi
+ echo "${{ secrets.SIGNING_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/keystore.jks"
+ {
+ echo "SIGNING_KEYSTORE=$RUNNER_TEMP/keystore.jks"
+ echo "SIGNING_STORE_PASSWORD=${{ secrets.SIGNING_STORE_PASSWORD }}"
+ echo "SIGNING_KEY_ALIAS=${{ secrets.SIGNING_KEY_ALIAS }}"
+ echo "SIGNING_KEY_PASSWORD=${{ secrets.SIGNING_KEY_PASSWORD }}"
+ } >> "$GITHUB_ENV"
+ - name: Lint and unit tests
+ run: ./gradlew :app:lintRelease :app:testReleaseUnitTest
+ - name: Build release APK and AAB
+ run: ./gradlew :app:assembleRelease :app:bundleRelease
+ - name: Collect artifacts
+ run: |
+ VERSION=$(sed -nE 's/.*versionName = "(.*)"/\1/p' app/build.gradle.kts)
+ cp app/build/outputs/apk/release/*.apk "librespeed_${VERSION}.apk"
+ cp app/build/outputs/bundle/release/*.aab "librespeed_${VERSION}.aab"
+ - uses: actions/upload-artifact@v7
+ with:
+ name: librespeed-release
+ path: |
+ Speedtest-Android/librespeed_*.apk
+ Speedtest-Android/librespeed_*.aab
+ - uses: softprops/action-gh-release@v3
+ with:
+ draft: true
+ files: Speedtest-Android/librespeed_*.apk
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000..7252f92
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,16 @@
+name: Security
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ dependency-review:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/dependency-review-action@v4
+ with:
+ fail-on-severity: moderate
diff --git a/.gitignore b/.gitignore
index 098af8c..10cb847 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/_PRIVATE
.directory
+.DS_Store
diff --git a/PRIVACY.md b/PRIVACY.md
new file mode 100644
index 0000000..32f1d77
--- /dev/null
+++ b/PRIVACY.md
@@ -0,0 +1,26 @@
+# Privacy Policy
+
+LibreSpeed for Android does not collect, store or share any personal data by itself.
+
+## What happens during a test
+
+Running a speed test transfers data between your device and the LibreSpeed server you selected. Like any web server, the tested server can see your IP address and technical connection details for the duration of the test. The app displays your public IP address and ISP information as reported by the tested server; this information stays on your device.
+
+## Telemetry (off by default)
+
+The app contains an optional, explicitly opt-in telemetry switch in Settings. When — and only when — you enable it, the following is submitted to the tested server after each test:
+
+- the test result (download, upload, ping, jitter),
+- client information (application identifier and version),
+- your IP address and ISP information as seen by the server,
+- a technical log of the test run.
+
+Nothing is submitted while the switch is off. Telemetry is processed by the operator of the server you tested against, not by the LibreSpeed project.
+
+## Local data
+
+Test history, favorite servers, custom servers and settings are stored only on your device and can be deleted at any time from within the app or by clearing the app's data.
+
+## Permissions
+
+The app requests the INTERNET permission only, which is required to run a speed test.
diff --git a/README.md b/README.md
index 9656793..d38b380 100644
--- a/README.md
+++ b/README.md
@@ -1,52 +1,50 @@
-
-
-# LibreSpeed Android Template
-The LibreSpeed Android template allows you to configure and distribute an Android app that performs a speedtest using your existing [LibreSpeed](https://github.com/librespeed/speedtest) server(s).
+
+
+
+
-The template is easy to configure, customize and distribute.
-
-## Try it
+# LibreSpeed for Android
-[
](https://f-droid.org/packages/com.dosse.speedtest/)
+Free and open source internet speed test for Android — no ads, and no tracking by default; optional telemetry is explicitly opt-in.
-Alternatively, you can [download a demo APK](https://downloads.fdossena.com/geth.php?r=speedtest-android-apk)
+LibreSpeed measures your connection against community-run [LibreSpeed](https://github.com/librespeed/speedtest) servers, or against your own self-hosted one.
-## Compatibility
-Android 4.0.3 and up (SDK 15), all architectures.
+
+
+
+
+
## Features
-* Download
-* Upload
-* Ping
-* Jitter
-* IP Address, ISP, distance from server (optional)
-* Telemetry (optional)
-* Results sharing (optional)
-* Multiple Points of Test (optional)
-
+* Download, upload, ping, jitter and approximate packet loss
+* IPv4 and IPv6
+* Server selection with favorites and custom self-hosted servers; a bundled
+ snapshot of the public server list is used as fallback when the remote list
+ cannot be loaded
+* Test history with sharing
+* Material 3 design, dark and light theme, large screen support
+* Telemetry strictly opt-in (off by default)
+
+## Compatibility
-## Server requirements
-One or more servers with [LibreSpeed](https://github.com/librespeed/speedtest) installed.
+Android 8.0 and up (minSdk 26), targets Android 16 (API 36).
-## Donate
-[](https://liberapay.com/fdossena/donate)
-[Donate with PayPal](https://www.paypal.me/sineisochronic)
+## Building
-## License
-Copyright (C) 2020 Federico Dossena
+The project lives in `Speedtest-Android/`:
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Lesser General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
+```
+cd Speedtest-Android
+./gradlew :app:assembleDebug
+```
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
+Release builds are produced by the GitHub Actions workflow on tags (`v*`) and require the `SIGNING_*` repository secrets — the release job fails rather than shipping a debug-signed build. The signed APK is attached to the GitHub release (Obtainium-friendly); the AAB for Play submission is kept as a workflow artifact only.
+
+## Privacy & security
+
+See [PRIVACY.md](PRIVACY.md) and [SECURITY.md](SECURITY.md). The app requests the INTERNET permission only.
+
+## License
-You should have received a copy of the GNU Lesser General Public License
-along with this program. If not, see .
+LGPL-3.0, same as the rest of the LibreSpeed project.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..9e26839
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,16 @@
+# Security Policy
+
+## Supported versions
+
+Only the latest release of LibreSpeed for Android receives fixes.
+
+## Reporting a vulnerability
+
+Please report security issues privately through
+[GitHub Security Advisories](https://github.com/librespeed/speedtest-android/security/advisories/new)
+instead of opening a public issue. Include steps to reproduce and the app
+version. You will get a response as soon as possible, and a fix will be
+released before the details are published.
+
+Vulnerabilities in the LibreSpeed server backend belong to the
+[librespeed/speedtest](https://github.com/librespeed/speedtest) project.
diff --git a/Speedtest-Android/app/build.gradle b/Speedtest-Android/app/build.gradle
deleted file mode 100644
index 78bbc7e..0000000
--- a/Speedtest-Android/app/build.gradle
+++ /dev/null
@@ -1,24 +0,0 @@
-apply plugin: 'com.android.application'
-
-android {
- compileSdkVersion 28
- buildToolsVersion "29.0.0"
- defaultConfig {
- applicationId "your.name.here.speedtest"
- minSdkVersion 15
- targetSdkVersion 28
- versionCode 9
- versionName '1.2.3'
- testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
- }
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
-}
-
-dependencies {
- implementation fileTree(dir: 'libs', include: ['*.jar'])
-}
diff --git a/Speedtest-Android/app/build.gradle.kts b/Speedtest-Android/app/build.gradle.kts
new file mode 100644
index 0000000..19be099
--- /dev/null
+++ b/Speedtest-Android/app/build.gradle.kts
@@ -0,0 +1,130 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.licensee)
+}
+
+android {
+ namespace = "org.librespeed.speedtest"
+ compileSdk = 37
+
+ defaultConfig {
+ applicationId = "org.librespeed.speedtest"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 10
+ versionName = "2.0.0"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ testOptions {
+ //the engine reads android.os.Build for its default User-Agent
+ unitTests.isReturnDefaultValues = true
+ //robolectric screenshot tests render real resources
+ unitTests.isIncludeAndroidResources = true
+ unitTests.all { test ->
+ //the roborazzi gradle plugin does not support AGP 9 yet; forward its
+ //mode flags manually: -Proborazzi.test.record=true / verify / compare
+ listOf("roborazzi.test.record", "roborazzi.test.verify", "roborazzi.test.compare").forEach { key ->
+ providers.gradleProperty(key).orNull?.let { test.systemProperty(key, it) }
+ }
+ }
+ }
+
+ signingConfigs {
+ //populated from the environment in CI; local builds fall back to the debug key
+ create("release") {
+ val keystorePath = System.getenv("SIGNING_KEYSTORE")
+ if (!keystorePath.isNullOrEmpty()) {
+ storeFile = file(keystorePath)
+ storePassword = System.getenv("SIGNING_STORE_PASSWORD")
+ keyAlias = System.getenv("SIGNING_KEY_ALIAS")
+ keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ if (!System.getenv("SIGNING_KEYSTORE").isNullOrEmpty()) {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+}
+
+licensee {
+ allow("Apache-2.0")
+ allow("MIT")
+ allow("BSD-3-Clause")
+}
+
+//ships the licensee report as an asset so the licenses screen shows the real dependency list
+abstract class LicenseeAssetTask : DefaultTask() {
+ @get:InputFile
+ abstract val inputFile: RegularFileProperty
+
+ @get:OutputDirectory
+ abstract val outputDir: DirectoryProperty
+
+ @TaskAction
+ fun copy() {
+ inputFile.get().asFile.copyTo(outputDir.get().file("licenses.json").asFile, overwrite = true)
+ }
+}
+
+androidComponents {
+ onVariants { variant ->
+ val capitalized = variant.name.replaceFirstChar { it.uppercase() }
+ val copyTask = tasks.register("copy${capitalized}LicenseeAsset", LicenseeAssetTask::class.java) {
+ inputFile.set(layout.buildDirectory.file("reports/licensee/android$capitalized/artifacts.json"))
+ dependsOn("licenseeAndroid$capitalized")
+ }
+ variant.sources.assets?.addGeneratedSourceDirectory(copyTask, LicenseeAssetTask::outputDir)
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material3.window.size)
+ implementation(libs.androidx.compose.material.icons.extended)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.window)
+ implementation(libs.androidx.work.runtime)
+ implementation(libs.androidx.profileinstaller)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+ testImplementation(libs.junit)
+ testImplementation(libs.mockwebserver)
+ //real org.json for unit tests; the mockable android.jar only has stubs
+ testImplementation(libs.json)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.roborazzi)
+ testImplementation(libs.roborazzi.compose)
+ testImplementation(libs.androidx.test.ext.junit)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+}
diff --git a/Speedtest-Android/app/src/androidTest/java/org/librespeed/speedtest/RecreationTest.kt b/Speedtest-Android/app/src/androidTest/java/org/librespeed/speedtest/RecreationTest.kt
new file mode 100644
index 0000000..3b38d2b
--- /dev/null
+++ b/Speedtest-Android/app/src/androidTest/java/org/librespeed/speedtest/RecreationTest.kt
@@ -0,0 +1,26 @@
+package org.librespeed.speedtest
+
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Configuration changes (rotation, theme, locale) recreate the activity; the UI must survive them. */
+@RunWith(AndroidJUnit4::class)
+class RecreationTest {
+
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun activityRecreationKeepsTheMainScreen() {
+ rule.onNodeWithText("Speedtest").assertExists()
+ rule.activityRule.scenario.recreate()
+ rule.waitForIdle()
+ rule.onNodeWithText("Speedtest").assertExists()
+ rule.onNodeWithText("Settings").assertExists()
+ }
+
+}
diff --git a/Speedtest-Android/app/src/androidTest/java/org/librespeed/speedtest/SmokeTest.kt b/Speedtest-Android/app/src/androidTest/java/org/librespeed/speedtest/SmokeTest.kt
new file mode 100644
index 0000000..d84455a
--- /dev/null
+++ b/Speedtest-Android/app/src/androidTest/java/org/librespeed/speedtest/SmokeTest.kt
@@ -0,0 +1,25 @@
+package org.librespeed.speedtest
+
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Launches the app and checks the main navigation renders on every supported API level. */
+@RunWith(AndroidJUnit4::class)
+class SmokeTest {
+
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun bottomNavigationShowsAllTabs() {
+ rule.onNodeWithText("Speedtest").assertExists()
+ rule.onNodeWithText("History").assertExists()
+ rule.onNodeWithText("Servers").assertExists()
+ rule.onNodeWithText("Settings").assertExists()
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/AndroidManifest.xml b/Speedtest-Android/app/src/main/AndroidManifest.xml
index 685d61d..02b31c0 100644
--- a/Speedtest-Android/app/src/main/AndroidManifest.xml
+++ b/Speedtest-Android/app/src/main/AndroidManifest.xml
@@ -1,19 +1,36 @@
-
+
+
+
+
+
+
+
+
+
+
+ android:theme="@style/Theme.LibreSpeed">
+
+
+
+
+ android:name=".MainActivity"
+ android:exported="true"
+ android:launchMode="singleTop">
@@ -21,6 +38,4 @@
-
-
-
\ No newline at end of file
+
diff --git a/Speedtest-Android/app/src/main/assets/ServerList.json b/Speedtest-Android/app/src/main/assets/ServerList.json
index 7719f0f..38317ea 100644
--- a/Speedtest-Android/app/src/main/assets/ServerList.json
+++ b/Speedtest-Android/app/src/main/assets/ServerList.json
@@ -1,10 +1 @@
-[
- {
- "name":"Helsinki, Finland",
- "server":"//fi.openspeed.org",
- "dlURL":"garbage.php",
- "ulURL":"empty.php",
- "pingURL":"empty.php",
- "getIpURL":"getIP.php"
- }
-]
+"//librespeed.org/backend-servers/servers.php"
\ No newline at end of file
diff --git a/Speedtest-Android/app/src/main/assets/ServerListFallback.json b/Speedtest-Android/app/src/main/assets/ServerListFallback.json
new file mode 100644
index 0000000..f2a4cc1
--- /dev/null
+++ b/Speedtest-Android/app/src/main/assets/ServerListFallback.json
@@ -0,0 +1 @@
+[{"name":"Amsterdam, Netherlands (Clouvider)","server":"https:\/\/ams.speedtest.clouvider.net\/backend","id":51,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Clouvider","sponsorURL":"https:\/\/www.clouvider.co.uk\/"},{"name":"Amsterdam, Netherlands (Sharktech)","server":"https:\/\/amsspeed.sharktech.net","id":94,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Sharktech","sponsorURL":"https:\/\/sharktech.net"},{"name":"Argalasti, Magnesia, Greece (Cosmote)","server":"https:\/\/argalasti.skoultsos.eu\/","id":104,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"skoultsos.eu","sponsorURL":"https:\/\/skoultsos.eu"},{"name":"Atlanta, United States (Clouvider)","server":"https:\/\/atl.speedtest.clouvider.net\/backend","id":53,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Clouvider","sponsorURL":"https:\/\/www.clouvider.co.uk\/"},{"name":"Bangalore, India (DigitalOcean)","server":"https:\/\/in1.backend.librespeed.org\/","id":75,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"DigitalOcean","sponsorURL":"https:\/\/www.digitalocean.com"},{"name":"Bari, Italy (GARR)","server":"https:\/\/st-be-ba1.infra.garr.it","id":33,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Consortium GARR","sponsorURL":"https:\/\/garr.it"},{"name":"Belgrade, Serbia (SOX)","server":"https:\/\/speedtest1.sox.rs\/librespeed\/","id":106,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Serbian Open eXchange","sponsorURL":"https:\/\/sox.rs"},{"name":"Bologna, Italy (GARR)","server":"https:\/\/st-be-bo1.infra.garr.it","id":34,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Consortium GARR","sponsorURL":"https:\/\/garr.it"},{"name":"Bucharest, Romania (ByteShield)","server":"https:\/\/speedtest.byteshield.ro:6060\/","id":98,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"ByteShield Hosting SRL","sponsorURL":"NULL"},{"name":"Chicago, USA (Sharktech)","server":"https:\/\/chispeed.sharktech.net","id":93,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Sharktech","sponsorURL":"https:\/\/sharktech.net"},{"name":"Denver, USA (Sharktech)","server":"https:\/\/denspeed.sharktech.net","id":92,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Sharktech","sponsorURL":"https:\/\/sharktech.net"},{"name":"Frankfurt, Germany (Clouvider)","server":"https:\/\/fra.speedtest.clouvider.net\/backend","id":50,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Clouvider","sponsorURL":"https:\/\/www.clouvider.co.uk\/"},{"name":"Frankfurt, Germany (FRA01)","server":"https:\/\/speedtest.lumischvps.cloud\/","id":86,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"LumischVPS","sponsorURL":"https:\/\/discord.gg\/GxYzPwJmA2"},{"name":"Frankfurt, Germany (FS IT-Systeme GmbH)","server":"https:\/\/speed.fs-it.systems\/","id":105,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"FS IT-Systeme GmbH","sponsorURL":"https:\/\/go.bytevault.systems\/fsit-web-spd1"},{"name":"Ghom, Iran (Amin IDC)","server":"https:\/\/fastme.ir\/","id":77,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Bardia Moshiri","sponsorURL":"https:\/\/bardia.tech\/"},{"name":"Grand Rapids, Michigan (RackGenius)","server":"https:\/\/mispeed.rackgenius.com\/","id":100,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"RackGenius","sponsorURL":"https:\/\/rackgenius.com\/"},{"name":"Helsinki, Finland (3) (Hetzner)","server":"https:\/\/finew.openspeed.org\/","id":22,"dlURL":"backend437\/garbage.php","ulURL":"backend437\/empty.php","pingURL":"backend437\/empty.php","getIpURL":"backend437\/getIP.php","sponsorName":"Daily Health Insurance Group","sponsorURL":"https:\/\/dhig.net\/"},{"name":"Helsinki, Finland (5) (Hetzner)","server":"https:\/\/fast.kabi.tk\/","id":24,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"KABI.tk","sponsorURL":"https:\/\/kabi.tk"},{"name":"Helsinki, Finland (Hetzner)","server":"https:\/\/www.librespeed.fi\/","id":101,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Pekka Jalonen","sponsorURL":"https:\/\/jalonen.net\/"},{"name":"Johannesburg, South Africa (Host Africa)","server":"https:\/\/za1.backend.librespeed.org\/","id":70,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"HOSTAFRICA","sponsorURL":"https:\/\/www.hostafrica.co.za"},{"name":"Las Vegas, USA (Sharktech)","server":"https:\/\/lasspeed.sharktech.net","id":90,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Sharktech","sponsorURL":"https:\/\/sharktech.net"},{"name":"London, England (Clouvider)","server":"https:\/\/lon.speedtest.clouvider.net\/backend","id":49,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Clouvider","sponsorURL":"https:\/\/www.clouvider.co.uk\/"},{"name":"Los Angeles, United States (1) (Clouvider)","server":"https:\/\/la.speedtest.clouvider.net\/backend","id":54,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Clouvider","sponsorURL":"https:\/\/www.clouvider.co.uk\/"},{"name":"Los Angeles, USA (Sharktech)","server":"https:\/\/laxspeed.sharktech.net","id":91,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Sharktech","sponsorURL":"https:\/\/sharktech.net"},{"name":"New York, United States (2) (Clouvider)","server":"https:\/\/nyc.speedtest.clouvider.net\/backend","id":52,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Clouvider","sponsorURL":"https:\/\/www.clouvider.co.uk\/"},{"name":"Nottingham, England (LayerIP)","server":"https:\/\/uk1.backend.librespeed.org","id":43,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"fosshost.org","sponsorURL":"https:\/\/fosshost.org"},{"name":"Novi Sad, Vojvodina, Serbia (E-CAPS.net)","server":"https:\/\/speed1.e-caps.net","id":103,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"E-CAPS.net","sponsorURL":"https:\/\/e-caps.net"},{"name":"Nuremberg, Germany (1) (Hetzner)","server":"https:\/\/de1.backend.librespeed.org","id":28,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Snopyta","sponsorURL":"https:\/\/snopyta.org"},{"name":"Nuremberg, Germany (2) (Hetzner)","server":"https:\/\/de4.backend.librespeed.org","id":27,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"LibreSpeed","sponsorURL":"https:\/\/librespeed.org"},{"name":"Nuremberg, Germany (3) (Hetzner)","server":"https:\/\/de3.backend.librespeed.org","id":30,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"LibreSpeed","sponsorURL":"https:\/\/librespeed.org"},{"name":"Nuremberg, Germany (4) (Hetzner)","server":"https:\/\/de5.backend.librespeed.org","id":31,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"LibreSpeed","sponsorURL":"https:\/\/librespeed.org"},{"name":"Nuremberg, Germany (6) (Hetzner)","server":"https:\/\/librespeed.lukas-heinrich.com\/","id":46,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"luki9100","sponsorURL":"https:\/\/lukas-heinrich.com\/"},{"name":"Ohio, USA (Rust backend)","server":"https:\/\/librespeed-rs.ir\/","id":95,"dlURL":"backend\/garbage","ulURL":"backend\/empty","pingURL":"backend\/empty","getIpURL":"backend\/getIP","sponsorName":"Sudo Dios","sponsorURL":"https:\/\/github.com\/SudoDios"},{"name":"Poznan, Poland (INEA)","server":"https:\/\/speedtest.kamilszczepanski.com","id":74,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Kamil Szczepa?ski","sponsorURL":"https:\/\/kamilszczepanski.com"},{"name":"Prague, Czech Republic (CESNET)","server":"https:\/\/speedtest.cesnet.cz","id":79,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"CESNET","sponsorURL":"https:\/\/www.cesnet.cz"},{"name":"Prague, Czech Republic (Turris)","server":"https:\/\/librespeed.turris.cz","id":85,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Turris","sponsorURL":"https:\/\/www.turris.com"},{"name":"Roma, Italy (GARR)","server":"https:\/\/st-be-rm2.infra.garr.it","id":35,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Consortium GARR","sponsorURL":"https:\/\/garr.it"},{"name":"Serbia (SOX)","server":"https:\/\/speedtest2.sox.rs","id":87,"dlURL":"libre\/backend\/garbage.php","ulURL":"libre\/backend\/empty.php","pingURL":"libre\/backend\/empty.php","getIpURL":"libre\/backend\/getIP.php","sponsorName":"Serbian Open eXchange (SOX)","sponsorURL":"https:\/\/sox.rs"},{"name":"Singapore (Salvatore Cahyo)","server":"https:\/\/speedtest.dsgroupmedia.com","id":68,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Salvatore Cahyo","sponsorURL":"https:\/\/salvatorecahyo.my.id"},{"name":"Tehran, Iran (Fanava)","server":"https:\/\/speedme.ir\/","id":76,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Bardia Moshiri","sponsorURL":"https:\/\/bardia.tech"},{"name":"Tehran, Iran (Faraso)","server":"https:\/\/st.bardia.tech","id":80,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"Bardia Moshiri","sponsorURL":"https:\/\/bardia.tech\/"},{"name":"Tokyo, Japan (A573)","server":"https:\/\/librespeed.a573.net\/","id":82,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"A573","sponsorURL":"https:\/\/mirror.a573.net\/"},{"name":"Vilnius, Lithuania (RackRay)","server":"https:\/\/lt1.backend.librespeed.org\/","id":69,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Time4VPS","sponsorURL":"https:\/\/www.time4vps.com"},{"name":"Virginia, United States, OVH","server":"https:\/\/speed.riverside.rocks\/","id":78,"dlURL":"garbage.php","ulURL":"empty.php","pingURL":"empty.php","getIpURL":"getIP.php","sponsorName":"Riverside Rocks","sponsorURL":"https:\/\/riverside.rocks"},{"name":"Volzhsky, Russia (PowerNet)","server":"https:\/\/speedtest.powernet.com.ru\/","id":102,"dlURL":"backend\/garbage.php","ulURL":"backend\/empty.php","pingURL":"backend\/empty.php","getIpURL":"backend\/getIP.php","sponsorName":"PowerNet","sponsorURL":"powernet.com.ru\/"}]
\ No newline at end of file
diff --git a/Speedtest-Android/app/src/main/baseline-prof.txt b/Speedtest-Android/app/src/main/baseline-prof.txt
new file mode 100644
index 0000000..4df351c
--- /dev/null
+++ b/Speedtest-Android/app/src/main/baseline-prof.txt
@@ -0,0 +1,8 @@
+# Baseline profile: classes and methods compiled ahead of time at install,
+# so cold start skips the JIT warm-up. Hand-written wildcard rules covering
+# the whole app and the engine; replace with a macrobenchmark-generated
+# profile once the androidx.baselineprofile plugin supports AGP 9.
+HSPLorg/librespeed/speedtest/**->**(**)**
+Lorg/librespeed/speedtest/**;
+HSPLcom/fdossena/speedtest/**->**(**)**
+Lcom/fdossena/speedtest/**;
diff --git a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/Speedtest.java b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/Speedtest.java
index 03a52c8..0164e29 100644
--- a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/Speedtest.java
+++ b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/Speedtest.java
@@ -195,15 +195,20 @@ public void onPingJitterUpdate(double ping, double jitter, double progress) {
callback.onPingJitterUpdate(ping, jitter, progress);
}
+ @Override
+ public void onLossUpdate(double loss) {
+ callback.onLossUpdate(loss);
+ }
+
@Override
public void onIPInfoUpdate(String ipInfo) {
callback.onIPInfoUpdate(ipInfo);
}
@Override
- public void onTestIDReceived(String id) {
- String shareURL=prepareShareURL(telemetryConfig);
- if(shareURL!=null) shareURL=String.format(shareURL,id);
+ public void onTestIDReceived(String id, String shareURLTemplate) {
+ String shareURL=shareURLTemplate;
+ if(shareURL!=null&&id!=null) shareURL=String.format(shareURL,id);
callback.onTestIDReceived(id,shareURL);
}
@@ -226,16 +231,6 @@ public void onCriticalFailure(String err) {
}
}
- private String prepareShareURL(TelemetryConfig c){
- if(c==null) return null;
- String server=c.getServer(), shareURL=c.getShareURL();
- if(server==null||server.isEmpty()||shareURL==null||shareURL.isEmpty()) return null;
- if(!server.endsWith("/")) server=server+"/";
- while(shareURL.startsWith("/")) shareURL=shareURL.substring(1);
- if(server.startsWith("//")) server="https:"+server;
- return server+shareURL;
- }
-
public void abort(){
synchronized (mutex) {
if (state == 2) ss.stopASAP();
@@ -251,6 +246,7 @@ public static abstract class SpeedtestHandler{
public abstract void onDownloadUpdate(double dl, double progress);
public abstract void onUploadUpdate(double ul, double progress);
public abstract void onPingJitterUpdate(double ping, double jitter, double progress);
+ public abstract void onLossUpdate(double loss);
public abstract void onIPInfoUpdate(String ipInfo);
public abstract void onTestIDReceived(String id, String shareURL);
public abstract void onEnd();
diff --git a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/base/Connection.java b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/base/Connection.java
index 6e722e2..09cac8b 100644
--- a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/base/Connection.java
+++ b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/base/Connection.java
@@ -17,11 +17,24 @@
public class Connection {
private Socket socket;
+
+ public boolean isIPv6(){
+ return socket!=null&&socket.getInetAddress() instanceof java.net.Inet6Address;
+ }
private String host; private int port;
private int mode=MODE_NOT_SET;
private static final int MODE_NOT_SET=0, MODE_HTTP=1, MODE_HTTPS=2;
- private static final String USER_AGENT="Speedtest-Android/1.2.3 (SDK "+Build.VERSION.SDK_INT+"; "+Build.PRODUCT+"; Android "+Build.VERSION.RELEASE+")",
+ // Replaced at startup with ClientInfo.userAgent, which can name the app
+ // version; this default is what the engine sends if that never runs.
+ private static String userAgent="librespeed-android (android "+Build.VERSION.RELEASE+"; "
+ +(Build.SUPPORTED_ABIS!=null&&Build.SUPPORTED_ABIS.length>0?Build.SUPPORTED_ABIS[0]:"unknown")+"; "+Build.PRODUCT+")";
+
+ public static void setUserAgent(String ua){
+ userAgent=ua;
+ }
+
+ private static final String
LOCALE= Build.VERSION.SDK_INT>=21?Locale.getDefault().toLanguageTag():null;
public Connection(String url, int connectTimeout, int soTimeout, int recvBuffer, int sendBuffer){
@@ -150,7 +163,7 @@ public void GET(String path, boolean keepAlive) throws Exception{
PrintStream ps=getPrintStream();
ps.print("GET "+path+" HTTP/1.1\r\n");
ps.print("Host: "+host+"\r\n");
- ps.print("User-Agent: "+USER_AGENT);
+ ps.print("User-Agent: "+userAgent+"\r\n");
ps.print("Connection: "+(keepAlive?"keep-alive":"close")+"\r\n");
ps.print("Accept-Encoding: identity\r\n");
if(LOCALE!=null) ps.print("Accept-Language: "+LOCALE+"\r\n");
@@ -167,7 +180,7 @@ public void POST(String path, boolean keepAlive, String contentType, long conten
PrintStream ps=getPrintStream();
ps.print("POST "+path+" HTTP/1.1\r\n");
ps.print("Host: "+host+"\r\n");
- ps.print("User-Agent: "+USER_AGENT+"\r\n");
+ ps.print("User-Agent: "+userAgent+"\r\n");
ps.print("Connection: "+(keepAlive?"keep-alive":"close")+"\r\n");
ps.print("Accept-Encoding: identity\r\n");
if(LOCALE!=null) ps.print("Accept-Language: "+LOCALE+"\r\n");
@@ -201,7 +214,8 @@ public HashMap parseResponseHeaders() throws Exception{
try{
HashMap ret=new HashMap<>();
String s=readLineUnbuffered();
- if(!s.contains("200 OK")) throw new Exception("Did not receive an HTTP 200 ("+s.trim()+")");
+ String[] statusParts=s.trim().split(" ");
+ if(statusParts.length<2||!statusParts[1].startsWith("2")) throw new Exception("Did not receive an HTTP 2xx ("+s.trim()+")");
while(true){
s=readLineUnbuffered();
if(s.trim().isEmpty()) break;
diff --git a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/ping/PingStream.java b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/ping/PingStream.java
index e661d6f..40a3aca 100644
--- a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/ping/PingStream.java
+++ b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/ping/PingStream.java
@@ -10,6 +10,11 @@ public abstract class PingStream {
private int remainingPings=10;
private int connectTimeout, soTimeout, recvBuffer, sendBuffer;
private Connection c=null;
+
+ public Boolean usedIPv6(){
+ Connection conn=c;
+ return conn==null?null:conn.isIPv6();
+ }
private Pinger pinger;
private String errorHandlingMode= SpeedtestConfig.ONERROR_ATTEMPT_RESTART;
private boolean stopASAP=false;
diff --git a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/ping/Pinger.java b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/ping/Pinger.java
index 8b0c534..5aeb3fb 100644
--- a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/ping/Pinger.java
+++ b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/ping/Pinger.java
@@ -31,7 +31,10 @@ public void run(){
if(l==null) break;
l=l.trim().toLowerCase();
if(l.equals("transfer-encoding: chunked")) chunked=true;
- if(l.contains("200 ok")) ok=true;
+ if(l.startsWith("http/")){
+ String[] statusParts=l.split(" ");
+ if(statusParts.length>=2&&statusParts[1].startsWith("2")) ok=true;
+ }
if(l.trim().isEmpty()){
if(chunked){c.readLineUnbuffered(); c.readLineUnbuffered();}
break;
diff --git a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/serverSelector/ServerSelector.java b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/serverSelector/ServerSelector.java
index b353fdb..8148431 100644
--- a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/serverSelector/ServerSelector.java
+++ b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/serverSelector/ServerSelector.java
@@ -84,6 +84,7 @@ public void onError(String err) {
public boolean onPong(long ns) {
float p=ns/1000000f;
if(tp.ping==-1||p 200 ? 200 : b;
+ //truncating to whole milliseconds is intended; the cast keeps it explicit
+ bonusT += (long) (b > 200 ? 200 : b);
}
double progress = (t + bonusT) / (double) (config.getTime_dl_max() * 1000);
speed = (speed * 8 * config.getOverheadCompensationFactor()) / (config.getUseMebibits() ? 1048576.0 : 1000000.0);
@@ -177,7 +179,8 @@ public void onError(String err) {
double speed = totUploaded / ((t<100?100:t) / 1000.0);
if (config.getTime_auto()) {
double b = (2.5 * speed) / 100000.0;
- bonusT += b > 200 ? 200 : b;
+ //truncating to whole milliseconds is intended; the cast keeps it explicit
+ bonusT += (long) (b > 200 ? 200 : b);
}
double progress = (t + bonusT) / (double) (config.getTime_ul_max() * 1000);
speed = (speed * 8 * config.getOverheadCompensationFactor()) / (config.getUseMebibits() ? 1048576.0 : 1000000.0);
@@ -209,6 +212,7 @@ public void onError(String err) {
@Override
public boolean onPong(long ns) {
counter++;
+ pongsReceived++;
double ms = ns / 1000000.0;
if (ms < minPing) minPing = ms;
ping = minPing;
@@ -232,33 +236,79 @@ public void onDone() {
if(stopASAP) return;
log.l("Ping: "+ ping+" "+jitter+ " (took "+(System.currentTimeMillis()-start)+"ms)");
onPingJitterUpdate(ping,jitter,1);
+ //approximate packet loss from HTTP pings that never returned
+ int expected=config.getCount_ping();
+ if(expected>0){
+ loss=pongsReceived>=expected?0:100.0*(expected-pongsReceived)/expected;
+ onLossUpdate(loss);
+ }
}
private void sendTelemetry(){
if(telemetryConfig.getTelemetryLevel().equals(TelemetryConfig.LEVEL_DISABLED)) return;
if(stopASAP&&telemetryConfig.getTelemetryLevel().equals(TelemetryConfig.LEVEL_BASIC)) return;
+ //the tested server may run its own results backend (this is what the web client uses);
+ //try it first, then fall back to the centrally configured endpoint
+ String base=testServerTelemetryBase();
+ String localId=submitTelemetry(backend.getServer(),base.isEmpty()?"results/telemetry.php":base+"/results/telemetry.php");
+ if(localId!=null){
+ onTestIDReceived(localId,shareUrlTemplate(backend.getServer(),base.isEmpty()?"results/?id=%s":base+"/results/?id=%s"));
+ return;
+ }
+ String centralId=submitTelemetry(telemetryConfig.getServer(),telemetryConfig.getPath());
+ if(centralId!=null){
+ onTestIDReceived(centralId,shareUrlTemplate(telemetryConfig.getServer(),telemetryConfig.getShareURL()));
+ }
+ }
+
+ //endpoints usually live in /backend/, the results backend in /results/
+ private String testServerTelemetryBase(){
+ String pingURL=backend.getPingURL()==null?"":backend.getPingURL();
+ int slash=pingURL.lastIndexOf('/');
+ String dir=slash==-1?"":pingURL.substring(0,slash);
+ if(dir.endsWith("backend")) dir=dir.substring(0,dir.length()-"backend".length());
+ while(dir.startsWith("/")) dir=dir.substring(1);
+ while(dir.endsWith("/")) dir=dir.substring(0,dir.length()-1);
+ return dir;
+ }
+
+ private String submitTelemetry(String server, String path){
+ if(server==null||server.isEmpty()||path==null||path.isEmpty()) return null;
try{
- Connection c=new Connection(telemetryConfig.getServer(),-1,-1,-1,-1);
- Telemetry t=new Telemetry(c,telemetryConfig.getPath(),telemetryConfig.getTelemetryLevel(),ipIsp,config.getTelemetry_extra(),dl==-1?"":String.format(Locale.ENGLISH,"%.2f",dl),ul==-1?"":String.format(Locale.ENGLISH,"%.2f",ul),ping==-1?"":String.format(Locale.ENGLISH,"%.2f",ping),jitter==-1?"":String.format(Locale.ENGLISH,"%.2f",jitter),log.getLog()) {
+ Connection c=new Connection(server,-1,-1,-1,-1);
+ final String[] result=new String[1];
+ Telemetry t=new Telemetry(c,path,telemetryConfig.getTelemetryLevel(),ipIsp,config.getTelemetry_extra(),dl==-1?"":String.format(Locale.ENGLISH,"%.2f",dl),ul==-1?"":String.format(Locale.ENGLISH,"%.2f",ul),ping==-1?"":String.format(Locale.ENGLISH,"%.2f",ping),jitter==-1?"":String.format(Locale.ENGLISH,"%.2f",jitter),log.getLog()) {
@Override
public void onDataReceived(String data) {
- if(data.startsWith("id")){
- onTestIDReceived(data.split(" ")[1]);
+ if(data!=null&&data.startsWith("id")){
+ String[] parts=data.split(" ");
+ if(parts.length>1) result[0]=parts[1];
}
}
@Override
public void onError(String err) {
- System.err.println("Telemetry error: "+err);
+ System.err.println("Telemetry error ("+server+"): "+err);
}
};
t.join();
+ return result[0];
}catch (Throwable t){
- System.err.println("Failed to send telemetry: "+t.toString());
- t.printStackTrace(System.err);
+ System.err.println("Failed to send telemetry to "+server+": "+t);
+ return null;
}
}
+ private String shareUrlTemplate(String serverBase, String sharePath){
+ if(serverBase==null||serverBase.isEmpty()||sharePath==null||sharePath.isEmpty()) return null;
+ String server=serverBase;
+ if(!server.endsWith("/")) server=server+"/";
+ String path=sharePath;
+ while(path.startsWith("/")) path=path.substring(1);
+ if(server.startsWith("//")) server="https:"+server;
+ return server+path;
+ }
+
public void abort(){
if(stopASAP) return;
log.l("Manually aborted");
@@ -268,8 +318,9 @@ public void abort(){
public abstract void onDownloadUpdate(double dl, double progress);
public abstract void onUploadUpdate(double ul, double progress);
public abstract void onPingJitterUpdate(double ping, double jitter, double progress);
+ public abstract void onLossUpdate(double loss);
public abstract void onIPInfoUpdate(String ipInfo);
- public abstract void onTestIDReceived(String id);
+ public abstract void onTestIDReceived(String id, String shareURLTemplate);
public abstract void onEnd();
public abstract void onCriticalFailure(String err);
diff --git a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/ui/GaugeView.java b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/ui/GaugeView.java
deleted file mode 100644
index 11a0571..0000000
--- a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/ui/GaugeView.java
+++ /dev/null
@@ -1,122 +0,0 @@
-package com.fdossena.speedtest.ui;
-
-import android.content.Context;
-import android.content.res.TypedArray;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.graphics.RectF;
-import android.util.AttributeSet;
-import android.view.View;
-
-import your.name.here.speedtest.R;
-
-public class GaugeView extends View {
- private float strokeWidth;
- private int backgroundColor;
- private int fillColor;
- private int startAngle;
- private int angles;
- private int maxValue;
- private int value=0;
-
- public GaugeView(Context context, AttributeSet attrs) {
- super(context, attrs);
- TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.GaugeView, 0, 0);
- setStrokeWidth(a.getDimension(R.styleable.GaugeView_gauge_strokeWidth, 10));
- setBackgroundColor(a.getColor(R.styleable.GaugeView_gauge_backgroundColor, 0xFFCCCCCC));
- setFillColor(a.getColor(R.styleable.GaugeView_gauge_fillColor, 0xFFFFFFFF));
- setStartAngle(a.getInt(R.styleable.GaugeView_gauge_startAngle, 135));
- setAngles(a.getInt(R.styleable.GaugeView_gauge_angles, 270));
- setMaxValue(a.getInt(R.styleable.GaugeView_gauge_maxValue, 1000));
- }
-
- public GaugeView(Context context) {
- super(context);
- }
-
- private Paint paint=null;
- private RectF rect=null;
- @Override
- protected void onDraw(Canvas canvas) {
- super.onDraw(canvas);
- float size = getWidth()16*1024*1024) throw new Exception("Too big");
- options.inJustDecodeBounds = false;
- DisplayMetrics displayMetrics = new DisplayMetrics();
- getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
- int vh = displayMetrics.heightPixels, vw = displayMetrics.widthPixels;
- double desired=Math.max(vw,vh) * 0.7;
- double scale=desired/Math.max(iw,ih);
- final Bitmap b = Bitmap.createScaledBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.testbackground, options),(int)(iw*scale), (int)(ih*scale), true);
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- v.setImageBitmap(b);
- }
- });
- }catch (Throwable t){
- System.err.println("Failed to load testbackground ("+t.getMessage()+")");
- }
- page_init();
- }
- }.start();
- }
-
- private static Speedtest st=null;
-
- private void page_init(){
- new Thread(){
- @Override
- public void run() {
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- transition(R.id.page_init,TRANSITION_LENGTH);
- }
- });
- final TextView t=((TextView)findViewById(R.id.init_text));
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- t.setText(R.string.init_init);
- }
- });
- SpeedtestConfig config=null;
- TelemetryConfig telemetryConfig=null;
- TestPoint[] servers=null;
- try{
- String c=readFileFromAssets("SpeedtestConfig.json");
- JSONObject o=new JSONObject(c);
- config=new SpeedtestConfig(o);
- c=readFileFromAssets("TelemetryConfig.json");
- o=new JSONObject(c);
- telemetryConfig=new TelemetryConfig(o);
- if(telemetryConfig.getTelemetryLevel().equals(TelemetryConfig.LEVEL_DISABLED)){
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- hideView(R.id.privacy_open);
- }
- });
- }
- if(st!=null){
- try{st.abort();}catch (Throwable e){}
- }
- st=new Speedtest();
- st.setSpeedtestConfig(config);
- st.setTelemetryConfig(telemetryConfig);
- c=readFileFromAssets("ServerList.json");
- if(c.startsWith("\"")||c.startsWith("'")){ //fetch server list from URL
- if(!st.loadServerList(c.subSequence(1,c.length()-1).toString())){
- throw new Exception("Failed to load server list");
- }
- }else{ //use provided server list
- JSONArray a=new JSONArray(c);
- if(a.length()==0) throw new Exception("No test points");
- ArrayList s=new ArrayList<>();
- for(int i=0;i availableServers=new ArrayList<>();
- for(TestPoint t:servers) {
- if (t.getPing() != -1) availableServers.add(t);
- }
- int selectedId=availableServers.indexOf(selected);
- final Spinner spinner=(Spinner)findViewById(R.id.serverList);
- ArrayList options=new ArrayList();
- for(TestPoint t:availableServers){
- options.add(t.getName());
- }
- ArrayAdapter adapter=new ArrayAdapter(this,android.R.layout.simple_spinner_dropdown_item,options.toArray(new String[0]));
- adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
- spinner.setAdapter(adapter);
- spinner.setSelection(selectedId);
- final Button b=(Button)findViewById(R.id.start);
- b.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- reinitOnResume=false;
- page_test(availableServers.get(spinner.getSelectedItemPosition()));
- b.setOnClickListener(null);
- }
- });
- TextView t=(TextView)findViewById(R.id.privacy_open);
- t.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- page_privacy();
- }
- });
- }
-
- private void page_privacy(){
- transition(R.id.page_privacy,TRANSITION_LENGTH);
- reinitOnResume=false;
- ((WebView)findViewById(R.id.privacy_policy)).loadUrl(getString(R.string.privacy_policy));
- TextView t=(TextView)findViewById(R.id.privacy_close);
- t.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- transition(R.id.page_serverSelect,TRANSITION_LENGTH);
- reinitOnResume=true;
- }
- });
- }
-
- private void page_test(final TestPoint selected){
- transition(R.id.page_test,TRANSITION_LENGTH);
- st.setSelectedServer(selected);
- ((TextView)findViewById(R.id.serverName)).setText(selected.getName());
- ((TextView)findViewById(R.id.dlText)).setText(format(0));
- ((TextView)findViewById(R.id.ulText)).setText(format(0));
- ((TextView)findViewById(R.id.pingText)).setText(format(0));
- ((TextView)findViewById(R.id.jitterText)).setText(format(0));
- ((ProgressBar)findViewById(R.id.dlProgress)).setProgress(0);
- ((ProgressBar)findViewById(R.id.ulProgress)).setProgress(0);
- ((GaugeView)findViewById(R.id.dlGauge)).setValue(0);
- ((GaugeView)findViewById(R.id.ulGauge)).setValue(0);
- ((TextView)findViewById(R.id.ipInfo)).setText("");
- ((ImageView)findViewById(R.id.logo_inapp)).setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- String url=getString(R.string.logo_inapp_link);
- if(url.isEmpty()) return;
- Intent i=new Intent(Intent.ACTION_VIEW);
- i.setData(Uri.parse(url));
- startActivity(i);
- }
- });
- final View endTestArea=findViewById(R.id.endTestArea);
- final int endTestAreaHeight=endTestArea.getHeight();
- ViewGroup.LayoutParams p=endTestArea.getLayoutParams();
- p.height=0;
- endTestArea.setLayoutParams(p);
- findViewById(R.id.shareButton).setVisibility(View.GONE);
- st.start(new Speedtest.SpeedtestHandler() {
- @Override
- public void onDownloadUpdate(final double dl, final double progress) {
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- ((TextView)findViewById(R.id.dlText)).setText(progress==0?"...": format(dl));
- ((GaugeView)findViewById(R.id.dlGauge)).setValue(progress==0?0:mbpsToGauge(dl));
- ((ProgressBar)findViewById(R.id.dlProgress)).setProgress((int)(100*progress));
- }
- });
- }
-
- @Override
- public void onUploadUpdate(final double ul, final double progress) {
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- ((TextView)findViewById(R.id.ulText)).setText(progress==0?"...": format(ul));
- ((GaugeView)findViewById(R.id.ulGauge)).setValue(progress==0?0:mbpsToGauge(ul));
- ((ProgressBar)findViewById(R.id.ulProgress)).setProgress((int)(100*progress));
- }
- });
-
- }
-
- @Override
- public void onPingJitterUpdate(final double ping, final double jitter, final double progress) {
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- ((TextView)findViewById(R.id.pingText)).setText(progress==0?"...": format(ping));
- ((TextView)findViewById(R.id.jitterText)).setText(progress==0?"...": format(jitter));
- }
- });
- }
-
- @Override
- public void onIPInfoUpdate(final String ipInfo) {
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- ((TextView)findViewById(R.id.ipInfo)).setText(ipInfo);
- }
- });
- }
-
- @Override
- public void onTestIDReceived(final String id, final String shareURL) {
- if(shareURL==null||shareURL.isEmpty()||id==null||id.isEmpty()) return;
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- Button shareButton=(Button)findViewById(R.id.shareButton);
- shareButton.setVisibility(View.VISIBLE);
- shareButton.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- Intent share = new Intent(android.content.Intent.ACTION_SEND);
- share.setType("text/plain");
- share.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
- share.putExtra(Intent.EXTRA_TEXT, shareURL);
- startActivity(Intent.createChooser(share, getString(R.string.test_share)));
- }
- });
- }
- });
- }
-
- @Override
- public void onEnd() {
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- final Button restartButton=(Button)findViewById(R.id.restartButton);
- restartButton.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- page_init();
- restartButton.setOnClickListener(null);
- }
- });
- }
- });
- final long startT=System.currentTimeMillis(), endT=startT+TRANSITION_LENGTH;
- new Thread(){
- public void run(){
- while(System.currentTimeMillis()=Build.VERSION_CODES.N) {
- l = getResources().getConfiguration().getLocales().get(0);
- }else{
- l=getResources().getConfiguration().locale;
- }
- if(d<10) return String.format(l,"%.2f",d);
- if(d<100) return String.format(l,"%.1f",d);
- return ""+Math.round(d);
- }
-
- private int mbpsToGauge(double s){
- return (int)(1000*(1-(1/(Math.pow(1.3,Math.sqrt(s))))));
- }
-
- private String readFileFromAssets(String name) throws Exception{
- BufferedReader b=new BufferedReader(new InputStreamReader(getAssets().open(name)));
- String ret="";
- try{
- for(;;){
- String s=b.readLine();
- if(s==null) break;
- ret+=s;
- }
- }catch(EOFException e){}
- return ret;
- }
-
- private void hideView(int id){
- View v=findViewById(id);
- if(v!=null) v.setVisibility(View.GONE);
- }
-
- private boolean reinitOnResume=false;
- @Override
- protected void onResume() {
- super.onResume();
- if(reinitOnResume){
- reinitOnResume=false;
- page_init();
- }
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
- try{st.abort();}catch (Throwable t){}
- }
-
- @Override
- public void onBackPressed() {
- if(currentPage==R.id.page_privacy)
- transition(R.id.page_serverSelect,TRANSITION_LENGTH);
- else super.onBackPressed();
- }
-
- //PAGE TRANSITION SYSTEM
-
- private int currentPage=-1;
- private boolean transitionBusy=false; //TODO: improve mutex
- private int TRANSITION_LENGTH=300;
-
- private void transition(final int page, final int duration){
- if(transitionBusy){
- new Thread(){
- public void run(){
- try{sleep(10);}catch (Throwable t){}
- transition(page,duration);
- }
- }.start();
- }else transitionBusy=true;
- if(page==currentPage) return;
- final ViewGroup oldPage=currentPage==-1?null:(ViewGroup)findViewById(currentPage),
- newPage=page==-1?null:(ViewGroup)findViewById(page);
- new Thread(){
- public void run(){
- long t=System.currentTimeMillis(), endT=t+duration;
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- if(newPage!=null){
- newPage.setAlpha(0);
- newPage.setVisibility(View.VISIBLE);
- }
- if(oldPage!=null){
- oldPage.setAlpha(1);
- }
- }
- });
- while(t()
+ ?.firstOrNull { it.state == FoldingFeature.State.HALF_OPENED && it.orientation == FoldingFeature.Orientation.HORIZONTAL }
+ ?.let { HingeBounds(it.bounds.top, it.bounds.bottom) }
+ LibreSpeedTheme(
+ mode = when (themeMode) {
+ "light" -> ThemeMode.LIGHT
+ "dark" -> ThemeMode.DARK
+ else -> ThemeMode.SYSTEM
+ }
+ ) {
+ App(windowWidth = windowSizeClass.widthSizeClass, hinge = hinge)
+ }
+ }
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/AppPreferences.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/AppPreferences.kt
new file mode 100644
index 0000000..7c855f0
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/AppPreferences.kt
@@ -0,0 +1,171 @@
+package org.librespeed.speedtest.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import org.json.JSONArray
+import org.json.JSONObject
+
+private val Context.dataStore by preferencesDataStore(name = "settings")
+
+//the public list carries a stable id; custom servers fall back to name+server
+fun TestPoint.key(): String = if (serverId > 0) "id:$serverId" else "${name}|${server}"
+
+class AppPreferences(private val context: Context) {
+
+ private object Keys {
+ val FAVORITES = stringSetPreferencesKey("favorite_servers")
+ val CUSTOM_SERVERS = stringPreferencesKey("custom_servers")
+ val REMEMBERED_SERVER = stringPreferencesKey("remembered_server")
+ val THEME_MODE = stringPreferencesKey("theme_mode")
+ val USE_MBYTES = booleanPreferencesKey("use_mbytes")
+ val TELEMETRY = booleanPreferencesKey("telemetry_enabled")
+ val SINGLE_CONNECTION = booleanPreferencesKey("single_connection")
+ val TEST_MODE = stringPreferencesKey("test_mode")
+ val ASKED_PHONE_STATE = booleanPreferencesKey("asked_phone_state")
+ val SCHEDULED_TESTS = stringPreferencesKey("scheduled_tests")
+ val GEO_CACHE = stringPreferencesKey("geo_cache")
+ }
+
+ suspend fun getGeoCache(): Map> = try {
+ val json = JSONObject(context.dataStore.data.first()[Keys.GEO_CACHE] ?: "{}")
+ json.keys().asSequence().mapNotNull { name ->
+ val parts = json.getString(name).split(",")
+ parts.takeIf { it.size == 2 }?.let { name to (it[0].toDouble() to it[1].toDouble()) }
+ }.toMap()
+ } catch (_: Exception) {
+ emptyMap()
+ }
+
+ suspend fun putGeoCache(name: String, lat: Double, lon: Double) {
+ context.dataStore.edit { preferences ->
+ val json = try {
+ JSONObject(preferences[Keys.GEO_CACHE] ?: "{}")
+ } catch (_: Exception) {
+ JSONObject()
+ }
+ json.put(name, "$lat,$lon")
+ preferences[Keys.GEO_CACHE] = json.toString()
+ }
+ }
+
+ val themeMode: Flow =
+ context.dataStore.data.map { it[Keys.THEME_MODE] ?: "system" }
+
+ val useMBytes: Flow =
+ context.dataStore.data.map { it[Keys.USE_MBYTES] ?: false }
+
+ val telemetryEnabled: Flow =
+ context.dataStore.data.map { it[Keys.TELEMETRY] ?: false }
+
+ /** "standard", "single" or "stability"; migrates the old single-connection switch. */
+ val testMode: Flow =
+ context.dataStore.data.map {
+ it[Keys.TEST_MODE] ?: if (it[Keys.SINGLE_CONNECTION] == true) "single" else "standard"
+ }
+
+ suspend fun setTestMode(value: String) {
+ context.dataStore.edit { it[Keys.TEST_MODE] = value }
+ }
+
+ /** The one-time READ_PHONE_STATE prompt before a test on a mobile network. */
+ val askedPhoneState: Flow =
+ context.dataStore.data.map { it[Keys.ASKED_PHONE_STATE] ?: false }
+
+ suspend fun markPhoneStateAsked() {
+ context.dataStore.edit { it[Keys.ASKED_PHONE_STATE] = true }
+ }
+
+ /** "off", "6h", "daily" or "weekly" */
+ val scheduledTests: Flow =
+ context.dataStore.data.map { it[Keys.SCHEDULED_TESTS] ?: "off" }
+
+ suspend fun setScheduledTests(value: String) {
+ context.dataStore.edit { it[Keys.SCHEDULED_TESTS] = value }
+ }
+
+ suspend fun setThemeMode(mode: String) {
+ context.dataStore.edit { it[Keys.THEME_MODE] = mode }
+ }
+
+ suspend fun setUseMBytes(value: Boolean) {
+ context.dataStore.edit { it[Keys.USE_MBYTES] = value }
+ }
+
+ suspend fun setTelemetryEnabled(value: Boolean) {
+ context.dataStore.edit { it[Keys.TELEMETRY] = value }
+ }
+
+ val favorites: Flow> =
+ context.dataStore.data.map { it[Keys.FAVORITES] ?: emptySet() }
+
+ val rememberedServer: Flow =
+ context.dataStore.data.map { it[Keys.REMEMBERED_SERVER] }
+
+ val customServers: Flow> =
+ context.dataStore.data.map { preferences ->
+ parseCustomServers(preferences[Keys.CUSTOM_SERVERS] ?: "[]")
+ }
+
+ suspend fun toggleFavorite(key: String) {
+ context.dataStore.edit { preferences ->
+ val current = preferences[Keys.FAVORITES] ?: emptySet()
+ preferences[Keys.FAVORITES] = if (key in current) current - key else current + key
+ }
+ }
+
+ suspend fun setRememberedServer(key: String?) {
+ context.dataStore.edit { preferences ->
+ if (key == null) preferences.remove(Keys.REMEMBERED_SERVER)
+ else preferences[Keys.REMEMBERED_SERVER] = key
+ }
+ }
+
+ suspend fun addCustomServer(testPoint: TestPoint) {
+ context.dataStore.edit { preferences ->
+ val list = parseCustomServers(preferences[Keys.CUSTOM_SERVERS] ?: "[]")
+ if (list.any { it.key() == testPoint.key() }) return@edit
+ val array = JSONArray()
+ (list + testPoint).forEach { array.put(it.toJson()) }
+ preferences[Keys.CUSTOM_SERVERS] = array.toString()
+ }
+ }
+
+ suspend fun removeCustomServer(key: String) {
+ context.dataStore.edit { preferences ->
+ val list = parseCustomServers(preferences[Keys.CUSTOM_SERVERS] ?: "[]")
+ val array = JSONArray()
+ list.filter { it.key() != key }.forEach { array.put(it.toJson()) }
+ preferences[Keys.CUSTOM_SERVERS] = array.toString()
+ }
+ }
+
+ private fun parseCustomServers(json: String): List = try {
+ val array = JSONArray(json)
+ (0 until array.length()).mapNotNull { index ->
+ try {
+ TestPoint(array.getJSONObject(index))
+ } catch (_: Exception) {
+ null
+ }
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+
+ private fun TestPoint.toJson(): JSONObject = JSONObject()
+ .put("name", name)
+ .put("server", server)
+ .put("dlURL", dlURL)
+ .put("ulURL", ulURL)
+ .put("pingURL", pingURL)
+ .put("getIpURL", getIpURL)
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/ClientInfo.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/ClientInfo.kt
new file mode 100644
index 0000000..7825bc2
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/ClientInfo.kt
@@ -0,0 +1,31 @@
+package org.librespeed.speedtest.data
+
+import android.os.Build
+import org.librespeed.speedtest.BuildConfig
+
+object ClientInfo {
+
+ val client: String = "LibreSpeed Android ${BuildConfig.VERSION_NAME}"
+
+ /**
+ * Product and version, then the platform -- the shape the LibreSpeed CLIs
+ * send, so a server sees one family across the clients and its telemetry
+ * can tell which kind of machine measured. The device product is part of
+ * it because the hardware bounds what a connection can show.
+ */
+ val userAgent: String =
+ "librespeed-android/${BuildConfig.VERSION_NAME} " +
+ "(android ${tag(Build.VERSION.RELEASE)}; " +
+ "${tag(Build.SUPPORTED_ABIS?.firstOrNull())}; ${tag(Build.PRODUCT)})"
+
+ /**
+ * Build properties are set by whoever built the ROM, so they are bounded
+ * and reduced to a conservative alphabet before going into a header: a
+ * stray line break there would split the request itself.
+ */
+ private fun tag(value: String?): String =
+ value.orEmpty()
+ .filter { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it in "._-" }
+ .take(24)
+ .ifEmpty { "unknown" }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/CustomServerFactory.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/CustomServerFactory.kt
new file mode 100644
index 0000000..532209e
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/CustomServerFactory.kt
@@ -0,0 +1,50 @@
+package org.librespeed.speedtest.data
+
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import java.net.URI
+
+object CustomServerFactory {
+
+ /**
+ * Builds a TestPoint from a user supplied URL. The engine only uses host:port of "server",
+ * so any sub-path has to move into the endpoint fields. Without a scheme the engine tries
+ * HTTPS first and falls back to HTTP ("//host").
+ */
+ @Throws(IllegalArgumentException::class)
+ fun create(name: String, url: String): TestPoint {
+ require(name.isNotBlank()) { "Name cannot be empty" }
+ //decide about the scheme before touching slashes, otherwise "https://" degenerates
+ val cleaned = url.trim()
+ val trimmed = (if (cleaned.contains("://")) cleaned else "https://$cleaned").trimEnd('/')
+ val uri = try {
+ URI(trimmed)
+ } catch (e: Exception) {
+ throw IllegalArgumentException("Invalid URL", e)
+ }
+ val host = uri.host?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Invalid URL")
+ //IPv6 literals must stay bracketed inside the URL
+ val bracketedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
+ //the scheme the user actually typed; no scheme means protocol relative
+ val scheme = when {
+ cleaned.startsWith("http://") -> "http://"
+ cleaned.startsWith("https://") -> "https://"
+ else -> "//"
+ }
+ val server = buildString {
+ append(scheme)
+ append(bracketedHost)
+ if (uri.port != -1) append(":${uri.port}")
+ }
+ val basePath = uri.path.trim('/')
+ fun endpoint(file: String) = if (basePath.isEmpty()) file else "$basePath/$file"
+ return TestPoint(
+ name.trim(),
+ server,
+ endpoint("garbage.php"),
+ endpoint("empty.php"),
+ endpoint("empty.php"),
+ endpoint("getIP.php")
+ )
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/DiagnosticReport.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/DiagnosticReport.kt
new file mode 100644
index 0000000..3aa6615
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/DiagnosticReport.kt
@@ -0,0 +1,57 @@
+package org.librespeed.speedtest.data
+
+import android.content.Context
+import android.os.Build
+import org.librespeed.speedtest.ui.history.formatDate
+import java.util.Locale
+
+object DiagnosticReport {
+
+ /** Plain-text summary for bug reports; never includes the IP address. */
+ fun build(
+ context: Context,
+ lastEntry: HistoryEntry?,
+ telemetryEnabled: Boolean,
+ testMode: String,
+ serverCount: Int
+ ): String = buildString {
+ appendLine("LibreSpeed diagnostic report")
+ appendLine("Application: ${ClientInfo.client} (${org.librespeed.speedtest.BuildConfig.BUILD_TYPE})")
+ appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})")
+ appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+ appendLine("Locale: ${Locale.getDefault()}")
+ appendLine("Network: ${NetworkInfo.describe(context) ?: "unknown"}")
+ NetworkInfo.detail(context)?.let { appendLine("Mobile network: $it") }
+ appendLine("Test mode: $testMode")
+ appendLine("Telemetry: ${if (telemetryEnabled) "on" else "off"}")
+ appendLine("Servers available: $serverCount")
+ if (lastEntry == null) {
+ appendLine("Last test: none")
+ } else {
+ appendLine("Last test: ${formatDate(lastEntry.date)}")
+ appendLine(" Server: ${lastEntry.server}")
+ appendLine(
+ String.format(
+ Locale.US, " Download: %.2f Mbps, Upload: %.2f Mbps",
+ lastEntry.download, lastEntry.upload
+ )
+ )
+ appendLine(
+ String.format(
+ Locale.US, " Ping: %.1f ms, Jitter: %.1f ms, Loss: %s",
+ lastEntry.ping, lastEntry.jitter,
+ if (lastEntry.loss >= 0) String.format(Locale.US, "%.1f %%", lastEntry.loss) else "n/a"
+ )
+ )
+ if (lastEntry.loadedDown >= 0) {
+ appendLine(String.format(Locale.US, " Latency under download: %.1f ms", lastEntry.loadedDown))
+ }
+ if (lastEntry.loadedUp >= 0) {
+ appendLine(String.format(Locale.US, " Latency under upload: %.1f ms", lastEntry.loadedUp))
+ }
+ lastEntry.networkType?.let { appendLine(" Network: $it") }
+ if (lastEntry.ipVersion != 0) appendLine(" Protocol: IPv${lastEntry.ipVersion}")
+ }
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/GeoDistance.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/GeoDistance.kt
new file mode 100644
index 0000000..842e40a
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/GeoDistance.kt
@@ -0,0 +1,34 @@
+package org.librespeed.speedtest.data
+
+import kotlin.math.asin
+import kotlin.math.cos
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+object GeoDistance {
+
+ fun km(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val earthRadiusKm = 6371.0
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2).pow(2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
+ return 2 * earthRadiusKm * asin(sqrt(a))
+ }
+
+ /** "Prague, Czech Republic (CESNET)" -> "Prague, Czech Republic" */
+ fun cleanName(name: String): String = name.substringBefore("(").trim().trimEnd(',')
+
+ /** "Prague, Czech Republic (CESNET)" -> "CESNET" or null */
+ fun sponsor(name: String): String? =
+ name.substringAfter("(", "").removeSuffix(")").trim().ifEmpty { null }
+
+ /** "https://librespeed.org/backend/" -> "librespeed.org/backend" */
+ fun hostLabel(url: String): String = url
+ .removePrefix("https://")
+ .removePrefix("http://")
+ .removePrefix("//")
+ .trimEnd('/')
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/HistoryDatabase.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/HistoryDatabase.kt
new file mode 100644
index 0000000..bbd08b8
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/HistoryDatabase.kt
@@ -0,0 +1,156 @@
+package org.librespeed.speedtest.data
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteOpenHelper
+import org.json.JSONArray
+
+data class HistoryEntry(
+ val id: Long = 0,
+ val date: Long,
+ val server: String,
+ val ping: Double,
+ val jitter: Double,
+ val download: Double,
+ val upload: Double,
+ val loss: Double,
+ val ipInfo: String?,
+ val ipVersion: Int,
+ val shareUrl: String?,
+ val networkType: String? = null,
+ val downloadSamples: List = emptyList(),
+ val uploadSamples: List = emptyList(),
+ val durationMs: Long = 0,
+ val mode: String? = null,
+ val loadedDown: Double = -1.0,
+ val loadedUp: Double = -1.0,
+ val networkDetail: String? = null
+)
+
+class HistoryDatabase(context: Context) : SQLiteOpenHelper(context, "history.db", null, 4) {
+
+ override fun onCreate(db: SQLiteDatabase) {
+ db.execSQL(
+ "CREATE TABLE history (" +
+ "id INTEGER PRIMARY KEY AUTOINCREMENT," +
+ "date INTEGER NOT NULL," +
+ "server TEXT NOT NULL," +
+ "ping REAL NOT NULL," +
+ "jitter REAL NOT NULL," +
+ "download REAL NOT NULL," +
+ "upload REAL NOT NULL," +
+ "loss REAL NOT NULL," +
+ "ipInfo TEXT," +
+ "ipVersion INTEGER NOT NULL DEFAULT 0," +
+ "shareUrl TEXT," +
+ "networkType TEXT," +
+ "dlSamples TEXT," +
+ "ulSamples TEXT," +
+ "duration INTEGER NOT NULL DEFAULT 0," +
+ "mode TEXT," +
+ "loadedDown REAL NOT NULL DEFAULT -1," +
+ "loadedUp REAL NOT NULL DEFAULT -1," +
+ "networkDetail TEXT)"
+ )
+ }
+
+ override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
+ if (oldVersion < 2) {
+ db.execSQL("ALTER TABLE history ADD COLUMN networkType TEXT")
+ db.execSQL("ALTER TABLE history ADD COLUMN dlSamples TEXT")
+ db.execSQL("ALTER TABLE history ADD COLUMN ulSamples TEXT")
+ }
+ if (oldVersion < 3) {
+ db.execSQL("ALTER TABLE history ADD COLUMN duration INTEGER NOT NULL DEFAULT 0")
+ }
+ if (oldVersion < 4) {
+ db.execSQL("ALTER TABLE history ADD COLUMN mode TEXT")
+ db.execSQL("ALTER TABLE history ADD COLUMN loadedDown REAL NOT NULL DEFAULT -1")
+ db.execSQL("ALTER TABLE history ADD COLUMN loadedUp REAL NOT NULL DEFAULT -1")
+ db.execSQL("ALTER TABLE history ADD COLUMN networkDetail TEXT")
+ }
+ }
+
+ fun insert(entry: HistoryEntry): Long = writableDatabase.insert(
+ "history", null,
+ ContentValues().apply {
+ put("date", entry.date)
+ put("server", entry.server)
+ put("ping", entry.ping)
+ put("jitter", entry.jitter)
+ put("download", entry.download)
+ put("upload", entry.upload)
+ put("loss", entry.loss)
+ put("ipInfo", entry.ipInfo)
+ put("ipVersion", entry.ipVersion)
+ put("shareUrl", entry.shareUrl)
+ put("networkType", entry.networkType)
+ put("dlSamples", entry.downloadSamples.toJson())
+ put("ulSamples", entry.uploadSamples.toJson())
+ put("duration", entry.durationMs)
+ put("mode", entry.mode)
+ put("loadedDown", entry.loadedDown)
+ put("loadedUp", entry.loadedUp)
+ put("networkDetail", entry.networkDetail)
+ }
+ )
+
+ fun readAll(): List = query("SELECT * FROM history ORDER BY date DESC", null)
+
+ fun read(id: Long): HistoryEntry? = query("SELECT * FROM history WHERE id=?", arrayOf(id.toString())).firstOrNull()
+
+ fun delete(id: Long) {
+ writableDatabase.delete("history", "id=?", arrayOf(id.toString()))
+ }
+
+ fun clear() {
+ writableDatabase.delete("history", null, null)
+ }
+
+ private fun query(sql: String, args: Array?): List {
+ val result = mutableListOf()
+ readableDatabase.rawQuery(sql, args).use { cursor ->
+ while (cursor.moveToNext()) {
+ result.add(
+ HistoryEntry(
+ id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
+ date = cursor.getLong(cursor.getColumnIndexOrThrow("date")),
+ server = cursor.getString(cursor.getColumnIndexOrThrow("server")),
+ ping = cursor.getDouble(cursor.getColumnIndexOrThrow("ping")),
+ jitter = cursor.getDouble(cursor.getColumnIndexOrThrow("jitter")),
+ download = cursor.getDouble(cursor.getColumnIndexOrThrow("download")),
+ upload = cursor.getDouble(cursor.getColumnIndexOrThrow("upload")),
+ loss = cursor.getDouble(cursor.getColumnIndexOrThrow("loss")),
+ ipInfo = cursor.getString(cursor.getColumnIndexOrThrow("ipInfo")),
+ ipVersion = cursor.getInt(cursor.getColumnIndexOrThrow("ipVersion")),
+ shareUrl = cursor.getString(cursor.getColumnIndexOrThrow("shareUrl")),
+ networkType = cursor.getString(cursor.getColumnIndexOrThrow("networkType")),
+ downloadSamples = cursor.getString(cursor.getColumnIndexOrThrow("dlSamples")).fromJson(),
+ uploadSamples = cursor.getString(cursor.getColumnIndexOrThrow("ulSamples")).fromJson(),
+ durationMs = cursor.getLong(cursor.getColumnIndexOrThrow("duration")),
+ mode = cursor.getString(cursor.getColumnIndexOrThrow("mode")),
+ loadedDown = cursor.getDouble(cursor.getColumnIndexOrThrow("loadedDown")),
+ loadedUp = cursor.getDouble(cursor.getColumnIndexOrThrow("loadedUp")),
+ networkDetail = cursor.getString(cursor.getColumnIndexOrThrow("networkDetail"))
+ )
+ )
+ }
+ }
+ return result
+ }
+
+ private fun List.toJson(): String {
+ val array = JSONArray()
+ forEach { array.put(it) }
+ return array.toString()
+ }
+
+ private fun String?.fromJson(): List = try {
+ val array = JSONArray(this ?: "[]")
+ (0 until array.length()).map { array.getDouble(it) }
+ } catch (_: Exception) {
+ emptyList()
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/NetworkInfo.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/NetworkInfo.kt
new file mode 100644
index 0000000..dc8025c
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/NetworkInfo.kt
@@ -0,0 +1,102 @@
+package org.librespeed.speedtest.data
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.telephony.TelephonyManager
+import androidx.core.content.ContextCompat
+
+object NetworkInfo {
+
+ /** Best-effort description like "Wi-Fi 6", "Ethernet", "5G" or "Cellular"; null when unknown. */
+ fun describe(context: Context): String? {
+ return try {
+ val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork) ?: return null
+ when {
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> wifiName(context, capabilities)
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> cellularName(context)
+ else -> null
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ fun isCellular(context: Context): Boolean = try {
+ val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ connectivity.getNetworkCapabilities(connectivity.activeNetwork)
+ ?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
+ } catch (_: Exception) {
+ false
+ }
+
+ /** Needs READ_PHONE_STATE for the generation; falls back to plain "Cellular" without it. */
+ private fun cellularName(context: Context): String {
+ return try {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE)
+ != PackageManager.PERMISSION_GRANTED
+ ) return "Cellular"
+ val telephony = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
+ when (telephony.dataNetworkType) {
+ TelephonyManager.NETWORK_TYPE_NR -> "5G"
+ TelephonyManager.NETWORK_TYPE_LTE -> "4G LTE"
+ TelephonyManager.NETWORK_TYPE_HSPAP, TelephonyManager.NETWORK_TYPE_HSPA,
+ TelephonyManager.NETWORK_TYPE_HSDPA, TelephonyManager.NETWORK_TYPE_HSUPA,
+ TelephonyManager.NETWORK_TYPE_UMTS -> "3G"
+ TelephonyManager.NETWORK_TYPE_EDGE, TelephonyManager.NETWORK_TYPE_GPRS -> "2G"
+ else -> "Cellular"
+ }
+ } catch (_: Exception) {
+ "Cellular"
+ }
+ }
+
+ /** Cellular extras like "T-Mobile CZ · signal 3/4"; null on other transports or when unavailable. */
+ fun detail(context: Context): String? {
+ return try {
+ val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork) ?: return null
+ if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) return null
+ val telephony = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
+ val operator = telephony.networkOperatorName?.takeIf { it.isNotBlank() }
+ val signal = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ telephony.signalStrength?.level?.let { "signal $it/4" }
+ } else {
+ null
+ }
+ listOfNotNull(operator, signal).joinToString(" · ").ifEmpty { null }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun wifiName(context: Context, capabilities: NetworkCapabilities): String {
+ val standard = try {
+ val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ capabilities.transportInfo as? WifiInfo
+ } else {
+ null
+ } ?: @Suppress("DEPRECATION") run {
+ (context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)?.connectionInfo
+ }
+ if (info != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) info.wifiStandard else 0
+ } catch (_: Exception) {
+ 0
+ }
+ return when (standard) {
+ 8 -> "Wi-Fi 7" //ScanResult.WIFI_STANDARD_11BE
+ 6 -> "Wi-Fi 6" //WIFI_STANDARD_11AX
+ 5 -> "Wi-Fi 5" //WIFI_STANDARD_11AC
+ 4 -> "Wi-Fi 4" //WIFI_STANDARD_11N
+ else -> "Wi-Fi"
+ }
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/TestStats.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/TestStats.kt
new file mode 100644
index 0000000..86aa324
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/TestStats.kt
@@ -0,0 +1,40 @@
+package org.librespeed.speedtest.data
+
+import kotlin.math.roundToInt
+import kotlin.math.sqrt
+
+object TestStats {
+
+ /** Bufferbloat grade from the latency increase under load, using the common Waveform buckets. */
+ fun bufferbloatGrade(idleMs: Double, loadedMs: Double): String? {
+ if (!idleMs.isFinite() || !loadedMs.isFinite()) return null
+ if (idleMs < 0 || loadedMs < 0) return null
+ val delta = loadedMs - idleMs
+ return when {
+ delta <= 5.0 -> "A+"
+ delta <= 30.0 -> "A"
+ delta <= 60.0 -> "B"
+ delta <= 200.0 -> "C"
+ delta <= 400.0 -> "D"
+ else -> "F"
+ }
+ }
+
+ data class Stability(val min: Double, val max: Double, val average: Double, val variationPct: Int)
+
+ /** Spread of the sampled speeds; null when there are not enough usable samples to say anything. */
+ fun stability(samples: List): Stability? {
+ val finite = samples.filter { it.isFinite() && it >= 0 }
+ if (finite.size < 5) return null
+ val average = finite.average()
+ if (average <= 0) return null
+ val deviation = sqrt(finite.sumOf { (it - average) * (it - average) } / finite.size)
+ return Stability(
+ min = finite.min(),
+ max = finite.max(),
+ average = average,
+ variationPct = (deviation / average * 100).roundToInt()
+ )
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/engine/TestEngine.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/engine/TestEngine.kt
new file mode 100644
index 0000000..a63437e
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/engine/TestEngine.kt
@@ -0,0 +1,127 @@
+package org.librespeed.speedtest.engine
+
+import android.content.Context
+import com.fdossena.speedtest.core.Speedtest
+import com.fdossena.speedtest.core.config.SpeedtestConfig
+import com.fdossena.speedtest.core.config.TelemetryConfig
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+import kotlin.coroutines.resume
+
+enum class TestMode(val key: String) {
+ STANDARD("standard"), SINGLE("single"), STABILITY("stability"), COMPARE("compare");
+
+ companion object {
+ fun fromKey(key: String?): TestMode = entries.find { it.key == key } ?: STANDARD
+ }
+}
+
+class TestEngine(private val context: Context) {
+
+ private var speedtest: Speedtest? = null
+
+ data class Discovery(val servers: List, val selected: TestPoint?)
+
+ /** Loads the server list (remote first, bundled fallback), pings all servers and picks the best one. */
+ suspend fun discover(customServers: List): Discovery = withContext(Dispatchers.IO) {
+ val st = newSpeedtest(telemetryEnabled = false)
+ customServers.forEach { runCatching { st.addTestPoint(it) } }
+ var loaded = false
+ val data = readAsset("ServerList.json")?.trim()
+ if (data != null) {
+ loaded = if (data.startsWith("\"") || data.startsWith("'")) {
+ st.loadServerList(data.substring(1, data.length - 1))
+ } else {
+ runCatching { st.addTestPoints(JSONArray(data)); true }.getOrDefault(false)
+ }
+ }
+ if (!loaded) {
+ readAsset("ServerListFallback.json")?.let {
+ runCatching { st.addTestPoints(JSONArray(it)); loaded = true }
+ }
+ }
+ if (!loaded && customServers.isEmpty()) throw IOException("Failed to load the server list")
+ val selected = suspendCancellableCoroutine { continuation ->
+ st.selectServer(object : Speedtest.ServerSelectedHandler() {
+ override fun onServerSelected(server: TestPoint?) {
+ continuation.resume(server)
+ }
+ })
+ continuation.invokeOnCancellation { runCatching { st.abort() } }
+ }
+ Discovery(st.testPoints.toList(), selected)
+ }
+
+ /**
+ * Prepares a fresh test run against an already known server, without re-pinging everything.
+ * [configOverrides] lets tests shorten the phases; production callers leave it null.
+ */
+ fun prepare(
+ servers: List,
+ selected: TestPoint,
+ telemetryEnabled: Boolean,
+ mode: TestMode = TestMode.STANDARD,
+ configOverrides: JSONObject? = null
+ ) {
+ val st = newSpeedtest(telemetryEnabled, mode, configOverrides)
+ st.addTestPoints(servers.toTypedArray())
+ st.setSelectedServer(selected)
+ speedtest = st
+ }
+
+ fun start(handler: Speedtest.SpeedtestHandler) {
+ speedtest?.start(handler)
+ }
+
+ fun abort() {
+ runCatching { speedtest?.abort() }
+ }
+
+ private fun newSpeedtest(
+ telemetryEnabled: Boolean,
+ mode: TestMode = TestMode.STANDARD,
+ configOverrides: JSONObject? = null
+ ): Speedtest {
+ val st = Speedtest()
+ val configJson = runCatching { JSONObject(readAsset("SpeedtestConfig.json") ?: "{}") }.getOrDefault(JSONObject())
+ when (mode) {
+ TestMode.SINGLE -> {
+ configJson.put("dl_parallelStreams", 1)
+ configJson.put("ul_parallelStreams", 1)
+ }
+ TestMode.STABILITY -> {
+ //a long sustained download shows how steady the line really is
+ configJson.put("test_order", "P_D")
+ configJson.put("time_dl_max", 60)
+ configJson.put("time_auto", false)
+ }
+ TestMode.COMPARE -> {
+ configJson.put("test_order", "P_D")
+ configJson.put("time_dl_max", 5)
+ configJson.put("time_auto", false)
+ }
+ TestMode.STANDARD -> Unit
+ }
+ configOverrides?.keys()?.forEach { key -> configJson.put(key, configOverrides.get(key)) }
+ runCatching { st.setSpeedtestConfig(SpeedtestConfig(configJson)) }
+ val telemetryJson = if (telemetryEnabled) {
+ JSONObject().put("telemetryLevel", TelemetryConfig.LEVEL_FULL)
+ } else {
+ readAsset("TelemetryConfig.json")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
+ }
+ runCatching { st.setTelemetryConfig(TelemetryConfig(telemetryJson)) }
+ return st
+ }
+
+ private fun readAsset(name: String): String? = try {
+ context.assets.open(name).bufferedReader().use { it.readText() }
+ } catch (_: Exception) {
+ null
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/HistoryExport.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/HistoryExport.kt
new file mode 100644
index 0000000..ce95240
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/HistoryExport.kt
@@ -0,0 +1,89 @@
+package org.librespeed.speedtest.share
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import org.json.JSONArray
+import org.json.JSONObject
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.HistoryEntry
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+object HistoryExport {
+
+ fun shareCsv(context: Context, entries: List) =
+ share(context, buildCsv(entries), "librespeed-history.csv", "text/csv")
+
+ fun shareJson(context: Context, entries: List) =
+ share(context, buildJson(entries), "librespeed-history.json", "application/json")
+
+ fun buildCsv(entries: List): String = buildString {
+ appendLine("date,server,network,protocol,download_mbps,upload_mbps,ping_ms,jitter_ms,loss_pct,loaded_down_ms,loaded_up_ms,duration_s,mode")
+ val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
+ entries.forEach { entry ->
+ appendLine(
+ listOf(
+ format.format(Date(entry.date)),
+ entry.server.csv(),
+ entry.networkType.orEmpty().csv(),
+ if (entry.ipVersion != 0) "IPv${entry.ipVersion}" else "",
+ entry.download.num(),
+ entry.upload.num(),
+ entry.ping.num(),
+ entry.jitter.num(),
+ if (entry.loss >= 0) entry.loss.num() else "",
+ if (entry.loadedDown >= 0) entry.loadedDown.num() else "",
+ if (entry.loadedUp >= 0) entry.loadedUp.num() else "",
+ if (entry.durationMs > 0) (entry.durationMs / 1000.0).num() else "",
+ entry.mode.orEmpty()
+ ).joinToString(",")
+ )
+ }
+ }
+
+ fun buildJson(entries: List): String {
+ val array = JSONArray()
+ entries.forEach { entry ->
+ array.put(JSONObject().apply {
+ put("date", entry.date)
+ put("server", entry.server)
+ entry.networkType?.let { put("network", it) }
+ if (entry.ipVersion != 0) put("protocol", "IPv${entry.ipVersion}")
+ put("download_mbps", entry.download)
+ put("upload_mbps", entry.upload)
+ put("ping_ms", entry.ping)
+ put("jitter_ms", entry.jitter)
+ if (entry.loss >= 0) put("loss_pct", entry.loss)
+ if (entry.loadedDown >= 0) put("loaded_down_ms", entry.loadedDown)
+ if (entry.loadedUp >= 0) put("loaded_up_ms", entry.loadedUp)
+ if (entry.durationMs > 0) put("duration_ms", entry.durationMs)
+ entry.mode?.let { put("mode", it) }
+ })
+ }
+ return array.toString(2)
+ }
+
+ private fun share(context: Context, content: String, filename: String, mime: String) {
+ try {
+ val directory = File(context.cacheDir, "share").apply { mkdirs() }
+ val file = File(directory, filename)
+ file.writeText(content)
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = mime
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, context.getString(R.string.nav_history)))
+ } catch (_: Exception) {
+ }
+ }
+
+ private fun String.csv(): String = if (contains(',') || contains('"')) "\"${replace("\"", "\"\"")}\"" else this
+
+ private fun Double.num(): String = String.format(Locale.US, "%.2f", this)
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/ShareImage.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/ShareImage.kt
new file mode 100644
index 0000000..a03fc0d
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/ShareImage.kt
@@ -0,0 +1,184 @@
+package org.librespeed.speedtest.share
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.LinearGradient
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.RadialGradient
+import android.graphics.Shader
+import android.graphics.Typeface
+import androidx.core.content.FileProvider
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.GeoDistance
+import org.librespeed.speedtest.data.HistoryEntry
+import org.librespeed.speedtest.ui.history.formatDate
+import java.io.File
+import java.util.Locale
+
+object ShareImage {
+
+ private const val TEAL = 0xFF2DD4BF.toInt()
+ private const val PURPLE = 0xFFA78BFA.toInt()
+ private const val WHITE = 0xFFE4E9F2.toInt()
+ private const val GRAY = 0xFF9AA4B8.toInt()
+ private const val BACKGROUND_TOP = 0xFF0B1120.toInt()
+ private const val BACKGROUND_BOTTOM = 0xFF1A1440.toInt()
+
+ private const val WIDTH = 1080
+ private const val HEIGHT = 1350
+
+ fun share(context: Context, entry: HistoryEntry, useMBytes: Boolean) {
+ try {
+ val bitmap = render(context, entry, useMBytes)
+ val directory = File(context.cacheDir, "share").apply { mkdirs() }
+ val file = File(directory, "librespeed-result.png")
+ file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
+ bitmap.recycle()
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "image/png"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_result)))
+ } catch (_: Exception) {
+ }
+ }
+
+ fun render(context: Context, entry: HistoryEntry, useMBytes: Boolean): Bitmap {
+ val bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ drawBackground(canvas)
+
+ val unit = context.getString(if (useMBytes) R.string.unit_mbytes else R.string.unit_mbps)
+ fun speed(value: Double) = String.format(Locale.US, "%.2f", if (useMBytes) value / 8 else value)
+
+ fun paint(color: Int, size: Float, bold: Boolean = false) = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ this.color = color
+ textSize = size
+ typeface = if (bold) Typeface.create(Typeface.DEFAULT, Typeface.BOLD) else Typeface.DEFAULT
+ textAlign = Paint.Align.CENTER
+ }
+
+ val centerX = WIDTH / 2f
+
+ //wordmark
+ val titlePaint = paint(TEAL, 92f, bold = true).apply { textAlign = Paint.Align.LEFT }
+ val libreWidth = titlePaint.measureText("Libre")
+ val speedWidth = titlePaint.measureText("Speed")
+ val titleStart = centerX - (libreWidth + speedWidth) / 2
+ canvas.drawText("Libre", titleStart, 170f, titlePaint)
+ titlePaint.color = WHITE
+ canvas.drawText("Speed", titleStart + libreWidth, 170f, titlePaint)
+ canvas.drawText(context.getString(R.string.share_image_subtitle), centerX, 235f, paint(GRAY, 42f))
+
+ //download
+ drawCircleArrow(canvas, centerX - measureSpeedHalf(speed(entry.download)) - 70f, 425f, TEAL, down = true)
+ canvas.drawText(speed(entry.download), centerX, 460f, paint(TEAL, 140f, bold = true))
+ canvas.drawText(unit, centerX + measureSpeedHalf(speed(entry.download)) + 85f, 455f, paint(GRAY, 46f))
+ canvas.drawText(context.getString(R.string.test_download).uppercase(Locale.ROOT), centerX, 535f, paint(GRAY, 44f, bold = true))
+
+ //upload
+ drawCircleArrow(canvas, centerX - measureSpeedHalf(speed(entry.upload)) - 70f, 690f, PURPLE, down = false)
+ canvas.drawText(speed(entry.upload), centerX, 725f, paint(PURPLE, 140f, bold = true))
+ canvas.drawText(unit, centerX + measureSpeedHalf(speed(entry.upload)) + 85f, 720f, paint(GRAY, 46f))
+ canvas.drawText(context.getString(R.string.test_upload).uppercase(Locale.ROOT), centerX, 800f, paint(GRAY, 44f, bold = true))
+
+ //ping + jitter
+ val leftX = WIDTH / 3f
+ val rightX = 2 * WIDTH / 3f
+ canvas.drawText(String.format(Locale.US, "%.0f ms", entry.ping), leftX, 950f, paint(WHITE, 72f, bold = true))
+ canvas.drawText(context.getString(R.string.test_ping).uppercase(Locale.ROOT), leftX, 1005f, paint(GRAY, 40f))
+ canvas.drawText(String.format(Locale.US, "%.0f ms", entry.jitter), rightX, 950f, paint(WHITE, 72f, bold = true))
+ canvas.drawText(context.getString(R.string.test_jitter).uppercase(Locale.ROOT), rightX, 1005f, paint(GRAY, 40f))
+
+ //server + date, above the waves
+ canvas.drawText(
+ listOfNotNull(GeoDistance.cleanName(entry.server), GeoDistance.sponsor(entry.server)).joinToString(" · "),
+ centerX, 1075f, paint(WHITE, 44f)
+ )
+ canvas.drawText(formatDate(entry.date), centerX, 1133f, paint(GRAY, 40f))
+
+ //rounded border framing the whole card, like the mockup
+ canvas.drawRoundRect(
+ android.graphics.RectF(5f, 5f, WIDTH - 5f, HEIGHT - 5f), 48f, 48f,
+ Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = 0x30FFFFFF
+ style = Paint.Style.STROKE
+ strokeWidth = 5f
+ }
+ )
+
+ return bitmap
+ }
+
+ private fun measureSpeedHalf(text: String): Float =
+ Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ textSize = 140f
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
+ }.measureText(text) / 2
+
+ private fun drawBackground(canvas: Canvas) {
+ val width = WIDTH.toFloat()
+ val height = HEIGHT.toFloat()
+ canvas.drawRect(0f, 0f, width, height, Paint().apply {
+ shader = LinearGradient(0f, 0f, 0f, height, BACKGROUND_TOP, BACKGROUND_BOTTOM, Shader.TileMode.CLAMP)
+ })
+ //soft teal glow behind the wordmark and purple glow behind the upload value
+ canvas.drawRect(0f, 0f, width, 500f, Paint().apply {
+ shader = RadialGradient(width / 2f, 60f, 620f, 0x2E2DD4BF, 0x002DD4BF, Shader.TileMode.CLAMP)
+ })
+ canvas.drawRect(0f, 350f, width, height, Paint().apply {
+ shader = RadialGradient(width / 2f, 700f, 760f, 0x1E7C5CFC, 0x007C5CFC, Shader.TileMode.CLAMP)
+ })
+ //subtle gradient waves along the very bottom, below the server line
+ wave(canvas, height - 210f, 70f, 0x2E2DD4BF, 0x2EA78BFA, phase = 0f)
+ wave(canvas, height - 145f, 60f, 0x38A78BFA, 0x382DD4BF, phase = 0.5f)
+ wave(canvas, height - 80f, 55f, 0x4D2DD4BF, 0x4D7C5CFC, phase = 0.25f)
+ }
+
+ private fun wave(canvas: Canvas, top: Float, amplitude: Float, colorStart: Int, colorEnd: Int, phase: Float) {
+ val width = WIDTH.toFloat()
+ val height = HEIGHT.toFloat()
+ val path = Path().apply {
+ moveTo(0f, top + amplitude * phase)
+ cubicTo(
+ width * 0.25f, top - amplitude,
+ width * 0.45f, top + amplitude * 1.4f,
+ width * 0.7f, top + amplitude * 0.2f
+ )
+ cubicTo(
+ width * 0.85f, top - amplitude * 0.5f,
+ width * 0.95f, top + amplitude * 0.6f,
+ width, top - amplitude * 0.2f
+ )
+ lineTo(width, height)
+ lineTo(0f, height)
+ close()
+ }
+ canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ shader = LinearGradient(0f, top - amplitude, width, height, colorStart, colorEnd, Shader.TileMode.CLAMP)
+ })
+ }
+
+ private fun drawCircleArrow(canvas: Canvas, cx: Float, cy: Float, color: Int, down: Boolean) {
+ val radius = 36f
+ val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ this.color = color
+ style = Paint.Style.STROKE
+ strokeWidth = 6f
+ strokeCap = Paint.Cap.ROUND
+ }
+ canvas.drawCircle(cx, cy, radius, stroke)
+ val shaft = 17f
+ val head = 10f
+ val direction = if (down) 1f else -1f
+ canvas.drawLine(cx, cy - shaft * direction, cx, cy + shaft * direction, stroke)
+ canvas.drawLine(cx, cy + shaft * direction, cx - head, cy + (shaft - head) * direction, stroke)
+ canvas.drawLine(cx, cy + shaft * direction, cx + head, cy + (shaft - head) * direction, stroke)
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/ShareResult.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/ShareResult.kt
new file mode 100644
index 0000000..1b71b75
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/ShareResult.kt
@@ -0,0 +1,89 @@
+package org.librespeed.speedtest.share
+
+import android.content.Context
+import android.content.Intent
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.GeoDistance
+import java.util.Locale
+
+object ShareResult {
+
+ fun copy(context: Context, text: String) {
+ try {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
+ clipboard.setPrimaryClip(android.content.ClipData.newPlainText("LibreSpeed", text))
+ } catch (_: Exception) {
+ }
+ }
+
+ fun shareLink(context: Context, url: String) {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, url)
+ }
+ try {
+ context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_result)))
+ } catch (_: Exception) {
+ }
+ }
+
+ fun buildText(
+ context: Context,
+ server: String,
+ download: Double,
+ upload: Double,
+ ping: Double,
+ jitter: Double,
+ loss: Double,
+ useMBytes: Boolean,
+ shareUrl: String?,
+ networkType: String? = null,
+ ipVersion: Int = 0
+ ): String {
+ val unit = context.getString(if (useMBytes) R.string.unit_mbytes else R.string.unit_mbps)
+ fun speed(value: Double) =
+ String.format(Locale.US, "%.2f %s", if (useMBytes) value / 8 else value, unit)
+
+ return buildString {
+ appendLine("LibreSpeed Speed Test")
+ appendLine("${context.getString(R.string.test_download)}: ${speed(download)}")
+ appendLine("${context.getString(R.string.test_upload)}: ${speed(upload)}")
+ appendLine("${context.getString(R.string.test_ping)}: ${String.format(Locale.US, "%.1f", ping)} ms")
+ appendLine("${context.getString(R.string.test_jitter)}: ${String.format(Locale.US, "%.1f", jitter)} ms")
+ if (loss >= 0) {
+ appendLine("${context.getString(R.string.test_loss)}: ${String.format(Locale.US, "%.1f", loss)} %")
+ }
+ appendLine("${context.getString(R.string.detail_server)}: ${GeoDistance.cleanName(server)}")
+ networkType?.let { appendLine("${context.getString(R.string.detail_network)}: $it") }
+ if (ipVersion != 0) {
+ appendLine("${context.getString(R.string.detail_protocol)}: IPv$ipVersion")
+ }
+ shareUrl?.let { appendLine(it) }
+ }.trimEnd()
+ }
+
+ fun share(
+ context: Context,
+ server: String,
+ download: Double,
+ upload: Double,
+ ping: Double,
+ jitter: Double,
+ loss: Double,
+ useMBytes: Boolean,
+ shareUrl: String?,
+ networkType: String? = null,
+ ipVersion: Int = 0
+ ) {
+ val text = buildText(context, server, download, upload, ping, jitter, loss, useMBytes, shareUrl, networkType, ipVersion)
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, text)
+ }
+ try {
+ context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_result)))
+ } catch (_: Exception) {
+ }
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/App.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/App.kt
new file mode 100644
index 0000000..738a41a
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/App.kt
@@ -0,0 +1,180 @@
+package org.librespeed.speedtest.ui
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.ui.history.HistoryScreen
+import org.librespeed.speedtest.ui.result.ResultScreen
+import org.librespeed.speedtest.ui.servers.ServersScreen
+import org.librespeed.speedtest.ui.settings.SettingsScreen
+import org.librespeed.speedtest.ui.speedtest.SpeedtestScreen
+import org.librespeed.speedtest.ui.speedtest.SpeedtestViewModel
+
+/** Fold crease position in window coordinates (px), for tabletop layouts. */
+data class HingeBounds(val top: Int, val bottom: Int)
+
+enum class Destination(val route: String, @StringRes val label: Int, val icon: ImageVector) {
+ SPEEDTEST("speedtest", R.string.nav_speedtest, Icons.Filled.Speed),
+ HISTORY("history", R.string.nav_history, Icons.Filled.History),
+ SERVERS("servers", R.string.nav_servers, Icons.Filled.Public),
+ SETTINGS("settings", R.string.nav_settings, Icons.Filled.Settings)
+}
+
+@androidx.compose.material3.ExperimentalMaterial3Api
+@Composable
+fun App(windowWidth: WindowWidthSizeClass = WindowWidthSizeClass.Compact, hinge: HingeBounds? = null) {
+ val navController = rememberNavController()
+ val backStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = backStackEntry?.destination?.route
+ val speedtestViewModel: SpeedtestViewModel = viewModel()
+ val useRail = windowWidth != WindowWidthSizeClass.Compact
+ val onResultScreen = currentRoute?.startsWith("result/") == true ||
+ currentRoute?.startsWith("testdetails/") == true ||
+ currentRoute?.startsWith("share/") == true ||
+ currentRoute == "compare" || currentRoute == "licenses"
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ bottomBar = {
+ if (!useRail && !onResultScreen) {
+ NavigationBar {
+ Destination.entries.forEach { destination ->
+ NavigationBarItem(
+ selected = currentRoute == destination.route,
+ onClick = { navController.navigateTo(destination) },
+ icon = { Icon(destination.icon, contentDescription = null) },
+ label = { Text(stringResource(destination.label)) }
+ )
+ }
+ }
+ }
+ }
+ ) { padding ->
+ Row(Modifier.fillMaxSize().padding(padding)) {
+ if (useRail && !onResultScreen) {
+ NavigationRail {
+ Destination.entries.forEach { destination ->
+ NavigationRailItem(
+ selected = currentRoute == destination.route,
+ onClick = { navController.navigateTo(destination) },
+ icon = { Icon(destination.icon, contentDescription = null) },
+ label = { Text(stringResource(destination.label)) }
+ )
+ }
+ }
+ }
+ NavHost(
+ navController = navController,
+ startDestination = Destination.SPEEDTEST.route,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ composable(Destination.SPEEDTEST.route) {
+ SpeedtestScreen(
+ viewModel = speedtestViewModel,
+ onServersClick = { navController.navigateTo(Destination.SERVERS) },
+ onSettingsClick = { navController.navigateTo(Destination.SETTINGS) },
+ onResult = { id -> navController.navigate("result/$id") },
+ hinge = hinge
+ )
+ }
+ composable(Destination.HISTORY.route) {
+ HistoryScreen(onOpen = { id -> navController.navigate("result/$id") })
+ }
+ composable(Destination.SERVERS.route) {
+ ServersScreen(speedtestViewModel, onCompareClick = { navController.navigate("compare") })
+ }
+ composable("compare") {
+ org.librespeed.speedtest.ui.servers.CompareScreen(
+ speedtestViewModel = speedtestViewModel,
+ onBack = { navController.popBackStack() }
+ )
+ }
+ composable(Destination.SETTINGS.route) {
+ val uiState by speedtestViewModel.state.collectAsStateWithLifecycle()
+ SettingsScreen(
+ serverLabel = uiState.selectedServer?.name,
+ serverPinned = uiState.pinnedServer,
+ serverCount = uiState.servers.size,
+ onServersClick = { navController.navigateTo(Destination.SERVERS) },
+ onLicensesClick = { navController.navigate("licenses") }
+ )
+ }
+ composable("licenses") {
+ org.librespeed.speedtest.ui.settings.LicensesScreen(onBack = { navController.popBackStack() })
+ }
+ composable(
+ route = "result/{id}",
+ arguments = listOf(navArgument("id") { type = NavType.LongType })
+ ) { entry ->
+ ResultScreen(
+ entryId = entry.arguments?.getLong("id") ?: 0L,
+ onBack = { navController.popBackStack() },
+ onTestAgain = { serverName ->
+ navController.navigateTo(Destination.SPEEDTEST)
+ speedtestViewModel.testAgain(serverName)
+ },
+ onTestDetails = { id -> navController.navigate("testdetails/$id") },
+ onShare = { id -> navController.navigate("share/$id") }
+ )
+ }
+ composable(
+ route = "testdetails/{id}",
+ arguments = listOf(navArgument("id") { type = NavType.LongType })
+ ) { entry ->
+ org.librespeed.speedtest.ui.result.TestDetailsScreen(
+ entryId = entry.arguments?.getLong("id") ?: 0L,
+ onBack = { navController.popBackStack() }
+ )
+ }
+ composable(
+ route = "share/{id}",
+ arguments = listOf(navArgument("id") { type = NavType.LongType })
+ ) { entry ->
+ org.librespeed.speedtest.ui.result.ShareScreen(
+ entryId = entry.arguments?.getLong("id") ?: 0L,
+ onBack = { navController.popBackStack() }
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun NavHostController.navigateTo(destination: Destination) {
+ navigate(destination.route) {
+ popUpTo(graph.findStartDestination().id) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/components/Sparkline.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/components/Sparkline.kt
new file mode 100644
index 0000000..03bb2e1
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/components/Sparkline.kt
@@ -0,0 +1,51 @@
+package org.librespeed.speedtest.ui.components
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+
+@Composable
+fun Sparkline(
+ data: List,
+ color: Color,
+ modifier: Modifier = Modifier
+) {
+ Canvas(modifier = modifier) {
+ if (data.size < 2) return@Canvas
+ val max = data.max().takeIf { it > 0 } ?: return@Canvas
+ val stepX = size.width / (data.size - 1)
+ val vPadding = size.height * 0.1f
+ val usable = size.height - vPadding * 2
+ fun pointAt(index: Int): Offset {
+ val y = size.height - vPadding - (data[index] / max * usable).toFloat()
+ return Offset(stepX * index, y)
+ }
+
+ val line = Path()
+ line.moveTo(pointAt(0).x, pointAt(0).y)
+ for (i in 1 until data.size) {
+ val previous = pointAt(i - 1)
+ val current = pointAt(i)
+ val midX = (previous.x + current.x) / 2
+ line.cubicTo(midX, previous.y, midX, current.y, current.x, current.y)
+ }
+ drawPath(line, color = color, style = Stroke(width = 4f, cap = StrokeCap.Round))
+
+ val fill = Path().apply {
+ addPath(line)
+ lineTo(size.width, size.height)
+ lineTo(0f, size.height)
+ close()
+ }
+ drawPath(
+ fill,
+ brush = Brush.verticalGradient(listOf(color.copy(alpha = 0.25f), Color.Transparent))
+ )
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/components/SpeedGauge.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/components/SpeedGauge.kt
new file mode 100644
index 0000000..deeb3f4
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/components/SpeedGauge.kt
@@ -0,0 +1,116 @@
+package org.librespeed.speedtest.ui.components
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.librespeed.speedtest.ui.theme.Purple
+import org.librespeed.speedtest.ui.theme.Teal
+import kotlin.math.cos
+import kotlin.math.sin
+
+private val SCALE_STOPS = doubleArrayOf(0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0)
+private const val START_ANGLE = 135f
+private const val SWEEP = 270f
+
+/** Maps a speed to a 0..1 position on the non-linear gauge scale. */
+private fun scaleFraction(speed: Double): Float {
+ if (speed <= 0) return 0f
+ if (speed >= SCALE_STOPS.last()) return 1f
+ for (i in 1 until SCALE_STOPS.size) {
+ if (speed <= SCALE_STOPS[i]) {
+ val segment = (speed - SCALE_STOPS[i - 1]) / (SCALE_STOPS[i] - SCALE_STOPS[i - 1])
+ return ((i - 1) + segment).toFloat() / (SCALE_STOPS.size - 1)
+ }
+ }
+ return 1f
+}
+
+@Composable
+fun SpeedGauge(
+ speed: Double,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit
+) {
+ val fraction by animateFloatAsState(
+ targetValue = scaleFraction(speed),
+ animationSpec = tween(durationMillis = 300),
+ label = "gauge"
+ )
+ val trackColor = MaterialTheme.colorScheme.surfaceVariant
+ val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
+ val labelSizePx = with(LocalDensity.current) { 11.sp.toPx() }
+
+ Box(modifier = modifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
+ Canvas(modifier = Modifier.matchParentSize()) {
+ val stroke = size.minDimension * 0.055f
+ val labelInset = labelSizePx * 2.2f
+ val diameter = size.minDimension - stroke - labelInset * 2
+ val topLeft = Offset((size.width - diameter) / 2, (size.height - diameter) / 2)
+ val arcSize = Size(diameter, diameter)
+
+ drawArc(
+ color = trackColor,
+ startAngle = START_ANGLE,
+ sweepAngle = SWEEP,
+ useCenter = false,
+ topLeft = topLeft,
+ size = arcSize,
+ style = Stroke(width = stroke, cap = StrokeCap.Round)
+ )
+ if (fraction > 0f) {
+ // sweep gradient starts at 3 o'clock; rotate so it begins at the gauge start
+ rotate(degrees = START_ANGLE - 5f) {
+ drawArc(
+ brush = Brush.sweepGradient(
+ 0f to Teal,
+ (SWEEP + 10f) / 360f to Purple,
+ 1f to Teal
+ ),
+ startAngle = 5f,
+ sweepAngle = (SWEEP * fraction).coerceAtLeast(1f),
+ useCenter = false,
+ topLeft = topLeft,
+ size = arcSize,
+ style = Stroke(width = stroke, cap = StrokeCap.Round)
+ )
+ }
+ }
+ // scale labels
+ val paint = android.graphics.Paint().apply {
+ color = labelColor.toArgb()
+ textSize = labelSizePx
+ textAlign = android.graphics.Paint.Align.CENTER
+ isAntiAlias = true
+ }
+ val labelRadius = diameter / 2 + stroke / 2 + labelSizePx * 1.1f
+ val center = Offset(size.width / 2, size.height / 2)
+ SCALE_STOPS.forEachIndexed { index, stop ->
+ val angleDeg = START_ANGLE + SWEEP * index / (SCALE_STOPS.size - 1)
+ val rad = Math.toRadians(angleDeg.toDouble())
+ val x = center.x + labelRadius * cos(rad).toFloat()
+ val y = center.y + labelRadius * sin(rad).toFloat() + labelSizePx * 0.35f
+ val text = if (stop < 1) "0" else stop.toInt().toString()
+ drawContext.canvas.nativeCanvas.drawText(text, x, y, paint)
+ }
+ }
+ content()
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/history/HistoryScreen.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/history/HistoryScreen.kt
new file mode 100644
index 0000000..729a9e8
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/history/HistoryScreen.kt
@@ -0,0 +1,391 @@
+package org.librespeed.speedtest.ui.history
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDownward
+import androidx.compose.material.icons.filled.ArrowUpward
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.DeleteOutline
+import androidx.compose.material.icons.filled.FilterList
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material.icons.filled.SettingsEthernet
+import androidx.compose.material.icons.filled.SignalCellularAlt
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.GeoDistance
+import org.librespeed.speedtest.data.HistoryEntry
+import org.librespeed.speedtest.share.HistoryExport
+import org.librespeed.speedtest.ui.components.Sparkline
+import org.librespeed.speedtest.ui.theme.Purple
+import org.librespeed.speedtest.ui.theme.Teal
+import java.text.DateFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun HistoryScreen(
+ onOpen: (Long) -> Unit,
+ viewModel: HistoryViewModel = viewModel()
+) {
+ val context = LocalContext.current
+ val entries by viewModel.entries.collectAsStateWithLifecycle()
+ val filter by viewModel.filter.collectAsStateWithLifecycle()
+ val allNetworks by viewModel.allNetworks.collectAsStateWithLifecycle()
+ val allServers by viewModel.allServers.collectAsStateWithLifecycle()
+ var confirmClear by remember { mutableStateOf(false) }
+ var filterDialog by remember { mutableStateOf(false) }
+ var exportMenu by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) { viewModel.load() }
+
+ Column(Modifier.fillMaxSize()) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.nav_history),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = { filterDialog = true }, enabled = entries.isNotEmpty() || filter.active) {
+ Icon(
+ Icons.Filled.FilterList,
+ contentDescription = stringResource(R.string.history_filter),
+ tint = if (filter.active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(onClick = { confirmClear = true }, enabled = entries.isNotEmpty()) {
+ Icon(Icons.Filled.DeleteOutline, contentDescription = stringResource(R.string.history_clear))
+ }
+ Box {
+ IconButton(onClick = { exportMenu = true }, enabled = entries.isNotEmpty()) {
+ Icon(Icons.Filled.MoreVert, contentDescription = null)
+ }
+ DropdownMenu(expanded = exportMenu, onDismissRequest = { exportMenu = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.history_export_csv)) },
+ onClick = {
+ exportMenu = false
+ HistoryExport.shareCsv(context, entries)
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.history_export_json)) },
+ onClick = {
+ exportMenu = false
+ HistoryExport.shareJson(context, entries)
+ }
+ )
+ }
+ }
+ }
+ if (entries.isEmpty()) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ text = stringResource(R.string.history_empty),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ } else {
+ LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp)) {
+ if (entries.size >= 3) {
+ item(key = "trends") {
+ TrendsCard(entries)
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+ items(entries, key = { it.id }) { entry ->
+ HistoryRow(entry = entry, onClick = { onOpen(entry.id) })
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+ }
+ }
+
+ if (confirmClear) {
+ AlertDialog(
+ onDismissRequest = { confirmClear = false },
+ title = { Text(stringResource(R.string.history_clear)) },
+ text = { Text(stringResource(R.string.history_clear_confirm)) },
+ confirmButton = {
+ TextButton(onClick = {
+ confirmClear = false
+ viewModel.clear()
+ }) { Text(stringResource(R.string.history_clear)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { confirmClear = false }) { Text(stringResource(R.string.dialog_cancel)) }
+ }
+ )
+ }
+ if (filterDialog) {
+ FilterDialog(
+ filter = filter,
+ networks = allNetworks,
+ servers = allServers,
+ onApply = { viewModel.setFilter(it) },
+ onDismiss = { filterDialog = false }
+ )
+ }
+}
+
+fun formatDate(timestamp: Long): String =
+ DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(timestamp))
+
+@Composable
+private fun TrendsCard(entries: List) {
+ //oldest to newest so the curve reads left to right
+ val chronological = remember(entries) { entries.sortedBy { it.date } }
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(Modifier.padding(14.dp)) {
+ Text(
+ text = stringResource(R.string.history_trends).uppercase(Locale.ROOT),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(8.dp))
+ Row {
+ TrendColumn(
+ modifier = Modifier.weight(1f),
+ label = stringResource(R.string.test_download),
+ average = chronological.map { it.download }.average(),
+ samples = chronological.map { it.download },
+ accent = Teal
+ )
+ Spacer(Modifier.width(14.dp))
+ TrendColumn(
+ modifier = Modifier.weight(1f),
+ label = stringResource(R.string.test_upload),
+ average = chronological.map { it.upload }.average(),
+ samples = chronological.map { it.upload },
+ accent = Purple
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TrendColumn(
+ modifier: Modifier,
+ label: String,
+ average: Double,
+ samples: List,
+ accent: androidx.compose.ui.graphics.Color
+) {
+ Column(modifier) {
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text(
+ text = String.format(Locale.US, "%.0f", average),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = accent
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "${stringResource(R.string.unit_mbps)} ø",
+ modifier = Modifier.padding(bottom = 2.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Text(
+ text = label.uppercase(Locale.ROOT),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Sparkline(
+ data = samples,
+ color = accent,
+ modifier = Modifier.fillMaxWidth().height(34.dp).padding(top = 4.dp)
+ )
+ }
+}
+
+@Composable
+private fun FilterDialog(
+ filter: HistoryFilter,
+ networks: List,
+ servers: List,
+ onApply: (HistoryFilter) -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.history_filter)) },
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ FilterGroup(
+ title = stringResource(R.string.filter_period),
+ options = listOf(
+ stringResource(R.string.filter_all) to null,
+ stringResource(R.string.filter_days_7) to 7,
+ stringResource(R.string.filter_days_30) to 30
+ ),
+ selected = filter.days,
+ onSelect = { onApply(filter.copy(days = it)) }
+ )
+ if (networks.isNotEmpty()) {
+ FilterGroup(
+ title = stringResource(R.string.filter_network),
+ options = listOf(stringResource(R.string.filter_all) to null) + networks.map { it to it },
+ selected = filter.network,
+ onSelect = { onApply(filter.copy(network = it)) }
+ )
+ }
+ if (servers.isNotEmpty()) {
+ FilterGroup(
+ title = stringResource(R.string.filter_server),
+ options = listOf(stringResource(R.string.filter_all) to null) + servers.map { it to it },
+ selected = filter.server,
+ onSelect = { onApply(filter.copy(server = it)) }
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_close)) }
+ }
+ )
+}
+
+@Composable
+private fun FilterGroup(
+ title: String,
+ options: List>,
+ selected: T?,
+ onSelect: (T?) -> Unit
+) {
+ Text(
+ text = title.uppercase(Locale.ROOT),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 10.dp, bottom = 2.dp)
+ )
+ options.forEach { (label, value) ->
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(selected = selected == value, onClick = { onSelect(value) })
+ Text(label, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+}
+
+@Composable
+private fun HistoryRow(entry: HistoryEntry, onClick: () -> Unit) {
+ Card(
+ onClick = onClick,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = when {
+ entry.networkType == null -> Icons.Filled.Public
+ entry.networkType.startsWith("Wi-Fi") -> Icons.Filled.Wifi
+ entry.networkType == "Ethernet" -> Icons.Filled.SettingsEthernet
+ else -> Icons.Filled.SignalCellularAlt
+ },
+ contentDescription = entry.networkType,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = listOfNotNull(formatDate(entry.date), entry.networkType).joinToString(" • "),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = GeoDistance.cleanName(entry.server),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = String.format(Locale.US, "%.0f", entry.download),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = Teal
+ )
+ Text(
+ text = stringResource(R.string.unit_mbps),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(Modifier.width(14.dp))
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = String.format(Locale.US, "%.0f", entry.upload),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = Purple
+ )
+ Text(
+ text = stringResource(R.string.unit_mbps),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Icon(
+ Icons.Filled.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/history/HistoryViewModel.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/history/HistoryViewModel.kt
new file mode 100644
index 0000000..a0e22bb
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/history/HistoryViewModel.kt
@@ -0,0 +1,78 @@
+package org.librespeed.speedtest.ui.history
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.librespeed.speedtest.data.GeoDistance
+import org.librespeed.speedtest.data.HistoryDatabase
+import org.librespeed.speedtest.data.HistoryEntry
+
+data class HistoryFilter(
+ val network: String? = null,
+ val server: String? = null,
+ val days: Int? = null
+) {
+ val active: Boolean get() = network != null || server != null || days != null
+}
+
+class HistoryViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val database = HistoryDatabase(application)
+ private val _entries = MutableStateFlow>(emptyList())
+
+ private val _filter = MutableStateFlow(HistoryFilter())
+ val filter: StateFlow = _filter
+
+ val entries: StateFlow> =
+ combine(_entries, _filter) { entries, filter ->
+ entries.filter { entry ->
+ (filter.network == null || entry.networkType?.startsWith(filter.network) == true) &&
+ (filter.server == null || GeoDistance.cleanName(entry.server) == filter.server) &&
+ (filter.days == null || entry.date >= System.currentTimeMillis() - filter.days * 86_400_000L)
+ }
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
+
+ /** Distinct values available for the filter dialog. */
+ val allNetworks: StateFlow> =
+ combine(_entries, _filter) { entries, _ ->
+ entries.mapNotNull { it.networkType?.substringBefore(" ") }.distinct().sorted()
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
+
+ val allServers: StateFlow> =
+ combine(_entries, _filter) { entries, _ ->
+ entries.map { GeoDistance.cleanName(it.server) }.distinct().sorted()
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
+
+ fun setFilter(filter: HistoryFilter) {
+ _filter.value = filter
+ }
+
+ fun load() {
+ viewModelScope.launch {
+ _entries.value = withContext(Dispatchers.IO) { database.readAll() }
+ }
+ }
+
+ fun delete(entry: HistoryEntry) {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) { database.delete(entry.id) }
+ load()
+ }
+ }
+
+ fun clear() {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) { database.clear() }
+ load()
+ }
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/ResultScreen.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/ResultScreen.kt
new file mode 100644
index 0000000..2c3ec90
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/ResultScreen.kt
@@ -0,0 +1,441 @@
+package org.librespeed.speedtest.ui.result
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.DataUsage
+import androidx.compose.material.icons.filled.Lan
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.MyLocation
+import androidx.compose.material.icons.filled.NetworkCheck
+import androidx.compose.material.icons.filled.NetworkPing
+import androidx.compose.material.icons.filled.PhoneAndroid
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.SsidChart
+import androidx.compose.material.icons.filled.Tag
+import androidx.compose.material.icons.outlined.ArrowCircleDown
+import androidx.compose.material.icons.outlined.ArrowCircleUp
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.AppPreferences
+import org.librespeed.speedtest.data.GeoDistance
+import org.librespeed.speedtest.data.HistoryDatabase
+import org.librespeed.speedtest.data.HistoryEntry
+import org.librespeed.speedtest.data.TestStats
+import org.librespeed.speedtest.share.ShareResult
+import org.librespeed.speedtest.ui.components.Sparkline
+import org.librespeed.speedtest.ui.history.formatDate
+import org.librespeed.speedtest.ui.theme.Purple
+import org.librespeed.speedtest.ui.theme.Teal
+import java.util.Locale
+
+@Composable
+fun ResultScreen(
+ entryId: Long,
+ onBack: () -> Unit,
+ onTestAgain: (String) -> Unit,
+ onTestDetails: (Long) -> Unit,
+ onShare: (Long) -> Unit
+) {
+ val context = LocalContext.current
+ val prefs = remember { AppPreferences(context.applicationContext) }
+ val useMBytes by prefs.useMBytes.collectAsStateWithLifecycle(initialValue = false)
+ var entry by remember { mutableStateOf(null) }
+ var missing by remember { mutableStateOf(false) }
+ var menuOpen by remember { mutableStateOf(false) }
+ var bloatInfo by remember { mutableStateOf(false) }
+
+ LaunchedEffect(entryId) {
+ val loaded = withContext(Dispatchers.IO) { HistoryDatabase(context.applicationContext).read(entryId) }
+ if (loaded == null) missing = true else entry = loaded
+ }
+ LaunchedEffect(missing) { if (missing) onBack() }
+
+ val result = entry ?: return
+ val unitLabel = stringResource(if (useMBytes) R.string.unit_mbytes else R.string.unit_mbps)
+ fun display(value: Double): Double = if (useMBytes) value / 8 else value
+
+ fun copyText() {
+ ShareResult.copy(
+ context,
+ ShareResult.buildText(
+ context, result.server, result.download, result.upload, result.ping,
+ result.jitter, result.loss, useMBytes, result.shareUrl,
+ result.networkType, result.ipVersion
+ )
+ )
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.dialog_close))
+ }
+ Text(
+ text = stringResource(R.string.result_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = { onShare(result.id) }) {
+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_result), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Box {
+ IconButton(onClick = { menuOpen = true }) {
+ Icon(Icons.Filled.MoreVert, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.test_details_title)) },
+ onClick = {
+ menuOpen = false
+ onTestDetails(result.id)
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.share_copy)) },
+ onClick = {
+ menuOpen = false
+ copyText()
+ }
+ )
+ val deleteScope = rememberCoroutineScope()
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.history_delete), color = MaterialTheme.colorScheme.error) },
+ onClick = {
+ menuOpen = false
+ deleteScope.launch(Dispatchers.IO) {
+ HistoryDatabase(context.applicationContext).delete(result.id)
+ }
+ onBack()
+ }
+ )
+ }
+ }
+ }
+
+ Column(Modifier.widthIn(max = 560.dp).fillMaxWidth().padding(horizontal = 24.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp)
+ ) {
+ Icon(
+ Icons.Filled.Public,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(10.dp))
+ Column {
+ Text(
+ text = formatDate(result.date),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = listOfNotNull(
+ GeoDistance.cleanName(result.server),
+ GeoDistance.sponsor(result.server)
+ ).joinToString(" · "),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ SpeedCard(
+ modifier = Modifier.weight(1f),
+ icon = Icons.Outlined.ArrowCircleDown,
+ label = stringResource(R.string.test_download),
+ value = display(result.download),
+ unit = unitLabel,
+ accent = Teal,
+ samples = result.downloadSamples
+ )
+ SpeedCard(
+ modifier = Modifier.weight(1f),
+ icon = Icons.Outlined.ArrowCircleUp,
+ label = stringResource(R.string.test_upload),
+ value = display(result.upload),
+ unit = unitLabel,
+ accent = Purple,
+ samples = result.uploadSamples
+ )
+ }
+
+ Spacer(Modifier.height(12.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ MetricBox(Modifier.weight(1f), Icons.Filled.NetworkPing, stringResource(R.string.test_ping), result.ping, stringResource(R.string.unit_ms))
+ MetricBox(Modifier.weight(1f), Icons.Filled.SsidChart, stringResource(R.string.test_jitter), result.jitter, stringResource(R.string.unit_ms))
+ }
+
+ Spacer(Modifier.height(16.dp))
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
+ if (result.ipVersion != 0) {
+ DetailRow(stringResource(R.string.detail_protocol), "IPv${result.ipVersion}", Icons.Filled.Lan)
+ RowDivider()
+ }
+ result.ipInfo?.takeIf { it.isNotBlank() }?.let { info ->
+ DetailRow(stringResource(R.string.detail_ip), info.substringBefore(" - ").trim(), Icons.Filled.MyLocation)
+ RowDivider()
+ }
+ DetailRow(stringResource(R.string.detail_client), "Android ${android.os.Build.VERSION.RELEASE}", Icons.Filled.PhoneAndroid)
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_server), GeoDistance.cleanName(result.server), Icons.Filled.Public)
+ result.shareUrl?.let {
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_result_id), it.substringAfterLast("=", it), Icons.Filled.Tag)
+ }
+ if (result.durationMs > 0) {
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_duration), String.format(Locale.US, "%.1f s", result.durationMs / 1000.0), Icons.Filled.Schedule)
+ }
+ val totalMb = (result.downloadSamples.sum() + result.uploadSamples.sum()) * 0.1 / 8
+ if (totalMb > 0) {
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_data), String.format(Locale.US, "~ %.0f MB", totalMb), Icons.Filled.DataUsage)
+ }
+ val loadedMax = maxOf(result.loadedDown, result.loadedUp)
+ TestStats.bufferbloatGrade(result.ping, loadedMax)?.let { grade ->
+ RowDivider()
+ DetailRow(
+ stringResource(R.string.detail_bufferbloat),
+ String.format(Locale.US, "%s · +%.0f ms", grade, (loadedMax - result.ping).coerceAtLeast(0.0)),
+ Icons.Filled.NetworkCheck,
+ onClick = { bloatInfo = true }
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+ Button(
+ onClick = { onShare(result.id) },
+ modifier = Modifier.fillMaxWidth().height(52.dp)
+ ) {
+ Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.share_result), style = MaterialTheme.typography.titleMedium)
+ }
+ Spacer(Modifier.height(8.dp))
+ OutlinedButton(
+ onClick = { onTestAgain(result.server) },
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary),
+ modifier = Modifier.fillMaxWidth().height(52.dp)
+ ) {
+ Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.result_test_again), style = MaterialTheme.typography.titleMedium)
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onTestDetails(result.id) }
+ .padding(vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.more_details),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.weight(1f)
+ )
+ Icon(
+ Icons.Filled.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ Spacer(Modifier.height(12.dp))
+ }
+ }
+
+ if (bloatInfo) {
+ AlertDialog(
+ onDismissRequest = { bloatInfo = false },
+ title = { Text(stringResource(R.string.detail_bufferbloat)) },
+ text = { Text(stringResource(R.string.bufferbloat_info)) },
+ confirmButton = {
+ TextButton(onClick = { bloatInfo = false }) { Text(stringResource(R.string.dialog_close)) }
+ }
+ )
+ }
+}
+
+@Composable
+private fun SpeedCard(
+ modifier: Modifier,
+ icon: ImageVector,
+ label: String,
+ value: Double,
+ unit: String,
+ accent: Color,
+ samples: List
+) {
+ Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
+ Column(Modifier.padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(icon, contentDescription = null, tint = accent, modifier = Modifier.size(16.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = label.uppercase(Locale.ROOT),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = String.format(Locale.US, "%.2f", value),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = accent
+ )
+ Text(
+ text = unit,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ if (samples.size > 1) {
+ Sparkline(
+ data = samples,
+ color = accent,
+ modifier = Modifier.fillMaxWidth().height(30.dp).padding(top = 4.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun MetricBox(modifier: Modifier, icon: ImageVector, title: String, value: Double, unit: String) {
+ Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
+ Column(Modifier.padding(vertical = 14.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(13.dp))
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = title.uppercase(Locale.ROOT),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text(
+ text = if (value < 0) "—" else String.format(Locale.US, "%.1f", value),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = unit,
+ modifier = Modifier.padding(bottom = 3.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun DetailRow(title: String, value: String, icon: ImageVector? = null, onClick: (() -> Unit)? = null) {
+ val rowModifier = if (onClick != null) {
+ Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 9.dp)
+ } else {
+ Modifier.fillMaxWidth().padding(vertical = 9.dp)
+ }
+ Row(rowModifier, verticalAlignment = Alignment.CenterVertically) {
+ icon?.let {
+ Icon(
+ it,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(Modifier.width(10.dp))
+ }
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.width(16.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.End,
+ modifier = Modifier.weight(1f)
+ )
+ }
+}
+
+@Composable
+internal fun RowDivider() {
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/ShareScreen.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/ShareScreen.kt
new file mode 100644
index 0000000..03a50e8
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/ShareScreen.kt
@@ -0,0 +1,167 @@
+package org.librespeed.speedtest.ui.result
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Link
+import androidx.compose.material.icons.filled.Photo
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.AppPreferences
+import org.librespeed.speedtest.data.HistoryDatabase
+import org.librespeed.speedtest.data.HistoryEntry
+import org.librespeed.speedtest.share.ShareImage
+import org.librespeed.speedtest.share.ShareResult
+
+@Composable
+fun ShareScreen(entryId: Long, onBack: () -> Unit) {
+ val context = LocalContext.current
+ val prefs = remember { AppPreferences(context.applicationContext) }
+ val useMBytes by prefs.useMBytes.collectAsStateWithLifecycle(initialValue = false)
+ var entry by remember { mutableStateOf(null) }
+ var missing by remember { mutableStateOf(false) }
+ var preview by remember { mutableStateOf(null) }
+
+ LaunchedEffect(entryId) {
+ val loaded = withContext(Dispatchers.IO) { HistoryDatabase(context.applicationContext).read(entryId) }
+ if (loaded == null) missing = true else entry = loaded
+ }
+ LaunchedEffect(missing) { if (missing) onBack() }
+ LaunchedEffect(entry, useMBytes) {
+ entry?.let { loaded ->
+ preview = withContext(Dispatchers.Default) { ShareImage.render(context, loaded, useMBytes) }
+ }
+ }
+
+ val result = entry ?: return
+
+ fun copyText() {
+ ShareResult.copy(
+ context,
+ ShareResult.buildText(
+ context, result.server, result.download, result.upload, result.ping,
+ result.jitter, result.loss, useMBytes, result.shareUrl,
+ result.networkType, result.ipVersion
+ )
+ )
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 48.dp, top = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dialog_close))
+ }
+ Text(
+ text = stringResource(R.string.share_result),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ Column(Modifier.widthIn(max = 560.dp).fillMaxWidth().padding(horizontal = 24.dp)) {
+ preview?.let { bitmap ->
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .clip(RoundedCornerShape(20.dp))
+ )
+ }
+
+ Spacer(Modifier.padding(top = 16.dp))
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
+ ShareOption(
+ icon = Icons.Filled.Photo,
+ title = stringResource(R.string.share_image),
+ subtitle = stringResource(R.string.share_image_hint)
+ ) { ShareImage.share(context, result, useMBytes) }
+ result.shareUrl?.let { url ->
+ OptionDivider()
+ ShareOption(
+ icon = Icons.Filled.Link,
+ title = stringResource(R.string.share_link),
+ subtitle = stringResource(R.string.share_link_hint)
+ ) { ShareResult.shareLink(context, url) }
+ }
+ OptionDivider()
+ ShareOption(
+ icon = Icons.Filled.ContentCopy,
+ title = stringResource(R.string.share_copy),
+ subtitle = stringResource(R.string.share_copy_hint)
+ ) { copyText() }
+ }
+ }
+ Spacer(Modifier.padding(bottom = 20.dp))
+ }
+ }
+}
+
+@Composable
+private fun OptionDivider() {
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
+}
+
+@Composable
+private fun ShareOption(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp))
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
+ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/TestDetailsScreen.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/TestDetailsScreen.kt
new file mode 100644
index 0000000..a05a7b8
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/TestDetailsScreen.kt
@@ -0,0 +1,237 @@
+package org.librespeed.speedtest.ui.result
+
+import android.os.Build
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.TrendingDown
+import androidx.compose.material.icons.automirrored.filled.TrendingUp
+import androidx.compose.material.icons.filled.Android
+import androidx.compose.material.icons.filled.Apps
+import androidx.compose.material.icons.filled.ArrowDownward
+import androidx.compose.material.icons.filled.ArrowUpward
+import androidx.compose.material.icons.filled.Business
+import androidx.compose.material.icons.filled.CloudUpload
+import androidx.compose.material.icons.filled.DataUsage
+import androidx.compose.material.icons.filled.Event
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Lan
+import androidx.compose.material.icons.filled.MyLocation
+import androidx.compose.material.icons.filled.NetworkCheck
+import androidx.compose.material.icons.filled.NetworkPing
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.SignalCellularAlt
+import androidx.compose.material.icons.filled.Smartphone
+import androidx.compose.material.icons.filled.SsidChart
+import androidx.compose.material.icons.filled.Tag
+import androidx.compose.material.icons.filled.Timeline
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.librespeed.speedtest.BuildConfig
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.GeoDistance
+import org.librespeed.speedtest.data.HistoryDatabase
+import org.librespeed.speedtest.data.HistoryEntry
+import org.librespeed.speedtest.data.TestStats
+import org.librespeed.speedtest.ui.history.formatDate
+import java.util.Locale
+
+@Composable
+fun TestDetailsScreen(entryId: Long, onBack: () -> Unit) {
+ val context = LocalContext.current
+ var entry by remember { mutableStateOf(null) }
+ var missing by remember { mutableStateOf(false) }
+
+ LaunchedEffect(entryId) {
+ val loaded = withContext(Dispatchers.IO) { HistoryDatabase(context.applicationContext).read(entryId) }
+ if (loaded == null) missing = true else entry = loaded
+ }
+ LaunchedEffect(missing) { if (missing) onBack() }
+
+ val result = entry ?: return
+
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 16.dp, top = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.dialog_close))
+ }
+ Text(
+ text = stringResource(R.string.test_details_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+
+ Column(Modifier.widthIn(max = 560.dp).fillMaxWidth().padding(horizontal = 20.dp)) {
+ Section(stringResource(R.string.section_test)) {
+ result.shareUrl?.let {
+ DetailRow(stringResource(R.string.detail_result_id), it.substringAfterLast("=", it), Icons.Filled.Tag)
+ RowDivider()
+ }
+ DetailRow(stringResource(R.string.section_date), formatDate(result.date), Icons.Filled.Event)
+ if (result.durationMs > 0) {
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_duration), String.format(Locale.US, "%.1f s", result.durationMs / 1000.0), Icons.Filled.Schedule)
+ }
+ result.mode?.let {
+ RowDivider()
+ DetailRow(
+ stringResource(R.string.detail_mode),
+ stringResource(
+ when (it) {
+ "single" -> R.string.mode_single
+ "stability" -> R.string.mode_stability
+ "scheduled" -> R.string.mode_scheduled
+ else -> R.string.mode_standard
+ }
+ ),
+ Icons.Filled.Tune
+ )
+ }
+ }
+
+ Section(stringResource(R.string.section_server)) {
+ DetailRow(stringResource(R.string.servers_add_name), GeoDistance.cleanName(result.server), Icons.Filled.Public)
+ GeoDistance.sponsor(result.server)?.let {
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_isp), it, Icons.Filled.Business)
+ }
+ if (result.ping >= 0) {
+ RowDivider()
+ DetailRow(stringResource(R.string.info_latency), String.format(Locale.US, "%.1f %s", result.ping, stringResource(R.string.unit_ms)), Icons.Filled.NetworkPing)
+ }
+ }
+
+ Section(stringResource(R.string.section_network)) {
+ if (result.ipVersion != 0) {
+ DetailRow(stringResource(R.string.detail_protocol), "IPv${result.ipVersion}", Icons.Filled.Lan)
+ RowDivider()
+ }
+ result.networkType?.let {
+ DetailRow(stringResource(R.string.detail_network), it, Icons.Filled.Wifi)
+ RowDivider()
+ }
+ result.ipInfo?.takeIf { it.isNotBlank() }?.let { info ->
+ val ip = info.substringBefore(" - ").trim()
+ val provider = info.substringAfter(" - ", "").trim()
+ DetailRow(stringResource(R.string.detail_ip), ip, Icons.Filled.MyLocation)
+ if (provider.isNotEmpty()) {
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_isp), provider, Icons.Filled.Business)
+ }
+ }
+ result.networkDetail?.let {
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_operator), it, Icons.Filled.SignalCellularAlt)
+ }
+ if (result.loadedDown >= 0) {
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_loaded_dl), String.format(Locale.US, "%.1f %s", result.loadedDown, stringResource(R.string.unit_ms)), Icons.Filled.NetworkCheck)
+ }
+ if (result.loadedUp >= 0) {
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_loaded_ul), String.format(Locale.US, "%.1f %s", result.loadedUp, stringResource(R.string.unit_ms)), Icons.Filled.NetworkCheck)
+ }
+ TestStats.bufferbloatGrade(result.ping, maxOf(result.loadedDown, result.loadedUp))?.let { grade ->
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_bufferbloat), grade, Icons.Filled.NetworkCheck)
+ }
+ }
+
+ if (result.mode == "stability") {
+ TestStats.stability(result.downloadSamples)?.let { s ->
+ Section(stringResource(R.string.section_stability)) {
+ DetailRow(stringResource(R.string.detail_min), String.format(Locale.US, "%.2f Mbps", s.min), Icons.AutoMirrored.Filled.TrendingDown)
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_max), String.format(Locale.US, "%.2f Mbps", s.max), Icons.AutoMirrored.Filled.TrendingUp)
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_avg), String.format(Locale.US, "%.2f Mbps", s.average), Icons.Filled.Timeline)
+ RowDivider()
+ DetailRow(stringResource(R.string.detail_variation), "± ${s.variationPct} %", Icons.Filled.SsidChart)
+ }
+ }
+ }
+
+ Section(stringResource(R.string.section_data)) {
+ val downloadMb = result.downloadSamples.sum() * 0.1 / 8
+ val uploadMb = result.uploadSamples.sum() * 0.1 / 8
+ if (downloadMb > 0) {
+ DetailRow(stringResource(R.string.detail_dl_data), String.format(Locale.US, "~ %.0f MB", downloadMb), Icons.Filled.ArrowDownward)
+ RowDivider()
+ }
+ if (uploadMb > 0) {
+ DetailRow(stringResource(R.string.detail_ul_data), String.format(Locale.US, "~ %.0f MB", uploadMb), Icons.Filled.ArrowUpward)
+ RowDivider()
+ }
+ DetailRow(stringResource(R.string.detail_data), String.format(Locale.US, "~ %.0f MB", downloadMb + uploadMb), Icons.Filled.DataUsage)
+ }
+
+ Section(stringResource(R.string.section_telemetry)) {
+ DetailRow(
+ stringResource(R.string.settings_section_telemetry),
+ stringResource(if (result.shareUrl != null) R.string.telemetry_submitted else R.string.telemetry_not_submitted),
+ Icons.Filled.CloudUpload
+ )
+ }
+
+ Section(stringResource(R.string.section_client)) {
+ DetailRow(stringResource(R.string.section_application), "LibreSpeed", Icons.Filled.Apps)
+ RowDivider()
+ DetailRow(stringResource(R.string.section_version), BuildConfig.VERSION_NAME, Icons.Filled.Info)
+ RowDivider()
+ DetailRow("Android", Build.VERSION.RELEASE, Icons.Filled.Android)
+ RowDivider()
+ DetailRow(stringResource(R.string.section_device), "${Build.MANUFACTURER} ${Build.MODEL}", Icons.Filled.Smartphone)
+ }
+ Spacer(Modifier.height(20.dp))
+ }
+ }
+}
+
+@Composable
+private fun Section(title: String, content: @Composable () -> Unit) {
+ Text(
+ text = title.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 6.dp)
+ )
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { content() }
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/CompareScreen.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/CompareScreen.kt
new file mode 100644
index 0000000..4591a3f
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/CompareScreen.kt
@@ -0,0 +1,256 @@
+package org.librespeed.speedtest.ui.servers
+
+import android.app.Application
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.EmojiEvents
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewModelScope
+import com.fdossena.speedtest.core.Speedtest
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.GeoDistance
+import org.librespeed.speedtest.data.key
+import org.librespeed.speedtest.engine.TestEngine
+import org.librespeed.speedtest.engine.TestMode
+import org.librespeed.speedtest.ui.speedtest.SpeedtestViewModel
+import org.librespeed.speedtest.ui.theme.Teal
+import java.util.Locale
+import kotlin.coroutines.resume
+
+data class CompareRow(
+ val server: TestPoint,
+ val download: Double? = null,
+ val running: Boolean = false
+)
+
+data class CompareUiState(
+ val rows: List = emptyList(),
+ val running: Boolean = false,
+ val finished: Boolean = false
+)
+
+class CompareViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val _state = MutableStateFlow(CompareUiState())
+ val state: StateFlow = _state
+ private var engine: TestEngine? = null
+
+ /** Short download test against the three fastest reachable servers, one after another. */
+ fun run(servers: List) {
+ if (_state.value.running) return
+ val top = servers.filter { it.ping >= 0 }.sortedBy { it.ping }.take(3)
+ if (top.isEmpty()) return
+ _state.value = CompareUiState(rows = top.map { CompareRow(it) }, running = true)
+ viewModelScope.launch(Dispatchers.IO) {
+ top.forEachIndexed { index, testPoint ->
+ updateRow(index) { it.copy(running = true) }
+ val download = quickDownload(testPoint)
+ updateRow(index) { it.copy(running = false, download = download) }
+ }
+ _state.update { it.copy(running = false, finished = true) }
+ }
+ }
+
+ private fun updateRow(index: Int, transform: (CompareRow) -> CompareRow) {
+ _state.update { state ->
+ state.copy(rows = state.rows.mapIndexed { i, row -> if (i == index) transform(row) else row })
+ }
+ }
+
+ private suspend fun quickDownload(testPoint: TestPoint): Double =
+ suspendCancellableCoroutine { continuation ->
+ val testEngine = TestEngine(getApplication())
+ engine = testEngine
+ testEngine.prepare(listOf(testPoint), testPoint, telemetryEnabled = false, mode = TestMode.COMPARE)
+ var last = -1.0
+ testEngine.start(object : Speedtest.SpeedtestHandler() {
+ override fun onDownloadUpdate(dl: Double, progress: Double) {
+ if (dl > 0) last = dl
+ }
+
+ override fun onUploadUpdate(ul: Double, progress: Double) = Unit
+ override fun onPingJitterUpdate(ping: Double, jitter: Double, progress: Double) = Unit
+ override fun onLossUpdate(loss: Double) = Unit
+ override fun onIPInfoUpdate(ipInfo: String?) = Unit
+ override fun onTestIDReceived(id: String?, shareURL: String?) = Unit
+
+ override fun onEnd() {
+ if (continuation.isActive) continuation.resume(last)
+ }
+
+ override fun onCriticalFailure(err: String?) {
+ if (continuation.isActive) continuation.resume(-1.0)
+ }
+ })
+ continuation.invokeOnCancellation { testEngine.abort() }
+ }
+
+ override fun onCleared() {
+ runCatching { engine?.abort() }
+ }
+
+}
+
+@Composable
+fun CompareScreen(
+ speedtestViewModel: SpeedtestViewModel,
+ onBack: () -> Unit,
+ compareViewModel: CompareViewModel = viewModel()
+) {
+ val servers by speedtestViewModel.state.collectAsStateWithLifecycle()
+ val state by compareViewModel.state.collectAsStateWithLifecycle()
+ val best = state.rows
+ .filter { (it.download ?: -1.0) > 0 }
+ .maxByOrNull { it.download ?: -1.0 }
+ ?.takeIf { state.finished }
+
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 16.dp, top = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.dialog_close))
+ }
+ Text(
+ text = stringResource(R.string.servers_compare),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+
+ Column(Modifier.widthIn(max = 560.dp).fillMaxWidth().padding(horizontal = 20.dp)) {
+ Text(
+ text = stringResource(R.string.compare_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(12.dp))
+
+ state.rows.forEach { row ->
+ val isBest = best?.server?.key() == row.server.key()
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ border = if (isBest) BorderStroke(1.dp, Teal.copy(alpha = 0.7f)) else null,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (isBest) {
+ Icon(Icons.Filled.EmojiEvents, contentDescription = null, tint = Teal, modifier = Modifier.size(16.dp))
+ Spacer(Modifier.width(6.dp))
+ }
+ Text(
+ text = GeoDistance.cleanName(row.server.name),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Text(
+ text = "${GeoDistance.hostLabel(row.server.server)} · ${row.server.ping.toInt()} ${stringResource(R.string.unit_ms)}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (state.finished && row.download != null && row.download > 0) {
+ TextButton(
+ onClick = {
+ speedtestViewModel.selectServer(row.server)
+ onBack()
+ },
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)
+ ) {
+ Text(stringResource(R.string.compare_use), style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ }
+ when {
+ row.running -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
+ row.download != null && row.download > 0 -> Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = String.format(Locale.US, "%.1f", row.download),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = Teal
+ )
+ Text(
+ text = stringResource(R.string.unit_mbps),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ row.download != null -> Text(
+ text = stringResource(R.string.servers_unreachable),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+
+ Spacer(Modifier.height(8.dp))
+ Button(
+ onClick = { compareViewModel.run(servers.servers) },
+ enabled = !state.running && servers.servers.any { it.ping >= 0 },
+ modifier = Modifier.fillMaxWidth().height(52.dp)
+ ) {
+ Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.compare_run), style = MaterialTheme.typography.titleMedium)
+ }
+ Spacer(Modifier.height(20.dp))
+ }
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/ServerSheet.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/ServerSheet.kt
new file mode 100644
index 0000000..5611b28
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/ServerSheet.kt
@@ -0,0 +1,259 @@
+package org.librespeed.speedtest.ui.servers
+
+import android.content.Intent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.outlined.StarBorder
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.GeoDistance
+import org.librespeed.speedtest.data.HistoryDatabase
+import org.librespeed.speedtest.data.key
+import org.librespeed.speedtest.ui.history.formatDate
+import java.net.Inet4Address
+import java.net.Inet6Address
+import java.net.InetAddress
+
+@androidx.compose.material3.ExperimentalMaterial3Api
+@Composable
+fun ServerSheet(
+ server: TestPoint,
+ favorite: Boolean,
+ custom: Boolean,
+ distanceKm: Int?,
+ onSelect: () -> Unit,
+ onToggleFavorite: () -> Unit,
+ onPing: () -> Unit,
+ onDelete: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ val context = LocalContext.current
+ val host = GeoDistance.hostLabel(server.server)
+ var ipv4 by remember { mutableStateOf(null) }
+ var ipv6 by remember { mutableStateOf(null) }
+ var lastTest by remember { mutableStateOf(null) }
+
+ LaunchedEffect(server.key()) {
+ withContext(Dispatchers.IO) {
+ try {
+ val addresses = InetAddress.getAllByName(host.substringBefore("/").substringBefore(":"))
+ ipv4 = addresses.firstOrNull { it is Inet4Address }?.hostAddress
+ ipv6 = addresses.firstOrNull { it is Inet6Address }?.hostAddress
+ } catch (_: Exception) {
+ }
+ lastTest = try {
+ HistoryDatabase(context.applicationContext).readAll()
+ .firstOrNull { it.server == server.name }?.date
+ } catch (_: Exception) {
+ null
+ }
+ }
+ }
+
+ ModalBottomSheet(onDismissRequest = onDismiss) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(start = 20.dp, end = 20.dp, bottom = 24.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = GeoDistance.cleanName(server.name),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = host,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ if (server.ping >= 0) {
+ Text(
+ text = "${server.ping.toInt()} ${stringResource(R.string.unit_ms)}",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ IconButton(onClick = onToggleFavorite) {
+ Icon(
+ imageVector = if (favorite) Icons.Filled.Star else Icons.Outlined.StarBorder,
+ contentDescription = stringResource(R.string.servers_favorite),
+ tint = if (favorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ HorizontalDivider(Modifier.padding(vertical = 10.dp), color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
+
+ SheetAction(
+ icon = Icons.Filled.PlayArrow,
+ title = stringResource(R.string.sheet_select),
+ subtitle = stringResource(R.string.sheet_select_hint),
+ onClick = onSelect
+ )
+ if (!favorite) {
+ SheetAction(
+ icon = Icons.Outlined.StarBorder,
+ title = stringResource(R.string.sheet_favorite),
+ subtitle = stringResource(R.string.sheet_favorite_hint),
+ onClick = onToggleFavorite
+ )
+ }
+ SheetAction(
+ icon = Icons.Filled.Info,
+ title = stringResource(R.string.sheet_info),
+ subtitle = stringResource(R.string.sheet_info_hint),
+ onClick = null
+ )
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
+ InfoRow(stringResource(R.string.info_location), GeoDistance.cleanName(server.name))
+ GeoDistance.sponsor(server.name)?.let { InfoRow(stringResource(R.string.detail_isp), it) }
+ InfoRow(stringResource(R.string.detail_server), host)
+ distanceKm?.let { InfoRow(stringResource(R.string.info_distance), "~ $it km") }
+ if (server.ping >= 0) InfoRow(stringResource(R.string.info_latency), "${server.ping.toInt()} ${stringResource(R.string.unit_ms)}")
+ ipv4?.let { InfoRow("IPv4", it) }
+ ipv6?.let { InfoRow("IPv6", it) }
+ lastTest?.let { InfoRow(stringResource(R.string.info_last_test), formatDate(it)) }
+ }
+ }
+ SheetAction(
+ icon = Icons.Filled.Speed,
+ title = stringResource(R.string.sheet_ping),
+ subtitle = stringResource(R.string.sheet_ping_hint),
+ onClick = onPing
+ )
+ val shareTitle = stringResource(R.string.sheet_share)
+ val shareText = stringResource(R.string.share_server_text, GeoDistance.cleanName(server.name), server.server)
+ SheetAction(
+ icon = Icons.Filled.Share,
+ title = shareTitle,
+ subtitle = stringResource(R.string.sheet_share_hint),
+ onClick = {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, shareText)
+ }
+ try {
+ context.startActivity(Intent.createChooser(intent, shareTitle))
+ } catch (_: Exception) {
+ }
+ }
+ )
+ if (favorite) {
+ SheetAction(
+ icon = Icons.Filled.Delete,
+ title = stringResource(R.string.sheet_unfavorite),
+ subtitle = stringResource(R.string.sheet_unfavorite_hint),
+ tint = FavoriteRed,
+ onClick = onToggleFavorite
+ )
+ }
+ if (custom) {
+ SheetAction(
+ icon = Icons.Filled.Delete,
+ title = stringResource(R.string.servers_delete),
+ subtitle = stringResource(R.string.sheet_delete_hint),
+ tint = FavoriteRed,
+ onClick = onDelete
+ )
+ }
+ }
+ }
+}
+
+private val FavoriteRed = Color(0xFFEF4444)
+
+@Composable
+private fun SheetAction(
+ icon: ImageVector,
+ title: String,
+ subtitle: String,
+ tint: Color = MaterialTheme.colorScheme.primary,
+ onClick: (() -> Unit)?
+) {
+ val rowModifier = if (onClick != null) {
+ Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 10.dp)
+ } else {
+ Modifier.fillMaxWidth().padding(vertical = 10.dp)
+ }
+ Row(rowModifier, verticalAlignment = Alignment.CenterVertically) {
+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (tint == FavoriteRed) tint else MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(title: String, value: String) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.width(130.dp)
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/ServersScreen.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/ServersScreen.kt
new file mode 100644
index 0000000..48eae20
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/ServersScreen.kt
@@ -0,0 +1,361 @@
+package org.librespeed.speedtest.ui.servers
+
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material.icons.filled.Insights
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.outlined.StarBorder
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Tab
+import androidx.compose.material3.SecondaryTabRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.GeoDistance
+import org.librespeed.speedtest.data.key
+import org.librespeed.speedtest.ui.speedtest.SpeedtestViewModel
+import org.librespeed.speedtest.ui.theme.DangerRed
+import org.librespeed.speedtest.ui.theme.Teal
+
+private val Amber = Color(0xFFF7941D)
+
+@androidx.compose.material3.ExperimentalMaterial3Api
+@Composable
+fun ServersScreen(viewModel: SpeedtestViewModel, onCompareClick: () -> Unit) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ //start on All servers when there are no favorites yet
+ var tabOverride by remember { mutableIntStateOf(-1) }
+ val tab = if (tabOverride != -1) tabOverride else if (state.favorites.isEmpty()) 1 else 0
+ var showAddDialog by remember { mutableStateOf(false) }
+ var sheetServer by remember { mutableStateOf(null) }
+ var hasLocation by remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
+ PackageManager.PERMISSION_GRANTED
+ )
+ }
+ val locationLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ hasLocation = granted
+ if (granted) viewModel.computeDistances()
+ }
+
+ val sorted = remember(state.servers, state.customKeys) {
+ state.servers
+ .filter { it.ping >= 0 || it.key() in state.customKeys }
+ .sortedBy { if (it.ping < 0) Float.MAX_VALUE else it.ping }
+ }
+ val visible = if (tab == 0) sorted.filter { it.key() in state.favorites } else sorted
+
+ Column(Modifier.fillMaxSize()) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.nav_servers),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = onCompareClick) {
+ Icon(Icons.Filled.Insights, contentDescription = stringResource(R.string.servers_compare))
+ }
+ IconButton(onClick = { showAddDialog = true }) {
+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.servers_add))
+ }
+ IconButton(onClick = { viewModel.refreshServers() }, enabled = !state.selectingServers) {
+ if (state.selectingServers) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
+ } else {
+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.servers_refresh))
+ }
+ }
+ }
+ Card(
+ onClick = { viewModel.useAutoSelect() },
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ border = if (!state.pinnedServer) BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)) else null,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Filled.Public,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.server_auto_select),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ if (!state.pinnedServer && state.selectedServer != null) {
+ Text(
+ text = GeoDistance.cleanName(state.selectedServer!!.name),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ if (!state.pinnedServer) {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ if (!hasLocation) {
+ TextButton(
+ onClick = { locationLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION) },
+ modifier = Modifier.padding(horizontal = 8.dp)
+ ) {
+ Text(stringResource(R.string.servers_show_distances))
+ }
+ }
+ SecondaryTabRow(selectedTabIndex = tab, containerColor = MaterialTheme.colorScheme.background) {
+ Tab(selected = tab == 0, onClick = { tabOverride = 0 }, text = { Text(stringResource(R.string.servers_favorites)) })
+ Tab(selected = tab == 1, onClick = { tabOverride = 1 }, text = { Text(stringResource(R.string.servers_all)) })
+ }
+ if (visible.isEmpty()) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ text = stringResource(if (tab == 0) R.string.servers_no_favorites else R.string.servers_none),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ } else {
+ LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp)) {
+ items(visible, key = { it.key() }) { server ->
+ ServerRow(
+ server = server,
+ selected = state.pinnedServer && state.selectedServer?.key() == server.key(),
+ favorite = server.key() in state.favorites,
+ distanceKm = state.distances[server.key()],
+ onClick = { viewModel.selectServer(server) },
+ onToggleFavorite = { viewModel.toggleFavorite(server) },
+ onMore = { sheetServer = server }
+ )
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+ }
+ }
+
+ sheetServer?.let { server ->
+ ServerSheet(
+ server = server,
+ favorite = server.key() in state.favorites,
+ custom = server.key() in state.customKeys,
+ distanceKm = state.distances[server.key()],
+ onSelect = {
+ viewModel.selectServer(server)
+ sheetServer = null
+ },
+ onToggleFavorite = { viewModel.toggleFavorite(server) },
+ onPing = { viewModel.pingServer(server) },
+ onDelete = {
+ viewModel.removeCustomServer(server)
+ sheetServer = null
+ },
+ onDismiss = { sheetServer = null }
+ )
+ }
+
+ if (showAddDialog) {
+ AddServerDialog(
+ onDismiss = { showAddDialog = false },
+ onAdd = { name, url ->
+ if (viewModel.addCustomServer(name, url)) {
+ showAddDialog = false
+ true
+ } else false
+ }
+ )
+ }
+}
+
+private fun latencyColor(ping: Float): Color = when {
+ ping < 50 -> Teal
+ ping < 150 -> Amber
+ else -> DangerRed
+}
+
+@Composable
+private fun ServerRow(
+ server: TestPoint,
+ selected: Boolean,
+ favorite: Boolean,
+ distanceKm: Int?,
+ onClick: () -> Unit,
+ onToggleFavorite: () -> Unit,
+ onMore: () -> Unit
+) {
+ Card(
+ onClick = onClick,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ border = if (selected) BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)) else null,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = GeoDistance.cleanName(server.name),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = GeoDistance.hostLabel(server.server),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ if (server.ping >= 0) {
+ Text(
+ text = "${server.ping.toInt()} ${stringResource(R.string.unit_ms)}",
+ style = MaterialTheme.typography.labelMedium,
+ color = latencyColor(server.ping)
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.servers_unreachable),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ distanceKm?.let {
+ Text(
+ text = "$it km",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ IconButton(onClick = onToggleFavorite) {
+ Icon(
+ imageVector = if (favorite) Icons.Filled.Star else Icons.Outlined.StarBorder,
+ contentDescription = stringResource(R.string.servers_favorite),
+ tint = if (favorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(onClick = onMore) {
+ Icon(
+ Icons.Filled.MoreVert,
+ contentDescription = stringResource(R.string.servers_details),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddServerDialog(onDismiss: () -> Unit, onAdd: (String, String) -> Boolean) {
+ var name by remember { mutableStateOf("") }
+ var url by remember { mutableStateOf("") }
+ var failed by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.servers_add)) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ singleLine = true,
+ label = { Text(stringResource(R.string.servers_add_name)) }
+ )
+ Spacer(Modifier.height(10.dp))
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ singleLine = true,
+ label = { Text(stringResource(R.string.servers_add_url)) },
+ placeholder = { Text("https://speedtest.example.com/backend") },
+ isError = failed
+ )
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = stringResource(if (failed) R.string.servers_add_invalid else R.string.servers_add_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = if (failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ enabled = name.isNotBlank() && url.isNotBlank(),
+ onClick = { failed = !onAdd(name, url) }
+ ) { Text(stringResource(R.string.servers_add_confirm)) }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ }
+ )
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/settings/LicensesScreen.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/settings/LicensesScreen.kt
new file mode 100644
index 0000000..e2eb2ff
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/settings/LicensesScreen.kt
@@ -0,0 +1,141 @@
+package org.librespeed.speedtest.ui.settings
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.librespeed.speedtest.R
+
+data class Library(val name: String, val license: String, val url: String)
+
+private val ENGINE = Library(
+ "LibreSpeed speedtest engine", "LGPL-3.0", "https://github.com/librespeed/speedtest-android"
+)
+
+/** Reads the licensee-generated report bundled as an asset; the engine is in-tree, so it is added by hand. */
+internal fun loadLibraries(context: Context): List {
+ val fromReport = try {
+ val json = context.assets.open("licenses.json").bufferedReader().use { it.readText() }
+ val array = JSONArray(json)
+ (0 until array.length()).mapNotNull { index ->
+ val artifact = array.getJSONObject(index)
+ val licenses = artifact.optJSONArray("spdxLicenses") ?: return@mapNotNull null
+ if (licenses.length() == 0) return@mapNotNull null
+ val first = licenses.getJSONObject(0)
+ Library(
+ name = artifact.optString("name").ifEmpty { artifact.getString("artifactId") },
+ license = first.optString("identifier"),
+ url = artifact.optJSONObject("scm")?.optString("url")?.takeIf { it.isNotEmpty() }
+ ?: first.optString("url")
+ )
+ }.distinctBy { it.name }.sortedBy { it.name.lowercase() }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ return listOf(ENGINE) + fromReport
+}
+
+@Composable
+fun LicensesScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ var libraries by remember { mutableStateOf(listOf(ENGINE)) }
+
+ LaunchedEffect(Unit) {
+ libraries = withContext(Dispatchers.IO) { loadLibraries(context.applicationContext) }
+ }
+
+ Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 16.dp, top = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.dialog_close))
+ }
+ Text(
+ text = stringResource(R.string.settings_licenses),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+
+ LazyColumn(Modifier.widthIn(max = 560.dp).fillMaxWidth().padding(horizontal = 20.dp)) {
+ item {
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
+ libraries.forEachIndexed { index, library ->
+ if (index > 0) {
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = library.url.isNotEmpty()) {
+ try {
+ context.startActivity(Intent(Intent.ACTION_VIEW, library.url.toUri()))
+ } catch (_: Exception) {
+ }
+ }
+ .padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = library.name,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = library.license,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Icon(
+ Icons.Filled.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(20.dp))
+ }
+ }
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/settings/SettingsScreen.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..c090f1c
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/settings/SettingsScreen.kt
@@ -0,0 +1,491 @@
+package org.librespeed.speedtest.ui.settings
+
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.AppPreferences
+import org.librespeed.speedtest.data.ClientInfo
+
+private const val PROJECT_URL = "https://github.com/librespeed/speedtest-android"
+
+private data class Language(val tag: String, val label: String)
+
+//labels stay in their own language on purpose; add new locales here and in locales_config.xml
+private val LANGUAGES = listOf(
+ Language("en", "English"),
+ Language("cs", "Čeština")
+)
+
+@Composable
+fun SettingsScreen(
+ serverLabel: String?,
+ serverPinned: Boolean,
+ serverCount: Int,
+ onServersClick: () -> Unit,
+ onLicensesClick: () -> Unit
+) {
+ val context = LocalContext.current
+ val prefs = remember { AppPreferences(context.applicationContext) }
+ val scope = rememberCoroutineScope()
+
+ val themeMode by prefs.themeMode.collectAsStateWithLifecycle(initialValue = "system")
+ val useMBytes by prefs.useMBytes.collectAsStateWithLifecycle(initialValue = false)
+ val telemetry by prefs.telemetryEnabled.collectAsStateWithLifecycle(initialValue = false)
+ val testMode by prefs.testMode.collectAsStateWithLifecycle(initialValue = "standard")
+ val scheduledTests by prefs.scheduledTests.collectAsStateWithLifecycle(initialValue = "off")
+ val notificationLauncher = rememberLauncherForActivityResult(
+ androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
+ ) { }
+
+ var unitsDialog by remember { mutableStateOf(false) }
+ var languageDialog by remember { mutableStateOf(false) }
+ var scheduledDialog by remember { mutableStateOf(false) }
+ var themeDialog by remember { mutableStateOf(false) }
+ var testModeDialog by remember { mutableStateOf(false) }
+ var whatIsSentDialog by remember { mutableStateOf(false) }
+ var reportDialog by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp)
+ ) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.nav_settings),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ SectionTitle(stringResource(R.string.settings_section_general))
+ SettingsCard {
+ ValueRow(
+ title = stringResource(R.string.settings_server),
+ value = when {
+ serverPinned && serverLabel != null -> serverLabel
+ else -> stringResource(R.string.server_auto_select)
+ },
+ onClick = onServersClick
+ )
+ RowDivider()
+ ValueRow(
+ title = stringResource(R.string.settings_units),
+ value = stringResource(if (useMBytes) R.string.unit_mbytes else R.string.unit_mbps),
+ onClick = { unitsDialog = true }
+ )
+ if (android.os.Build.VERSION.SDK_INT >= 33) {
+ RowDivider()
+ val localeManager = remember {
+ context.getSystemService(android.app.LocaleManager::class.java)
+ }
+ val current = localeManager?.applicationLocales?.takeIf { !it.isEmpty }?.get(0)?.language
+ ValueRow(
+ title = stringResource(R.string.settings_language),
+ value = LANGUAGES.find { it.tag == current }?.label
+ ?: stringResource(R.string.language_system),
+ onClick = { languageDialog = true }
+ )
+ }
+ RowDivider()
+ ValueRow(
+ title = stringResource(R.string.settings_theme),
+ value = stringResource(
+ when (themeMode) {
+ "light" -> R.string.theme_light
+ "dark" -> R.string.theme_dark
+ else -> R.string.theme_system
+ }
+ ),
+ onClick = { themeDialog = true }
+ )
+ RowDivider()
+ ValueRow(
+ title = stringResource(R.string.settings_scheduled),
+ value = stringResource(
+ when (scheduledTests) {
+ "6h" -> R.string.scheduled_6h
+ "daily" -> R.string.scheduled_daily
+ "weekly" -> R.string.scheduled_weekly
+ else -> R.string.scheduled_off
+ }
+ ),
+ onClick = { scheduledDialog = true }
+ )
+ RowDivider()
+ ValueRow(
+ title = stringResource(R.string.settings_test_mode),
+ value = stringResource(
+ when (testMode) {
+ "single" -> R.string.test_mode_single
+ "stability" -> R.string.test_mode_stability
+ else -> R.string.test_mode_standard
+ }
+ ),
+ onClick = { testModeDialog = true }
+ )
+ }
+ SectionTitle(stringResource(R.string.settings_section_telemetry))
+ SettingsCard {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.settings_telemetry),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = stringResource(R.string.settings_telemetry_summary),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = telemetry,
+ onCheckedChange = { checked -> scope.launch { prefs.setTelemetryEnabled(checked) } }
+ )
+ }
+ Text(
+ text = stringResource(R.string.settings_whats_sent),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .clickable(onClick = { whatIsSentDialog = true }, role = Role.Button)
+ .padding(top = 10.dp, bottom = 2.dp)
+ )
+ }
+ SectionTitle(stringResource(R.string.settings_section_diagnostics))
+ SettingsCard {
+ ValueRow(
+ title = stringResource(R.string.settings_report),
+ value = stringResource(R.string.settings_report_hint),
+ onClick = { reportDialog = true }
+ )
+ }
+ SectionTitle(stringResource(R.string.settings_section_about))
+ SettingsCard {
+ Column(Modifier.padding(vertical = 10.dp)) {
+ Text(
+ text = ClientInfo.client,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = stringResource(R.string.settings_license),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ RowDivider()
+ NavRow(stringResource(R.string.settings_report_issue)) { context.openUrl("$PROJECT_URL/issues") }
+ RowDivider()
+ NavRow(stringResource(R.string.settings_privacy)) { context.openUrl("$PROJECT_URL/blob/master/PRIVACY.md") }
+ RowDivider()
+ NavRow(stringResource(R.string.settings_licenses), onLicensesClick)
+ }
+ Spacer(Modifier.height(16.dp))
+ }
+
+ if (unitsDialog) {
+ RadioDialog(
+ title = stringResource(R.string.settings_units),
+ options = listOf(
+ stringResource(R.string.unit_mbps_long) to !useMBytes,
+ stringResource(R.string.unit_mbytes_long) to useMBytes
+ ),
+ hint = stringResource(R.string.settings_units_hint),
+ onSelect = { index -> scope.launch { prefs.setUseMBytes(index == 1) } },
+ onDismiss = { unitsDialog = false }
+ )
+ }
+ if (languageDialog && android.os.Build.VERSION.SDK_INT >= 33) {
+ val localeManager = context.getSystemService(android.app.LocaleManager::class.java)
+ val current = localeManager?.applicationLocales?.takeIf { !it.isEmpty }?.get(0)?.language
+ val options = listOf(null to stringResource(R.string.language_system)) +
+ LANGUAGES.map { it.tag to it.label }
+ RadioDialog(
+ title = stringResource(R.string.settings_language),
+ options = options.map { (tag, label) -> label to (current == tag) },
+ hint = null,
+ onSelect = { index ->
+ localeManager?.applicationLocales = options[index].first
+ ?.let { android.os.LocaleList.forLanguageTags(it) }
+ ?: android.os.LocaleList.getEmptyLocaleList()
+ },
+ onDismiss = { languageDialog = false }
+ )
+ }
+ if (scheduledDialog) {
+ RadioDialog(
+ title = stringResource(R.string.settings_scheduled),
+ options = listOf(
+ stringResource(R.string.scheduled_off) to (scheduledTests == "off"),
+ stringResource(R.string.scheduled_6h) to (scheduledTests == "6h"),
+ stringResource(R.string.scheduled_daily) to (scheduledTests == "daily"),
+ stringResource(R.string.scheduled_weekly) to (scheduledTests == "weekly")
+ ),
+ hint = stringResource(R.string.scheduled_hint),
+ onSelect = { index ->
+ val mode = listOf("off", "6h", "daily", "weekly")[index]
+ scope.launch { prefs.setScheduledTests(mode) }
+ org.librespeed.speedtest.work.ScheduledTests.apply(context.applicationContext, mode)
+ if (mode != "off" && android.os.Build.VERSION.SDK_INT >= 33) {
+ notificationLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
+ }
+ },
+ onDismiss = { scheduledDialog = false }
+ )
+ }
+ if (themeDialog) {
+ RadioDialog(
+ title = stringResource(R.string.settings_theme),
+ options = listOf(
+ stringResource(R.string.theme_system) to (themeMode == "system"),
+ stringResource(R.string.theme_light) to (themeMode == "light"),
+ stringResource(R.string.theme_dark) to (themeMode == "dark")
+ ),
+ hint = null,
+ onSelect = { index ->
+ scope.launch { prefs.setThemeMode(listOf("system", "light", "dark")[index]) }
+ },
+ onDismiss = { themeDialog = false }
+ )
+ }
+ if (testModeDialog) {
+ RadioDialog(
+ title = stringResource(R.string.settings_test_mode),
+ options = listOf(
+ stringResource(R.string.test_mode_standard) to (testMode == "standard"),
+ stringResource(R.string.test_mode_single) to (testMode == "single"),
+ stringResource(R.string.test_mode_stability) to (testMode == "stability")
+ ),
+ hint = stringResource(R.string.test_mode_hint),
+ onSelect = { index ->
+ scope.launch { prefs.setTestMode(listOf("standard", "single", "stability")[index]) }
+ },
+ onDismiss = { testModeDialog = false }
+ )
+ }
+ if (reportDialog) {
+ val report = remember {
+ var last: org.librespeed.speedtest.data.HistoryEntry? = null
+ try {
+ last = org.librespeed.speedtest.data.HistoryDatabase(context.applicationContext).readAll().firstOrNull()
+ } catch (_: Exception) {
+ }
+ org.librespeed.speedtest.data.DiagnosticReport.build(
+ context, last,
+ telemetryEnabled = telemetry, testMode = testMode, serverCount = serverCount
+ )
+ }
+ AlertDialog(
+ onDismissRequest = { reportDialog = false },
+ title = { Text(stringResource(R.string.settings_report)) },
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ Text(
+ text = stringResource(R.string.settings_report_send),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(report, style = MaterialTheme.typography.bodySmall)
+ }
+ },
+ confirmButton = {
+ Row {
+ TextButton(onClick = {
+ try {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, report)
+ }
+ context.startActivity(Intent.createChooser(intent, null))
+ } catch (_: Exception) {
+ }
+ reportDialog = false
+ }) { Text(stringResource(R.string.report_share)) }
+ TextButton(onClick = {
+ org.librespeed.speedtest.share.ShareResult.copy(context, report)
+ reportDialog = false
+ }) { Text(stringResource(R.string.share_copy)) }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { reportDialog = false }) { Text(stringResource(R.string.dialog_close)) }
+ }
+ )
+ }
+ if (whatIsSentDialog) {
+ AlertDialog(
+ onDismissRequest = { whatIsSentDialog = false },
+ title = { Text(stringResource(R.string.settings_whats_sent)) },
+ text = {
+ Column {
+ Text(stringResource(R.string.settings_telemetry_detail))
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.settings_telemetry_detail_off),
+ fontWeight = FontWeight.Bold
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { whatIsSentDialog = false }) { Text(stringResource(R.string.dialog_close)) }
+ }
+ )
+ }
+}
+
+private fun android.content.Context.openUrl(url: String) {
+ try {
+ startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
+ } catch (_: Exception) {
+ }
+}
+
+@Composable
+private fun SectionTitle(text: String) {
+ Text(
+ text = text.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 20.dp, bottom = 8.dp)
+ )
+}
+
+@Composable
+private fun SettingsCard(content: @Composable () -> Unit) {
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
+ Column(Modifier.fillMaxWidth().padding(16.dp)) { content() }
+ }
+}
+
+@Composable
+private fun ValueRow(title: String, value: String, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick, role = Role.Button).padding(vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Icon(
+ Icons.Filled.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun RowDivider() {
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
+}
+
+@Composable
+private fun NavRow(label: String, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick, role = Role.Button).padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+ Icon(
+ Icons.Filled.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun RadioDialog(
+ title: String,
+ options: List>,
+ hint: String?,
+ onSelect: (Int) -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ Column {
+ options.forEachIndexed { index, (label, selected) ->
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable {
+ onSelect(index)
+ onDismiss()
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(selected = selected, onClick = {
+ onSelect(index)
+ onDismiss()
+ })
+ Text(label, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ hint?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_close)) }
+ }
+ )
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/speedtest/SpeedtestScreen.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/speedtest/SpeedtestScreen.kt
new file mode 100644
index 0000000..e265793
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/speedtest/SpeedtestScreen.kt
@@ -0,0 +1,499 @@
+package org.librespeed.speedtest.ui.speedtest
+
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDownward
+import androidx.compose.material.icons.filled.ArrowUpward
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.NetworkPing
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInWindow
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.dp
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.AppPreferences
+import org.librespeed.speedtest.data.NetworkInfo
+import org.librespeed.speedtest.ui.HingeBounds
+import org.librespeed.speedtest.ui.components.SpeedGauge
+import org.librespeed.speedtest.ui.components.Sparkline
+import org.librespeed.speedtest.ui.theme.Purple
+import org.librespeed.speedtest.ui.theme.Teal
+import java.util.Locale
+import kotlin.math.roundToInt
+
+@Composable
+fun SpeedtestScreen(
+ viewModel: SpeedtestViewModel,
+ onServersClick: () -> Unit,
+ onSettingsClick: () -> Unit,
+ onResult: (Long) -> Unit,
+ hinge: HingeBounds? = null
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val resultId by viewModel.lastResultId.collectAsStateWithLifecycle()
+ val unitLabel = stringResource(if (state.useMBytes) R.string.unit_mbytes else R.string.unit_mbps)
+ fun display(value: Double): Double = if (state.useMBytes) value / 8 else value
+
+ LaunchedEffect(resultId) {
+ resultId?.let {
+ viewModel.consumeResult()
+ onResult(it)
+ }
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(modifier = Modifier.widthIn(max = 500.dp).fillMaxWidth()) {
+ Row(
+ modifier = Modifier.align(Alignment.Center),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_logo),
+ contentDescription = null,
+ modifier = Modifier.size(28.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = buildAnnotatedString {
+ withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { append("Libre") }
+ withStyle(SpanStyle(color = MaterialTheme.colorScheme.onBackground)) { append("Speed") }
+ },
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ IconButton(
+ onClick = onSettingsClick,
+ modifier = Modifier.align(Alignment.CenterEnd)
+ ) {
+ Icon(
+ Icons.Filled.Settings,
+ contentDescription = stringResource(R.string.nav_settings),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ Spacer(Modifier.height(6.dp))
+ ServerChip(
+ state = state,
+ onClick = {
+ if (state.serverListFailed) viewModel.refreshServers() else onServersClick()
+ }
+ )
+
+ if (hinge != null) {
+ //half-open fold: gauge above the crease, controls below it, nothing on the hinge
+ HingeSplit(
+ hinge = hinge,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ top = {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ GaugeSection(state, unitLabel, display = { display(it) })
+ }
+ },
+ bottom = {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ MetricsSection(state, viewModel, unitLabel, display = { display(it) })
+ }
+ }
+ )
+ } else {
+ Spacer(Modifier.weight(1f))
+ GaugeSection(state, unitLabel, display = { display(it) })
+ Spacer(Modifier.weight(1f))
+ MetricsSection(state, viewModel, unitLabel, display = { display(it) })
+ }
+ Spacer(Modifier.height(12.dp))
+ }
+}
+
+/** Splits the available space exactly at the fold crease, keeping a small gap around it. */
+@Composable
+private fun HingeSplit(
+ hinge: HingeBounds,
+ modifier: Modifier,
+ top: @Composable () -> Unit,
+ bottom: @Composable () -> Unit
+) {
+ var windowY by remember { mutableIntStateOf(0) }
+ val gap = with(LocalDensity.current) { 12.dp.roundToPx() }
+ Layout(
+ contents = listOf(top, bottom),
+ modifier = modifier.onGloballyPositioned { windowY = it.positionInWindow().y.roundToInt() }
+ ) { (topMeasurables, bottomMeasurables), constraints ->
+ val height = constraints.maxHeight
+ val creaseTop = (hinge.top - windowY - gap).coerceIn(0, height)
+ val creaseBottom = (hinge.bottom - windowY + gap).coerceIn(creaseTop, height)
+ val topPlaceable = topMeasurables.first()
+ .measure(Constraints.fixed(constraints.maxWidth, creaseTop))
+ val bottomPlaceable = bottomMeasurables.first()
+ .measure(Constraints.fixed(constraints.maxWidth, height - creaseBottom))
+ layout(constraints.maxWidth, height) {
+ topPlaceable.place(0, 0)
+ bottomPlaceable.place(0, creaseBottom)
+ }
+ }
+}
+
+@Composable
+private fun GaugeSection(state: SpeedtestUiState, unitLabel: String, display: (Double) -> Double) {
+ SpeedGauge(
+ speed = state.currentSpeed,
+ modifier = Modifier.widthIn(max = 384.dp).fillMaxWidth()
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = if (state.phase == Phase.IDLE || state.phase == Phase.ERROR) "—"
+ else String.format(Locale.US, "%.2f", display(state.currentSpeed)),
+ style = MaterialTheme.typography.displaySmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Text(
+ text = unitLabel,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(22.dp))
+ PhaseLabel(state)
+ }
+ }
+}
+
+@Composable
+private fun MetricsSection(
+ state: SpeedtestUiState,
+ viewModel: SpeedtestViewModel,
+ unitLabel: String,
+ display: (Double) -> Double
+) {
+ Row(modifier = Modifier.widthIn(max = 500.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ MetricCard(
+ modifier = Modifier.weight(1f),
+ title = stringResource(R.string.test_download),
+ icon = { Icon(Icons.Filled.ArrowDownward, null, tint = Teal, modifier = Modifier.size(16.dp)) },
+ value = if (state.download < 0) state.download else display(state.download),
+ unit = unitLabel,
+ accent = Teal,
+ samples = state.downloadSamples
+ )
+ MetricCard(
+ modifier = Modifier.weight(1f),
+ title = stringResource(R.string.test_upload),
+ icon = { Icon(Icons.Filled.ArrowUpward, null, tint = Purple, modifier = Modifier.size(16.dp)) },
+ value = if (state.upload < 0) state.upload else display(state.upload),
+ unit = unitLabel,
+ accent = Purple,
+ samples = state.uploadSamples
+ )
+ }
+
+ Spacer(Modifier.height(10.dp))
+ Row(modifier = Modifier.widthIn(max = 500.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ SmallMetric(Modifier.weight(1f), stringResource(R.string.test_ping), state.ping, stringResource(R.string.unit_ms))
+ SmallMetric(Modifier.weight(1f), stringResource(R.string.test_jitter), state.jitter, stringResource(R.string.unit_ms))
+ SmallMetric(Modifier.weight(1f), stringResource(R.string.test_loss), state.loss, "%")
+ }
+
+ Spacer(Modifier.height(14.dp))
+ val running = state.phase in setOf(Phase.PING, Phase.DOWNLOAD, Phase.UPLOAD)
+ val context = LocalContext.current
+ val prefs = remember { AppPreferences(context.applicationContext) }
+ val askedPhoneState by prefs.askedPhoneState.collectAsStateWithLifecycle(initialValue = true)
+ val scope = rememberCoroutineScope()
+ val phoneStateLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { viewModel.startOrStop() }
+ OutlinedButton(
+ onClick = {
+ //ask so mobile tests can label 4G/5G; the test starts either way and we
+ //keep offering the dialog for as long as the system still shows it
+ val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) ==
+ PackageManager.PERMISSION_GRANTED
+ val rationale = (context as? android.app.Activity)
+ ?.let { ActivityCompat.shouldShowRequestPermissionRationale(it, Manifest.permission.READ_PHONE_STATE) }
+ ?: false
+ val needsPrompt = !running && !granted && NetworkInfo.isCellular(context) &&
+ (!askedPhoneState || rationale)
+ if (needsPrompt) {
+ scope.launch { prefs.markPhoneStateAsked() }
+ phoneStateLauncher.launch(Manifest.permission.READ_PHONE_STATE)
+ } else {
+ viewModel.startOrStop()
+ }
+ },
+ enabled = state.selectedServer != null || state.phase != Phase.IDLE,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary),
+ modifier = Modifier.widthIn(max = 500.dp).fillMaxWidth().height(52.dp)
+ ) {
+ Icon(
+ imageVector = if (running) Icons.Filled.Stop else Icons.Filled.PlayArrow,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = stringResource(
+ when {
+ running -> R.string.test_stop
+ state.phase == Phase.ERROR -> R.string.test_restart
+ else -> R.string.test_start
+ }
+ ),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+
+ if (state.phase == Phase.ERROR) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = state.error ?: stringResource(R.string.test_failed),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+@Composable
+private fun ServerChip(state: SpeedtestUiState, onClick: () -> Unit) {
+ Card(
+ onClick = onClick,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ modifier = Modifier.widthIn(max = 500.dp).fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Public,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ when {
+ state.selectingServers -> Text(
+ text = stringResource(R.string.servers_selecting),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ state.serverListFailed -> Text(
+ text = stringResource(R.string.servers_load_failed),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ state.selectedServer != null -> {
+ Text(
+ text = stringResource(
+ if (state.pinnedServer) R.string.server_selected else R.string.server_auto_select
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = state.selectedServer.name,
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ else -> Text(
+ text = stringResource(R.string.servers_none),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ if (state.selectingServers) {
+ CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
+ } else if (state.serverListFailed) {
+ Icon(Icons.Filled.Refresh, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ state.selectedServer?.takeIf { it.ping >= 0 }?.let {
+ Text(
+ text = "${it.ping.toInt()} ${stringResource(R.string.unit_ms)}",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ Spacer(Modifier.width(6.dp))
+ Icon(
+ Icons.Filled.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PhaseLabel(state: SpeedtestUiState) {
+ val text = when (state.phase) {
+ Phase.PING -> stringResource(R.string.phase_ping)
+ Phase.DOWNLOAD -> stringResource(R.string.phase_download)
+ Phase.UPLOAD -> stringResource(R.string.phase_upload)
+ else -> ""
+ }
+ if (text.isNotEmpty()) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ when (state.phase) {
+ Phase.DOWNLOAD -> Icon(Icons.Filled.ArrowDownward, null, tint = Teal, modifier = Modifier.size(20.dp))
+ Phase.UPLOAD -> Icon(Icons.Filled.ArrowUpward, null, tint = Purple, modifier = Modifier.size(20.dp))
+ else -> Icon(Icons.Filled.NetworkPing, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+}
+
+@Composable
+private fun MetricCard(
+ modifier: Modifier,
+ title: String,
+ icon: @Composable () -> Unit,
+ value: Double,
+ unit: String,
+ accent: Color,
+ samples: List
+) {
+ Card(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
+ ) {
+ Column(Modifier.padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ icon()
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = title.uppercase(Locale.ROOT),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = if (value < 0) "—" else String.format(Locale.US, "%.2f", value),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = unit,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Sparkline(
+ data = samples,
+ color = accent,
+ modifier = Modifier.fillMaxWidth().height(30.dp).padding(top = 4.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun SmallMetric(modifier: Modifier, title: String, value: Double, unit: String) {
+ Card(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
+ ) {
+ Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(2.dp))
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text(
+ text = if (value < 0) "—" else String.format(Locale.US, "%.1f", value),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = unit,
+ modifier = Modifier.padding(bottom = 2.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/speedtest/SpeedtestViewModel.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/speedtest/SpeedtestViewModel.kt
new file mode 100644
index 0000000..b070e59
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/speedtest/SpeedtestViewModel.kt
@@ -0,0 +1,391 @@
+package org.librespeed.speedtest.ui.speedtest
+
+import android.Manifest
+import android.app.Application
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.Geocoder
+import android.location.Location
+import android.location.LocationManager
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.fdossena.speedtest.core.Speedtest
+import com.fdossena.speedtest.core.base.Connection
+import com.fdossena.speedtest.core.ping.Pinger
+import com.fdossena.speedtest.core.serverSelector.ServerSelector
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.librespeed.speedtest.data.AppPreferences
+import org.librespeed.speedtest.data.CustomServerFactory
+import org.librespeed.speedtest.data.GeoDistance
+import org.librespeed.speedtest.data.HistoryDatabase
+import org.librespeed.speedtest.data.HistoryEntry
+import org.librespeed.speedtest.data.NetworkInfo
+import org.librespeed.speedtest.data.key
+import org.librespeed.speedtest.engine.TestEngine
+import org.librespeed.speedtest.engine.TestMode
+import java.util.Collections
+import kotlin.math.roundToInt
+
+enum class Phase { IDLE, PING, DOWNLOAD, UPLOAD, ERROR }
+
+data class SpeedtestUiState(
+ val phase: Phase = Phase.IDLE,
+ val selectingServers: Boolean = false,
+ val serverListFailed: Boolean = false,
+ val servers: List = emptyList(),
+ val selectedServer: TestPoint? = null,
+ val pinnedServer: Boolean = false,
+ val currentSpeed: Double = 0.0,
+ val download: Double = -1.0,
+ val upload: Double = -1.0,
+ val ping: Double = -1.0,
+ val jitter: Double = -1.0,
+ val loss: Double = -1.0,
+ val downloadSamples: List = emptyList(),
+ val uploadSamples: List = emptyList(),
+ val ipInfo: String? = null,
+ val shareUrl: String? = null,
+ val error: String? = null,
+ val favorites: Set = emptySet(),
+ val customKeys: Set = emptySet(),
+ val useMBytes: Boolean = false,
+ val distances: Map = emptyMap()
+)
+
+class SpeedtestViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val engine = TestEngine(application)
+ private val prefs = AppPreferences(application)
+ private val history = HistoryDatabase(application)
+ private val _state = MutableStateFlow(SpeedtestUiState())
+ val state: StateFlow = _state
+
+ /** Id of the freshly saved result; the UI navigates to it and calls [consumeResult]. */
+ val lastResultId = MutableStateFlow(null)
+
+ private var telemetryEnabled = false
+ private var testMode = "standard"
+ private var autoSelected: TestPoint? = null
+ private var aborted = false
+
+ //side channel measuring latency while the line is under load (bufferbloat)
+ private var loadedPinger: Pinger? = null
+ private val loadedDownSamples = Collections.synchronizedList(mutableListOf())
+ private val loadedUpSamples = Collections.synchronizedList(mutableListOf())
+
+ init {
+ viewModelScope.launch {
+ prefs.favorites.collect { favorites -> _state.update { it.copy(favorites = favorites) } }
+ }
+ viewModelScope.launch {
+ prefs.useMBytes.collect { useMBytes -> _state.update { it.copy(useMBytes = useMBytes) } }
+ }
+ viewModelScope.launch { prefs.telemetryEnabled.collect { telemetryEnabled = it } }
+ viewModelScope.launch { prefs.testMode.collect { testMode = it } }
+ refreshServers()
+ }
+
+ fun consumeResult() {
+ lastResultId.value = null
+ }
+
+ fun refreshServers() {
+ if (_state.value.selectingServers) return
+ _state.update { it.copy(selectingServers = true, serverListFailed = false) }
+ viewModelScope.launch {
+ try {
+ val custom = prefs.customServers.first()
+ val discovery = engine.discover(custom)
+ autoSelected = discovery.selected
+ val remembered = prefs.rememberedServer.first()
+ ?.let { key -> discovery.servers.find { it.key() == key && it.ping >= 0 } }
+ _state.update {
+ it.copy(
+ selectingServers = false,
+ servers = discovery.servers,
+ selectedServer = remembered ?: discovery.selected,
+ pinnedServer = remembered != null,
+ customKeys = custom.map { c -> c.key() }.toSet(),
+ serverListFailed = discovery.selected == null && discovery.servers.isEmpty()
+ )
+ }
+ } catch (_: Exception) {
+ _state.update { it.copy(selectingServers = false, serverListFailed = true) }
+ }
+ computeDistances()
+ }
+ }
+
+ /** Fills in distances to servers when coarse location is granted; results stay on the device. */
+ fun computeDistances() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val app = getApplication()
+ if (ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_COARSE_LOCATION)
+ != PackageManager.PERMISSION_GRANTED
+ ) return@launch
+ val location = lastKnownLocation(app) ?: return@launch
+ if (!Geocoder.isPresent()) return@launch
+ val geocoder = Geocoder(app)
+ val cache = prefs.getGeoCache().toMutableMap()
+ for (server in _state.value.servers) {
+ val place = GeoDistance.cleanName(server.name)
+ val coordinates = cache[place] ?: try {
+ @Suppress("DEPRECATION")
+ geocoder.getFromLocationName(place, 1)?.firstOrNull()
+ ?.let { it.latitude to it.longitude }
+ ?.also {
+ cache[place] = it
+ prefs.putGeoCache(place, it.first, it.second)
+ }
+ } catch (_: Exception) {
+ null
+ } ?: continue
+ val km = GeoDistance.km(location.latitude, location.longitude, coordinates.first, coordinates.second)
+ _state.update {
+ it.copy(distances = it.distances + (server.key() to km.roundToInt()))
+ }
+ }
+ }
+ }
+
+ //only called after computeDistances verified ACCESS_COARSE_LOCATION
+ @android.annotation.SuppressLint("MissingPermission")
+ private fun lastKnownLocation(app: Application): Location? = try {
+ val manager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+ manager.getProviders(true)
+ .mapNotNull { provider -> runCatching { manager.getLastKnownLocation(provider) }.getOrNull() }
+ .maxByOrNull { it.time }
+ } catch (_: Exception) {
+ null
+ }
+
+ fun selectServer(testPoint: TestPoint) {
+ if (isRunning()) return
+ _state.update { it.copy(selectedServer = testPoint, pinnedServer = true) }
+ viewModelScope.launch { prefs.setRememberedServer(testPoint.key()) }
+ }
+
+ /** Returns to automatic server selection. */
+ fun useAutoSelect() {
+ if (isRunning()) return
+ _state.update { it.copy(selectedServer = autoSelected ?: it.selectedServer, pinnedServer = false) }
+ viewModelScope.launch { prefs.setRememberedServer(null) }
+ }
+
+ fun toggleFavorite(testPoint: TestPoint) {
+ viewModelScope.launch { prefs.toggleFavorite(testPoint.key()) }
+ }
+
+ /** Re-pings a single server and refreshes the list when done. */
+ fun pingServer(testPoint: TestPoint) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ object : ServerSelector(arrayOf(testPoint), 2000) {
+ override fun onServerSelected(server: TestPoint?) {
+ _state.update { it.copy(servers = it.servers.toList()) }
+ }
+ }.start()
+ } catch (_: Exception) {
+ }
+ }
+ }
+
+ /** Returns false when the URL is invalid. */
+ fun addCustomServer(name: String, url: String): Boolean {
+ val testPoint = try {
+ CustomServerFactory.create(name, url)
+ } catch (_: Exception) {
+ return false
+ }
+ viewModelScope.launch {
+ prefs.addCustomServer(testPoint)
+ refreshServers()
+ }
+ return true
+ }
+
+ fun removeCustomServer(testPoint: TestPoint) {
+ viewModelScope.launch {
+ prefs.removeCustomServer(testPoint.key())
+ refreshServers()
+ }
+ }
+
+ fun startOrStop() {
+ if (isRunning()) stop() else start()
+ }
+
+ private fun isRunning(): Boolean = _state.value.phase in setOf(Phase.PING, Phase.DOWNLOAD, Phase.UPLOAD)
+
+ private fun startLoadedPinger(server: TestPoint) {
+ if (loadedPinger != null) return
+ loadedPinger = try {
+ object : Pinger(Connection(server.server), server.pingURL) {
+ override fun onPong(ns: Long): Boolean {
+ val ms = ns / 1_000_000.0
+ when (_state.value.phase) {
+ Phase.DOWNLOAD -> loadedDownSamples.add(ms)
+ Phase.UPLOAD -> loadedUpSamples.add(ms)
+ else -> Unit
+ }
+ return true
+ }
+
+ override fun onError(err: String?) = Unit
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun stopLoadedPinger() {
+ runCatching { loadedPinger?.stopASAP() }
+ loadedPinger = null
+ }
+
+ private fun start() {
+ val current = _state.value
+ val server = current.selectedServer ?: return
+ aborted = false
+ val networkType = NetworkInfo.describe(getApplication())
+ val networkDetail = NetworkInfo.detail(getApplication())
+ val mode = TestMode.fromKey(testMode)
+ loadedDownSamples.clear()
+ loadedUpSamples.clear()
+ val startedAt = System.currentTimeMillis()
+ _state.update {
+ it.copy(
+ phase = Phase.PING,
+ currentSpeed = 0.0,
+ download = -1.0, upload = -1.0, ping = -1.0, jitter = -1.0, loss = -1.0,
+ downloadSamples = emptyList(), uploadSamples = emptyList(),
+ ipInfo = null, shareUrl = null, error = null
+ )
+ }
+ engine.prepare(current.servers, server, telemetryEnabled, mode)
+ engine.start(object : Speedtest.SpeedtestHandler() {
+ override fun onDownloadUpdate(dl: Double, progress: Double) {
+ startLoadedPinger(server)
+ _state.update {
+ it.copy(
+ phase = Phase.DOWNLOAD,
+ currentSpeed = dl,
+ download = dl,
+ downloadSamples = if (progress > 0) it.downloadSamples + dl else it.downloadSamples
+ )
+ }
+ }
+
+ override fun onUploadUpdate(ul: Double, progress: Double) {
+ startLoadedPinger(server)
+ _state.update {
+ it.copy(
+ phase = Phase.UPLOAD,
+ currentSpeed = ul,
+ upload = ul,
+ uploadSamples = if (progress > 0) it.uploadSamples + ul else it.uploadSamples
+ )
+ }
+ }
+
+ override fun onPingJitterUpdate(ping: Double, jitter: Double, progress: Double) {
+ _state.update { it.copy(phase = Phase.PING, ping = ping, jitter = jitter) }
+ }
+
+ override fun onLossUpdate(loss: Double) {
+ _state.update { it.copy(loss = loss) }
+ }
+
+ override fun onIPInfoUpdate(ipInfo: String?) {
+ _state.update { it.copy(ipInfo = ipInfo) }
+ }
+
+ override fun onTestIDReceived(id: String?, shareURL: String?) {
+ _state.update { it.copy(shareUrl = shareURL) }
+ }
+
+ override fun onEnd() {
+ stopLoadedPinger()
+ val finished = _state.value
+ if (aborted || finished.download < 0) {
+ resetToIdle()
+ return
+ }
+ viewModelScope.launch {
+ val entryId = withContext(Dispatchers.IO) {
+ history.insert(
+ HistoryEntry(
+ date = System.currentTimeMillis(),
+ server = server.name,
+ ping = finished.ping,
+ jitter = finished.jitter,
+ download = finished.download,
+ upload = finished.upload,
+ loss = finished.loss,
+ ipInfo = finished.ipInfo,
+ ipVersion = server.ipVersion,
+ shareUrl = finished.shareUrl,
+ networkType = networkType,
+ downloadSamples = finished.downloadSamples,
+ uploadSamples = finished.uploadSamples,
+ durationMs = System.currentTimeMillis() - startedAt,
+ mode = mode.key,
+ loadedDown = loadedDownSamples.toList().average().takeIf { !it.isNaN() } ?: -1.0,
+ loadedUp = loadedUpSamples.toList().average().takeIf { !it.isNaN() } ?: -1.0,
+ networkDetail = networkDetail
+ )
+ )
+ }
+ resetToIdle()
+ lastResultId.value = entryId
+ }
+ }
+
+ override fun onCriticalFailure(err: String?) {
+ stopLoadedPinger()
+ _state.update { it.copy(phase = Phase.ERROR, error = err, currentSpeed = 0.0) }
+ }
+ })
+ }
+
+ private fun resetToIdle() {
+ _state.update {
+ it.copy(
+ phase = Phase.IDLE,
+ currentSpeed = 0.0,
+ download = -1.0, upload = -1.0, ping = -1.0, jitter = -1.0, loss = -1.0,
+ downloadSamples = emptyList(), uploadSamples = emptyList(),
+ ipInfo = null, shareUrl = null, error = null
+ )
+ }
+ }
+
+ /** Selects the server with the given name (without pinning it) and starts a test. */
+ fun testAgain(serverName: String) {
+ if (isRunning()) return
+ _state.value.servers.find { it.name == serverName }?.let { server ->
+ _state.update { it.copy(selectedServer = server) }
+ }
+ startOrStop()
+ }
+
+ private fun stop() {
+ aborted = true
+ stopLoadedPinger()
+ engine.abort()
+ }
+
+ override fun onCleared() {
+ stopLoadedPinger()
+ engine.abort()
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/theme/Theme.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/theme/Theme.kt
new file mode 100644
index 0000000..14c06d0
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/theme/Theme.kt
@@ -0,0 +1,78 @@
+package org.librespeed.speedtest.ui.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.ui.graphics.Color
+
+val Teal = Color(0xFF2DD4BF)
+val TealDeep = Color(0xFF14B8A6)
+val Purple = Color(0xFFA78BFA)
+val NightBackground = Color(0xFF0C111C)
+val NightSurface = Color(0xFF151C2C)
+val NightSurfaceHigh = Color(0xFF1D2537)
+val DayBackground = Color(0xFFF6F8FB)
+val DaySurface = Color(0xFFFFFFFF)
+val DangerRed = Color(0xFFEF6461)
+
+private val DarkColors = darkColorScheme(
+ primary = Teal,
+ onPrimary = Color(0xFF00201C),
+ primaryContainer = Color(0xFF0F3B36),
+ onPrimaryContainer = Teal,
+ secondary = Purple,
+ onSecondary = Color(0xFF1F1147),
+ secondaryContainer = Color(0xFF2E2258),
+ onSecondaryContainer = Purple,
+ background = NightBackground,
+ onBackground = Color(0xFFE4E9F2),
+ surface = NightSurface,
+ onSurface = Color(0xFFE4E9F2),
+ surfaceVariant = NightSurfaceHigh,
+ onSurfaceVariant = Color(0xFF9AA4B8),
+ surfaceContainer = NightSurface,
+ surfaceContainerHigh = NightSurfaceHigh,
+ error = DangerRed,
+ outline = Color(0xFF3A445A)
+)
+
+private val LightColors = lightColorScheme(
+ primary = TealDeep,
+ onPrimary = Color.White,
+ primaryContainer = Color(0xFFC8F5EE),
+ onPrimaryContainer = Color(0xFF00332E),
+ secondary = Color(0xFF7C5CD6),
+ onSecondary = Color.White,
+ secondaryContainer = Color(0xFFE8E0FB),
+ onSecondaryContainer = Color(0xFF2A1A5E),
+ background = DayBackground,
+ onBackground = Color(0xFF1A1F2B),
+ surface = DaySurface,
+ onSurface = Color(0xFF1A1F2B),
+ surfaceVariant = Color(0xFFEDF1F7),
+ onSurfaceVariant = Color(0xFF5A6478),
+ surfaceContainer = DaySurface,
+ surfaceContainerHigh = Color(0xFFF0F3F8),
+ error = Color(0xFFC0342F),
+ outline = Color(0xFFC3CAD8)
+)
+
+enum class ThemeMode { SYSTEM, LIGHT, DARK }
+
+@Composable
+fun LibreSpeedTheme(
+ mode: ThemeMode = ThemeMode.SYSTEM,
+ content: @Composable () -> Unit
+) {
+ val dark = when (mode) {
+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
+ ThemeMode.LIGHT -> false
+ ThemeMode.DARK -> true
+ }
+ MaterialTheme(
+ colorScheme = if (dark) DarkColors else LightColors,
+ content = content
+ )
+}
diff --git a/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/work/ScheduledTestWorker.kt b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/work/ScheduledTestWorker.kt
new file mode 100644
index 0000000..6d893a2
--- /dev/null
+++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/work/ScheduledTestWorker.kt
@@ -0,0 +1,205 @@
+package org.librespeed.speedtest.work
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import com.fdossena.speedtest.core.Speedtest
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import org.librespeed.speedtest.MainActivity
+import org.librespeed.speedtest.R
+import org.librespeed.speedtest.data.AppPreferences
+import org.librespeed.speedtest.data.HistoryDatabase
+import org.librespeed.speedtest.data.HistoryEntry
+import org.librespeed.speedtest.data.NetworkInfo
+import org.librespeed.speedtest.data.key
+import org.librespeed.speedtest.engine.TestEngine
+import java.util.Collections
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.resume
+
+object ScheduledTests {
+
+ const val WORK_NAME = "scheduled_test"
+
+ /** mode: "off", "6h", "daily" or "weekly" */
+ fun apply(context: Context, mode: String) {
+ val workManager = WorkManager.getInstance(context)
+ if (mode == "off") {
+ workManager.cancelUniqueWork(WORK_NAME)
+ return
+ }
+ val (interval, unit) = when (mode) {
+ "6h" -> 6L to TimeUnit.HOURS
+ "weekly" -> 7L to TimeUnit.DAYS
+ else -> 1L to TimeUnit.DAYS
+ }
+ val request = PeriodicWorkRequestBuilder(interval, unit)
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .setRequiresBatteryNotLow(true)
+ .build()
+ )
+ .build()
+ workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
+ }
+
+}
+
+/** Runs a full speed test in the background and saves it to the history like a manual run. */
+class ScheduledTestWorker(context: Context, parameters: WorkerParameters) :
+ CoroutineWorker(context, parameters) {
+
+ override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
+ val app = applicationContext
+ val prefs = AppPreferences(app)
+ try {
+ val engine = TestEngine(app)
+ val discovery = engine.discover(prefs.customServers.first())
+ val remembered = prefs.rememberedServer.first()
+ ?.let { key -> discovery.servers.find { it.key() == key && it.ping >= 0 } }
+ val server = remembered ?: discovery.selected ?: return@withContext Result.retry()
+ val entry = runTest(engine, discovery.servers, server, prefs.telemetryEnabled.first())
+ ?: return@withContext Result.retry()
+ val id = HistoryDatabase(app).insert(entry)
+ notify(app, entry, id)
+ Result.success()
+ } catch (_: Exception) {
+ Result.retry()
+ }
+ }
+
+ private suspend fun runTest(
+ engine: TestEngine,
+ servers: List,
+ server: TestPoint,
+ telemetryEnabled: Boolean
+ ): HistoryEntry? = suspendCancellableCoroutine { continuation ->
+ val startedAt = System.currentTimeMillis()
+ val app = applicationContext
+ val networkType = NetworkInfo.describe(app)
+ val networkDetail = NetworkInfo.detail(app)
+ val downloadSamples = Collections.synchronizedList(mutableListOf())
+ val uploadSamples = Collections.synchronizedList(mutableListOf())
+ var download = -1.0
+ var upload = -1.0
+ var ping = -1.0
+ var jitter = -1.0
+ var loss = -1.0
+ var ipInfo: String? = null
+ var shareUrl: String? = null
+
+ engine.prepare(servers, server, telemetryEnabled)
+ engine.start(object : Speedtest.SpeedtestHandler() {
+ override fun onDownloadUpdate(dl: Double, progress: Double) {
+ if (dl > 0) download = dl
+ if (progress > 0) downloadSamples.add(dl)
+ }
+
+ override fun onUploadUpdate(ul: Double, progress: Double) {
+ if (ul > 0) upload = ul
+ if (progress > 0) uploadSamples.add(ul)
+ }
+
+ override fun onPingJitterUpdate(p: Double, j: Double, progress: Double) {
+ ping = p
+ jitter = j
+ }
+
+ override fun onLossUpdate(l: Double) {
+ loss = l
+ }
+
+ override fun onIPInfoUpdate(info: String?) {
+ ipInfo = info
+ }
+
+ override fun onTestIDReceived(id: String?, shareURL: String?) {
+ shareUrl = shareURL
+ }
+
+ override fun onEnd() {
+ if (!continuation.isActive) return
+ if (download < 0) {
+ continuation.resume(null)
+ return
+ }
+ continuation.resume(
+ HistoryEntry(
+ date = System.currentTimeMillis(),
+ server = server.name,
+ ping = ping,
+ jitter = jitter,
+ download = download,
+ upload = upload,
+ loss = loss,
+ ipInfo = ipInfo,
+ ipVersion = server.ipVersion,
+ shareUrl = shareUrl,
+ networkType = networkType,
+ downloadSamples = downloadSamples.toList(),
+ uploadSamples = uploadSamples.toList(),
+ durationMs = System.currentTimeMillis() - startedAt,
+ mode = "scheduled",
+ networkDetail = networkDetail
+ )
+ )
+ }
+
+ override fun onCriticalFailure(err: String?) {
+ if (continuation.isActive) continuation.resume(null)
+ }
+ })
+ continuation.invokeOnCancellation { engine.abort() }
+ }
+
+ private fun notify(context: Context, entry: HistoryEntry, entryId: Long) {
+ if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.createNotificationChannel(
+ NotificationChannel(
+ "results",
+ context.getString(R.string.notif_channel),
+ NotificationManager.IMPORTANCE_DEFAULT
+ )
+ )
+ val intent = PendingIntent.getActivity(
+ context, 0,
+ Intent(context, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ val text = String.format(
+ Locale.US, "↓ %.0f · ↑ %.0f %s · %.0f %s",
+ entry.download, entry.upload, context.getString(R.string.unit_mbps),
+ entry.ping, context.getString(R.string.unit_ms)
+ )
+ val notification = NotificationCompat.Builder(context, "results")
+ .setSmallIcon(R.drawable.ic_logo)
+ .setContentTitle(context.getString(R.string.notif_title))
+ .setContentText(text)
+ .setContentIntent(intent)
+ .setAutoCancel(true)
+ .build()
+ try {
+ manager.notify(entryId.toInt(), notification)
+ } catch (_: SecurityException) {
+ }
+ }
+
+}
diff --git a/Speedtest-Android/app/src/main/res/drawable/ic_launcher.png b/Speedtest-Android/app/src/main/res/drawable/ic_launcher.png
deleted file mode 100644
index 0254297..0000000
Binary files a/Speedtest-Android/app/src/main/res/drawable/ic_launcher.png and /dev/null differ
diff --git a/Speedtest-Android/app/src/main/res/drawable/ic_launcher_foreground.xml b/Speedtest-Android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..b89c61f
--- /dev/null
+++ b/Speedtest-Android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Speedtest-Android/app/src/main/res/drawable/ic_logo.xml b/Speedtest-Android/app/src/main/res/drawable/ic_logo.xml
new file mode 100644
index 0000000..fed6d7d
--- /dev/null
+++ b/Speedtest-Android/app/src/main/res/drawable/ic_logo.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Speedtest-Android/app/src/main/res/drawable/logo.png b/Speedtest-Android/app/src/main/res/drawable/logo.png
deleted file mode 100644
index eccac8f..0000000
Binary files a/Speedtest-Android/app/src/main/res/drawable/logo.png and /dev/null differ
diff --git a/Speedtest-Android/app/src/main/res/drawable/logo_inapp.png b/Speedtest-Android/app/src/main/res/drawable/logo_inapp.png
deleted file mode 100644
index 14ea78c..0000000
Binary files a/Speedtest-Android/app/src/main/res/drawable/logo_inapp.png and /dev/null differ
diff --git a/Speedtest-Android/app/src/main/res/drawable/testbackground.png b/Speedtest-Android/app/src/main/res/drawable/testbackground.png
deleted file mode 100644
index f19c990..0000000
Binary files a/Speedtest-Android/app/src/main/res/drawable/testbackground.png and /dev/null differ
diff --git a/Speedtest-Android/app/src/main/res/layout/activity_main.xml b/Speedtest-Android/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index ad35b91..0000000
--- a/Speedtest-Android/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,536 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Speedtest-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Speedtest-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..a8a8fa5
--- /dev/null
+++ b/Speedtest-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Speedtest-Android/app/src/main/res/values-cs/strings.xml b/Speedtest-Android/app/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000..ecc016a
--- /dev/null
+++ b/Speedtest-Android/app/src/main/res/values-cs/strings.xml
@@ -0,0 +1,200 @@
+
+ LibreSpeed
+
+ Test rychlosti
+ Historie
+ Servery
+ Nastavení
+
+ Mb/s
+ MB/s
+ ms
+
+ Stahování
+ Odesílání
+ Ping
+ Jitter
+ Ztráta
+ Spustit test
+ Zastavit test
+ Nový test
+ Test se nezdařil
+
+ Měřím odezvu…
+ Měřím stahování…
+ Měřím odesílání…
+ Hotovo
+
+ Vybírám server…
+ Nepodařilo se načíst seznam serverů — klepnutím opakujte
+ Žádné servery nejsou k dispozici
+ Oblíbené
+ Všechny servery
+ Zatím žádné oblíbené servery — označte je hvězdičkou v záložce Všechny servery
+ Obnovit servery
+ nedostupný
+ Oblíbený
+ Smazat server
+ Přidat vlastní server
+ Název
+ URL
+ URL musí ukazovat na LibreSpeed backend.
+ Neplatná URL
+ Přidat
+ Zrušit
+ Zavřít
+
+ Zatím žádné testy — spusťte svůj první test rychlosti
+ Smazat historii
+ Smazat všechny uložené výsledky?
+ Smazat
+ Server
+ Protokol
+ IP adresa
+
+ Zobrazení
+ Telemetrie
+ O aplikaci
+ Motiv
+ Podle systému
+ Světlý
+ Tmavý
+ Jazyk
+ Systémový
+ Jednotky
+ Mb/s — megabity za sekundu
+ MB/s — megabajty za sekundu
+ Internetové tarify se obvykle prodávají v Mb/s (megabitech). 8 Mb/s odpovídá 1 MB/s (megabajtu za sekundu), tedy jednotce, kterou většinou ukazují správci souborů.
+ Sdílet anonymní telemetrii
+ Pomozte zlepšovat LibreSpeed sdílením anonymních souhrnných informací o testech. Ve výchozím stavu vypnuto.
+ Po zapnutí se po každém testu odešle výsledek (rychlosti, ping, jitter), informace o klientovi a vaše IP adresa na testovaný server. Provozovatel serveru je uloží do své databáze výsledků a aplikace dostane odkaz na uložený výsledek. Žádný centrální sběr neexistuje.
+ Při vypnutí se neposílá nic.
+ Svobodný a otevřený software pod licencí LGPL-3.0
+ Nahlásit chybu nebo navrhnout funkci
+ Zásady ochrany soukromí
+
+ Sdílet výsledek
+ Otevřít URL výsledku
+ Výsledek LibreSpeed — Stahování: %1$s, Odesílání: %2$s, Ping: %3$s ms, Jitter: %4$s ms (server: %5$s)
+ Ztráta paketů: %1$s %%
+ Připojení: %1$s
+
+ Automatický výběr serveru
+ Automaticky (%1$s)
+ Vybraný server
+
+ Detail výsledku
+ Detail testu
+ Více informací
+ Test
+ Datum
+ Server
+ Síť
+ Klient
+ Aplikace
+ Verze
+ Zařízení
+ Data testu
+ Telemetrie
+ Data stahování
+ Data odesílání
+ Odesláno testovanému serveru
+ Neodesláno
+ Síť
+ Klient
+ ID testu
+
+ Test
+ Obecné
+ Server
+ Režim testu
+ Co se odesílá?
+ Licence open source
+ User agent
+ Poskytovatel
+ Podrobnosti serveru
+ Sponzor: %1$s
+ Vzdálenost: %1$d km
+ Zobrazit vzdálenosti serverů
+ Vybrat server
+ Použít tento server pro příští test
+ Přidat do oblíbených
+ Přidat tento server mezi oblíbené
+ Odebrat z oblíbených
+ Odebrat tento server z oblíbených
+ Informace o serveru
+ Podrobné informace o tomto serveru
+ Změřit ping
+ Změřit odezvu tohoto serveru
+ Sdílet server
+ Sdílet tento server s ostatními
+ Odebrat tento vlastní server
+ LibreSpeed server: %1$s — %2$s
+ Poloha
+ Vzdálenost
+ Odezva
+ Poslední test
+ Sdílet obrázek
+ Sdílet obrázek s výsledkem
+ Sdílet odkaz
+ Sdílet odkaz na stránku výsledku
+ Kopírovat výsledek
+ Zkopírovat textový výsledek
+ Test rychlosti internetu
+ Testovat znovu
+ Délka testu
+ Přenesená data
+ Standardní — více spojení
+ Jedno spojení
+ Jedno TCP spojení ukáže, kolik zvládne jediný stream; více spojení lépe vytíží rychlé linky. Test stability stahuje celou minutu a ukáže, jak je linka vyrovnaná.
+ Stabilita — 60 s stahování
+
+ Režim testu
+ Standardní
+ Jedno spojení
+ Stabilita
+ Bufferbloat
+ Odezva při stahování
+ Odezva při nahrávání
+ Stabilita
+ Minimum
+ Maximum
+ Průměr
+ Kolísání
+ Mobilní síť
+
+ Porovnat servery
+ Spustí krátký test stahování proti nejrychlejším serverům.
+ Spustit porovnání
+ Testuji %1$s…
+ Použít tento server
+
+ Filtr
+ Vše
+ Síť
+ Server
+ Období
+ Posledních 7 dní
+ Posledních 30 dní
+ Export CSV
+ Export JSON
+ Trendy
+
+ Plánované testy
+ Vypnuto
+ Každých 6 hodin
+ Denně
+ Týdně
+ Spustí celý test na pozadí a uloží ho do historie. Jeden běh může na rychlé lince přenést ~100 MB dat.
+ Plánovaný
+ Výsledky testů
+ Test rychlosti dokončen
+
+ Diagnostika
+ Diagnostický report
+ Souhrn aplikace, zařízení a posledního testu pro hlášení chyb. IP adresa není součástí.
+ Přiložte tento report ke GitHub issue, ať jde problém zreprodukovat.
+ Sdílet
+
+ Bufferbloat je nárůst odezvy ve chvíli, kdy je linka plně vytížená — právě kvůli němu se při stahování sekají hovory a hry. Známka A a lepší znamená, že připojení zůstává svižné; C a horší je znát. Zlepšíte ho zapnutím chytré správy front (SQM/QoS s fq_codel nebo CAKE) na routeru a nastavením šířky pásma na cca 95 %% naměřené rychlosti.
+
diff --git a/Speedtest-Android/app/src/main/res/values-v21/styles.xml b/Speedtest-Android/app/src/main/res/values-v21/styles.xml
deleted file mode 100644
index 371a081..0000000
--- a/Speedtest-Android/app/src/main/res/values-v21/styles.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/Speedtest-Android/app/src/main/res/values/attrs.xml b/Speedtest-Android/app/src/main/res/values/attrs.xml
deleted file mode 100644
index b2334b2..0000000
--- a/Speedtest-Android/app/src/main/res/values/attrs.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Speedtest-Android/app/src/main/res/values/colors.xml b/Speedtest-Android/app/src/main/res/values/colors.xml
index 031453f..c3768f2 100644
--- a/Speedtest-Android/app/src/main/res/values/colors.xml
+++ b/Speedtest-Android/app/src/main/res/values/colors.xml
@@ -1,26 +1,3 @@
-
- #6060AA
- #FFFFFF
- #404040
- #6060AA
- #AACCCCCC
- #AACCCCCC
- #AAAAAA
- #6060AA
- #616161
- #616161
- #616161
- #AA6060
- #AA6060
- #808080
- #6060AA
- #FFFFFF
- #6060AA
- #FFFFFF
- #BF360C
- #FFFFFF
- #6060AA
- #FFFFFF
- #808080
+ #1D2537
diff --git a/Speedtest-Android/app/src/main/res/values/dimens.xml b/Speedtest-Android/app/src/main/res/values/dimens.xml
deleted file mode 100644
index 5ac7ff8..0000000
--- a/Speedtest-Android/app/src/main/res/values/dimens.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
- 16dp
- 20dp
- 18dp
- 14dp
- 18dp
- 32dp
- 18dp
- 36dp
- 150dp
- 10dp
- 80dp
- 16dp
- 16dp
-
- 300dp
- 500dp
-
- 400dp
- 60dp
- 24dp
-
- 16dp
- 6dp
- 6dp
- 16dp
- 6dp
- 6dp
- 16dp
- 6dp
- 6dp
- 16dp
- 6dp
- 6dp
-
- 16dp
- 18dp
- 8dp
- 48dp
-
-
diff --git a/Speedtest-Android/app/src/main/res/values/strings.xml b/Speedtest-Android/app/src/main/res/values/strings.xml
index 7ca3a52..4fb6a46 100644
--- a/Speedtest-Android/app/src/main/res/values/strings.xml
+++ b/Speedtest-Android/app/src/main/res/values/strings.xml
@@ -1,26 +1,200 @@
- LibreSpeed Android Template
- https://github.com/librespeed/speedtest-android
- file:///android_asset/privacy_en.html
-
-
- Loading
- Configuration error
- Retry
- Selecting server
- No servers available
- Select a server
- Start
- Download
- Upload
- Mbps
+ LibreSpeed
+
+ Speedtest
+ History
+ Servers
+ Settings
+
+ Mbps
+ MB/s
+ ms
+
+ Download
+ Upload
Ping
Jitter
- ms
- Share result
+ Loss
+ Start test
+ Stop test
New test
- An error occurred
- Try again
- Privacy
- Back
+ The test failed
+
+ Testing latency…
+ Testing download…
+ Testing upload…
+ Done
+
+ Selecting server…
+ Failed to load the server list — tap to retry
+ No servers available
+ Favorites
+ All servers
+ No favorite servers yet — star one in the All servers tab
+ Refresh servers
+ unreachable
+ Favorite
+ Delete server
+ Add custom server
+ Name
+ URL
+ The URL must point to a LibreSpeed backend.
+ Invalid URL
+ Add
+ Cancel
+ Close
+
+ No tests yet — run your first speed test
+ Clear history
+ Delete all saved results?
+ Delete
+ Server
+ Protocol
+ IP address
+
+ Display
+ Telemetry
+ About
+ Theme
+ System
+ Light
+ Dark
+ Language
+ System
+ Units
+ Mbps — megabits per second
+ MB/s — megabytes per second
+ Internet plans are usually sold in Mbps (megabits). 8 Mbps equals 1 MB/s (megabyte per second), the unit file managers usually show.
+ Share anonymous telemetry
+ Help improve LibreSpeed by sharing anonymous aggregate test information. Off by default.
+ When enabled, the test result (speeds, ping, jitter), client information and your IP address are submitted to the tested server after each test. The server operator stores them in their results database and the app receives a link to the stored result. There is no central collection.
+ Nothing is sent while this is off.
+ Free and open source, licensed under LGPL-3.0
+ Report an issue or request a feature
+ Privacy policy
+
+ Share result
+ Open result URL
+ LibreSpeed result — Download: %1$s, Upload: %2$s, Ping: %3$s ms, Jitter: %4$s ms (server: %5$s)
+ Packet loss: %1$s %%
+ Connection: %1$s
+
+ Auto-select server
+ Auto-select (%1$s)
+ Selected server
+
+ Result details
+ Test details
+ More details
+ Test
+ Date
+ Server
+ Network
+ Client
+ Application
+ Version
+ Device
+ Test data
+ Telemetry
+ Download data
+ Upload data
+ Submitted to the tested server
+ Not submitted
+ Network
+ Client
+ Test ID
+
+ Test
+ General
+ Server
+ Test mode
+ What is sent?
+ Open source licenses
+ User agent
+ Provider
+ Server details
+ Sponsor: %1$s
+ Distance: %1$d km
+ Show server distances
+ Select server
+ Use this server for the next speed test
+ Add to favorites
+ Add this server to your favorites
+ Remove from favorites
+ Remove this server from your favorites
+ Server information
+ Detailed information about this server
+ Ping server
+ Measure ping to this server
+ Share server
+ Share this server with others
+ Remove this custom server
+ LibreSpeed server: %1$s — %2$s
+ Location
+ Distance
+ Latency
+ Last test
+ Share image
+ Share a picture of the result
+ Share link
+ Share a link to the result page
+ Copy result
+ Copy the result as text
+ Internet Speed Test
+ Test again
+ Test duration
+ Data used
+ Standard — multiple connections
+ Single connection
+ Stability — 60 s download
+ A single TCP connection shows how much one stream can use; multiple connections better saturate fast links. The stability test downloads for a full minute to show how steady the line is.
+
+ Test mode
+ Standard
+ Single connection
+ Stability
+ Bufferbloat
+ Latency under download
+ Latency under upload
+ Stability
+ Minimum
+ Maximum
+ Average
+ Variation
+ Mobile network
+
+ Compare servers
+ Runs a short download test against the fastest servers.
+ Run comparison
+ Testing %1$s…
+ Use this server
+
+ Filter
+ All
+ Network
+ Server
+ Period
+ Last 7 days
+ Last 30 days
+ Export CSV
+ Export JSON
+ Trends
+
+ Scheduled tests
+ Off
+ Every 6 hours
+ Daily
+ Weekly
+ Runs a full test in the background and saves it to history. A run can use ~100 MB of data on fast connections.
+ Scheduled
+ Test results
+ Speed test finished
+
+ Diagnostics
+ Diagnostic report
+ Summary of the app, device and last test for bug reports. Your IP address is not included.
+ Attach this report to a GitHub issue so the problem can be reproduced.
+ Share
+
+ Bufferbloat is how much your latency grows when the line is fully loaded — it is what makes calls and games stutter while something is downloading. A and better means the connection stays responsive; C or worse is noticeable. To improve it, enable smart queue management (SQM/QoS with fq_codel or CAKE) on your router and set its bandwidth to about 95 %% of the measured speed.
diff --git a/Speedtest-Android/app/src/main/res/values/styles.xml b/Speedtest-Android/app/src/main/res/values/styles.xml
deleted file mode 100644
index f4fd33f..0000000
--- a/Speedtest-Android/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/Speedtest-Android/app/src/main/res/values/themes.xml b/Speedtest-Android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..bc75e80
--- /dev/null
+++ b/Speedtest-Android/app/src/main/res/values/themes.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/Speedtest-Android/app/src/main/res/xml/file_paths.xml b/Speedtest-Android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..83ed5cc
--- /dev/null
+++ b/Speedtest-Android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/Speedtest-Android/app/src/main/res/xml/locales_config.xml b/Speedtest-Android/app/src/main/res/xml/locales_config.xml
new file mode 100644
index 0000000..c254888
--- /dev/null
+++ b/Speedtest-Android/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/CustomServerFactoryTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/CustomServerFactoryTest.kt
new file mode 100644
index 0000000..dbcedb6
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/CustomServerFactoryTest.kt
@@ -0,0 +1,68 @@
+package org.librespeed.speedtest.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+class CustomServerFactoryTest {
+
+ @Test
+ fun `https url with sub-path moves the path into endpoints`() {
+ val testPoint = CustomServerFactory.create("Test", "https://example.com/backend")
+ assertEquals("https://example.com", testPoint.server)
+ assertEquals("backend/garbage.php", testPoint.dlURL)
+ assertEquals("backend/empty.php", testPoint.pingURL)
+ assertEquals("backend/getIP.php", testPoint.getIpURL)
+ }
+
+ @Test
+ fun `url without scheme falls back to protocol relative`() {
+ val testPoint = CustomServerFactory.create("Test", "example.com")
+ assertEquals("//example.com", testPoint.server)
+ assertEquals("garbage.php", testPoint.dlURL)
+ }
+
+ @Test
+ fun `http scheme and port are preserved`() {
+ val testPoint = CustomServerFactory.create("Test", "http://example.com:8080")
+ assertEquals("http://example.com:8080", testPoint.server)
+ }
+
+ @Test
+ fun `ipv6 literal stays bracketed`() {
+ val testPoint = CustomServerFactory.create("Test", "https://[2001:db8::1]:8443/speed")
+ assertEquals("https://[2001:db8::1]:8443", testPoint.server)
+ assertEquals("speed/garbage.php", testPoint.dlURL)
+ }
+
+ @Test
+ fun `blank name is rejected`() {
+ assertThrows(IllegalArgumentException::class.java) {
+ CustomServerFactory.create(" ", "https://example.com")
+ }
+ }
+
+ @Test
+ fun `invalid url is rejected`() {
+ assertThrows(IllegalArgumentException::class.java) {
+ CustomServerFactory.create("Test", "not a url")
+ }
+ }
+
+ @Test
+ fun `urls without a usable host are rejected`() {
+ listOf("https://", "http:///path", "https://:8080", "%%%").forEach { url ->
+ assertThrows(url, IllegalArgumentException::class.java) {
+ CustomServerFactory.create("Test", url)
+ }
+ }
+ }
+
+ @Test
+ fun `whitespace and trailing slashes are trimmed`() {
+ val testPoint = CustomServerFactory.create("Test", " https://example.com/backend/// ")
+ assertEquals("https://example.com", testPoint.server)
+ assertEquals("backend/garbage.php", testPoint.dlURL)
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/GeoDistanceTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/GeoDistanceTest.kt
new file mode 100644
index 0000000..17dc5ac
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/GeoDistanceTest.kt
@@ -0,0 +1,40 @@
+package org.librespeed.speedtest.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class GeoDistanceTest {
+
+ @Test
+ fun `distance between Prague and Brno is about 185 km`() {
+ val km = GeoDistance.km(50.0755, 14.4378, 49.1951, 16.6068)
+ assertEquals(185.0, km, 10.0)
+ }
+
+ @Test
+ fun `distance to the same point is zero`() {
+ assertEquals(0.0, GeoDistance.km(50.0, 14.0, 50.0, 14.0), 0.001)
+ }
+
+ @Test
+ fun `clean name strips the sponsor`() {
+ assertEquals("Prague, Czech Republic", GeoDistance.cleanName("Prague, Czech Republic (CESNET)"))
+ }
+
+ @Test
+ fun `clean name keeps names without sponsor`() {
+ assertEquals("Helsinki, Finland", GeoDistance.cleanName("Helsinki, Finland"))
+ }
+
+ @Test
+ fun `sponsor is extracted from parentheses`() {
+ assertEquals("CESNET", GeoDistance.sponsor("Prague, Czech Republic (CESNET)"))
+ }
+
+ @Test
+ fun `sponsor is null without parentheses`() {
+ assertNull(GeoDistance.sponsor("Helsinki, Finland"))
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/HistoryMigrationTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/HistoryMigrationTest.kt
new file mode 100644
index 0000000..e2d84e0
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/HistoryMigrationTest.kt
@@ -0,0 +1,123 @@
+package org.librespeed.speedtest.data
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.sqlite.SQLiteDatabase
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+/** Users with old measurements must never lose their history on an app update. */
+@RunWith(AndroidJUnit4::class)
+//robolectric does not support SDK 36 yet
+@Config(sdk = [35])
+class HistoryMigrationTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ @Before
+ fun cleanSlate() {
+ context.deleteDatabase("history.db")
+ }
+
+ private fun createLegacyDatabase(version: Int) {
+ val path = context.getDatabasePath("history.db").apply { parentFile?.mkdirs() }
+ val db = SQLiteDatabase.openOrCreateDatabase(path, null)
+ val extraColumns = buildString {
+ if (version >= 2) append(", networkType TEXT, dlSamples TEXT, ulSamples TEXT")
+ if (version >= 3) append(", duration INTEGER NOT NULL DEFAULT 0")
+ }
+ db.execSQL(
+ "CREATE TABLE history (" +
+ "id INTEGER PRIMARY KEY AUTOINCREMENT," +
+ "date INTEGER NOT NULL," +
+ "server TEXT NOT NULL," +
+ "ping REAL NOT NULL," +
+ "jitter REAL NOT NULL," +
+ "download REAL NOT NULL," +
+ "upload REAL NOT NULL," +
+ "loss REAL NOT NULL," +
+ "ipInfo TEXT," +
+ "ipVersion INTEGER NOT NULL DEFAULT 0," +
+ "shareUrl TEXT" +
+ extraColumns + ")"
+ )
+ db.insert(
+ "history", null,
+ ContentValues().apply {
+ put("date", 1_700_000_000_000)
+ put("server", "Prague, Czech Republic (CESNET)")
+ put("ping", 8.5)
+ put("jitter", 1.2)
+ put("download", 512.25)
+ put("upload", 256.5)
+ put("loss", 0.0)
+ put("ipInfo", "203.0.113.7 - Example ISP")
+ put("ipVersion", 4)
+ put("shareUrl", "https://librespeed.example/results/?id=old1")
+ if (version >= 2) {
+ put("networkType", "Wi-Fi 6")
+ put("dlSamples", "[500.0,510.0]")
+ put("ulSamples", "[250.0,260.0]")
+ }
+ if (version >= 3) put("duration", 20_500)
+ }
+ )
+ db.version = version
+ db.close()
+ }
+
+ private fun assertLegacyRowSurvives(fromVersion: Int) {
+ createLegacyDatabase(fromVersion)
+ val entries = HistoryDatabase(context).readAll()
+ assertEquals(1, entries.size)
+ val entry = entries.single()
+ assertEquals(1_700_000_000_000, entry.date)
+ assertEquals("Prague, Czech Republic (CESNET)", entry.server)
+ assertEquals(512.25, entry.download, 0.0)
+ assertEquals(256.5, entry.upload, 0.0)
+ assertEquals(8.5, entry.ping, 0.0)
+ assertEquals(4, entry.ipVersion)
+ assertEquals("https://librespeed.example/results/?id=old1", entry.shareUrl)
+ //columns added later fall back to their defaults
+ assertEquals(null, entry.mode)
+ assertEquals(-1.0, entry.loadedDown, 0.0)
+ assertEquals(-1.0, entry.loadedUp, 0.0)
+ if (fromVersion >= 2) {
+ assertEquals("Wi-Fi 6", entry.networkType)
+ assertEquals(listOf(500.0, 510.0), entry.downloadSamples)
+ }
+ if (fromVersion >= 3) assertEquals(20_500, entry.durationMs)
+ }
+
+ @Test
+ fun `v1 history survives the upgrade to v4`() = assertLegacyRowSurvives(1)
+
+ @Test
+ fun `v2 history survives the upgrade to v4`() = assertLegacyRowSurvives(2)
+
+ @Test
+ fun `v3 history survives the upgrade to v4`() = assertLegacyRowSurvives(3)
+
+ @Test
+ fun `new entries round-trip through the upgraded database`() {
+ createLegacyDatabase(1)
+ val database = HistoryDatabase(context)
+ val id = database.insert(
+ HistoryEntry(
+ date = 2, server = "s", ping = 1.0, jitter = 1.0, download = 2.0, upload = 3.0,
+ loss = -1.0, ipInfo = null, ipVersion = 6, shareUrl = null,
+ mode = "stability", loadedDown = 12.5, loadedUp = 30.0, networkDetail = "op · signal 3/4"
+ )
+ )
+ val entry = database.read(id)!!
+ assertEquals("stability", entry.mode)
+ assertEquals(12.5, entry.loadedDown, 0.0)
+ assertEquals("op · signal 3/4", entry.networkDetail)
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/NetworkInfoTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/NetworkInfoTest.kt
new file mode 100644
index 0000000..2bc32cd
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/NetworkInfoTest.kt
@@ -0,0 +1,83 @@
+package org.librespeed.speedtest.data
+
+import android.Manifest
+import android.app.Application
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.telephony.TelephonyManager
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.shadows.ShadowNetworkCapabilities
+
+@RunWith(AndroidJUnit4::class)
+//robolectric does not support SDK 36 yet
+@Config(sdk = [35])
+class NetworkInfoTest {
+
+ private val context: Application = ApplicationProvider.getApplicationContext()
+
+ private fun setTransport(transport: Int) {
+ val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val capabilities = ShadowNetworkCapabilities.newInstance()
+ shadowOf(capabilities).addTransportType(transport)
+ shadowOf(connectivity).setNetworkCapabilities(connectivity.activeNetwork, capabilities)
+ }
+
+ @Test
+ fun `wifi is reported as wifi`() {
+ setTransport(NetworkCapabilities.TRANSPORT_WIFI)
+ assertEquals("Wi-Fi", NetworkInfo.describe(context))
+ }
+
+ @Test
+ fun `ethernet is reported as ethernet`() {
+ setTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
+ assertEquals("Ethernet", NetworkInfo.describe(context))
+ }
+
+ @Test
+ fun `cellular without the phone permission stays generic`() {
+ setTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
+ assertEquals("Cellular", NetworkInfo.describe(context))
+ }
+
+ @Test
+ fun `cellular with the permission reports the generation`() {
+ setTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
+ shadowOf(context).grantPermissions(Manifest.permission.READ_PHONE_STATE)
+ val telephony = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
+ shadowOf(telephony).setDataNetworkType(TelephonyManager.NETWORK_TYPE_NR)
+ assertEquals("5G", NetworkInfo.describe(context))
+ shadowOf(telephony).setDataNetworkType(TelephonyManager.NETWORK_TYPE_LTE)
+ assertEquals("4G LTE", NetworkInfo.describe(context))
+ shadowOf(telephony).setDataNetworkType(TelephonyManager.NETWORK_TYPE_UMTS)
+ assertEquals("3G", NetworkInfo.describe(context))
+ shadowOf(telephony).setDataNetworkType(TelephonyManager.NETWORK_TYPE_EDGE)
+ assertEquals("2G", NetworkInfo.describe(context))
+ shadowOf(telephony).setDataNetworkType(TelephonyManager.NETWORK_TYPE_UNKNOWN)
+ assertEquals("Cellular", NetworkInfo.describe(context))
+ }
+
+ @Test
+ fun `no active network means no description`() {
+ val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ shadowOf(connectivity).setNetworkCapabilities(connectivity.activeNetwork, null)
+ assertNull(NetworkInfo.describe(context))
+ }
+
+ @Test
+ fun `cellular detection helper matches the transport`() {
+ setTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
+ assertEquals(true, NetworkInfo.isCellular(context))
+ setTransport(NetworkCapabilities.TRANSPORT_WIFI)
+ assertEquals(false, NetworkInfo.isCellular(context))
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/ServerKeyTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/ServerKeyTest.kt
new file mode 100644
index 0000000..8d218b7
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/ServerKeyTest.kt
@@ -0,0 +1,26 @@
+package org.librespeed.speedtest.data
+
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class ServerKeyTest {
+
+ private fun testPoint(name: String, server: String) =
+ TestPoint(name, server, "garbage.php", "empty.php", "empty.php", "getIP.php")
+
+ @Test
+ fun `servers without list id fall back to name and host`() {
+ assertEquals("Prague|//a.example", testPoint("Prague", "//a.example").key())
+ }
+
+ @Test
+ fun `different hosts give different keys`() {
+ assertNotEquals(
+ testPoint("Prague", "//a.example").key(),
+ testPoint("Prague", "//b.example").key()
+ )
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/TestStatsTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/TestStatsTest.kt
new file mode 100644
index 0000000..eb09d08
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/data/TestStatsTest.kt
@@ -0,0 +1,80 @@
+package org.librespeed.speedtest.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TestStatsTest {
+
+ private fun grade(delta: Double): String? = TestStats.bufferbloatGrade(10.0, 10.0 + delta)
+
+ @Test
+ fun `grade boundaries fall into the documented buckets`() {
+ assertEquals("A+", grade(0.0))
+ assertEquals("A+", grade(5.0))
+ assertEquals("A", grade(5.001))
+ assertEquals("A", grade(30.0))
+ assertEquals("B", grade(30.001))
+ assertEquals("B", grade(60.0))
+ assertEquals("C", grade(60.001))
+ assertEquals("C", grade(200.0))
+ assertEquals("D", grade(200.001))
+ assertEquals("D", grade(400.0))
+ assertEquals("F", grade(400.001))
+ }
+
+ @Test
+ fun `loaded latency below idle is still an A+`() {
+ assertEquals("A+", TestStats.bufferbloatGrade(50.0, 20.0))
+ }
+
+ @Test
+ fun `sentinel and invalid inputs give no grade`() {
+ assertNull(TestStats.bufferbloatGrade(-1.0, 100.0))
+ assertNull(TestStats.bufferbloatGrade(10.0, -1.0))
+ assertNull(TestStats.bufferbloatGrade(-1.0, -1.0))
+ assertNull(TestStats.bufferbloatGrade(Double.NaN, 100.0))
+ assertNull(TestStats.bufferbloatGrade(10.0, Double.NaN))
+ assertNull(TestStats.bufferbloatGrade(10.0, Double.POSITIVE_INFINITY))
+ }
+
+ @Test
+ fun `stability needs at least five usable samples`() {
+ assertNull(TestStats.stability(emptyList()))
+ assertNull(TestStats.stability(List(4) { 100.0 }))
+ assertNull(TestStats.stability(List(4) { 100.0 } + Double.NaN))
+ }
+
+ @Test
+ fun `stability of a flat line has zero variation`() {
+ val stability = TestStats.stability(List(10) { 100.0 })!!
+ assertEquals(100.0, stability.min, 0.0)
+ assertEquals(100.0, stability.max, 0.0)
+ assertEquals(100.0, stability.average, 0.0)
+ assertEquals(0, stability.variationPct)
+ }
+
+ @Test
+ fun `stability reports the spread of a varying line`() {
+ val stability = TestStats.stability(listOf(50.0, 100.0, 150.0, 100.0, 100.0))!!
+ assertEquals(50.0, stability.min, 0.0)
+ assertEquals(150.0, stability.max, 0.0)
+ assertEquals(100.0, stability.average, 0.0)
+ assertEquals(32, stability.variationPct)
+ }
+
+ @Test
+ fun `all-zero and non-finite samples give no stability`() {
+ assertNull(TestStats.stability(List(10) { 0.0 }))
+ assertNull(TestStats.stability(List(10) { Double.NaN }))
+ assertNull(TestStats.stability(List(10) { Double.POSITIVE_INFINITY }))
+ }
+
+ @Test
+ fun `negative samples are ignored, not averaged in`() {
+ val stability = TestStats.stability(listOf(-50.0, 100.0, 100.0, 100.0, 100.0, 100.0))!!
+ assertEquals(100.0, stability.average, 0.0)
+ assertEquals(100.0, stability.min, 0.0)
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/ConnectionTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/ConnectionTest.kt
new file mode 100644
index 0000000..8a36a13
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/ConnectionTest.kt
@@ -0,0 +1,57 @@
+package org.librespeed.speedtest.engine
+
+import com.fdossena.speedtest.core.base.Connection
+import com.fdossena.speedtest.core.ping.Pinger
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+class ConnectionTest {
+
+ @Test
+ fun userAgentHeaderIsSingleLine() {
+ Connection.setUserAgent("librespeed-android/2.0.0 (android 16; arm64-v8a; raven)")
+ val server = MockWebServer()
+ server.enqueue(MockResponse().setResponseCode(200))
+ server.start()
+ try {
+ val connection = Connection(server.url("/").toString().trimEnd('/'))
+ connection.GET("/empty", true)
+ val recorded = server.takeRequest(10, TimeUnit.SECONDS)!!
+ val userAgent = recorded.getHeader("User-Agent")
+ assertEquals("librespeed-android/2.0.0 (android 16; arm64-v8a; raven)", userAgent)
+ assertFalse("User-Agent must not contain line breaks", userAgent!!.contains("\r") || userAgent.contains("\n"))
+ connection.close()
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun pingerAcceptsAnySuccessStatus() {
+ val server = MockWebServer()
+ //204 No Content is a perfectly fine answer for an empty ping endpoint
+ server.enqueue(MockResponse().setResponseCode(204))
+ server.start()
+ try {
+ val pong = CountDownLatch(1)
+ object : Pinger(Connection(server.url("/").toString().trimEnd('/')), "/empty") {
+ override fun onPong(ns: Long): Boolean {
+ pong.countDown()
+ return false
+ }
+
+ override fun onError(err: String?) = Unit
+ }
+ assertTrue("2xx response should count as a pong", pong.await(10, TimeUnit.SECONDS))
+ } finally {
+ server.shutdown()
+ }
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/LibrespeedIntegrationTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/LibrespeedIntegrationTest.kt
new file mode 100644
index 0000000..eca3af3
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/LibrespeedIntegrationTest.kt
@@ -0,0 +1,73 @@
+package org.librespeed.speedtest.engine
+
+import com.fdossena.speedtest.core.config.SpeedtestConfig
+import com.fdossena.speedtest.core.config.TelemetryConfig
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import com.fdossena.speedtest.core.worker.SpeedtestWorker
+import org.json.JSONObject
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
+import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * Runs the whole engine against a real LibreSpeed backend. Skipped unless the
+ * LIBRESPEED_URL environment variable points to one (CI starts the official
+ * docker image for this).
+ */
+class LibrespeedIntegrationTest {
+
+ @Test
+ fun fullTestAgainstRealBackend() {
+ val base = System.getenv("LIBRESPEED_URL")
+ assumeTrue("LIBRESPEED_URL not set; skipping the integration test", !base.isNullOrBlank())
+
+ val testPoint = TestPoint(
+ "docker", base!!.trimEnd('/'),
+ "/backend/garbage.php", "/backend/empty.php", "/backend/empty.php", "/backend/getIP.php"
+ )
+ val config = SpeedtestConfig(
+ JSONObject()
+ .put("test_order", "IP_D_U")
+ .put("time_dl_max", 3)
+ .put("time_ul_max", 3)
+ .put("time_auto", false)
+ .put("count_ping", 3)
+ )
+ var download = -1.0
+ var upload = -1.0
+ var ping = -1.0
+ var failure: String? = null
+ val done = CountDownLatch(1)
+ object : SpeedtestWorker(testPoint, config, TelemetryConfig()) {
+ override fun onDownloadUpdate(dl: Double, progress: Double) {
+ if (dl > 0) download = dl
+ }
+
+ override fun onUploadUpdate(ul: Double, progress: Double) {
+ if (ul > 0) upload = ul
+ }
+
+ override fun onPingJitterUpdate(p: Double, jitter: Double, progress: Double) {
+ if (p > 0) ping = p
+ }
+
+ override fun onLossUpdate(loss: Double) = Unit
+ override fun onIPInfoUpdate(ipInfo: String?) = Unit
+ override fun onTestIDReceived(id: String?, shareURLTemplate: String?) = Unit
+ override fun onEnd() = done.countDown()
+
+ override fun onCriticalFailure(err: String?) {
+ failure = err
+ done.countDown()
+ }
+ }
+ assertTrue("engine did not finish in time", done.await(120, TimeUnit.SECONDS))
+ assertTrue("critical failure: $failure", failure == null)
+ assertTrue("download should be measured, got $download", download > 0)
+ assertTrue("upload should be measured, got $upload", upload > 0)
+ assertTrue("ping should be measured, got $ping", ping > 0)
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/TelemetryPrivacyTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/TelemetryPrivacyTest.kt
new file mode 100644
index 0000000..dc7dfcc
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/TelemetryPrivacyTest.kt
@@ -0,0 +1,106 @@
+package org.librespeed.speedtest.engine
+
+import com.fdossena.speedtest.core.config.SpeedtestConfig
+import com.fdossena.speedtest.core.config.TelemetryConfig
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import com.fdossena.speedtest.core.worker.SpeedtestWorker
+import okhttp3.mockwebserver.Dispatcher
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okhttp3.mockwebserver.RecordedRequest
+import okio.Buffer
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Collections
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * Regression tests for the privacy contract: with telemetry disabled the engine
+ * must never call a telemetry endpoint; with telemetry enabled it reports to the
+ * tested server exactly once.
+ */
+class TelemetryPrivacyTest {
+
+ private fun quickConfig() = SpeedtestConfig(
+ JSONObject()
+ .put("test_order", "IP_D_U")
+ .put("time_dl_max", 1)
+ .put("time_ul_max", 1)
+ .put("time_auto", false)
+ .put("count_ping", 1)
+ .put("dl_parallelStreams", 1)
+ .put("ul_parallelStreams", 1)
+ )
+
+ private fun runWorker(server: MockWebServer, telemetry: TelemetryConfig): List {
+ val paths = Collections.synchronizedList(mutableListOf())
+ server.dispatcher = object : Dispatcher() {
+ override fun dispatch(request: RecordedRequest): MockResponse {
+ val path = request.path ?: ""
+ paths.add(path)
+ return when {
+ path.contains("garbage") -> MockResponse().setBody(Buffer().write(ByteArray(65536)))
+ path.contains("empty") -> MockResponse().setResponseCode(200)
+ path.contains("getIP") -> MockResponse().setBody("127.0.0.1 - Test ISP")
+ //the engine reads the id line by line, so it needs the newline
+ path.contains("telemetry") -> MockResponse().setBody("id 1234\n")
+ else -> MockResponse().setResponseCode(404)
+ }
+ }
+ }
+ val base = server.url("/").toString().trimEnd('/')
+ val testPoint = TestPoint("test", base, "/garbage", "/empty", "/empty", "/getIP")
+ val done = CountDownLatch(1)
+ val worker = object : SpeedtestWorker(testPoint, quickConfig(), telemetry) {
+ override fun onDownloadUpdate(dl: Double, progress: Double) = Unit
+ override fun onUploadUpdate(ul: Double, progress: Double) = Unit
+ override fun onPingJitterUpdate(ping: Double, jitter: Double, progress: Double) = Unit
+ override fun onLossUpdate(loss: Double) = Unit
+ override fun onIPInfoUpdate(ipInfo: String?) = Unit
+ override fun onTestIDReceived(id: String?, shareURLTemplate: String?) = Unit
+ override fun onEnd() = done.countDown()
+ override fun onCriticalFailure(err: String?) = done.countDown()
+ }
+ assertTrue("worker did not finish in time", done.await(60, TimeUnit.SECONDS))
+ worker.abort()
+ return paths.toList()
+ }
+
+ @Test
+ fun disabledTelemetrySendsNothing() {
+ val server = MockWebServer()
+ server.start()
+ try {
+ val paths = runWorker(server, TelemetryConfig())
+ assertEquals(
+ "no telemetry request may leave the device when telemetry is off",
+ emptyList(),
+ paths.filter { it.contains("telemetry") }
+ )
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun enabledTelemetryReportsToTestedServerOnce() {
+ val server = MockWebServer()
+ server.start()
+ try {
+ val telemetry = TelemetryConfig(TelemetryConfig.LEVEL_FULL, null, null, null)
+ val paths = runWorker(server, telemetry)
+ val telemetryCalls = paths.filter { it.contains("telemetry") }
+ assertEquals("exactly one telemetry submission expected", 1, telemetryCalls.size)
+ assertTrue(
+ "telemetry must go to the tested server results backend",
+ telemetryCalls.single().contains("/results/telemetry.php")
+ )
+ } finally {
+ server.shutdown()
+ }
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/TestEngineTelemetryTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/TestEngineTelemetryTest.kt
new file mode 100644
index 0000000..9d1cc82
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/engine/TestEngineTelemetryTest.kt
@@ -0,0 +1,140 @@
+package org.librespeed.speedtest.engine
+
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.fdossena.speedtest.core.Speedtest
+import com.fdossena.speedtest.core.serverSelector.TestPoint
+import okhttp3.mockwebserver.Dispatcher
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okhttp3.mockwebserver.RecordedRequest
+import okio.Buffer
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import java.util.Collections
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * The telemetry contract tested through the real application path:
+ * TestEngine reads its bundled config assets and builds the TelemetryConfig,
+ * exactly as the app does — not just the engine worker in isolation.
+ */
+@RunWith(AndroidJUnit4::class)
+//robolectric does not support SDK 36 yet
+@Config(sdk = [35])
+class TestEngineTelemetryTest {
+
+ //short phases so the suite stays fast; the telemetry path is untouched
+ private val quickTimes = JSONObject()
+ .put("time_dl_max", 2)
+ .put("time_ul_max", 2)
+ .put("time_auto", false)
+ .put("count_ping", 2)
+ .put("dl_parallelStreams", 1)
+ .put("ul_parallelStreams", 1)
+
+ private class Recorded : Dispatcher() {
+ val paths = Collections.synchronizedList(mutableListOf())
+
+ override fun dispatch(request: RecordedRequest): MockResponse {
+ val path = request.path ?: ""
+ paths.add(path)
+ return when {
+ path.contains("garbage") -> MockResponse().setBody(Buffer().write(ByteArray(65536)))
+ path.contains("empty") -> MockResponse().setResponseCode(200)
+ path.contains("getIP") -> MockResponse().setBody("127.0.0.1 - Test ISP\n")
+ path.contains("telemetry") -> MockResponse().setBody("id 1234\n")
+ else -> MockResponse().setResponseCode(404)
+ }
+ }
+ }
+
+ private class NoOpHandler(private val done: CountDownLatch) : Speedtest.SpeedtestHandler() {
+ override fun onDownloadUpdate(dl: Double, progress: Double) = Unit
+ override fun onUploadUpdate(ul: Double, progress: Double) = Unit
+ override fun onPingJitterUpdate(ping: Double, jitter: Double, progress: Double) = Unit
+ override fun onLossUpdate(loss: Double) = Unit
+ override fun onIPInfoUpdate(ipInfo: String?) = Unit
+ override fun onTestIDReceived(id: String?, shareURLTemplate: String?) = Unit
+ override fun onEnd() = done.countDown()
+ override fun onCriticalFailure(err: String?) = done.countDown()
+ }
+
+ private fun runThroughEngine(telemetryEnabled: Boolean): List {
+ val server = MockWebServer()
+ val dispatcher = Recorded()
+ server.dispatcher = dispatcher
+ server.start()
+ try {
+ val base = server.url("/").toString().trimEnd('/')
+ val testPoint = TestPoint("test", base, "/garbage", "/empty", "/empty", "/getIP")
+ val engine = TestEngine(ApplicationProvider.getApplicationContext())
+ engine.prepare(listOf(testPoint), testPoint, telemetryEnabled, configOverrides = quickTimes)
+ val done = CountDownLatch(1)
+ engine.start(NoOpHandler(done))
+ assertTrue("engine did not finish in time", done.await(360, TimeUnit.SECONDS))
+ return dispatcher.paths.toList()
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun `the app default sends no telemetry`() {
+ val paths = runThroughEngine(telemetryEnabled = false)
+ assertEquals(emptyList(), paths.filter { it.contains("telemetry") })
+ }
+
+ @Test
+ fun `enabling the switch submits exactly once to the tested server`() {
+ val paths = runThroughEngine(telemetryEnabled = true)
+ val telemetry = paths.filter { it.contains("telemetry") }
+ assertEquals(1, telemetry.size)
+ assertTrue(telemetry.single().contains("/results/telemetry.php"))
+ }
+
+ @Test
+ fun `abort before start is safe and the engine can run afterwards`() {
+ val server = MockWebServer()
+ server.dispatcher = Recorded()
+ server.start()
+ try {
+ val base = server.url("/").toString().trimEnd('/')
+ val testPoint = TestPoint("test", base, "/garbage", "/empty", "/empty", "/getIP")
+ val engine = TestEngine(ApplicationProvider.getApplicationContext())
+ engine.abort() //nothing prepared yet — must not throw
+ engine.prepare(listOf(testPoint), testPoint, telemetryEnabled = false, configOverrides = quickTimes)
+ val done = CountDownLatch(1)
+ engine.start(NoOpHandler(done))
+ assertTrue(done.await(360, TimeUnit.SECONDS))
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun `abort during the run finishes promptly`() {
+ val server = MockWebServer()
+ server.dispatcher = Recorded()
+ server.start()
+ try {
+ val base = server.url("/").toString().trimEnd('/')
+ val testPoint = TestPoint("test", base, "/garbage", "/empty", "/empty", "/getIP")
+ val engine = TestEngine(ApplicationProvider.getApplicationContext())
+ engine.prepare(listOf(testPoint), testPoint, telemetryEnabled = false, configOverrides = quickTimes)
+ val done = CountDownLatch(1)
+ engine.start(NoOpHandler(done))
+ Thread.sleep(500)
+ engine.abort()
+ assertTrue("aborted engine must still finish", done.await(120, TimeUnit.SECONDS))
+ } finally {
+ server.shutdown()
+ }
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/share/HistoryExportTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/share/HistoryExportTest.kt
new file mode 100644
index 0000000..8af328f
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/share/HistoryExportTest.kt
@@ -0,0 +1,87 @@
+package org.librespeed.speedtest.share
+
+import org.json.JSONArray
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.librespeed.speedtest.data.HistoryEntry
+
+class HistoryExportTest {
+
+ private val plain = HistoryEntry(
+ id = 1, date = 1_700_000_000_000, server = "Prague, Czech Republic (CESNET)",
+ ping = 8.5, jitter = 1.25, download = 512.256, upload = 256.5, loss = 0.5,
+ ipInfo = null, ipVersion = 4, shareUrl = null, networkType = "Wi-Fi 6",
+ durationMs = 20_500, mode = "standard", loadedDown = 12.0, loadedUp = 34.5
+ )
+
+ //commas, quotes and Czech diacritics in one name
+ private val tricky = plain.copy(
+ id = 2,
+ server = "Ústí nad Labem, \"CZ\", žluťoučký",
+ networkType = null,
+ ipVersion = 0,
+ loss = -1.0,
+ loadedDown = -1.0,
+ loadedUp = -1.0,
+ durationMs = 0,
+ mode = null
+ )
+
+ @Test
+ fun `csv escapes quotes and commas and keeps unicode`() {
+ val csv = HistoryExport.buildCsv(listOf(tricky))
+ val dataLine = csv.lines()[1]
+ assertTrue(dataLine.contains("\"Ústí nad Labem, \"\"CZ\"\", žluťoučký\""))
+ }
+
+ @Test
+ fun `csv writes sentinel values as empty fields`() {
+ val csv = HistoryExport.buildCsv(listOf(tricky))
+ val fields = csv.lines()[1].split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)".toRegex())
+ assertEquals("", fields[2]) //network
+ assertEquals("", fields[3]) //protocol
+ assertEquals("", fields[8]) //loss
+ assertEquals("", fields[9]) //loaded down
+ assertEquals("", fields[10]) //loaded up
+ assertEquals("", fields[11]) //duration
+ assertEquals("", fields[12]) //mode
+ }
+
+ @Test
+ fun `csv formats numbers with two decimals and a dot`() {
+ val csv = HistoryExport.buildCsv(listOf(plain))
+ val fields = csv.lines()[1].split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)".toRegex())
+ assertEquals("512.26", fields[4])
+ assertEquals("256.50", fields[5])
+ assertEquals("8.50", fields[6])
+ assertEquals("20.50", fields[11])
+ }
+
+ @Test
+ fun `csv has a header and one line per entry`() {
+ val csv = HistoryExport.buildCsv(listOf(plain, tricky))
+ val lines = csv.trimEnd().lines()
+ assertEquals(3, lines.size)
+ assertTrue(lines[0].startsWith("date,server,network,protocol,download_mbps"))
+ }
+
+ @Test
+ fun `json is valid and omits absent fields`() {
+ val parsed = JSONArray(HistoryExport.buildJson(listOf(plain, tricky)))
+ assertEquals(2, parsed.length())
+ val first = parsed.getJSONObject(0)
+ assertEquals("Prague, Czech Republic (CESNET)", first.getString("server"))
+ assertEquals("IPv4", first.getString("protocol"))
+ assertEquals(0.5, first.getDouble("loss_pct"), 0.0)
+ val second = parsed.getJSONObject(1)
+ assertEquals("Ústí nad Labem, \"CZ\", žluťoučký", second.getString("server"))
+ assertFalse(second.has("protocol"))
+ assertFalse(second.has("loss_pct"))
+ assertFalse(second.has("network"))
+ assertFalse(second.has("loaded_down_ms"))
+ assertFalse(second.has("duration_ms"))
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/ui/ScreenshotTest.kt b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/ui/ScreenshotTest.kt
new file mode 100644
index 0000000..c2ffc82
--- /dev/null
+++ b/Speedtest-Android/app/src/test/java/org/librespeed/speedtest/ui/ScreenshotTest.kt
@@ -0,0 +1,116 @@
+package org.librespeed.speedtest.ui
+
+import android.content.Context
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onRoot
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.github.takahirom.roborazzi.RoborazziOptions
+import com.github.takahirom.roborazzi.captureRoboImage
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.librespeed.speedtest.data.HistoryDatabase
+import org.librespeed.speedtest.data.HistoryEntry
+import org.librespeed.speedtest.ui.result.ResultScreen
+import org.librespeed.speedtest.ui.settings.LicensesScreen
+import org.librespeed.speedtest.ui.theme.LibreSpeedTheme
+import org.librespeed.speedtest.ui.theme.ThemeMode
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Golden screenshots for the most tweaked screens; regenerate them with
+ * ./gradlew :app:testDebugUnitTest -Proborazzi.test.record=true
+ */
+@RunWith(AndroidJUnit4::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [35], qualifiers = "w448dp-h997dp-xhdpi")
+class ScreenshotTest {
+
+ @get:Rule
+ val compose = createComposeRule()
+
+ //small anti-aliasing differences between platforms must not fail the build
+ private val options = RoborazziOptions(
+ compareOptions = RoborazziOptions.CompareOptions(changeThreshold = 0.01f)
+ )
+
+ @Before
+ fun pinEnvironment() {
+ Locale.setDefault(Locale.US)
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
+ }
+
+ @Test
+ fun resultScreen() {
+ val context = ApplicationProvider.getApplicationContext()
+ val id = HistoryDatabase(context).insert(
+ HistoryEntry(
+ date = 1_754_900_000_000,
+ server = "Prague, Czech Republic (CESNET)",
+ ping = 12.3,
+ jitter = 1.4,
+ download = 942.31,
+ upload = 487.12,
+ loss = 0.0,
+ ipInfo = "203.0.113.7 - Example ISP",
+ ipVersion = 4,
+ shareUrl = "https://librespeed.example/results/?id=abc123",
+ networkType = "Wi-Fi 6",
+ downloadSamples = List(60) { 900.0 + (it % 7) * 12 },
+ uploadSamples = List(60) { 460.0 + (it % 5) * 8 },
+ durationMs = 21_400,
+ mode = "standard",
+ loadedDown = 18.0,
+ loadedUp = 24.0
+ )
+ )
+ compose.setContent {
+ LibreSpeedTheme(mode = ThemeMode.DARK) {
+ ResultScreen(entryId = id, onBack = {}, onTestAgain = {}, onTestDetails = {}, onShare = {})
+ }
+ }
+ compose.waitUntil(10_000) {
+ compose.onAllNodesWithText("DOWNLOAD").fetchSemanticsNodes().isNotEmpty()
+ }
+ compose.onRoot().captureRoboImage("src/test/snapshots/result_screen.png", roborazziOptions = options)
+ }
+
+ @Test
+ fun settingsScreen() {
+ compose.setContent {
+ LibreSpeedTheme(mode = ThemeMode.DARK) {
+ org.librespeed.speedtest.ui.settings.SettingsScreen(
+ serverLabel = "Prague, Czech Republic (CESNET)",
+ serverPinned = true,
+ serverCount = 12,
+ onServersClick = {},
+ onLicensesClick = {}
+ )
+ }
+ }
+ compose.waitUntil(10_000) {
+ compose.onAllNodesWithText("Settings").fetchSemanticsNodes().isNotEmpty()
+ }
+ compose.onRoot().captureRoboImage("src/test/snapshots/settings_screen.png", roborazziOptions = options)
+ }
+
+ @Test
+ fun licensesScreen() {
+ compose.setContent {
+ LibreSpeedTheme(mode = ThemeMode.DARK) {
+ LicensesScreen(onBack = {})
+ }
+ }
+ compose.waitUntil(10_000) {
+ compose.onAllNodesWithText("LibreSpeed speedtest engine").fetchSemanticsNodes().isNotEmpty()
+ }
+ compose.onRoot().captureRoboImage("src/test/snapshots/licenses_screen.png", roborazziOptions = options)
+ }
+
+}
diff --git a/Speedtest-Android/app/src/test/snapshots/licenses_screen.png b/Speedtest-Android/app/src/test/snapshots/licenses_screen.png
new file mode 100644
index 0000000..0faebc7
Binary files /dev/null and b/Speedtest-Android/app/src/test/snapshots/licenses_screen.png differ
diff --git a/Speedtest-Android/app/src/test/snapshots/result_screen.png b/Speedtest-Android/app/src/test/snapshots/result_screen.png
new file mode 100644
index 0000000..7f0418b
Binary files /dev/null and b/Speedtest-Android/app/src/test/snapshots/result_screen.png differ
diff --git a/Speedtest-Android/app/src/test/snapshots/settings_screen.png b/Speedtest-Android/app/src/test/snapshots/settings_screen.png
new file mode 100644
index 0000000..2c0213d
Binary files /dev/null and b/Speedtest-Android/app/src/test/snapshots/settings_screen.png differ
diff --git a/Speedtest-Android/build.gradle b/Speedtest-Android/build.gradle
deleted file mode 100644
index 801467d..0000000
--- a/Speedtest-Android/build.gradle
+++ /dev/null
@@ -1,27 +0,0 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-
-buildscript {
- repositories {
- google()
- jcenter()
-
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:4.1.3'
-
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
-}
-
-allprojects {
- repositories {
- google()
- jcenter()
-
- }
-}
-
-task clean(type: Delete) {
- delete rootProject.buildDir
-}
diff --git a/Speedtest-Android/build.gradle.kts b/Speedtest-Android/build.gradle.kts
new file mode 100644
index 0000000..020183f
--- /dev/null
+++ b/Speedtest-Android/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
diff --git a/Speedtest-Android/gradle.properties b/Speedtest-Android/gradle.properties
index 82618ce..96093a6 100644
--- a/Speedtest-Android/gradle.properties
+++ b/Speedtest-Android/gradle.properties
@@ -1,15 +1,3 @@
-# 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=-Xmx1536m
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
-
-
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+org.gradle.caching=true
diff --git a/Speedtest-Android/gradle/libs.versions.toml b/Speedtest-Android/gradle/libs.versions.toml
new file mode 100644
index 0000000..9b74c4e
--- /dev/null
+++ b/Speedtest-Android/gradle/libs.versions.toml
@@ -0,0 +1,42 @@
+[versions]
+agp = "9.3.1"
+kotlin = "2.4.10"
+coreKtx = "1.19.0"
+activityCompose = "1.13.0"
+composeBom = "2026.06.01"
+lifecycle = "2.11.0"
+navigationCompose = "2.9.8"
+datastore = "1.2.1"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+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-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-compose-material3-window-size = { group = "androidx.compose.material3", name = "material3-window-size-class" }
+androidx-window = { group = "androidx.window", name = "window", version = "1.5.0" }
+androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version = "2.11.2" }
+androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version = "1.4.1" }
+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-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+junit = { group = "junit", name = "junit", version = "4.13.2" }
+mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version = "4.12.0" }
+json = { group = "org.json", name = "json", version = "20250107" }
+androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.3.0" }
+robolectric = { group = "org.robolectric", name = "robolectric", version = "4.15.1" }
+roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version = "1.44.0-alpha03" }
+roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version = "1.44.0-alpha03" }
+androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+licensee = { id = "app.cash.licensee", version = "1.13.0" }
diff --git a/Speedtest-Android/gradle/wrapper/gradle-wrapper.properties b/Speedtest-Android/gradle/wrapper/gradle-wrapper.properties
index 5f38c9b..1e922f4 100644
--- a/Speedtest-Android/gradle/wrapper/gradle-wrapper.properties
+++ b/Speedtest-Android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,7 @@
-#Mon Apr 12 15:03:57 CEST 2021
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip
diff --git a/Speedtest-Android/settings.gradle b/Speedtest-Android/settings.gradle
deleted file mode 100644
index 33b9b25..0000000
--- a/Speedtest-Android/settings.gradle
+++ /dev/null
@@ -1,2 +0,0 @@
-include ':app'
-rootProject.name='Speedtest-Android'
diff --git a/Speedtest-Android/settings.gradle.kts b/Speedtest-Android/settings.gradle.kts
new file mode 100644
index 0000000..3ca903b
--- /dev/null
+++ b/Speedtest-Android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "LibreSpeed"
+include(":app")
diff --git a/fastlane/metadata/android/cs-CZ/changelogs/10.txt b/fastlane/metadata/android/cs-CZ/changelogs/10.txt
new file mode 100644
index 0000000..099590c
--- /dev/null
+++ b/fastlane/metadata/android/cs-CZ/changelogs/10.txt
@@ -0,0 +1 @@
+Kompletní přepis: Material 3, tmavý režim, oblíbené a vlastní servery, historie, IPv6, čeština, moderní Android (API 36).
diff --git a/fastlane/metadata/android/cs-CZ/full_description.txt b/fastlane/metadata/android/cs-CZ/full_description.txt
new file mode 100644
index 0000000..a69ed33
--- /dev/null
+++ b/fastlane/metadata/android/cs-CZ/full_description.txt
@@ -0,0 +1,12 @@
+LibreSpeed měří rychlost vašeho připojení proti komunitním LibreSpeed serverům nebo vašemu vlastnímu.
+
+Funkce:
+
+- Stahování, odesílání, ping, jitter a přibližná ztráta paketů
+- Podpora IPv4 i IPv6
+- Výběr serveru s oblíbenými a vlastními servery
+- Historie testů se sdílením
+- Tmavý i světlý režim, design Material 3
+- Žádné reklamy ani sledování; telemetrie je přísně opt-in
+
+LibreSpeed je svobodný software pod licencí LGPL-3.0.
diff --git a/fastlane/metadata/android/cs-CZ/short_description.txt b/fastlane/metadata/android/cs-CZ/short_description.txt
new file mode 100644
index 0000000..e89eea8
--- /dev/null
+++ b/fastlane/metadata/android/cs-CZ/short_description.txt
@@ -0,0 +1 @@
+Svobodný a otevřený test rychlosti — bez reklam, telemetrie jen jako opt-in.
diff --git a/fastlane/metadata/android/cs-CZ/title.txt b/fastlane/metadata/android/cs-CZ/title.txt
new file mode 100644
index 0000000..4aee22a
--- /dev/null
+++ b/fastlane/metadata/android/cs-CZ/title.txt
@@ -0,0 +1 @@
+LibreSpeed
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/10.txt b/fastlane/metadata/android/en-US/changelogs/10.txt
new file mode 100644
index 0000000..16df699
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/10.txt
@@ -0,0 +1 @@
+Complete rewrite: Material 3 UI, dark theme, server favorites and custom servers, history, IPv6, Czech translation, modern Android (target API 36).
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
new file mode 100644
index 0000000..180b613
--- /dev/null
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -0,0 +1,12 @@
+LibreSpeed measures your internet connection speed against community-run LibreSpeed servers or your own self-hosted one.
+
+Features:
+
+- Download, upload, ping, jitter and approximate packet loss
+- IPv4 and IPv6 support
+- Server selection with favorites and custom self-hosted servers
+- Test history with sharing
+- Dark and light theme, Material 3 design
+- No ads, no tracking; telemetry is strictly opt-in
+
+LibreSpeed is free software licensed under LGPL-3.0.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
new file mode 100644
index 0000000..60a6048
--- /dev/null
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+Free and open source internet speed test — no ads, no tracking by default.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
new file mode 100644
index 0000000..4aee22a
--- /dev/null
+++ b/fastlane/metadata/android/en-US/title.txt
@@ -0,0 +1 @@
+LibreSpeed
\ No newline at end of file