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 Logo](https://github.com/adolfintel/speedtest-android/blob/master/.github/Readme-Logo.png?raw=true) - -# 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). + + + LibreSpeed + -The template is easy to configure, customize and distribute. - -## Try it +# LibreSpeed for Android -[Get it on F-Droid](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. +

+ Speed test result, dark theme + Test in progress, light theme + Settings +

## Features -* Download -* Upload -* Ping -* Jitter -* IP Address, ISP, distance from server (optional) -* Telemetry (optional) -* Results sharing (optional) -* Multiple Points of Test (optional) -![Screenshot](https://github.com/librespeed/speedtest-android/blob/master/.github/screenshots.png?raw=true) +* 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 -[![Donate with Liberapay](https://liberapay.com/assets/widgets/donate.svg)](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.

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..53f6a43 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 @@ -5,7 +5,6 @@ import org.json.JSONObject; import java.io.BufferedReader; -import java.io.InputStream; import java.io.InputStreamReader; import java.net.URL; import java.util.ArrayList; @@ -79,10 +78,10 @@ public void addTestPoints(JSONArray json){ private static class ServerListLoader { private static String read(String url){ + BufferedReader br=null; try{ URL u=new URL(url); - InputStream in=u.openStream(); - BufferedReader br=new BufferedReader(new InputStreamReader(u.openStream())); + br=new BufferedReader(new InputStreamReader(u.openStream())); String s=""; try{ for(;;){ @@ -90,11 +89,13 @@ private static String read(String url){ if(r==null) break; else s+=r; } }catch(Throwable t){} - br.close(); - in.close(); return s; }catch(Throwable t){ return null; + }finally{ + if(br!=null){ + try{br.close();}catch(Throwable t){} + } } } @@ -195,15 +196,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 +232,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 +247,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..a99dcb5 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 @@ -13,15 +13,31 @@ import java.util.Locale; import javax.net.SocketFactory; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; 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 String basePath=""; 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){ @@ -33,6 +49,7 @@ public Connection(String url, int connectTimeout, int soTimeout, int recvBuffer, URL u=new URL(url); host=u.getHost(); port=u.getPort(); + basePath=stripTrailingSlashes(u.getPath()); }catch(Throwable t){ throw new IllegalArgumentException("Malformed URL (HTTP)"); } @@ -42,6 +59,7 @@ public Connection(String url, int connectTimeout, int soTimeout, int recvBuffer, URL u=new URL(url); host=u.getHost(); port=u.getPort(); + basePath=stripTrailingSlashes(u.getPath()); }catch(Throwable t){ throw new IllegalArgumentException("Malformed URL (HTTPS)"); } @@ -52,24 +70,39 @@ public Connection(String url, int connectTimeout, int soTimeout, int recvBuffer, URL u=new URL("http:"+url); host=u.getHost(); port=u.getPort(); + basePath=stripTrailingSlashes(u.getPath()); }catch(Throwable t){ throw new IllegalArgumentException("Malformed URL (HTTP/HTTPS)"); } }else{ throw new IllegalArgumentException("Malformed URL (Unknown or unspecified protocol)"); } - try{ - if(mode == MODE_NOT_SET && tryHTTPS){ - SocketFactory factory = SSLSocketFactory.getDefault(); - socket=factory.createSocket(); + if(mode == MODE_NOT_SET && tryHTTPS){ + Socket s=new Socket(); + try{ if(connectTimeout>0){ - socket.connect(new InetSocketAddress(host, port==-1?443:port),connectTimeout); + s.connect(new InetSocketAddress(host, port==-1?443:port),connectTimeout); + s.setSoTimeout(connectTimeout); //bounds the handshake reads too }else{ - socket.connect(new InetSocketAddress(host, port==-1?443:port)); + s.connect(new InetSocketAddress(host, port==-1?443:port)); } + SSLSocketFactory factory=(SSLSocketFactory)SSLSocketFactory.getDefault(); + //wrapping with the hostname sets SNI; endpoint identification makes the + //handshake reject certificates that were not issued for this host + SSLSocket ssl=(SSLSocket)factory.createSocket(s,host,port==-1?443:port,true); + SSLParameters params=ssl.getSSLParameters(); + params.setEndpointIdentificationAlgorithm("HTTPS"); + ssl.setSSLParameters(params); + //the handshake must succeed before HTTPS is committed, otherwise a plain + //HTTP server would pass the TCP connect and block the HTTP fallback below + ssl.startHandshake(); + if(connectTimeout>0) s.setSoTimeout(0); + socket=ssl; mode=MODE_HTTPS; + }catch(Throwable t){ + try{s.close();}catch(Throwable t1){} } - }catch(Throwable t){} + } try{ if(mode == MODE_NOT_SET && tryHTTP){ SocketFactory factory = SocketFactory.getDefault(); @@ -144,13 +177,31 @@ public InputStreamReader getInputStreamReader(){ return isr; } + //RFC 7230 section 5.4: the Host header carries the port unless it is the scheme default + private String hostHeader(){ + if(port!=-1&&port!=(mode==MODE_HTTPS?443:80)) return host+":"+port; + return host; + } + + //endpoint paths from the server list are relative to the server URL's path, if it has one + private String resolvePath(String path){ + if(path.startsWith("/")) return path; + return basePath+"/"+path; + } + + private static String stripTrailingSlashes(String path){ + if(path==null) return ""; + while(path.endsWith("/")) path=path.substring(0,path.length()-1); + return path; + } + public void GET(String path, boolean keepAlive) throws Exception{ try{ - if(!path.startsWith("/")) path="/"+path; + path=resolvePath(path); 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("Host: "+hostHeader()+"\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"); @@ -163,11 +214,11 @@ public void GET(String path, boolean keepAlive) throws Exception{ public void POST(String path, boolean keepAlive, String contentType, long contentLength) throws Exception{ try{ - if(!path.startsWith("/")) path="/"+path; + path=resolvePath(path); 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("Host: "+hostHeader()+"\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 +252,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/download/DownloadStream.java b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/download/DownloadStream.java index b4acee5..d011c5e 100644 --- a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/download/DownloadStream.java +++ b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/download/DownloadStream.java @@ -9,11 +9,13 @@ public abstract class DownloadStream { private String server, path; private int ckSize; private int connectTimeout, soTimeout, recvBuffer, sendBuffer; - private Connection c=null; - private Downloader downloader; + private volatile Connection c=null; + private volatile Downloader downloader; private String errorHandlingMode= SpeedtestConfig.ONERROR_ATTEMPT_RESTART; - private long currentDownloaded=0, previouslyDownloaded=0; - private boolean stopASAP=false; + private volatile long currentDownloaded=0, previouslyDownloaded=0; + //ended means no downloader will ever (re)appear: hard failure or stopped + //before one was created; join() must not keep waiting past it + private volatile boolean stopASAP=false, ended=false; private Logger log; public DownloadStream(String server, String path, int ckSize, String errorHandlingMode, int connectTimeout, int soTimeout, int recvBuffer, int sendBuffer, Logger log){ @@ -41,6 +43,7 @@ public void run(){ try { c = new Connection(server, connectTimeout, soTimeout, recvBuffer, sendBuffer); if(stopASAP){ + ended=true; try{c.close();}catch (Throwable t){} return; } @@ -52,8 +55,10 @@ public void onProgress(long downloaded) { @Override public void onError(String err) { + if(stopASAP) return; log("A downloader died"); if(errorHandlingMode.equals(SpeedtestConfig.ONERROR_FAIL)){ + ended=true; DownloadStream.this.onError(err); return; } @@ -70,7 +75,10 @@ public void onError(String err) { if(errorHandlingMode.equals(SpeedtestConfig.ONERROR_MUST_RESTART)){ Utils.sleep(100); init(); - }else onError(t.toString()); + }else{ + ended=true; + onError(t.toString()); + } } } }.start(); @@ -82,6 +90,12 @@ public void onError(String err) { public void stopASAP(){ stopASAP=true; if(downloader !=null) downloader.stopASAP(); + //closing the connection unblocks a thread parked in a read or write, + //making the stop prompt instead of waiting out the socket timeout + Connection conn=c; + if(conn!=null){ + try{conn.close();}catch (Throwable t){} + } } public long getTotalDownloaded(){ @@ -95,8 +109,11 @@ public void resetDownloadCounter(){ } public void join(){ - while(downloader==null) Utils.sleep(0,100); - try{downloader.join();}catch (Throwable t){} + while(downloader==null&&!ended&&!stopASAP) Utils.sleep(1); + Downloader d=downloader; + if(d!=null){ + try{d.join();}catch (Throwable t){} + } } private void log(String s){ diff --git a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/download/Downloader.java b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/download/Downloader.java index a876ac9..502fd1a 100644 --- a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/download/Downloader.java +++ b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/download/Downloader.java @@ -9,8 +9,8 @@ public abstract class Downloader extends Thread{ private Connection c; private String path; private int ckSize; - private boolean stopASAP=false, resetASAP=false; - private long totDownloaded=0; + private volatile boolean stopASAP=false, resetASAP=false; + private volatile long totDownloaded=0; public Downloader(Connection c, String path, int ckSize){ this.c=c; @@ -38,6 +38,9 @@ public void run(){ if(stopASAP) break; int l=in.read(buf); if(stopASAP) break; + //an orderly close from the server would otherwise count -1 into the + //totals and turn this loop into a hot spin that never recovers + if(l<0) throw new Exception("Connection closed unexpectedly"); bytesLeft-=l; if(resetASAP){ totDownloaded=0; @@ -52,7 +55,9 @@ public void run(){ c.close(); }catch(Throwable t){ try{c.close();}catch(Throwable t1){} - onError(t.toString()); + //a stopped stream closes the connection to unblock this thread; + //that is a clean stop, not an error + if(!stopASAP) onError(t.toString()); } } @@ -70,4 +75,4 @@ public void resetDownloadCounter(){ public long getDownloaded() { return resetASAP?0:totDownloaded; } -} \ No newline at end of file +} diff --git a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/getIP/GetIP.java b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/getIP/GetIP.java index 51dd07f..d9310db 100644 --- a/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/getIP/GetIP.java +++ b/Speedtest-Android/app/src/main/java/com/fdossena/speedtest/core/getIP/GetIP.java @@ -16,7 +16,7 @@ public GetIP(Connection c, String path, boolean isp, String distance){ this.c=c; this.path=path; this.isp=isp; - if(!(distance==null||distance.equals(SpeedtestConfig.DISTANCE_KM)||distance.equals(SpeedtestConfig.DISTANCE_MILES))) throw new IllegalArgumentException("Distance must be null, mi or km"); + if(!(distance==null||distance.equals(SpeedtestConfig.DISTANCE_NO)||distance.equals(SpeedtestConfig.DISTANCE_KM)||distance.equals(SpeedtestConfig.DISTANCE_MILES))) throw new IllegalArgumentException("Distance must be null, no, mi or km"); this.distance=distance; start(); } @@ -26,7 +26,7 @@ public void run(){ String s=path; if(isp){ s+= Utils.url_sep(s)+"isp=true"; - if(!distance.equals(SpeedtestConfig.DISTANCE_NO)){ + if(distance!=null&&!distance.equals(SpeedtestConfig.DISTANCE_NO)){ s+=Utils.url_sep(s)+"distance="+distance; } } @@ -34,11 +34,19 @@ public void run(){ HashMap h=c.parseResponseHeaders(); BufferedReader br=new BufferedReader(c.getInputStreamReader()); if(h.get("content-length")!=null){ - //standard encoding - char[] buf=new char[Integer.parseInt(h.get("content-length"))]; - br.read(buf); - String data=new String(buf); - onDataReceived(data); + //standard encoding. content-length counts UTF-8 bytes but the shared reader + //(which may have buffered past the headers) yields chars, so read until the + //decoded chars account for the whole body instead of trusting a single read + int bytesExpected=Integer.parseInt(h.get("content-length")); + StringBuilder sb=new StringBuilder(); + int bytesReceived=0; + while(bytesReceived=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..4168078 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 @@ -15,7 +15,9 @@ public abstract class ServerSelector { private static final int NOT_STARTED=0, WORKING=1, DONE=2; private int timeout; private static final int PINGS=3, SLOW_THRESHOLD=500; - private boolean stopASAP=false; + //written under Speedtest's mutex, read from Pinger callback threads that + //never take it: without volatile an aborted selection keeps walking the list + private volatile boolean stopASAP=false; public ServerSelector(TestPoint[] servers, int timeout){ addTestPoints(servers); @@ -84,6 +86,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 +189,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 +222,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; @@ -228,37 +242,107 @@ public boolean onPong(long ns) { public void onDone() { } }; + //wait for the stream's terminal event rather than for whichever pinger + //thread is current: error recovery replaces that thread, and joining a + //replaced thread used to report loss for pings that were still being + //retried. the budget bounds a server that keeps erroring; past it, the + //unanswered pings count as lost + long connT=config.getPing_connectTimeout(), soT=config.getPing_soTimeout(); + long deadline=System.currentTimeMillis()+config.getCount_ping()*((connT>0?connT:2000)+(soT>0?soT:5000)+200); + while(!stopASAP&&!ps.hasEnded()&&System.currentTimeMillis()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; + //an aborted test transmits nothing, regardless of level: results are + //only submitted for tests that ran to completion + if(stopASAP) 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=new String[1]; + boolean localDelivered=submitTelemetry(backend.getServer(),base.isEmpty()?"results/telemetry.php":base+"/results/telemetry.php",localId); + if(localId[0]!=null){ + onTestIDReceived(localId[0],shareUrlTemplate(backend.getServer(),base.isEmpty()?"results/?id=%s":base+"/results/?id=%s")); + return; + } + //a server that accepted the POST but returned no usable id may still have + //stored the run; do not record it a second time at the central server + if(localDelivered) return; + String[] centralId=new String[1]; + submitTelemetry(telemetryConfig.getServer(),telemetryConfig.getPath(),centralId); + if(centralId[0]!=null){ + onTestIDReceived(centralId[0],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()); + //an absolute pingURL must yield an absolute base, so the telemetry path + //is not resolved against the server URL's own path a second time + boolean absolute=dir.startsWith("/"); + while(dir.startsWith("/")) dir=dir.substring(1); + while(dir.endsWith("/")) dir=dir.substring(0,dir.length()-1); + return absolute&&!dir.isEmpty()?"/"+dir:dir; + } + + //returns true when the server answered the POST with a 2xx, even if no share + //id could be parsed from the response; the id, if any, is left in idOut[0] + private boolean submitTelemetry(String server, String path, String[] idOut){ + if(server==null||server.isEmpty()||path==null||path.isEmpty()) return false; 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,config.getPing_connectTimeout(),config.getPing_soTimeout(),-1,-1); + final boolean[] delivered=new boolean[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]); + delivered[0]=true; + if(data!=null&&data.startsWith("id")){ + String[] parts=data.split(" "); + if(parts.length>1) idOut[0]=parts[1]; } } @Override public void onError(String err) { - System.err.println("Telemetry error: "+err); + System.err.println("Telemetry error ("+server+"): "+err); } }; - t.join(); + t.join(TELEMETRY_JOIN_TIMEOUT); + //if the join timed out, closing the connection unblocks the thread + try{c.close();}catch (Throwable t1){} + return delivered[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 false; } } + 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 +352,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..97af734 --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/CustomServerFactory.kt @@ -0,0 +1,52 @@ +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. Any sub-path moves into the endpoint + * fields, which keeps the entry independent of how the engine resolves a base path. + * 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, + //and anything the engine does not speak must be rejected, not downgraded + val scheme = when { + !cleaned.contains("://") -> "//" + cleaned.startsWith("http://", ignoreCase = true) -> "http://" + cleaned.startsWith("https://", ignoreCase = true) -> "https://" + else -> throw IllegalArgumentException("Only http(s) URLs are supported") + } + 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..c8cf5d2 --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/data/HistoryDatabase.kt @@ -0,0 +1,164 @@ +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, + /** A telemetry POST was made for this run; [shareUrl] additionally confirms the server stored it. */ + val telemetrySent: Boolean = false +) + +class HistoryDatabase(context: Context) : SQLiteOpenHelper(context, "history.db", null, 5) { + + 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," + + "telemetrySent INTEGER NOT NULL DEFAULT 0)" + ) + } + + 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") + } + if (oldVersion < 5) { + db.execSQL("ALTER TABLE history ADD COLUMN telemetrySent INTEGER NOT NULL DEFAULT 0") + } + } + + 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) + put("telemetrySent", if (entry.telemetrySent) 1 else 0) + } + ) + + 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")), + telemetrySent = cursor.getInt(cursor.getColumnIndexOrThrow("telemetrySent")) != 0 + ) + ) + } + } + 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..77ff278 --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/engine/TestEngine.kt @@ -0,0 +1,135 @@ +package org.librespeed.speedtest.engine + +import android.content.Context +import com.fdossena.speedtest.core.Speedtest +import com.fdossena.speedtest.core.base.Connection +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 org.librespeed.speedtest.data.ClientInfo +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) { + + init { + //background entry points (the scheduled worker) never pass MainActivity, + //so the sanitized app UA must be installed before the first connection + Connection.setUserAgent(ClientInfo.userAgent) + } + + 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..cee48aa --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/HistoryExport.kt @@ -0,0 +1,95 @@ +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().csv() + ).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) { + } + } + + //spreadsheets evaluate a leading =, +, - or @ even inside a quoted field, + //and RFC 4180 requires quoting embedded line breaks + private fun String.csv(): String { + val first = trimStart().firstOrNull() + val defused = if (first != null && first in "=+-@") "'$this" else this + return if (defused.any { it in ",\"\r\n" }) "\"${defused.replace("\"", "\"\"")}\"" else defused + } + + 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..0fe3749 --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/ShareImage.kt @@ -0,0 +1,187 @@ +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) = + if (value < 0) "—" else String.format(Locale.getDefault(), "%.2f", if (useMBytes) value / 8 else value) + fun ms(value: Double) = + if (value < 0) "—" else String.format(Locale.getDefault(), "%.0f %s", value, context.getString(R.string.unit_ms)) + + 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(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(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..7e0bc8d --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/share/ShareResult.kt @@ -0,0 +1,94 @@ +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) = + if (value < 0) "—" + else String.format(Locale.getDefault(), "%.2f %s", if (useMBytes) value / 8 else value, unit) + + fun number(value: Double) = + if (value < 0) "—" else String.format(Locale.getDefault(), "%.1f", value) + + return buildString { + appendLine( + context.getString( + R.string.share_text, + speed(download), speed(upload), number(ping), number(jitter), + GeoDistance.cleanName(server) + ) + ) + if (loss >= 0) { + appendLine(context.getString(R.string.share_text_loss, number(loss))) + } + networkType?.let { appendLine(context.getString(R.string.share_text_network, it)) } + if (ipVersion != 0) { + appendLine(context.getString(R.string.share_text_protocol, 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..55e7be2 --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/components/SpeedGauge.kt @@ -0,0 +1,123 @@ +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.LocalSpeedAccents +import java.util.Locale +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) +//the MB/s dial is not the Mbps dial divided by 8: that would label awkward stops like 31.25 +private val SCALE_STOPS_MBYTES = doubleArrayOf(0.0, 0.5, 1.0, 2.5, 5.0, 10.0, 25.0, 50.0, 100.0, 125.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, stops: DoubleArray): Float { + if (speed <= 0) return 0f + if (speed >= stops.last()) return 1f + for (i in 1 until stops.size) { + if (speed <= stops[i]) { + val segment = (speed - stops[i - 1]) / (stops[i] - stops[i - 1]) + return ((i - 1) + segment).toFloat() / (stops.size - 1) + } + } + return 1f +} + +/** [speed] is always in Mbps; [useMBytes] converts the dial to match a MB/s readout. */ +@Composable +fun SpeedGauge( + speed: Double, + modifier: Modifier = Modifier, + useMBytes: Boolean = false, + content: @Composable () -> Unit +) { + val stops = if (useMBytes) SCALE_STOPS_MBYTES else SCALE_STOPS + val fraction by animateFloatAsState( + targetValue = scaleFraction(if (useMBytes) speed / 8 else speed, stops), + animationSpec = tween(durationMillis = 300), + label = "gauge" + ) + val accents = LocalSpeedAccents.current + 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 accents.download, + (SWEEP + 10f) / 360f to accents.upload, + 1f to accents.download + ), + 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) + stops.forEachIndexed { index, stop -> + val angleDeg = START_ANGLE + SWEEP * index / (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 == 0.0) stop.toInt().toString() + else String.format(Locale.getDefault(), "%.1f", stop) + 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..53f5d62 --- /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.LocalSpeedAccents +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 = stringResource(R.string.more_options)) + } + 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)) + val accents = LocalSpeedAccents.current + Row { + TrendColumn( + modifier = Modifier.weight(1f), + label = stringResource(R.string.test_download), + average = chronological.map { it.download }.average(), + samples = chronological.map { it.download }, + accent = accents.download + ) + 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 = accents.upload + ) + } + } + } +} + +@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.getDefault(), "%.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.getDefault(), "%.0f", entry.download), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = LocalSpeedAccents.current.download + ) + 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.getDefault(), "%.0f", entry.upload), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = LocalSpeedAccents.current.upload + ) + 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..a0aacbd --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/ResultScreen.kt @@ -0,0 +1,442 @@ +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.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.CoroutineScope +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.LocalSpeedAccents +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.nav_back)) + } + 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 = stringResource(R.string.more_options), 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() + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.history_delete), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + //a composition-bound scope dies with the popped screen and can + //cancel the delete before it starts; this one outlives both + CoroutineScope(Dispatchers.IO).launch { + 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 + ) + } + } + + val accents = LocalSpeedAccents.current + 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 = accents.download, + 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 = accents.upload, + 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), stringResource(R.string.unit_seconds_fmt, String.format(Locale.getDefault(), "%.1f", 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), stringResource(R.string.data_mb_fmt, String.format(Locale.getDefault(), "%.0f", 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.getDefault(), "%s · +%.0f %s", grade, (loadedMax - result.ping).coerceAtLeast(0.0), stringResource(R.string.unit_ms)), + 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 = if (value < 0) "—" else String.format(Locale.getDefault(), "%.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.getDefault(), "%.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..d26b399 --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/result/TestDetailsScreen.kt @@ -0,0 +1,246 @@ +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.librespeed.speedtest.BuildConfig +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.ui.history.formatDate +import java.util.Locale + +@Composable +fun TestDetailsScreen(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) } + + 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.nav_back)) + } + 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), stringResource(R.string.unit_seconds_fmt, String.format(Locale.getDefault(), "%.1f", 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.getDefault(), "%.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.getDefault(), "%.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.getDefault(), "%.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 -> + val speedUnit = stringResource(if (useMBytes) R.string.unit_mbytes else R.string.unit_mbps) + fun speed(value: Double) = String.format(Locale.getDefault(), "%.2f %s", if (useMBytes) value / 8 else value, speedUnit) + Section(stringResource(R.string.section_stability)) { + DetailRow(stringResource(R.string.detail_min), speed(s.min), Icons.AutoMirrored.Filled.TrendingDown) + RowDivider() + DetailRow(stringResource(R.string.detail_max), speed(s.max), Icons.AutoMirrored.Filled.TrendingUp) + RowDivider() + DetailRow(stringResource(R.string.detail_avg), speed(s.average), Icons.Filled.Timeline) + RowDivider() + DetailRow(stringResource(R.string.detail_variation), "± ${s.variationPct} ${stringResource(R.string.unit_percent)}", 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 + fun mb(value: Double) = String.format(Locale.getDefault(), "%.0f", value) + if (downloadMb > 0) { + DetailRow(stringResource(R.string.detail_dl_data), stringResource(R.string.data_mb_fmt, mb(downloadMb)), Icons.Filled.ArrowDownward) + RowDivider() + } + if (uploadMb > 0) { + DetailRow(stringResource(R.string.detail_ul_data), stringResource(R.string.data_mb_fmt, mb(uploadMb)), Icons.Filled.ArrowUpward) + RowDivider() + } + DetailRow(stringResource(R.string.detail_data), stringResource(R.string.data_mb_fmt, mb(downloadMb + uploadMb)), Icons.Filled.DataUsage) + } + + Section(stringResource(R.string.section_telemetry)) { + //shareUrl only proves the server confirmed the stored result; the payload + //was transmitted whenever the run had telemetry on (legacy rows: shareUrl) + DetailRow( + stringResource(R.string.settings_section_telemetry), + stringResource(if (result.telemetrySent || 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..f366aeb --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/servers/CompareScreen.kt @@ -0,0 +1,257 @@ +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.LocalSpeedAccents +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.nav_back)) + } + 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)) + + val accent = LocalSpeedAccents.current.download + 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, accent.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 = accent, 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.getDefault(), "%.1f", row.download), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = accent + ) + 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..6cefbe6 --- /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.LocalSpeedAccents + +@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 + } + ) + } +} + +@Composable +private fun latencyColor(ping: Float): Color = LocalSpeedAccents.current.let { + when { + ping < 50 -> it.download + ping < 150 -> it.warn + else -> it.bad + } +} + +@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..17903d5 --- /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.nav_back)) + } + 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..239224c --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/speedtest/SpeedtestScreen.kt @@ -0,0 +1,500 @@ +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.LocalSpeedAccents +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(), + useMBytes = state.useMBytes + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (state.phase == Phase.IDLE || state.phase == Phase.ERROR) "—" + else String.format(Locale.getDefault(), "%.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 +) { + val accents = LocalSpeedAccents.current + 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 = accents.download, modifier = Modifier.size(16.dp)) }, + value = if (state.download < 0) state.download else display(state.download), + unit = unitLabel, + accent = accents.download, + samples = state.downloadSamples + ) + MetricCard( + modifier = Modifier.weight(1f), + title = stringResource(R.string.test_upload), + icon = { Icon(Icons.Filled.ArrowUpward, null, tint = accents.upload, modifier = Modifier.size(16.dp)) }, + value = if (state.upload < 0) state.upload else display(state.upload), + unit = unitLabel, + accent = accents.upload, + 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, stringResource(R.string.unit_percent)) + } + + 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 = LocalSpeedAccents.current.download, modifier = Modifier.size(20.dp)) + Phase.UPLOAD -> Icon(Icons.Filled.ArrowUpward, null, tint = LocalSpeedAccents.current.upload, 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.getDefault(), "%.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.getDefault(), "%.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..8341b81 --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/speedtest/SpeedtestViewModel.kt @@ -0,0 +1,405 @@ +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 + + //written by the UI thread (stop) and the engine's worker/stream threads + @Volatile + private var aborted = false + @Volatile + private var failed = false + + //side channel measuring latency while the line is under load (bufferbloat) + @Volatile + 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 + failed = false + val networkType = NetworkInfo.describe(getApplication()) + val networkDetail = NetworkInfo.detail(getApplication()) + val mode = TestMode.fromKey(testMode) + val telemetry = telemetryEnabled + 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, telemetry, 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() + //the engine always fires onEnd, even after onCriticalFailure already + //reported the run broken; a failed run keeps its error on screen and + //is never saved as a result + if (failed) return + 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, + telemetrySent = telemetry + ) + ) + } + resetToIdle() + lastResultId.value = entryId + } + } + + override fun onCriticalFailure(err: String?) { + failed = true + 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..546dbfc --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/ui/theme/Theme.kt @@ -0,0 +1,117 @@ +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.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +val Teal = Color(0xFF2DD4BF) +val TealDeep = Color(0xFF14B8A6) +val Purple = Color(0xFFA78BFA) + +/** The darker teal light mode needs; also its [primary]. */ +val TealDark = Color(0xFF0F766E) + +/** + * Hue-carrying accents resolved per theme: the two transfer directions and the + * latency scale. The bright dark-mode tones sit below the 3:1 contrast minimum + * on the light surfaces (TealDeep 2.49:1, Amber 2.28:1), so light mode gets + * darker shades of the same hues rather than a different palette. + */ +data class SpeedAccents( + val download: Color, + val upload: Color, + val warn: Color, + val bad: Color +) + +private val DarkAccents = SpeedAccents( + download = Teal, + upload = Purple, + warn = Color(0xFFF7941D), + //the same value as DangerRed, spelled out because that one is declared + //further down the file and top-level initialisation runs in order + bad = Color(0xFFEF6461) +) +private val LightAccents = SpeedAccents( + download = TealDark, + upload = Color(0xFF7C5CD6), + warn = Color(0xFFB45309), + bad = Color(0xFFC0342F) +) + +val LocalSpeedAccents = staticCompositionLocalOf { DarkAccents } +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( + //TealDeep reads at 2.49:1 on these surfaces, both as text and behind + //onPrimary; the darker teal clears 4.5:1 in either direction + primary = TealDark, + 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 + } + CompositionLocalProvider(LocalSpeedAccents provides if (dark) DarkAccents else LightAccents) { + 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..9d447f4 --- /dev/null +++ b/Speedtest-Android/app/src/main/java/org/librespeed/speedtest/work/ScheduledTestWorker.kt @@ -0,0 +1,206 @@ +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, + telemetrySent = telemetryEnabled + ) + ) + } + + 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.getDefault(), "↓ %.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 @@ - - - - - - - - - - - - - - - - - - - - - - - - -