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..54ab3bc
--- /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. Every request additionally carries the app's User-Agent header (application identifier and version, Android version, device product and CPU architecture) and the device language, which the server may log like any web server does. 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, Android version, device product, CPU architecture and language),
+- 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/assets/privacy_en.html b/Speedtest-Android/app/src/main/assets/privacy_en.html
index 053c477..f980452 100644
--- a/Speedtest-Android/app/src/main/assets/privacy_en.html
+++ b/Speedtest-Android/app/src/main/assets/privacy_en.html
@@ -17,38 +17,40 @@
Privacy Policy
-
This Speedtest app is configured with telemetry enabled.
-
What data we collect
+
LibreSpeed for Android does not collect, store or share any personal data by itself.
+
What happens during a test
- At the end of the test, the following data is collected and stored:
-
-
Test ID
-
Time of testing
-
Test results (download and upload speed, ping and jitter)
-
IP address
-
ISP information
-
Approximate location (inferred from IP address, not GPS)
-
Device manufacturer, model, Android version, and language
-
Test log (contains no personal information)
-
+ 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. Every request additionally carries the
+ app's User-Agent header (application identifier and version, Android version, device
+ product and CPU architecture) and the device language, which the server may log like any
+ web server does. The app displays your public IP address and ISP information as reported
+ by the tested server; this information stays on your device.
-
How we use the data
+
Telemetry (off by default)
- Data collected through this service is used to:
+ 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:
-
Allow sharing of test results (sharable image for forums, etc.)
-
To improve the service offered to you (for instance, to detect problems on our side)
+
the test result (download, upload, ping, jitter),
+
client information (application identifier and version, Android version, device product, CPU architecture and language),
+
your IP address and ISP information as seen by the server,
+
a technical log of the test run.
- No personal information is disclosed to third parties.
+ 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.
-
Your consent
+
Local data
- By starting the test, you consent to the terms of this privacy policy.
+ 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.
Data removal
- If you want to have your information deleted, you need to provide either the ID of the test or your IP address. This is the only way to identify your data, without this information we won't be able to comply with your request.
- Contact this email address for all deletion requests: TO BE FILLED BY DEVELOPER.
+ Telemetry you opted into is stored by the operator of the tested server; contact that
+ operator with the test ID shown in the app to have it removed.