) {
19 | Row(modifier = Modifier
20 | .fillMaxWidth()
21 | .wrapContentHeight()
22 | .padding(vertical = 8.dp),
23 | horizontalArrangement = Arrangement.SpaceEvenly,
24 | verticalAlignment = Alignment.CenterVertically){
25 |
26 | Text("Interval")
27 | Text("Transfer")
28 | Text("Bandwidth")
29 | }
30 |
31 | LazyColumn(
32 | modifier = Modifier
33 | .fillMaxWidth()
34 | .padding(8.dp)
35 | ) {
36 | items(testResults) { result ->
37 | val rowItem = parseLine(result)
38 | if (rowItem?.transfer != null) {
39 | Row(
40 | modifier = Modifier
41 | .fillMaxWidth()
42 | .padding(vertical = 6.dp)
43 | .padding(8.dp),
44 | horizontalArrangement = Arrangement.SpaceBetween
45 | ) {
46 | //Text("ID: ${result.id}", color = Color.White)
47 | Text(" ${rowItem?.start}-${rowItem?.end}s", color = Color.White)
48 | Text("${rowItem?.transfer} ${rowItem?.transferUnit}", color = Color.White)
49 | Text("${rowItem?.bitsPerSec} ${rowItem?.bitsPerSecUnit}", color = Color.White)
50 | }
51 | }
52 | }
53 | }
54 |
55 |
56 | }
57 |
58 | data class ParsedLine(
59 | val id: Int,
60 | val start: Double,
61 | val end: Double,
62 | val transfer: Double,
63 | val transferUnit: String,
64 | val bitsPerSec: Double,
65 | val bitsPerSecUnit: String
66 | )
67 |
68 | fun parseLine(line: String): ParsedLine? {
69 | // \[139\] 10\.01-11\.01 sec\s 5\.53 [A-Za-z0-9]+ 46\.4 Mbits/sec
70 | val regex = """\[\s*(\d+)]\s+(\d+\.\d+)-(\d+\.\d+)\s+sec\s+(\d+\.\d+)\s+([A-Za-z0-9]+)\s+(\d+\.\d+)\s([A-Za-z]+/[A-Za-z]+)""".toRegex()
71 |
72 | val matchResult = regex.find(line)
73 |
74 | return matchResult?.destructured?.let { (id, start, end, transfer, transferUnit, bitsPerSec, bitsPerSecUnit) ->
75 | ParsedLine(
76 | id = id.toInt(),
77 | start = start.toDouble(),
78 | end = end.toDouble(),
79 | transfer = transfer.toDouble(),
80 | transferUnit = transferUnit.toString(),
81 | bitsPerSec = bitsPerSec.toDouble(),
82 | bitsPerSecUnit = bitsPerSecUnit.toString()
83 | )
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/data/NetworkInfoRepository.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.data
2 |
3 | import android.annotation.SuppressLint
4 | import android.content.Context
5 | import android.net.ConnectivityManager
6 | import android.net.NetworkCapabilities
7 | import android.telephony.TelephonyManager
8 |
9 | class NetworkInfoRepository(private val context: Context) {
10 |
11 | private val appContext = context.applicationContext
12 |
13 | private val connectivityManager =
14 | appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
15 |
16 | fun getNetworkType(): String {
17 | return getNetwork(connectivityManager, appContext)
18 | }
19 |
20 | companion object {
21 | @Volatile
22 | private var instance: NetworkInfoRepository? = null
23 |
24 | fun getInstance(application: Context) =
25 | instance ?: synchronized(this) {
26 | instance ?: NetworkInfoRepository(application).also { instance = it }
27 | }
28 | }
29 |
30 |
31 | @SuppressLint("MissingPermission")
32 | fun getNetwork(connectivityManager: ConnectivityManager, context: Context): String {
33 | val nw = connectivityManager.activeNetwork ?: return "-"
34 | val actNw = connectivityManager.getNetworkCapabilities(nw) ?: return "-"
35 | when {
36 | actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> return "WIFI"
37 | //actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> return "ETHERNET"
38 | actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> {
39 | val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
40 | when (tm.dataNetworkType) {
41 | TelephonyManager.NETWORK_TYPE_GPRS -> return "GPRS"
42 | TelephonyManager.NETWORK_TYPE_EDGE -> return "EDGE"
43 | TelephonyManager.NETWORK_TYPE_CDMA -> return "CDMA"
44 | TelephonyManager.NETWORK_TYPE_1xRTT -> return "1xRTT"
45 | //TelephonyManager.NETWORK_TYPE_IDEN -> return ""
46 | TelephonyManager.NETWORK_TYPE_GSM -> return "GSM"
47 | TelephonyManager.NETWORK_TYPE_UMTS -> return "UMTS"
48 | TelephonyManager.NETWORK_TYPE_EVDO_0 -> return "EVDO_0"
49 | TelephonyManager.NETWORK_TYPE_EVDO_A -> return "EVDO_A"
50 | TelephonyManager.NETWORK_TYPE_HSDPA -> return "HSDPA"
51 | TelephonyManager.NETWORK_TYPE_HSUPA -> return "HSUPA"
52 | TelephonyManager.NETWORK_TYPE_HSPA -> return "HSPA"
53 | TelephonyManager.NETWORK_TYPE_EVDO_B -> return "EVDO_B"
54 | TelephonyManager.NETWORK_TYPE_EHRPD -> return "EHRPD"
55 | TelephonyManager.NETWORK_TYPE_HSPAP -> return "HSPAP"
56 | TelephonyManager.NETWORK_TYPE_TD_SCDMA -> return "SCDMA"
57 | TelephonyManager.NETWORK_TYPE_LTE -> return "LTE"
58 | TelephonyManager.NETWORK_TYPE_IWLAN, 19 -> return "IWLAN"
59 | TelephonyManager.NETWORK_TYPE_NR -> return "NR"
60 | else -> return "?"
61 | }
62 | }
63 | else -> return "?"
64 | }
65 | }
66 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # iPerf3 Android client
4 | ## What is iPerf3 ?
5 |
6 | iPerf3 is a tool for active measurements of the maximum achievable bandwidth on IP networks. It supports tuning of various parameters related to timing, buffers and protocols (TCP, UDP, SCTP with IPv4 and IPv6). For each test it reports the bandwidth, loss, and other parameters.
7 |
8 | For more information, see https://github.com/esnet/iperf, which also includes the iperf3 source code (note that this repository does not include any iperf3 source code).
9 |
10 | ## iPerf3 Android client
11 |
12 | In general, the app is capable of:
13 |
14 | - perform simple iperf3 upload and download tests
15 |
16 | - Save preconfigured;
17 |
18 | - Save test results and export them to the device;
19 |
20 | - Graph transfer and bitrate;
21 |
22 | This application does not share your personal data or geolocation, all work is offline.
23 | The coarse and fine location permissions are used to give context to the measurments, data can be exported from the app for further analysis.
24 | Location permissions are optional, iperf will still measure without them.
25 |
26 |
27 |
28 | # Usage
29 | Starting your own server:
30 |
31 | ```
32 | $ iperf3 -s
33 | -----------------------------------------------------------
34 | Server listening on 5201 (test #1)
35 | -----------------------------------------------------------
36 | ```
37 |
38 | ## On Android:
39 |
40 |
41 | # Public iPerf3 servers
42 |
43 | Servers iPerf3 servers will only allow one iPerf connection at a time. Multiple tests at the same time is not supported. If a test is in progress, the following message is displayed: "iperf3: error - the server is busy running a test. try again later"
44 |
45 | The Android iPerf3 client app comes with 4 presaved servers configured
46 |
47 |
48 |
49 | ## Available at:
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | # Acknowledgments
65 | - The main authors of [iPerf3](https://iperf.fr/) are (in alphabetical order): Jon Dugan, Seth Elliott, Bruce A. Mah, Jeff Poskanzer, Kaustubh Prabhu. Additional code contributions have come from (also in alphabetical order): Mark Ashley, Aaron Brown, Aeneas Jaißle, Susant Sahani, Bruce Simpson, Brian Tierney.
66 |
67 | - Khandker Mahmudur Rahman (mahmudur85) iPerf3 implementation for Android [iperf-jni](https://github.com/mahmudur85/iperf-jni)
68 |
69 | # License
70 |
71 | This program is Free Software: You can use, study share and improve it at your will. Specifically you can redistribute and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
72 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/ui/ui/MapScreen.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.ui.ui
2 |
3 | import android.util.Log
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.collectAsState
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.mutableDoubleStateOf
11 | import androidx.compose.runtime.mutableStateOf
12 | import androidx.compose.runtime.saveable.rememberSaveable
13 | import androidx.compose.runtime.setValue
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.platform.LocalContext
16 | import androidx.compose.ui.viewinterop.AndroidView
17 | import com.example.iperf3client.viewmodels.SpeedMapMarker
18 | import com.example.iperf3client.viewmodels.TestViewModel
19 | import org.osmdroid.config.Configuration
20 | import org.osmdroid.tileprovider.tilesource.TileSourceFactory
21 | import org.osmdroid.util.GeoPoint
22 | import org.osmdroid.views.MapView
23 | import org.osmdroid.views.overlay.Marker
24 |
25 |
26 | @Composable
27 | fun MapScreen(
28 | testViewModel: TestViewModel
29 | ) {
30 |
31 | Column(modifier = Modifier.fillMaxWidth()) {
32 | OsmdroidMapView(testViewModel)
33 | }
34 | }
35 |
36 |
37 | //https://stackoverflow.com/questions/76161027/android-jetpack-compose-open-street-map-conflict-with-tabrow
38 | @Composable
39 | fun OsmdroidMapView(testViewModel: TestViewModel) {
40 | LocalContext.current
41 | val mapMarker by testViewModel.mapMarker.collectAsState()
42 | if (mapMarker.isEmpty()) return
43 |
44 | // Save center location and zoom level
45 | var mapCenter by rememberSaveable {mutableStateOf(mapMarker.last().location)}
46 | var zoomLevel by rememberSaveable {mutableDoubleStateOf(20.0)}
47 |
48 |
49 | AndroidView(
50 | modifier = Modifier.fillMaxSize(),
51 | factory = { context ->
52 | var mapView = MapView(context).apply {
53 | setTileSource(TileSourceFactory.DEFAULT_TILE_SOURCE)
54 | setBuiltInZoomControls(true)
55 | setMultiTouchControls(true)
56 | clipToOutline = true
57 | controller.setZoom(zoomLevel)
58 | controller.setCenter(mapCenter)
59 | Configuration.getInstance().userAgentValue = "CACHO"
60 | }
61 | Log.wtf("CACHO", "adding existing ${mapMarker.size} markers")
62 | addExistingMarkers(mapView, mapMarker)
63 | mapView
64 | },
65 | update = { view ->
66 | view.controller.setCenter((mapMarker.last().location))
67 | Log.wtf("CACHO", "map: ${mapMarker.last().location} thr: ${mapMarker.last().throughput}")
68 | addMarker(view, mapMarker.last().location, mapMarker.last().throughput)
69 |
70 | view.controller.animateTo(mapMarker.last().location)
71 | }
72 | )
73 |
74 |
75 | }
76 |
77 | fun addExistingMarkers(view: MapView, mapMarkers: List) {
78 | for (speedMapMarker in mapMarkers){
79 | addMarker(view, speedMapMarker.location, speedMapMarker.throughput)
80 | }
81 | }
82 |
83 | fun addMarker(mapView: MapView, location: GeoPoint, x: Float) {
84 | val startMarker = Marker(mapView)
85 | startMarker.setPosition(GeoPoint(location))
86 | startMarker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
87 | startMarker.setTextIcon("Thr: $x")
88 |
89 | mapView.overlays.add(startMarker)
90 | }
91 |
92 |
93 |
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/utils/IpersOutputParser.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.utils
2 |
3 | class IpersOutputParser {
4 |
5 | /*
6 | regex: \[[0-9]+\]\s+[0-9]*\.[0-9]+-[0-9]*\.[0-9]+\s+sec\s+(?[0-9]*\.[0-9])+\s+MBytes\s+(?[0-9]*\.[0-9])+\s+Mbits/sec\s+receiver
7 | example: [127] 0.00-5.00 sec 15.7 MBytes 26.3 Mbits/sec receiver
8 | regex groups:
9 | -transfer = 15.7
10 | -bw = 26.3
11 | */
12 |
13 |
14 | companion object {
15 | private const val KBYTES = "KBytes"
16 | private const val KBITS_PER_SEC = "Kbits/sec"
17 | private const val BYTES = " Bytes "
18 | private const val BITS_PER_SEC = " bits/sec"
19 | private const val UPLOAD_EXTRA_VALUES_SEPARATOR =
20 | "/sec " // upload has more values that interfere with the validation
21 |
22 | private fun getTransferOrBwValues(input: String, value: String, pattern: String): String {
23 | val regex = Regex(
24 | pattern = pattern,
25 | options = setOf(RegexOption.IGNORE_CASE)
26 | )
27 |
28 | try {
29 | val matchResult = regex.find(input)!!
30 | return matchResult.groups[value]?.value.toString()
31 | } catch (e: Exception) {
32 | return ""
33 | }
34 |
35 | }
36 |
37 | fun getFinalTransferOrBwValues(input: String, value: String): String {
38 |
39 | val pattern =
40 | "\\[\\s*[0-9]+\\]\\s+[0-9]*\\.[0-9]+-[0-9]*\\.[0-9]+\\s+sec\\s+(?([+-]?(?=\\.\\d|\\d)(?:\\d+)?(?:\\.?\\d*))(?:[Ee]([+-]?\\d+))?+\\s+[A-Za-z]+)\\s+(?([+-]?(?=\\.\\d|\\d)(?:\\d+)?(?:\\.?\\d*))(?:[Ee]([+-]?\\d+))?+\\s+[A-Za-z]+)"
41 | return getTransferOrBwValues(input, value, pattern)
42 | }
43 |
44 | private fun getIntermediateTransferOrBwValues(input: String, value: String): String {
45 | val pattern =
46 | "\\[\\s*[0-9]+\\]\\s+[0-9]*\\.[0-9]+-[0-9]*\\.[0-9]+\\s+sec\\s+(?([+-]?(?=\\.\\d|\\d)(?:\\d+)?(?:\\.?\\d*))(?:[Ee]([+-]?\\d+))?)\\s+(?[A-Za-z]+)\\s+(?([+-]?(?=\\.\\d|\\d)(?:\\d+)?(?:\\.?\\d*))(?:[Ee]([+-]?\\d+))?) (?[A-Za-z]+/[A-Za-z]+)"
47 | return getTransferOrBwValues(input, value, pattern)
48 | }
49 |
50 |
51 | fun getIntermediateTransferOrBwValuesInMBytes(input: String, value: String): Float {
52 | try {
53 | val value = getIntermediateTransferOrBwValues(input, value).toFloat()
54 | val inputFormatted = input.substring(
55 | 0,
56 | input.indexOf(UPLOAD_EXTRA_VALUES_SEPARATOR) + UPLOAD_EXTRA_VALUES_SEPARATOR.length
57 | )
58 | if (inputFormatted.contains(KBYTES) || inputFormatted.contains(KBITS_PER_SEC)) {
59 | return kiloToMega(value)
60 | } else if (inputFormatted.contains(BYTES) || inputFormatted.contains(BITS_PER_SEC)) {
61 | return bitToMega(value)
62 | }
63 |
64 | return value
65 | } catch (e: Exception) {
66 | throw IllegalStateException("Iperf line is not a result or parsed wrong: $input")
67 | }
68 | }
69 |
70 | /*
71 | MBytes --> KBytes
72 | Mbits/sec --> Kbits/sec
73 | */
74 | private fun kiloToMega(transfer: Float): Float {
75 | return transfer / 1000
76 | }
77 |
78 | //0.00 Bytes 0.00 bits/sec
79 | private fun bitToMega(transfer: Float): Float {
80 | return transfer / 1000000
81 | }
82 |
83 |
84 | }
85 |
86 |
87 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.gradle.api.JavaVersion.VERSION_11
2 |
3 | plugins {
4 | alias(libs.plugins.android.application)
5 | alias(libs.plugins.kotlin.android)
6 | alias(libs.plugins.kotlin.compose)
7 | id("com.google.devtools.ksp")
8 | }
9 |
10 | android {
11 | namespace = "com.example.iperf3client"
12 | compileSdk = 35
13 |
14 | defaultConfig {
15 | applicationId = "com.example.iperf3client"
16 | minSdk = 24
17 | targetSdk = 35
18 | versionCode = 4
19 | versionName = "1.3"
20 |
21 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
22 |
23 | ksp {
24 | arg("room.schemaLocation", "$projectDir/schemas")
25 | }
26 | }
27 |
28 | buildTypes {
29 | release {
30 | isMinifyEnabled = false
31 | proguardFiles(
32 | getDefaultProguardFile("proguard-android-optimize.txt"),
33 | "proguard-rules.pro"
34 | )
35 | }
36 | }
37 |
38 | compileOptions {
39 | sourceCompatibility = VERSION_11
40 | targetCompatibility = VERSION_11
41 | }
42 | kotlinOptions {
43 | jvmTarget = "11"
44 | }
45 |
46 | buildFeatures {
47 | compose = true
48 | }
49 |
50 | testOptions {
51 | unitTests.isReturnDefaultValues = true
52 | }
53 |
54 | dependenciesInfo {
55 | // Disable dependency metadata in APKs and Bundles (for F-Droid/Google Play)
56 | includeInApk = false
57 | includeInBundle = false
58 | }
59 | }
60 |
61 | dependencies {
62 | // AndroidX Core
63 | implementation(libs.androidx.core.ktx)
64 | implementation(libs.androidx.lifecycle.runtime.ktx)
65 | implementation(libs.androidx.activity.compose)
66 |
67 | // Compose
68 | implementation(platform(libs.compose.bom))
69 | implementation(libs.androidx.ui)
70 | implementation(libs.androidx.ui.graphics)
71 | implementation(libs.androidx.ui.tooling.preview)
72 | implementation(libs.androidx.material3)
73 | debugImplementation(libs.androidx.ui.tooling)
74 | debugImplementation(libs.androidx.ui.test.manifest)
75 | androidTestImplementation(libs.androidx.ui.test.junit4)
76 |
77 | // Navigation
78 | implementation(libs.navigation.runtime)
79 | implementation(libs.navigation.compose)
80 |
81 | // Room
82 | implementation(libs.room.runtime)
83 | implementation(libs.room.ktx)
84 | ksp(libs.room.compiler)
85 | implementation(libs.room.paging)
86 | testImplementation(libs.room.testing)
87 |
88 | // WorkManager
89 | implementation(libs.work.runtime.ktx)
90 |
91 | // Vico
92 | implementation(libs.vico.core)
93 | implementation(libs.vico.compose)
94 | implementation(libs.vico.compose.m3)
95 | implementation(libs.vico.compose.m2)
96 |
97 | // Iperf
98 | implementation(libs.iperf)
99 |
100 | // Maps
101 | implementation(libs.osmdroid)
102 | implementation(libs.mapcompose)
103 |
104 | // Icons
105 | implementation(libs.material.icons.extended)
106 |
107 | // Testing
108 | testImplementation(libs.junit)
109 | testImplementation(libs.robolectric)
110 | testImplementation(libs.androidx.test.core)
111 | testImplementation(libs.mockk)
112 | testImplementation(libs.coroutines.test)
113 | testImplementation(libs.mockito.core)
114 | testImplementation(libs.mockito.junit.jupiter)
115 | testImplementation(libs.arch.core.testing)
116 | testImplementation(libs.turbine)
117 |
118 | // Android Instrumentation
119 | androidTestImplementation(libs.androidx.junit)
120 | androidTestImplementation(libs.androidx.espresso.core)
121 | }
122 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/utils/DbUtils.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.utils
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.net.Uri
6 | import android.util.Log
7 | import android.widget.Toast
8 | import androidx.activity.compose.ManagedActivityResultLauncher
9 | import androidx.activity.result.ActivityResult
10 | import androidx.core.content.FileProvider
11 | import com.example.iperf3client.data.TestDatabase
12 | import java.io.File
13 | import java.io.FileInputStream
14 | import java.io.FileOutputStream
15 |
16 |
17 | class DbUtils {
18 | companion object {
19 | fun backupDatabase(context: Context, databaseName: String, backupLocation: File) {
20 | val dbPath = context.getDatabasePath(databaseName).absolutePath
21 | val dbFile = File(dbPath)
22 | val backupFile = File(backupLocation, databaseName)
23 |
24 | FileInputStream(dbFile).use { input ->
25 | FileOutputStream(backupFile).use { output ->
26 | input.copyTo(output)
27 | }
28 | }
29 | // Verify the backup
30 | if (backupFile.exists() && backupFile.length() == dbFile.length()) {
31 | Log.d("Backup", "Database backup successful: ${backupFile.absolutePath}")
32 | Toast.makeText(
33 | context, "Database backup successful: ${backupFile.absolutePath}",
34 | Toast.LENGTH_LONG
35 | ).show()
36 | } else {
37 | Log.e("Backup", "Database backup failed.")
38 | Toast.makeText(
39 | context, "Database backup failed.",
40 | Toast.LENGTH_LONG
41 | ).show()
42 | }
43 | }
44 |
45 | fun shareCsvFile(context: Context) {
46 | val shareFile = context.getDatabasePath(TestDatabase.DATABASE_NAME)
47 |
48 | val shareIntent = Intent(Intent.ACTION_SEND)
49 | shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
50 | shareIntent.setType("text/csv")
51 |
52 | val uri =FileProvider.getUriForFile(context, context.packageName + ".provider", shareFile)
53 | shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
54 | context.startActivity(
55 | Intent.createChooser(
56 | shareIntent,
57 | "share"
58 | ))
59 | }
60 |
61 | fun shareRoomDatabase(
62 | context: Context,
63 | dbName: String,
64 | launcher: ManagedActivityResultLauncher
65 | ) {
66 | val dbFile = context.getDatabasePath(dbName)
67 | val sharedDb = File(context.filesDir, TestDatabase.DATABASE_NAME)
68 |
69 | dbFile.copyTo(sharedDb, overwrite = true)
70 | Log.d("DB_SHARE", "Database path: ${dbFile.absolutePath}")
71 |
72 | if (dbFile.exists()) {
73 | val fileUri: Uri = FileProvider.getUriForFile(
74 | context,
75 | "com.example.iperf3client.provider", // must match manifest authority
76 | sharedDb
77 | )
78 | val shareIntent = Intent(Intent.ACTION_SEND).apply {
79 | type = "application/octet-stream"
80 | putExtra(Intent.EXTRA_STREAM, fileUri)
81 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
82 | }
83 |
84 | launcher.launch(Intent.createChooser(shareIntent, "Share Database"))
85 | } else {
86 | Toast.makeText(context, "Database file not found", Toast.LENGTH_SHORT).show()
87 | }
88 | }
89 |
90 | }
91 | }
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.9.1"
3 | kotlin = "2.0.21"
4 | coreKtx = "1.15.0"
5 | junit = "4.13.2"
6 | junitVersion = "1.2.1"
7 | espressoCore = "3.6.1"
8 | lifecycleRuntimeKtx = "2.8.7"
9 | activityCompose = "1.10.1"
10 | composeBom = "2024.09.00"
11 | compileSdk = "35"
12 | minSdk = "24"
13 | targetSdk = "35"
14 | room = "2.6.1"
15 | vico = "2.1.1"
16 | navigation = "2.9.6"
17 | work = "2.11.0"
18 | osmdroid = "6.1.20"
19 | mapcompose = "2.16.2"
20 | materialIcons = "1.7.8"
21 | robolectric = "4.13"
22 | mockk = "1.13.7"
23 | coroutinesTest = "1.10.1"
24 | mockito = "5.2.0"
25 | archCoreTest = "2.2.0"
26 | turbine = "1.0.0"
27 | androidxTestCore = "1.7.0"
28 |
29 | [libraries]
30 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
31 | junit = { group = "junit", name = "junit", version.ref = "junit" }
32 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
33 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
34 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
35 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
36 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
37 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
38 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
39 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
40 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
41 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
42 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
43 |
44 | compose-bom = { module = "androidx.compose:compose-bom", version = "composeBom" }
45 |
46 | navigation-runtime = { module = "androidx.navigation:navigation-runtime-android", version.ref = "navigation" }
47 | navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
48 |
49 | room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
50 | room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
51 | room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
52 | room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
53 | room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
54 |
55 | work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
56 |
57 | vico-core = { module = "com.patrykandpatrick.vico:core", version.ref = "vico" }
58 | vico-compose = { module = "com.patrykandpatrick.vico:compose", version.ref = "vico" }
59 | vico-compose-m3 = { module = "com.patrykandpatrick.vico:compose-m3", version.ref = "vico" }
60 | vico-compose-m2 = { module = "com.patrykandpatrick.vico:compose-m2", version.ref = "vico" }
61 |
62 | iperf = { module = "com.synaptic-tools:iperf", version = "1.0.0" }
63 |
64 | osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid" }
65 | mapcompose = { module = "ovh.plrapps:mapcompose", version.ref = "mapcompose" }
66 | material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIcons" }
67 |
68 | robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
69 | mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
70 | coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" }
71 | mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
72 | mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" }
73 | arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "archCoreTest" }
74 | turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
75 | androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" }
76 |
77 | [plugins]
78 | android-application = { id = "com.android.application", version.ref = "agp" }
79 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
80 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
81 |
82 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/ui/ui/WelcomeScreen.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.ui.ui
2 |
3 | import androidx.activity.compose.BackHandler
4 | import androidx.compose.foundation.Image
5 | import androidx.compose.foundation.layout.BoxWithConstraints
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.offset
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.size
11 | import androidx.compose.material3.Button
12 | import androidx.compose.material3.MaterialTheme
13 | import androidx.compose.material3.Text
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.collectAsState
16 | import androidx.compose.runtime.getValue
17 | import androidx.compose.runtime.mutableIntStateOf
18 | import androidx.compose.runtime.saveable.rememberSaveable
19 | import androidx.compose.runtime.setValue
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.res.painterResource
23 | import androidx.compose.ui.text.font.FontWeight
24 | import androidx.compose.ui.text.style.TextAlign
25 | import androidx.compose.ui.unit.dp
26 | import androidx.compose.ui.unit.sp
27 | import com.example.iperf3client.R
28 | import com.example.iperf3client.viewmodels.TestViewModel
29 |
30 | @Composable
31 | fun WelcomeScreen(
32 | onNewTestButtonClicked: () -> Unit,
33 | modifier: Modifier = Modifier,
34 | testViewModel: TestViewModel
35 | ) {
36 | BackHandler {
37 | testViewModel.getTestCount()
38 | testViewModel.getResultsCount()
39 | }
40 |
41 | val resultsCount by testViewModel.resultsCount.collectAsState()
42 | val testsCount by testViewModel.testCount.collectAsState()
43 |
44 | var resultsNum by rememberSaveable { mutableIntStateOf(resultsCount) }
45 | var testsNum by rememberSaveable { mutableIntStateOf(testsCount) }
46 |
47 | BoxWithConstraints(
48 | modifier = Modifier.fillMaxSize(),
49 |
50 |
51 | contentAlignment = Alignment.TopCenter
52 | ) {
53 | val screenHeight = this.maxHeight
54 | val screenWidth = this.maxWidth
55 |
56 | val upperThreeQuartersHeight = screenHeight * 0.75f
57 |
58 | val iconCenterY = upperThreeQuartersHeight / 2
59 |
60 | val iconSize = 300.dp
61 |
62 | val yOffset = iconCenterY - (iconSize / 2)
63 | val textYOffset = screenHeight / 6
64 | val buttonYOffset = screenHeight / 2
65 |
66 | Text(
67 | text = "IPerf3 Android client",
68 | style = MaterialTheme.typography.headlineSmall,
69 | textAlign = TextAlign.Center,
70 | fontSize = 36.sp,
71 | fontWeight = FontWeight.Bold,
72 | // --- NEW WAY ---
73 | modifier = Modifier
74 | .align(Alignment.TopCenter)
75 | .offset(y = textYOffset)
76 | .fillMaxWidth()
77 | .padding(horizontal = 15.dp)
78 |
79 | )
80 |
81 | Image(
82 | painter = painterResource(id = R.mipmap.ic_launcher_foreground),
83 | contentDescription = "App Icon",
84 | modifier = Modifier
85 | .size(iconSize)
86 | // Apply the calculated vertical offset.
87 | .offset(y = yOffset)
88 | )
89 |
90 | Button(onClick = onNewTestButtonClicked,modifier = Modifier
91 | .align(Alignment.TopCenter)
92 | .offset(y = buttonYOffset)
93 | .fillMaxWidth()
94 | .padding(horizontal = screenWidth / 4, vertical = 32.dp)) {
95 | Text("New Test")
96 | }
97 |
98 | Text(
99 | text = "Saved Tests: $testsNum",
100 | style = MaterialTheme.typography.headlineSmall,
101 | textAlign = TextAlign.Center,
102 | modifier = Modifier
103 | .align(Alignment.TopCenter)
104 | .offset(y = (screenHeight* .8f) )
105 | .fillMaxWidth()
106 | .padding(horizontal = 32.dp)
107 |
108 | )
109 | Text(
110 | text = "Saved Results: $resultsNum",
111 | style = MaterialTheme.typography.headlineSmall,
112 | textAlign = TextAlign.Center,
113 | modifier = Modifier
114 | .align(Alignment.TopCenter)
115 | .offset(y = screenHeight * .9f)
116 | .fillMaxWidth()
117 | .padding(horizontal = 32.dp)
118 |
119 | )
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/ui/ui/SavedTestsScreen.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.ui
2 |
3 | import android.util.Log
4 | import androidx.activity.compose.BackHandler
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.layout.Arrangement
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Row
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.heightIn
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.layout.wrapContentHeight
13 | import androidx.compose.foundation.lazy.LazyColumn
14 | import androidx.compose.foundation.lazy.items
15 | import androidx.compose.material.icons.Icons
16 | import androidx.compose.material.icons.filled.PlayArrow
17 | import androidx.compose.material3.Card
18 | import androidx.compose.material3.CardDefaults
19 | import androidx.compose.material3.Icon
20 | import androidx.compose.material3.IconButton
21 | import androidx.compose.material3.Text
22 | import androidx.compose.runtime.Composable
23 | import androidx.compose.runtime.collectAsState
24 | import androidx.compose.runtime.getValue
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.unit.dp
28 | import com.example.iperf3client.data.TestUiState
29 | import com.example.iperf3client.viewmodels.TestViewModel
30 |
31 | @Composable
32 | fun SavedTestsScreen(
33 | testViewModel: TestViewModel,
34 | modifier: Modifier = Modifier,
35 | onCancelButtonClicked: () -> Unit,
36 | onItemClick: (id: Int?) -> Unit,
37 | onRunTestClicked: (
38 | server: String?,
39 | port: Int?,
40 | duration: Int?,
41 | interval: Int?,
42 | reverse: Boolean?,
43 | udp: Boolean
44 | ) -> Unit
45 | ) {
46 | BackHandler {
47 | testViewModel.getTests()
48 | }
49 | val testListState by testViewModel.testList.collectAsState()
50 | Log.wtf("CACHO", "SavedTestsScreen:testListState.size: ${testListState.size}")
51 | Column(
52 | modifier = Modifier.fillMaxWidth()
53 | ) {
54 |
55 | LazyColumn(
56 | modifier = Modifier
57 | .wrapContentHeight()
58 | .heightIn(max = 1000.dp)
59 | ) {
60 | items(testListState) { item ->
61 | Card(
62 | modifier = Modifier
63 | .fillMaxWidth()
64 | .padding(10.dp)
65 | .clickable {
66 | onItemClick(item.tid)
67 | },
68 | elevation = CardDefaults.cardElevation(10.dp),
69 | ) {
70 | Row {
71 | Column(modifier = Modifier.weight(4f)) {
72 | Text(text = "Test: ${item.tid.toString() ?: "New"}")
73 | Text(text = "${item.server}: ${item.port}")
74 | Row {
75 | Text(text = if (item.reverse) "Download" else "Upload", Modifier.weight(1f))
76 | Text(text = if (item.udp) "UDP" else "TCP", Modifier.weight(1f))
77 | }
78 | }
79 | Column(
80 | modifier = Modifier.weight(1f),
81 | verticalArrangement = Arrangement.Center,
82 | horizontalAlignment = Alignment.CenterHorizontally
83 | ) {
84 | IconButton(
85 | onClick = {
86 | onRunTestClicked(
87 | item.server,
88 | item.port,
89 | item.duration,
90 | item.interval,
91 | item.reverse,
92 | item.udp
93 | )
94 | }
95 | ) {
96 | Icon(
97 | imageVector = Icons.Default.PlayArrow,
98 | contentDescription = null
99 | )
100 | }
101 |
102 | }
103 | }
104 |
105 |
106 | }
107 |
108 | }
109 | }
110 | }
111 |
112 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/example/iperf3client/TestViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client
2 |
3 | import android.content.Context
4 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
5 | import androidx.test.platform.app.InstrumentationRegistry
6 | import androidx.test.runner.AndroidJUnit4
7 | import app.cash.turbine.test
8 | import com.example.iperf3client.data.TestRepository
9 | import com.example.iperf3client.data.TestUiState
10 | import com.example.iperf3client.viewmodels.TestViewModel
11 | import io.mockk.coVerify
12 | import io.mockk.mockk
13 | import junit.framework.TestCase.assertEquals
14 | import junit.framework.TestCase.assertNotNull
15 | import junit.framework.TestCase.assertNull
16 | import kotlinx.coroutines.test.runTest
17 | import org.junit.Before
18 | import org.junit.Rule
19 | import org.junit.Test
20 | import org.junit.runner.RunWith
21 |
22 | @RunWith(AndroidJUnit4::class)
23 | class TestViewModelTest {
24 | @get:Rule
25 | val instantTaskExecutorRule = InstantTaskExecutorRule()
26 |
27 | private lateinit var testViewModel: TestViewModel
28 | private val testRepository: TestRepository = mockk(relaxed = true)
29 |
30 |
31 | @Before
32 | fun setup() {
33 | val applicationContext: Context = InstrumentationRegistry.getInstrumentation().context
34 | // Initialize TestViewModel with the mocked dependencies
35 | testViewModel = TestViewModel(applicationContext)
36 | }
37 |
38 |
39 | /*
40 | * Test using turbine
41 | */
42 | @Test
43 | fun `saveUpdateTest should create a new test when tid is null`() = runTest {
44 | // Arrange
45 |
46 | // Act
47 | testViewModel.saveUpdateTest(
48 | server = "192.168.1.1",
49 | port = 8080,
50 | duration = 10,
51 | interval = 1,
52 | reverse = true
53 | )
54 | // Wait for the state change and verify the repository method was called
55 | testViewModel.uiState.test {
56 | // Collect the latest value
57 | val uiState = awaitItem()
58 |
59 | // Verify that the UI state was updated with the correct values
60 | assertNotNull(uiState)
61 | assertEquals("192.168.1.1", uiState.server)
62 | assertEquals(8080, uiState.port)
63 | assertEquals(10, uiState.duration)
64 | assertEquals(1, uiState.interval)
65 | assertEquals(true, uiState.reverse)
66 | assertNull(uiState.tid) // Tid should still be null
67 |
68 | }
69 | }
70 |
71 | /**
72 | * Test using turbine
73 | */
74 | @Test
75 | fun `saveUpdateTest should update an existing test when tid is not null`() = runTest {
76 | // Arrange
77 | testViewModel.uiState.value.tid = 99
78 | // Act
79 |
80 | testViewModel.saveUpdateTest(
81 | server = "192.168.1.1",
82 | port = 443,
83 | duration = 10,
84 | interval = 1,
85 | reverse = false
86 | )
87 | // Wait for the state change and verify the repository method was called
88 | testViewModel.uiState.test {
89 | // Collect the latest value
90 | val uiState = awaitItem()
91 |
92 | // Verify that the UI state was updated with the correct values
93 | assertNotNull(uiState)
94 | assertEquals("192.168.1.1", uiState.server)
95 | assertEquals(443, uiState.port)
96 | assertEquals(10, uiState.duration)
97 | assertEquals(1, uiState.interval)
98 | assertEquals(false, uiState.reverse)
99 | assertNotNull(uiState.tid)// Tid should not be null
100 | assertEquals(99,uiState.tid) // Tid should be 99
101 | }
102 | }
103 |
104 | @Test
105 | fun `saveUpdateTest should call updateTest if tid is not null`() = runTest {
106 | // Arrange
107 | TestUiState(
108 | tid = 1,
109 | server = "192.168.1.1",
110 | port = 8080,
111 | duration = 10,
112 | interval = 1,
113 | reverse = true,
114 | fav = false,
115 | output = ""
116 | )
117 | val updatedTest = TestUiState(
118 | tid = 1,
119 | server = "192.168.1.1",
120 | port = 8080,
121 | duration = 20,
122 | interval = 2,
123 | reverse = false,
124 | fav = false,
125 | output = ""
126 | )
127 |
128 |
129 | // Act
130 | testViewModel.saveUpdateTest(
131 | server = "192.168.1.1",
132 | port = 8080,
133 | duration = 20,
134 | interval = 2,
135 | reverse = false
136 | )
137 |
138 | // Verify that updateTest was called
139 | coVerify { testRepository.updateTest(updatedTest) }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
10 |
12 |
14 |
16 |
18 |
20 |
22 |
24 |
26 |
28 |
30 |
32 |
34 |
36 |
38 |
40 |
42 |
44 |
46 |
48 |
50 |
52 |
54 |
56 |
58 |
60 |
62 |
64 |
66 |
68 |
70 |
72 |
74 |
75 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/ui/ui/TestFilterForm.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.ui.ui
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.material3.Button
9 | import androidx.compose.material3.Checkbox
10 | import androidx.compose.material3.ExperimentalMaterial3Api
11 | import androidx.compose.material3.ModalBottomSheet
12 | import androidx.compose.material3.Text
13 | import androidx.compose.material3.rememberModalBottomSheetState
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.rememberCoroutineScope
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.unit.dp
19 | import androidx.compose.ui.unit.sp
20 | import kotlinx.coroutines.launch
21 | import androidx.compose.foundation.layout.*
22 | import androidx.compose.foundation.shape.RoundedCornerShape
23 | import androidx.compose.material3.*
24 | import androidx.compose.material3.HorizontalDivider
25 | import androidx.compose.runtime.*
26 | import androidx.compose.runtime.getValue
27 | import androidx.compose.runtime.saveable.rememberSaveable
28 | import androidx.compose.runtime.setValue
29 | import androidx.compose.ui.text.font.FontWeight
30 | import com.example.iperf3client.viewmodels.TestViewModel
31 |
32 |
33 | @OptIn(ExperimentalMaterial3Api::class)
34 | @Composable
35 | fun TestFilterForm(
36 | showSheet: Boolean,
37 | onDismiss: () -> Unit,
38 | testViewModel: TestViewModel
39 | ) {
40 | val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
41 | val scope = rememberCoroutineScope()
42 |
43 | if (showSheet) {
44 | ModalBottomSheet(
45 | onDismissRequest = {
46 | scope.launch { sheetState.hide() }
47 | .invokeOnCompletion { onDismiss() }
48 | },
49 | sheetState = sheetState
50 | ) {
51 | NetworkOptionsForm(onDismiss, testViewModel)
52 |
53 | }
54 | }
55 | }
56 |
57 | @Composable
58 | fun NetworkOptionsForm(onDismiss: () -> Unit, testViewModel: TestViewModel) {
59 | val filterState by testViewModel.filterState.collectAsState()
60 |
61 | // Checkbox states
62 | var upload by rememberSaveable { mutableStateOf(filterState.upload) }
63 | var download by rememberSaveable { mutableStateOf(filterState.download) }
64 | var tcp by rememberSaveable { mutableStateOf(filterState.tcp) }
65 | var udp by rememberSaveable { mutableStateOf(filterState.udp) }
66 |
67 | Card(
68 | modifier = Modifier
69 | .fillMaxWidth()
70 | .padding(16.dp),
71 | shape = RoundedCornerShape(16.dp),
72 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
73 | colors = CardDefaults.cardColors(
74 | containerColor = MaterialTheme.colorScheme.surface
75 | )
76 | ) {
77 | Column(
78 | modifier = Modifier
79 | .fillMaxWidth()
80 | .padding(20.dp),
81 | verticalArrangement = Arrangement.spacedBy(16.dp)
82 | ) {
83 | Text(
84 | text = "Test/Results Filter",
85 | fontSize = 20.sp,
86 | fontWeight = FontWeight.Bold,
87 | color = MaterialTheme.colorScheme.primary
88 | )
89 |
90 | HorizontalDivider()
91 |
92 | CheckboxRow("Upload", upload, onCheckedChange = {upload = it})
93 | CheckboxRow("Download", download, onCheckedChange = {download = it})
94 | CheckboxRow("TCP", tcp, onCheckedChange = {tcp = it})
95 | CheckboxRow("UDP", udp, onCheckedChange = {udp = it})
96 |
97 | Spacer(modifier = Modifier.height(12.dp))
98 |
99 | Button(
100 | onClick = {
101 | testViewModel.saveFilterState(tcp,udp,upload,download)
102 | testViewModel.getExecutedTests()
103 | testViewModel.getTests()
104 | onDismiss()
105 | },
106 | modifier = Modifier.align(Alignment.End),
107 | shape = RoundedCornerShape(8.dp)
108 | ) {
109 | Text("Apply")
110 | }
111 | }
112 | }
113 | }
114 |
115 | @Composable
116 | fun CheckboxRow(
117 | label: String,
118 | checked: Boolean,
119 | onCheckedChange: (Boolean) -> Unit
120 | ) {
121 | Row(
122 | modifier = Modifier
123 | .fillMaxWidth()
124 | .padding(horizontal = 8.dp),
125 | verticalAlignment = Alignment.CenterVertically,
126 | horizontalArrangement = Arrangement.SpaceBetween
127 | ) {
128 | Text(text = label, fontSize = 16.sp)
129 | Checkbox(
130 | checked = checked,
131 | onCheckedChange = onCheckedChange
132 | )
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client
2 |
3 | import android.Manifest
4 | import android.app.Activity
5 | import android.content.Context
6 | import android.content.pm.PackageManager
7 | import android.os.Build
8 | import android.os.Bundle
9 | import androidx.activity.ComponentActivity
10 | import androidx.activity.compose.setContent
11 | import androidx.activity.enableEdgeToEdge
12 | import androidx.compose.foundation.layout.fillMaxSize
13 | import androidx.compose.material3.MaterialTheme
14 | import androidx.compose.material3.Surface
15 | import androidx.compose.material3.Text
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.platform.LocalContext
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import androidx.core.app.ActivityCompat
21 | import androidx.core.app.ActivityCompat.requestPermissions
22 | import com.example.iperf3client.data.TestDatabase
23 | import com.example.iperf3client.ui.ui.theme.IPerf3ClientTheme
24 | import com.example.iperf3client.viewmodels.TestViewModel
25 |
26 | class MainActivity : ComponentActivity() {
27 | override fun onCreate(savedInstanceState: Bundle?) {
28 | super.onCreate(savedInstanceState)
29 |
30 | enableEdgeToEdge()
31 | setContent {
32 | val testViewModel = TestViewModel(
33 | LocalContext.current,
34 | TestDatabase.getInstance(applicationContext)
35 | )
36 | IPerf3ClientTheme {
37 | testViewModel.getTestCount()
38 | // A surface container using the 'background' color from the theme
39 | Surface(
40 | modifier = Modifier.fillMaxSize(),
41 | color = MaterialTheme.colorScheme.background
42 | ) {
43 | applicationContext
44 | checkPermissions(applicationContext)
45 | IperfApp(testViewModel, applicationContext)
46 | }
47 | }
48 | addSampleTestsToDB(testViewModel)
49 | }
50 | if (shouldAskPermissions()) {
51 | askPermissions(this)
52 | }
53 |
54 | }
55 | }
56 |
57 | private fun addSampleTestsToDB(testViewModel: TestViewModel) {
58 |
59 | if (testViewModel.testCount.value == 0) {
60 | testViewModel.saveNewTest(
61 | "paris.bbr.iperf.bytel.fr",
62 | 9220,
63 | 100,
64 | 1,
65 | true,
66 | false
67 | )
68 |
69 | testViewModel.saveNewTest(
70 | "paris.bbr.iperf.bytel.fr",
71 | 9221,
72 | 100,
73 | 1,
74 | false,
75 | false
76 | )
77 |
78 | testViewModel.saveNewTest(
79 | "ch.iperf.014.fr",
80 | 15317,
81 | 100,
82 | 1,
83 | true,
84 | false
85 | )
86 |
87 | testViewModel.saveNewTest(
88 | "ch.iperf.014.fr",
89 | 15318,
90 | 100,
91 | 1,
92 | false,
93 | false
94 | )
95 | }
96 | }
97 |
98 | private fun shouldAskPermissions(): Boolean {
99 | return (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1)
100 | }
101 |
102 | //@TargetApi(23)
103 | fun askPermissions(myActivity: Activity) {
104 | val permissions = arrayOf(
105 | Manifest.permission.READ_EXTERNAL_STORAGE,
106 | Manifest.permission.WRITE_EXTERNAL_STORAGE,
107 | Manifest.permission.ACCESS_FINE_LOCATION,
108 | Manifest.permission.ACCESS_COARSE_LOCATION,
109 | Manifest.permission.READ_PHONE_STATE
110 | )
111 | val requestCode = 200
112 | requestPermissions(myActivity, permissions, requestCode)
113 | }
114 |
115 | private fun checkPermissions(context: Context) {
116 | if (ActivityCompat.checkSelfPermission(
117 | context,
118 | Manifest.permission.ACCESS_FINE_LOCATION
119 | ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
120 | context,
121 | Manifest.permission.ACCESS_COARSE_LOCATION
122 | ) != PackageManager.PERMISSION_GRANTED
123 | ) {
124 | // TODO: Consider calling
125 | // ActivityCompat#requestPermissions
126 | // here to request the missing permissions, and then overriding
127 | // public void onRequestPermissionsResult(int requestCode, String[] permissions,
128 | // int[] grantResults)
129 | // to handle the case where the user grants the permission. See the documentation
130 | // for ActivityCompat#requestPermissions for more details.
131 | return
132 | }
133 | }
134 |
135 | @Composable
136 | fun Greeting(name: String, modifier: Modifier = Modifier) {
137 | Text(
138 | text = "Hello $name!",
139 | modifier = modifier
140 | )
141 | }
142 |
143 | @Preview(showBackground = true)
144 | @Composable
145 | fun GreetingPreview() {
146 | IPerf3ClientTheme {
147 | Greeting("Android")
148 | }
149 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/ui/ui/MeasurementsScreen.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.ui
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.heightIn
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.wrapContentHeight
11 | import androidx.compose.foundation.lazy.LazyColumn
12 | import androidx.compose.foundation.lazy.items
13 | import androidx.compose.material.icons.Icons
14 | import androidx.compose.material.icons.filled.Delete
15 | import androidx.compose.material.icons.filled.KeyboardArrowDown
16 | import androidx.compose.material.icons.filled.KeyboardArrowUp
17 | import androidx.compose.material3.Card
18 | import androidx.compose.material3.CardDefaults
19 | import androidx.compose.material3.Icon
20 | import androidx.compose.material3.IconButton
21 | import androidx.compose.material3.Text
22 | import androidx.compose.runtime.Composable
23 | import androidx.compose.runtime.collectAsState
24 | import androidx.compose.runtime.getValue
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.graphics.Color
28 | import androidx.compose.ui.text.font.FontWeight
29 | import androidx.compose.ui.unit.dp
30 | import com.example.iperf3client.data.ExecutedTestConfig
31 | import com.example.iperf3client.viewmodels.TestViewModel
32 |
33 | @Composable
34 | fun MeasurementsScreen(
35 | testViewModel: TestViewModel,
36 | modifier: Modifier = Modifier,
37 | onCancelButtonClicked: () -> Unit,
38 | onItemClick: (tid: Int?) -> Unit,
39 | onShareClick: () -> Unit
40 | ) {
41 |
42 | val testResults by testViewModel.executedTestsList.collectAsState()
43 |
44 | Column {
45 | Row( verticalAlignment = Alignment.CenterVertically) {
46 | Text(
47 | text = "Tests Results: ",
48 | modifier = Modifier.weight(7f)
49 | )
50 | }
51 | ExecutedTestsList(
52 | testResults,
53 | onDeleteTestResultsClicked = { executedTest ->
54 | testViewModel.deleteExecutedTestsWithResults(
55 | executedTest
56 | )
57 | },
58 | onItemClick
59 | )
60 | }
61 | }
62 |
63 | @Composable
64 | fun ExecutedTestsList(
65 | testResults: List,
66 | onDeleteTestResultsClicked: (executedTestId: ExecutedTestConfig) -> Unit,
67 | onItemClick: (tid: Int?) -> Unit
68 | ) {
69 | LazyColumn(
70 | modifier = Modifier
71 | .wrapContentHeight()
72 | .heightIn(max = 1000.dp)
73 | ) {
74 | items(testResults) { item ->
75 | Card(
76 | modifier = Modifier
77 | .fillMaxWidth()
78 | .padding(10.dp)
79 | .clickable {
80 | onItemClick(item.tid)
81 | },
82 | elevation = CardDefaults.cardElevation(10.dp),
83 | ) {
84 | Row {
85 | Column(modifier = Modifier.weight(5f)) {
86 | Text(text = item.date)
87 | }
88 | Column(modifier = Modifier.weight(1f)) {
89 | IconButton(
90 | onClick = {
91 | onDeleteTestResultsClicked(item)
92 | }
93 | ) {
94 | Icon(
95 | imageVector = Icons.Default.Delete,
96 | contentDescription = null
97 | )
98 | }
99 | }
100 | }
101 |
102 | /*
103 | CACHO: update [ ID] Interval Transfer Bandwidth Retr
104 | CACHO: update [ 65] 0.00-10.04 sec 445 MBytes 372 Mbits/sec 0 sender
105 | CACHO: update [ 65] 0.00-10.04 sec 443 MBytes 370 Mbits/sec receiver
106 | */
107 |
108 | Row(horizontalArrangement = Arrangement.SpaceBetween) {
109 | Text(
110 | text = "BW: ${item.bandwidth}",
111 | Modifier.weight(1f),
112 | fontWeight = FontWeight.Bold,
113 | color = Color.Cyan
114 | )
115 | Text(
116 | text = "Trans: ${item.transfer}",
117 | Modifier.weight(1f),
118 | fontWeight = FontWeight.Bold,
119 | color = Color.Green
120 | )
121 |
122 | }
123 | Row(horizontalArrangement = Arrangement.SpaceBetween) {
124 | Icon(
125 | imageVector = if (item.reverse) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowUp,
126 | "",
127 | Modifier.weight(1f, false)
128 | )
129 | Text(text = "Test: ${item.tid}", Modifier.weight(1f))
130 | Text(text = "Duration: ${item.duration}", Modifier.weight(1f))
131 | }
132 |
133 | Row {
134 | Text(text = "Server: ${item.server}", Modifier.weight(1f), color = Color.Yellow)
135 | Text(text = "Port: ${item.port}", Modifier.weight(1f))
136 | Text(text = if (item.upd) "UDP" else "TCP", Modifier.weight(1f))
137 |
138 |
139 | }
140 |
141 | }
142 |
143 | }
144 | }
145 |
146 |
147 | }
148 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/app/schemas/com.example.iperf3client.data.TestDatabase/1.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 1,
5 | "identityHash": "9154be7e38075b4b5191ab670c225cee",
6 | "entities": [
7 | {
8 | "tableName": "TestUiState",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tid` INTEGER PRIMARY KEY AUTOINCREMENT, `server` TEXT NOT NULL, `port` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `interval` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `output` TEXT NOT NULL, `favourite` INTEGER NOT NULL)",
10 | "fields": [
11 | {
12 | "fieldPath": "tid",
13 | "columnName": "tid",
14 | "affinity": "INTEGER",
15 | "notNull": false
16 | },
17 | {
18 | "fieldPath": "server",
19 | "columnName": "server",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "port",
25 | "columnName": "port",
26 | "affinity": "INTEGER",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "duration",
31 | "columnName": "duration",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "interval",
37 | "columnName": "interval",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "reverse",
43 | "columnName": "reverse",
44 | "affinity": "INTEGER",
45 | "notNull": true
46 | },
47 | {
48 | "fieldPath": "output",
49 | "columnName": "output",
50 | "affinity": "TEXT",
51 | "notNull": true
52 | },
53 | {
54 | "fieldPath": "fav",
55 | "columnName": "favourite",
56 | "affinity": "INTEGER",
57 | "notNull": true
58 | }
59 | ],
60 | "primaryKey": {
61 | "autoGenerate": true,
62 | "columnNames": [
63 | "tid"
64 | ]
65 | },
66 | "indices": [],
67 | "foreignKeys": []
68 | },
69 | {
70 | "tableName": "ExecutedTestConfig",
71 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tid` INTEGER PRIMARY KEY AUTOINCREMENT, `server` TEXT NOT NULL, `port` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `interval` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `output` TEXT NOT NULL, `date` TEXT NOT NULL, `bandwidth` TEXT NOT NULL, `transfer` TEXT NOT NULL)",
72 | "fields": [
73 | {
74 | "fieldPath": "tid",
75 | "columnName": "tid",
76 | "affinity": "INTEGER",
77 | "notNull": false
78 | },
79 | {
80 | "fieldPath": "server",
81 | "columnName": "server",
82 | "affinity": "TEXT",
83 | "notNull": true
84 | },
85 | {
86 | "fieldPath": "port",
87 | "columnName": "port",
88 | "affinity": "INTEGER",
89 | "notNull": true
90 | },
91 | {
92 | "fieldPath": "duration",
93 | "columnName": "duration",
94 | "affinity": "INTEGER",
95 | "notNull": true
96 | },
97 | {
98 | "fieldPath": "interval",
99 | "columnName": "interval",
100 | "affinity": "INTEGER",
101 | "notNull": true
102 | },
103 | {
104 | "fieldPath": "reverse",
105 | "columnName": "reverse",
106 | "affinity": "INTEGER",
107 | "notNull": true
108 | },
109 | {
110 | "fieldPath": "output",
111 | "columnName": "output",
112 | "affinity": "TEXT",
113 | "notNull": true
114 | },
115 | {
116 | "fieldPath": "date",
117 | "columnName": "date",
118 | "affinity": "TEXT",
119 | "notNull": true
120 | },
121 | {
122 | "fieldPath": "bandwidth",
123 | "columnName": "bandwidth",
124 | "affinity": "TEXT",
125 | "notNull": true
126 | },
127 | {
128 | "fieldPath": "transfer",
129 | "columnName": "transfer",
130 | "affinity": "TEXT",
131 | "notNull": true
132 | }
133 | ],
134 | "primaryKey": {
135 | "autoGenerate": true,
136 | "columnNames": [
137 | "tid"
138 | ]
139 | },
140 | "indices": [],
141 | "foreignKeys": []
142 | },
143 | {
144 | "tableName": "ExecutedTestResults",
145 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tid` INTEGER NOT NULL, `measurment` TEXT NOT NULL, `resultID` INTEGER PRIMARY KEY AUTOINCREMENT, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `altitude` REAL NOT NULL, `networkType` TEXT NOT NULL, FOREIGN KEY(`tid`) REFERENCES `ExecutedTestConfig`(`tid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
146 | "fields": [
147 | {
148 | "fieldPath": "tid",
149 | "columnName": "tid",
150 | "affinity": "INTEGER",
151 | "notNull": true
152 | },
153 | {
154 | "fieldPath": "measurment",
155 | "columnName": "measurment",
156 | "affinity": "TEXT",
157 | "notNull": true
158 | },
159 | {
160 | "fieldPath": "resultID",
161 | "columnName": "resultID",
162 | "affinity": "INTEGER",
163 | "notNull": false
164 | },
165 | {
166 | "fieldPath": "latitude",
167 | "columnName": "latitude",
168 | "affinity": "REAL",
169 | "notNull": true
170 | },
171 | {
172 | "fieldPath": "longitude",
173 | "columnName": "longitude",
174 | "affinity": "REAL",
175 | "notNull": true
176 | },
177 | {
178 | "fieldPath": "altitude",
179 | "columnName": "altitude",
180 | "affinity": "REAL",
181 | "notNull": true
182 | },
183 | {
184 | "fieldPath": "networkType",
185 | "columnName": "networkType",
186 | "affinity": "TEXT",
187 | "notNull": true
188 | }
189 | ],
190 | "primaryKey": {
191 | "autoGenerate": true,
192 | "columnNames": [
193 | "resultID"
194 | ]
195 | },
196 | "indices": [
197 | {
198 | "name": "index_ExecutedTestResults_tid",
199 | "unique": false,
200 | "columnNames": [
201 | "tid"
202 | ],
203 | "orders": [],
204 | "createSql": "CREATE INDEX IF NOT EXISTS `index_ExecutedTestResults_tid` ON `${TABLE_NAME}` (`tid`)"
205 | }
206 | ],
207 | "foreignKeys": [
208 | {
209 | "table": "ExecutedTestConfig",
210 | "onDelete": "CASCADE",
211 | "onUpdate": "NO ACTION",
212 | "columns": [
213 | "tid"
214 | ],
215 | "referencedColumns": [
216 | "tid"
217 | ]
218 | }
219 | ]
220 | }
221 | ],
222 | "views": [],
223 | "setupQueries": [
224 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
225 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9154be7e38075b4b5191ab670c225cee')"
226 | ]
227 | }
228 | }
--------------------------------------------------------------------------------
/app/schemas/com.example.iperf3client.data.TestDatabase/2.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 2,
5 | "identityHash": "f742c4b12931856f1521ee70012b93d8",
6 | "entities": [
7 | {
8 | "tableName": "TestUiState",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tid` INTEGER PRIMARY KEY AUTOINCREMENT, `server` TEXT NOT NULL, `port` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `interval` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `output` TEXT NOT NULL, `favourite` INTEGER NOT NULL, `udp` INTEGER NOT NULL DEFAULT false)",
10 | "fields": [
11 | {
12 | "fieldPath": "tid",
13 | "columnName": "tid",
14 | "affinity": "INTEGER",
15 | "notNull": false
16 | },
17 | {
18 | "fieldPath": "server",
19 | "columnName": "server",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "port",
25 | "columnName": "port",
26 | "affinity": "INTEGER",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "duration",
31 | "columnName": "duration",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "interval",
37 | "columnName": "interval",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "reverse",
43 | "columnName": "reverse",
44 | "affinity": "INTEGER",
45 | "notNull": true
46 | },
47 | {
48 | "fieldPath": "output",
49 | "columnName": "output",
50 | "affinity": "TEXT",
51 | "notNull": true
52 | },
53 | {
54 | "fieldPath": "fav",
55 | "columnName": "favourite",
56 | "affinity": "INTEGER",
57 | "notNull": true
58 | },
59 | {
60 | "fieldPath": "udp",
61 | "columnName": "udp",
62 | "affinity": "INTEGER",
63 | "notNull": true,
64 | "defaultValue": "false"
65 | }
66 | ],
67 | "primaryKey": {
68 | "autoGenerate": true,
69 | "columnNames": [
70 | "tid"
71 | ]
72 | },
73 | "indices": [],
74 | "foreignKeys": []
75 | },
76 | {
77 | "tableName": "ExecutedTestConfig",
78 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tid` INTEGER PRIMARY KEY AUTOINCREMENT, `server` TEXT NOT NULL, `port` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `interval` INTEGER NOT NULL, `reverse` INTEGER NOT NULL, `output` TEXT NOT NULL, `date` TEXT NOT NULL, `bandwidth` TEXT NOT NULL, `transfer` TEXT NOT NULL, `udp` INTEGER NOT NULL DEFAULT false)",
79 | "fields": [
80 | {
81 | "fieldPath": "tid",
82 | "columnName": "tid",
83 | "affinity": "INTEGER",
84 | "notNull": false
85 | },
86 | {
87 | "fieldPath": "server",
88 | "columnName": "server",
89 | "affinity": "TEXT",
90 | "notNull": true
91 | },
92 | {
93 | "fieldPath": "port",
94 | "columnName": "port",
95 | "affinity": "INTEGER",
96 | "notNull": true
97 | },
98 | {
99 | "fieldPath": "duration",
100 | "columnName": "duration",
101 | "affinity": "INTEGER",
102 | "notNull": true
103 | },
104 | {
105 | "fieldPath": "interval",
106 | "columnName": "interval",
107 | "affinity": "INTEGER",
108 | "notNull": true
109 | },
110 | {
111 | "fieldPath": "reverse",
112 | "columnName": "reverse",
113 | "affinity": "INTEGER",
114 | "notNull": true
115 | },
116 | {
117 | "fieldPath": "output",
118 | "columnName": "output",
119 | "affinity": "TEXT",
120 | "notNull": true
121 | },
122 | {
123 | "fieldPath": "date",
124 | "columnName": "date",
125 | "affinity": "TEXT",
126 | "notNull": true
127 | },
128 | {
129 | "fieldPath": "bandwidth",
130 | "columnName": "bandwidth",
131 | "affinity": "TEXT",
132 | "notNull": true
133 | },
134 | {
135 | "fieldPath": "transfer",
136 | "columnName": "transfer",
137 | "affinity": "TEXT",
138 | "notNull": true
139 | },
140 | {
141 | "fieldPath": "upd",
142 | "columnName": "udp",
143 | "affinity": "INTEGER",
144 | "notNull": true,
145 | "defaultValue": "false"
146 | }
147 | ],
148 | "primaryKey": {
149 | "autoGenerate": true,
150 | "columnNames": [
151 | "tid"
152 | ]
153 | },
154 | "indices": [],
155 | "foreignKeys": []
156 | },
157 | {
158 | "tableName": "ExecutedTestResults",
159 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tid` INTEGER NOT NULL, `measurment` TEXT NOT NULL, `resultID` INTEGER PRIMARY KEY AUTOINCREMENT, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `altitude` REAL NOT NULL, `networkType` TEXT NOT NULL, FOREIGN KEY(`tid`) REFERENCES `ExecutedTestConfig`(`tid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
160 | "fields": [
161 | {
162 | "fieldPath": "tid",
163 | "columnName": "tid",
164 | "affinity": "INTEGER",
165 | "notNull": true
166 | },
167 | {
168 | "fieldPath": "measurment",
169 | "columnName": "measurment",
170 | "affinity": "TEXT",
171 | "notNull": true
172 | },
173 | {
174 | "fieldPath": "resultID",
175 | "columnName": "resultID",
176 | "affinity": "INTEGER",
177 | "notNull": false
178 | },
179 | {
180 | "fieldPath": "latitude",
181 | "columnName": "latitude",
182 | "affinity": "REAL",
183 | "notNull": true
184 | },
185 | {
186 | "fieldPath": "longitude",
187 | "columnName": "longitude",
188 | "affinity": "REAL",
189 | "notNull": true
190 | },
191 | {
192 | "fieldPath": "altitude",
193 | "columnName": "altitude",
194 | "affinity": "REAL",
195 | "notNull": true
196 | },
197 | {
198 | "fieldPath": "networkType",
199 | "columnName": "networkType",
200 | "affinity": "TEXT",
201 | "notNull": true
202 | }
203 | ],
204 | "primaryKey": {
205 | "autoGenerate": true,
206 | "columnNames": [
207 | "resultID"
208 | ]
209 | },
210 | "indices": [
211 | {
212 | "name": "index_ExecutedTestResults_tid",
213 | "unique": false,
214 | "columnNames": [
215 | "tid"
216 | ],
217 | "orders": [],
218 | "createSql": "CREATE INDEX IF NOT EXISTS `index_ExecutedTestResults_tid` ON `${TABLE_NAME}` (`tid`)"
219 | }
220 | ],
221 | "foreignKeys": [
222 | {
223 | "table": "ExecutedTestConfig",
224 | "onDelete": "CASCADE",
225 | "onUpdate": "NO ACTION",
226 | "columns": [
227 | "tid"
228 | ],
229 | "referencedColumns": [
230 | "tid"
231 | ]
232 | }
233 | ]
234 | }
235 | ],
236 | "views": [],
237 | "setupQueries": [
238 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
239 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f742c4b12931856f1521ee70012b93d8')"
240 | ]
241 | }
242 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/ui/ui/NewTestScreen.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.ui
2 |
3 | import android.util.Log
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.layout.width
12 | import androidx.compose.foundation.text.KeyboardOptions
13 | import androidx.compose.material.icons.Icons
14 | import androidx.compose.material.icons.filled.Delete
15 | import androidx.compose.material3.Button
16 | import androidx.compose.material3.Checkbox
17 | import androidx.compose.material3.Icon
18 | import androidx.compose.material3.IconButton
19 | import androidx.compose.material3.OutlinedTextField
20 | import androidx.compose.material3.Text
21 | import androidx.compose.runtime.Composable
22 | import androidx.compose.runtime.collectAsState
23 | import androidx.compose.runtime.getValue
24 | import androidx.compose.runtime.mutableIntStateOf
25 | import androidx.compose.runtime.mutableStateOf
26 | import androidx.compose.runtime.saveable.rememberSaveable
27 | import androidx.compose.runtime.setValue
28 | import androidx.compose.ui.Alignment
29 | import androidx.compose.ui.Modifier
30 | import androidx.compose.ui.platform.LocalContext
31 | import androidx.compose.ui.text.input.KeyboardType
32 | import androidx.compose.ui.unit.dp
33 | import androidx.core.text.isDigitsOnly
34 | import com.example.iperf3client.ui.ui.ResultsTableScreen
35 | import com.example.iperf3client.viewmodels.TestViewModel
36 |
37 | @Composable
38 | fun NewTestScreen(
39 | testViewModel: TestViewModel,
40 | modifier: Modifier = Modifier,
41 | onCancelButtonClicked: () -> Unit
42 | ) {
43 |
44 | Log.wtf("CACHO:", "RunningTestScreen START")
45 |
46 | val testResults by testViewModel.testResults.collectAsState()
47 | val testUiState by testViewModel.uiState.collectAsState()
48 | val isTestRunningState by testViewModel.isIPerfTestRunning.collectAsState()
49 | val senderTransfer by testViewModel.enderTransfer.collectAsState()
50 | val senderBandwidth by testViewModel.senderBandwidth.collectAsState()
51 | val receiverTransfer by testViewModel.receiverTransfer.collectAsState()
52 | val receiverBandwidth by testViewModel.receiverBandwidth.collectAsState()
53 |
54 | var server by rememberSaveable { mutableStateOf(testUiState.server) }
55 | var port by rememberSaveable { mutableIntStateOf(testUiState.port) }
56 | var duration by rememberSaveable { mutableIntStateOf(testUiState.duration) }
57 | var interval by rememberSaveable { mutableIntStateOf(testUiState.interval) }
58 | var reverse by rememberSaveable { mutableStateOf(testUiState.reverse) }
59 | var udp by rememberSaveable { mutableStateOf(testUiState.udp) }
60 | var isSaved by rememberSaveable { mutableStateOf(false) }
61 |
62 | var context = LocalContext.current
63 |
64 | Column(
65 | modifier = modifier.padding(horizontal = 2.dp, vertical = 10.dp)
66 | ) {
67 | Row(
68 | horizontalArrangement = Arrangement.SpaceBetween,
69 | verticalAlignment = Alignment.CenterVertically
70 | ) {
71 | Text(text = "Test: ${testUiState.tid ?: "New"}", modifier = Modifier
72 | .weight(7f))
73 | IconButton(modifier = Modifier
74 | .weight(1f),
75 | enabled = testUiState.tid!=null,
76 | onClick = {
77 | testViewModel.deleteTest(testUiState)
78 | testViewModel.getTests()
79 | //testUiState.tid=null
80 | }
81 | ) {
82 | Icon(
83 | imageVector = Icons.Default.Delete,
84 | contentDescription = null
85 | )
86 | }
87 | }
88 | Row(
89 | //modifier = Modifier.fillMaxWidth().padding(5.dp),
90 | horizontalArrangement = Arrangement.SpaceEvenly,
91 | verticalAlignment = Alignment.CenterVertically
92 | ) {
93 | OutlinedTextField(
94 | modifier = Modifier.weight(3f),
95 | value = server,
96 | maxLines = 1,
97 | onValueChange = {
98 | server = it
99 | isSaved = false
100 | },
101 | label = { Text("Server:") }
102 | )
103 |
104 | Spacer(Modifier.width(10.dp))
105 |
106 | OutlinedTextField(
107 | modifier = Modifier.weight(1f),
108 | value = port.toString(),
109 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
110 | onValueChange = {
111 | if (it.isDigitsOnly() and (it.length < 6) ) {
112 | if (it=="") port = 0
113 | else port = it.toInt()
114 | }
115 |
116 | isSaved = false
117 | },
118 | label = { Text("Port:") },
119 | )
120 | }
121 |
122 | Row(
123 | //modifier = Modifier.fillMaxWidth().padding(5.dp),
124 | horizontalArrangement = Arrangement.SpaceEvenly,
125 | verticalAlignment = Alignment.CenterVertically
126 | ) {
127 |
128 |
129 | OutlinedTextField(
130 | modifier = Modifier.weight(1f),
131 | value = duration.toString(),
132 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
133 | onValueChange = {
134 | if (it.isDigitsOnly() and (it.length <= 4) and (it.isNotEmpty()))
135 | duration = it.toInt()
136 |
137 | isSaved = false
138 | },
139 | label = { Text("Duration:") },
140 | )
141 |
142 | Spacer(Modifier.width(10.dp))
143 | OutlinedTextField(
144 | modifier = Modifier.weight(1f),
145 | value = interval.toString(),
146 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
147 | onValueChange = {
148 | if (it.isDigitsOnly() and (it.length <= 2) and (it.isNotEmpty()))
149 | interval = it.toInt()
150 |
151 | isSaved = false
152 | },
153 | label = { Text("Interval:") },
154 | )
155 | }
156 |
157 | Row(
158 | modifier = Modifier.fillMaxWidth(),
159 | horizontalArrangement = Arrangement.SpaceBetween
160 | ) {
161 | Text("Reverse")
162 | Checkbox(
163 | checked = reverse,
164 | onCheckedChange = {
165 | reverse = it
166 | isSaved = false
167 | }
168 | )
169 |
170 | Text("UDP")
171 | Checkbox(
172 | checked = udp,
173 | onCheckedChange = {
174 | udp = it
175 | isSaved = false
176 | }
177 | )
178 |
179 | }
180 |
181 | Row(
182 | horizontalArrangement = Arrangement.SpaceBetween,
183 | modifier = Modifier
184 | .fillMaxWidth()
185 | .padding(8.dp)
186 | ) {
187 | Button(
188 | enabled = !isTestRunningState,
189 | onClick = {
190 | testViewModel.runIperfTest(
191 | server,
192 | port,
193 | duration,
194 | interval,
195 | reverse,
196 | udp,
197 | context
198 | )
199 | }) {
200 | Text("Run")
201 | }
202 |
203 | Button(
204 | enabled = !isSaved,
205 | onClick = {
206 | if (port >= 0) {
207 | testViewModel.saveUpdateTest(
208 | server,
209 | port,
210 | duration,
211 | interval,
212 | reverse,
213 | udp
214 | )
215 | isSaved = true
216 | }
217 | }) {
218 | Text("Save")
219 | }
220 |
221 | Button(
222 | enabled = isTestRunningState,
223 | onClick = {
224 | testViewModel.cancelIperfJob()
225 |
226 | }
227 | ) {
228 | Text("Stop")
229 | }
230 | }
231 |
232 |
233 |
234 | Spacer(Modifier.height(10.dp))
235 |
236 | Row {
237 | Text("Sender:", modifier = Modifier.weight(1f))
238 | Text("Transfer \n$senderTransfer", modifier = Modifier.weight(2f))
239 | Text("Bandwidth \n$senderBandwidth", modifier = Modifier.weight(2f))
240 | }
241 | Row {
242 | Text("Receiver:", modifier = Modifier.weight(1f))
243 | Text("Transfer \n$receiverTransfer", modifier = Modifier.weight(2f))
244 | Text("Bandwidth \n$receiverBandwidth", modifier = Modifier.weight(2f))
245 | }
246 |
247 | Spacer(Modifier.height(10.dp))
248 |
249 | ResultsTableScreen(testResults)
250 | }
251 | }
252 |
253 |
254 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/IperfStartScreen.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client
2 |
3 | import android.annotation.SuppressLint
4 | import android.content.Context
5 | import android.net.Uri
6 | import android.os.Environment
7 | import androidx.activity.compose.rememberLauncherForActivityResult
8 | import androidx.activity.result.contract.ActivityResultContracts
9 | import androidx.annotation.StringRes
10 | import androidx.compose.foundation.layout.Arrangement
11 | import androidx.compose.foundation.layout.Box
12 | import androidx.compose.foundation.layout.PaddingValues
13 | import androidx.compose.foundation.layout.Row
14 | import androidx.compose.foundation.layout.Spacer
15 | import androidx.compose.foundation.layout.fillMaxSize
16 | import androidx.compose.foundation.layout.height
17 | import androidx.compose.foundation.layout.padding
18 | import androidx.compose.material.icons.Icons
19 | import androidx.compose.material.icons.filled.Add
20 | import androidx.compose.material.icons.filled.DateRange
21 | import androidx.compose.material.icons.filled.FilterAlt
22 | import androidx.compose.material.icons.filled.Home
23 | import androidx.compose.material.icons.filled.Info
24 | import androidx.compose.material.icons.filled.Menu
25 | import androidx.compose.material.icons.filled.MoreVert
26 | import androidx.compose.material.icons.filled.Output
27 | import androidx.compose.material.icons.filled.PlayArrow
28 | import androidx.compose.material.icons.filled.Share
29 | import androidx.compose.material.icons.filled.Star
30 | import androidx.compose.material.icons.outlined.Add
31 | import androidx.compose.material.icons.outlined.DateRange
32 | import androidx.compose.material.icons.outlined.Home
33 | import androidx.compose.material.icons.outlined.Info
34 | import androidx.compose.material.icons.outlined.PlayArrow
35 | import androidx.compose.material.icons.outlined.Star
36 | import androidx.compose.material3.DrawerValue
37 | import androidx.compose.material3.DropdownMenu
38 | import androidx.compose.material3.DropdownMenuItem
39 | import androidx.compose.material3.ExperimentalMaterial3Api
40 | import androidx.compose.material3.Icon
41 | import androidx.compose.material3.IconButton
42 | import androidx.compose.material3.ModalDrawerSheet
43 | import androidx.compose.material3.ModalNavigationDrawer
44 | import androidx.compose.material3.NavigationDrawerItem
45 | import androidx.compose.material3.NavigationDrawerItemDefaults
46 | import androidx.compose.material3.Scaffold
47 | import androidx.compose.material3.Text
48 | import androidx.compose.material3.TopAppBar
49 | import androidx.compose.material3.rememberDrawerState
50 | import androidx.compose.runtime.Composable
51 | import androidx.compose.runtime.LaunchedEffect
52 | import androidx.compose.runtime.collectAsState
53 | import androidx.compose.runtime.getValue
54 | import androidx.compose.runtime.mutableIntStateOf
55 | import androidx.compose.runtime.mutableStateOf
56 | import androidx.compose.runtime.remember
57 | import androidx.compose.runtime.rememberCoroutineScope
58 | import androidx.compose.runtime.saveable.rememberSaveable
59 | import androidx.compose.runtime.setValue
60 | import androidx.compose.ui.Alignment
61 | import androidx.compose.ui.Modifier
62 | import androidx.compose.ui.graphics.vector.ImageVector
63 | import androidx.compose.ui.platform.LocalContext
64 | import androidx.compose.ui.res.dimensionResource
65 | import androidx.compose.ui.unit.dp
66 | import androidx.navigation.NavHostController
67 | import androidx.navigation.compose.NavHost
68 | import androidx.navigation.compose.composable
69 | import androidx.navigation.compose.currentBackStackEntryAsState
70 | import androidx.navigation.compose.rememberNavController
71 | import com.example.iperf3client.data.TestDatabase
72 | import com.example.iperf3client.ui.MeasurementsScreen
73 | import com.example.iperf3client.ui.NewTestScreen
74 | import com.example.iperf3client.ui.SavedTestsScreen
75 | import com.example.iperf3client.ui.ui.AboutScreen
76 | import com.example.iperf3client.ui.ui.RunningTestScreen
77 | import com.example.iperf3client.ui.ui.TestFilterForm
78 | import com.example.iperf3client.ui.ui.WelcomeScreen
79 | import com.example.iperf3client.ui.ui.navigator.NavigationItems
80 | import com.example.iperf3client.utils.DbUtils
81 | import com.example.iperf3client.utils.DbUtils.Companion.shareRoomDatabase
82 | import com.example.iperf3client.viewmodels.TestViewModel
83 | import kotlinx.coroutines.launch
84 | import java.io.FileInputStream
85 |
86 | /**
87 | * enum values that represent the screens in the app
88 | */
89 | enum class IperfScreen(@StringRes val title: Int) {
90 | Start(title = R.string.app_name),
91 | NewTest(title = R.string.new_test),
92 | RunningTest(title = R.string.running_test),
93 | SavedTests(title = R.string.saved_tests),
94 | Measurements(title = R.string.measurements),
95 | About(title = R.string.measurements)
96 |
97 | }
98 |
99 |
100 | @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
101 | @Composable
102 | fun IperfApp(
103 | testViewModel: TestViewModel,
104 | context: Context,
105 | navController: NavHostController = rememberNavController(),
106 |
107 | ) {
108 | testViewModel.getResultsCount()
109 |
110 | NavigationDrawer(navController, testViewModel, context)
111 | }
112 |
113 | @Composable
114 | fun Navigation(
115 | navController: NavHostController,
116 | innerPadding: PaddingValues,
117 | testVM: TestViewModel,
118 | context: Context
119 | ) {
120 | val testUiState by testVM.uiState.collectAsState()
121 |
122 | NavHost(
123 | navController = navController,
124 | startDestination = IperfScreen.Start.name,
125 | modifier = Modifier
126 | .fillMaxSize()
127 | //.verticalScroll(rememberScrollState())
128 | .padding(innerPadding)
129 | ) {
130 |
131 |
132 | composable(route = IperfScreen.Start.name) {
133 | WelcomeScreen(
134 | onNewTestButtonClicked = {
135 | testUiState.tid = null
136 | navController.navigate(IperfScreen.NewTest.name)
137 | },
138 | modifier = Modifier
139 | .fillMaxSize()
140 | .padding(dimensionResource(R.dimen.padding_medium)),
141 | testVM
142 | )
143 | }
144 | composable(route = IperfScreen.NewTest.name) {
145 | NewTestScreen(
146 | testVM,
147 | onCancelButtonClicked = {
148 | cancelOrderAndNavigateToStart(navController)
149 | },
150 | modifier = Modifier
151 | .fillMaxSize()
152 | .padding(dimensionResource(R.dimen.padding_medium))
153 | )
154 | }
155 | composable(route = IperfScreen.RunningTest.name) {
156 | RunningTestScreen(
157 | testVM,
158 | onCancelButtonClicked = {
159 | cancelOrderAndNavigateToStart(navController)
160 | },
161 | modifier = Modifier
162 | .fillMaxSize()
163 | .padding(dimensionResource(R.dimen.padding_medium))
164 | )
165 | }
166 | composable(route = IperfScreen.SavedTests.name) {
167 | SavedTestsScreen(
168 | testVM,
169 | onCancelButtonClicked = {
170 | cancelOrderAndNavigateToStart(navController)
171 | },
172 | onItemClick = {
173 | if (it != null) {
174 | testVM.getTest(it)
175 | testVM.clearTestScreen()
176 | }
177 | navController.navigate(IperfScreen.NewTest.name)
178 | },
179 | onRunTestClicked = { server, port, duration, interval, reverse, udp ->
180 | if (port != null && server != null && duration != null && interval != null && reverse != null) {
181 | testVM.runIperfTest(
182 | server,
183 | port,
184 | duration,
185 | interval,
186 | reverse,
187 | udp,
188 | context
189 | )
190 | }
191 | },
192 | modifier = Modifier
193 | .fillMaxSize()
194 | .padding(dimensionResource(R.dimen.padding_medium))
195 | )
196 | }
197 | composable(route = IperfScreen.Measurements.name) {
198 | MeasurementsScreen(
199 | testVM,
200 | onCancelButtonClicked = {
201 | cancelOrderAndNavigateToStart(navController)
202 | },
203 | onItemClick = { tid ->
204 | testVM.loadGraphAndMapExistingResults(tid)
205 | navController.navigate(IperfScreen.RunningTest.name)
206 | },
207 | modifier = Modifier
208 | .fillMaxSize()
209 | .padding(dimensionResource(R.dimen.padding_medium)),
210 | onShareClick = {
211 | DbUtils.backupDatabase(
212 | context,
213 | TestDatabase.DATABASE_NAME,
214 | Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
215 | )
216 | }
217 | )
218 | }
219 | composable(route = IperfScreen.About.name) {
220 | AboutScreen(context)
221 | }
222 | }
223 | }
224 |
225 | //navController.navigate(CupcakeScreen.Pickup.name
226 | @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
227 | @OptIn(ExperimentalMaterial3Api::class)
228 | @Composable
229 | fun NavigationDrawer(
230 | navController: NavHostController,
231 | testViewModel: TestViewModel,
232 | context: Context
233 | ) {
234 | val items = listOf(
235 | NavigationItems(
236 | title = "Home",
237 | selectedIcon = Icons.Filled.Home,
238 | unselectedIcon = Icons.Outlined.Home,
239 | screen = IperfScreen.Start
240 |
241 | ),
242 | NavigationItems(
243 | title = "Saved Tests",
244 | selectedIcon = Icons.Filled.Star,
245 | unselectedIcon = Icons.Outlined.Star,
246 | screen = IperfScreen.SavedTests
247 | ),
248 | NavigationItems(
249 | title = "New Test",
250 | selectedIcon = Icons.Filled.Add,
251 | unselectedIcon = Icons.Outlined.Add,
252 | screen = IperfScreen.NewTest,
253 | ),
254 | NavigationItems(
255 | title = "Running Test",
256 | selectedIcon = Icons.Filled.PlayArrow,
257 | unselectedIcon = Icons.Outlined.PlayArrow,
258 | screen = IperfScreen.RunningTest
259 | ),
260 | NavigationItems(
261 | title = "Results",
262 | selectedIcon = Icons.Filled.DateRange,
263 | unselectedIcon = Icons.Outlined.DateRange,
264 | screen = IperfScreen.Measurements
265 | ),
266 | NavigationItems(
267 | title = "About",
268 | selectedIcon = Icons.Filled.Info,
269 | unselectedIcon = Icons.Outlined.Info,
270 | screen = IperfScreen.About
271 | )
272 | )
273 |
274 | //Remember Clicked item state
275 | var selectedItemIndex by rememberSaveable {
276 | mutableIntStateOf(0)
277 | }
278 |
279 | val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
280 | val scope = rememberCoroutineScope()
281 |
282 | ModalNavigationDrawer(
283 | drawerState = drawerState,
284 | drawerContent = {
285 | ModalDrawerSheet {
286 | Spacer(modifier = Modifier.height(16.dp))
287 | items.forEachIndexed { index, item ->
288 | NavigationDrawerItem(
289 | label = { Text(text = item.title) },
290 | selected = index == selectedItemIndex,
291 | onClick = {
292 | when (item.screen) {
293 | IperfScreen.SavedTests -> testViewModel.getTests()
294 | IperfScreen.NewTest -> testViewModel.resetTestConfig()
295 | IperfScreen.Measurements -> testViewModel.getExecutedTests()
296 | IperfScreen.RunningTest -> {} //TODO() //testViewModel.testResults = MutableStateFlow(listOf()).asStateFlow()
297 | IperfScreen.Start -> testViewModel.getResultsCount()
298 | else -> {}
299 | }
300 | navController.navigate(item.screen.name)
301 |
302 | selectedItemIndex = index
303 | scope.launch {
304 | drawerState.close()
305 | }
306 | },
307 | icon = {
308 | Icon(
309 | imageVector = if (index == selectedItemIndex) {
310 | item.selectedIcon
311 | } else item.unselectedIcon,
312 | contentDescription = item.title
313 | )
314 | },
315 | badge = {
316 | item.badgeCount?.let {
317 | Text(text = item.badgeCount.toString())
318 | }
319 | },
320 | modifier = Modifier
321 | .padding(NavigationDrawerItemDefaults.ItemPadding)
322 | )
323 | }
324 |
325 | }
326 | },
327 | ) {
328 | Scaffold(
329 | topBar = {
330 | TopAppBar(
331 | title = {
332 | Text(text = "iPerf3")
333 | },
334 | navigationIcon = {
335 | IconButton(onClick = {
336 | scope.launch {
337 | drawerState.apply {
338 | if (isClosed) open() else close()
339 | }
340 | }
341 | }) {
342 | Icon(
343 | imageVector = Icons.Default.Menu,
344 | contentDescription = "Menu"
345 | )
346 | }
347 | },
348 | actions = {
349 | DropdownMenu(testViewModel)
350 | }
351 |
352 | )
353 | }
354 | ) { innerPadding ->
355 | Navigation(navController, innerPadding, testViewModel, context)
356 | }
357 | }
358 | }
359 |
360 | @OptIn(ExperimentalMaterial3Api::class)
361 | @Composable
362 | fun DropdownMenu(testViewModel: TestViewModel) {
363 | val context = LocalContext.current
364 | var expanded by remember { mutableStateOf(false) }
365 | var exportUri by remember { mutableStateOf(null) }
366 | var showSheet by remember { mutableStateOf(false) }
367 |
368 | val exportLauncher = rememberLauncherForActivityResult(
369 | contract = ActivityResultContracts.CreateDocument("application/octet-stream")
370 | ) { uri: Uri? ->
371 | if (uri != null) {
372 | exportUri = uri
373 | }
374 | }
375 |
376 | LaunchedEffect(exportUri) {
377 | exportUri?.let { uri ->
378 | val dbFile = context.getDatabasePath(TestDatabase.DATABASE_NAME)
379 | if (dbFile.exists()) {
380 | try {
381 | FileInputStream(dbFile).use { input ->
382 | context.contentResolver.openOutputStream(uri)?.use { output ->
383 | input.copyTo(output)
384 | }
385 | }
386 | // Success message if needed
387 | } catch (e: Exception) {
388 | e.printStackTrace()
389 | // Error message if needed
390 | }
391 | }
392 | exportUri = null
393 | }
394 | }
395 |
396 | Box(
397 | modifier = Modifier
398 | .padding(16.dp)
399 | ) {
400 | IconButton(onClick = { expanded = !expanded }) {
401 | Icon(Icons.Default.MoreVert, contentDescription = "More options")
402 | }
403 | DropdownMenu(
404 | expanded = expanded,
405 | onDismissRequest = { expanded = false }
406 | ) {
407 | val launcher =
408 | rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {}
409 |
410 | KebabMenuItem("Share", Icons.Filled.Share) {
411 | expanded = false
412 | shareRoomDatabase(
413 | context,
414 | TestDatabase.DATABASE_NAME,
415 | launcher
416 | )
417 | }
418 | KebabMenuItem("Export DB", Icons.Filled.Output) {
419 | expanded = false
420 | exportLauncher.launch(TestDatabase.DATABASE_NAME)
421 | }
422 | KebabMenuItem("Filter", Icons.Filled.FilterAlt) {
423 | expanded = false
424 | showSheet = true
425 | }
426 | }
427 | }
428 |
429 | TestFilterForm(
430 | showSheet = showSheet,
431 | onDismiss = { showSheet = false },
432 | testViewModel
433 | )
434 | }
435 |
436 | private fun cancelOrderAndNavigateToStart(
437 | navController: NavHostController
438 | ) {
439 | navController.popBackStack(IperfScreen.Start.name, inclusive = false)
440 | }
441 |
442 | @Composable
443 | private fun getCurrentScreen(navController: NavHostController): IperfScreen {
444 | // Get current back stack entry
445 | val backStackEntry by navController.currentBackStackEntryAsState()
446 | // Get the name of the current screen
447 | return IperfScreen.valueOf(
448 | backStackEntry?.destination?.route ?: IperfScreen.Start.name
449 | )
450 | }
451 |
452 | @Composable
453 | private fun KebabMenuItem(text: String, icon: ImageVector, onClick: () -> Unit) {
454 | DropdownMenuItem(
455 | text = {
456 | Row(
457 | verticalAlignment = Alignment.CenterVertically,
458 | horizontalArrangement = Arrangement.spacedBy(8.dp)
459 | ) {
460 | Icon(
461 | imageVector = icon,
462 | contentDescription = "",
463 | )
464 | Text(text)
465 | }
466 | },
467 | onClick = onClick
468 | )
469 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/example/iperf3client/viewmodels/TestViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.example.iperf3client.viewmodels
2 |
3 |
4 | import android.content.Context
5 | import android.util.Log
6 | import androidx.lifecycle.ViewModel
7 | import androidx.lifecycle.viewModelScope
8 | import com.example.iperf3client.data.ExecutedTestConfig
9 | import com.example.iperf3client.data.LocationRepository
10 | import com.example.iperf3client.data.MeasLatLon
11 | import com.example.iperf3client.data.NetworkInfoRepository
12 | import com.example.iperf3client.data.ResultsRepository
13 | import com.example.iperf3client.data.TestDatabase
14 | import com.example.iperf3client.data.TestRepository
15 | import com.example.iperf3client.data.TestUiState
16 | import com.example.iperf3client.utils.IpersOutputParser
17 | import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
18 | import com.patrykandpatrick.vico.core.cartesian.data.lineSeries
19 | import com.synaptictools.iperf.IPerf
20 | import com.synaptictools.iperf.IPerfConfig
21 | import kotlinx.coroutines.CoroutineDispatcher
22 | import kotlinx.coroutines.Dispatchers
23 | import kotlinx.coroutines.Job
24 | import kotlinx.coroutines.flow.MutableStateFlow
25 | import kotlinx.coroutines.flow.StateFlow
26 | import kotlinx.coroutines.flow.asStateFlow
27 | import kotlinx.coroutines.flow.update
28 | import kotlinx.coroutines.launch
29 | import kotlinx.coroutines.withContext
30 | import org.osmdroid.util.GeoPoint
31 | import java.io.File
32 | import java.text.SimpleDateFormat
33 | import java.util.Calendar
34 | import java.util.Locale
35 | import kotlin.collections.last
36 |
37 | class TestViewModel(applicationContext: Context, testDB: TestDatabase) : ViewModel() {
38 | private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
39 | private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
40 |
41 | private val resultBuilder: StringBuilder? = StringBuilder()
42 | private val repository = TestRepository.getInstance(testDB)
43 | private val resultsRepository = ResultsRepository.getInstance(applicationContext)
44 | private val locationRepository = LocationRepository.getInstance(applicationContext)
45 | private val networkInfoRepository = NetworkInfoRepository.getInstance(applicationContext)
46 | private val modelProd: CartesianChartModelProducer = CartesianChartModelProducer()
47 |
48 | private var _mapMarkers = MutableStateFlow(listOf(SpeedMapMarker(GeoPoint(0.0, 0.0), 0F)))
49 | private var _transferArray = MutableStateFlow(listOf(0F))
50 | private var _bwArray = MutableStateFlow(listOf(0F))
51 | private val _uiStateFlow = MutableStateFlow(newTestConfig())
52 | private val _filterStateFlow = MutableStateFlow(FilterState())
53 | private val _iPerfRequestResultFlow = MutableStateFlow("")
54 | private var _isIPerfTestRunningFlow = MutableStateFlow(false)
55 | private var _testResults = MutableStateFlow(listOf())
56 | private var _loadedTestResults = MutableStateFlow(listOf())
57 | private val _testListFlow = MutableStateFlow(listOf())
58 | private val _testCountFlow = MutableStateFlow(0)
59 | private val _modelProducer = MutableStateFlow(modelProd)
60 | private val _senderTransfer = MutableStateFlow("")
61 | private val _senderBandwidth = MutableStateFlow("")
62 | private val _receiverTransfer = MutableStateFlow("")
63 | private val _receiverBandwidth = MutableStateFlow("")
64 |
65 | private val _resultsCount = MutableStateFlow(0)
66 |
67 | private val _executedTestsList = MutableStateFlow(listOf())
68 |
69 | var uiState: StateFlow = _uiStateFlow.asStateFlow()
70 | var filterState: StateFlow = _filterStateFlow.asStateFlow()
71 | var isIPerfTestRunning: StateFlow = _isIPerfTestRunningFlow.asStateFlow()
72 | var testList: StateFlow> = _testListFlow.asStateFlow()
73 | var testCount: StateFlow = _testCountFlow.asStateFlow()
74 | var testResults: StateFlow> = _testResults.asStateFlow()
75 | val locationFlow = locationRepository.locationFlow
76 | var modelProducer = _modelProducer.asStateFlow()
77 | var executedTestsList: StateFlow> = _executedTestsList.asStateFlow()
78 |
79 | var enderTransfer = _senderTransfer.asStateFlow()
80 | var senderBandwidth = _senderBandwidth.asStateFlow()
81 | var receiverTransfer = _receiverTransfer.asStateFlow()
82 | var receiverBandwidth = _receiverBandwidth.asStateFlow()
83 | var resultsCount = _resultsCount.asStateFlow()
84 | var mapMarker = _mapMarkers.asStateFlow()
85 |
86 | private var filesDir: File = applicationContext.applicationContext.filesDir
87 | private lateinit var iperfJob: Job
88 |
89 | private var resultID: Long = 0
90 |
91 | fun clearTestScreen() {
92 | if (!_isIPerfTestRunningFlow.value) {
93 | _senderTransfer.value = ""
94 | _senderBandwidth.value = ""
95 | _receiverTransfer.value = ""
96 | _receiverBandwidth.value = ""
97 | _transferArray.value = listOf(0F)
98 | _bwArray.value = listOf(0F)
99 | modelProducer(_bwArray.value, _transferArray.value)
100 | locationRepository.tracker.stopLocationUpdates()
101 | }
102 | }
103 |
104 | fun saveFilterState(tcp: Boolean, udp: Boolean, upload: Boolean, download: Boolean) {
105 | _filterStateFlow.value = FilterState("", tcp, udp, upload, download)
106 | }
107 |
108 | /**
109 | *
110 | */
111 | fun saveUpdateTest(
112 | server: String,
113 | port: Int,
114 | duration: Int,
115 | interval: Int,
116 | reverse: Boolean,
117 | udp: Boolean
118 | ) {
119 | _uiStateFlow.value = TestUiState(
120 | _uiStateFlow.value.tid,
121 | server,
122 | port,
123 | duration,
124 | interval,
125 | reverse,
126 | "",
127 | true,
128 | udp
129 | )
130 | viewModelScope.launch(Dispatchers.IO) { //this: CoroutineScope
131 | if (_uiStateFlow.value.tid == null) {
132 | _uiStateFlow.value = repository.createTest(_uiStateFlow.value)
133 | } else {
134 | repository.updateTest(_uiStateFlow.value)
135 | }
136 | }
137 | }
138 |
139 |
140 | fun saveNewTest(
141 | server: String,
142 | port: Int,
143 | duration: Int,
144 | interval: Int,
145 | reverse: Boolean,
146 | udp: Boolean
147 | ) {
148 | val test = TestUiState(
149 | null,
150 | server,
151 | port,
152 | duration,
153 | interval,
154 | reverse,
155 | "",
156 | true,
157 | udp
158 | )
159 | viewModelScope.launch(Dispatchers.IO) { //this: CoroutineScope
160 | _uiStateFlow.value = repository.createTest(test)
161 | }
162 | }
163 |
164 | fun deleteTest(test: TestUiState) {
165 | viewModelScope.launch(Dispatchers.IO) { //this: CoroutineScope
166 | repository.deleteTest(test)
167 | }
168 | }
169 |
170 | fun getTests(
171 | server: String = "",
172 | tcp: Boolean = true,
173 | udp: Boolean = true,
174 | upload: Boolean = true,
175 | download: Boolean = true
176 | ) {
177 | _testListFlow.value = emptyList()
178 | viewModelScope.launch(Dispatchers.IO) { //this: CoroutineScope
179 | _testListFlow.value = _testListFlow.value + repository.getTests(
180 | _filterStateFlow.value.tcp,
181 | _filterStateFlow.value.udp,
182 | _filterStateFlow.value.upload,
183 | _filterStateFlow.value.download
184 | )
185 | }
186 | }
187 |
188 | fun getTestCount() {
189 | viewModelScope.launch(Dispatchers.IO) { //this: CoroutineScope
190 | _testCountFlow.value = repository.getTestCount()
191 | }
192 | }
193 |
194 | fun getTest(testID: Int): TestUiState {
195 | viewModelScope.launch(Dispatchers.IO) { //this: CoroutineScope
196 | _uiStateFlow.value = repository.getTest(testID)
197 | Log.wtf("CACHO", "TestViewModel:getTest!!! ${_uiStateFlow.value.tid}")
198 | }
199 | return _uiStateFlow.value
200 | }
201 |
202 | fun runIperfTest(
203 | server: String,
204 | port: Int,
205 | duration: Int,
206 | interval: Int,
207 | reverse: Boolean,
208 | udp: Boolean,
209 | context: Context
210 | ) {
211 |
212 | _mapMarkers.value = listOf(SpeedMapMarker(GeoPoint(0.0, 0.0), 0F))
213 | _transferArray.value = listOf() //clear graph
214 | _bwArray.value = listOf() //clear graph
215 | _testResults.value = listOf() // clear lazylist from previous results
216 | _uiStateFlow.value = TestUiState(
217 | _uiStateFlow.value.tid,
218 | server,
219 | port,
220 | duration,
221 | interval,
222 | reverse,
223 | "",
224 | _uiStateFlow.value.fav,
225 | udp
226 | )
227 | val stream = File(filesDir, "iperf3.XXXXXX")
228 | var config = IPerfConfig(
229 | hostname = _uiStateFlow.value.server,
230 | port = _uiStateFlow.value.port,
231 | stream = stream.path,
232 | download = _uiStateFlow.value.reverse,
233 | json = false,
234 | duration = _uiStateFlow.value.duration,
235 | interval = _uiStateFlow.value.interval,
236 | useUDP = udp
237 | )
238 |
239 | _isIPerfTestRunningFlow.update { true }
240 | locationRepository.tracker.startLocationUpdates()
241 | saveResultsTestConfig(
242 | ExecutedTestConfig(
243 | null,
244 | config.hostname,
245 | config.port,
246 | config.duration,
247 | config.interval,
248 | config.download,
249 | "CHANGE ME",
250 | getFormattedTime(),
251 | "0",
252 | "0",
253 | config.useUDP
254 | )
255 | )
256 | iperfJob = viewModelScope.launch {
257 | doStartRequest(config, context)
258 | }
259 | }
260 |
261 | private fun getFormattedTime(): String {
262 | val calendar: Calendar = Calendar.getInstance()
263 | val dateFormatter =
264 | SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.getDefault())
265 | return dateFormatter.format(calendar.getTime())
266 | }
267 |
268 | fun cancelIperfJob() {
269 | IPerf.deInit()
270 | _isIPerfTestRunningFlow.update { false }
271 | locationRepository.tracker.stopLocationUpdates()
272 | }
273 |
274 | private suspend fun doStartRequest(config: IPerfConfig, context: Context) {
275 | withContext(Dispatchers.IO) {
276 | try {
277 | IPerf.seCallBack {
278 | success {
279 | _isIPerfTestRunningFlow.update { false }
280 | locationRepository.tracker.stopLocationUpdates()
281 | saveMeasurement(
282 | resultID,
283 | "iPerf request done, running = ${_isIPerfTestRunningFlow.value}"
284 | )
285 | println("CACHO: iPerf request done, running = ${_isIPerfTestRunningFlow.value}")
286 | }
287 | update { text ->
288 | resultBuilder?.append(text)
289 | _isIPerfTestRunningFlow.update { true }
290 | locationRepository.tracker.startLocationUpdates()
291 | _iPerfRequestResultFlow.value = (resultBuilder.toString())
292 | _testResults.value = listOf(text ?: "") + _testResults.value
293 |
294 |
295 | saveMeasurement(resultID, text ?: "")
296 | //_testResults.value = _testResults.value.toMutableList().apply { add(text?:"") }
297 | println("CACHO: update $text, running = ${_isIPerfTestRunningFlow.value}")
298 |
299 | try {
300 | if (text != null) {
301 | buildAndSaveMeasurmment(text, config)
302 | getGraphVals(text)
303 | }
304 | } catch (e: Exception) {
305 | println("CACHO: update -> exception ${e.toString()}")
306 | }
307 | }
308 | error { e ->
309 | resultBuilder?.append("\niPerf request failed:\n error: $e")
310 | _testResults.value =
311 | listOf("iPerf request failed:\n error: $e") + _testResults.value
312 | saveMeasurement(resultID, "iPerf request failed:\n error: $e")
313 | //_testResults.value = _testResults.value.toMutableList().plus("iPerf request failed:\n error: $e")
314 | _isIPerfTestRunningFlow.update { false }
315 | locationRepository.tracker.stopLocationUpdates()
316 | println("CACHO: error $resultBuilder, running = ${_isIPerfTestRunningFlow.value}")
317 | }
318 | }
319 | IPerf.request(config)
320 | } catch (e: Exception) {
321 | println("CACHO: error on doStartRequest() -> ${e.message}")
322 | }
323 |
324 | }
325 |
326 | }
327 |
328 | /*
329 | CACHO: update [ ID] Interval Transfer Bandwidth Retr
330 | CACHO: update [ 65] 0.00-10.04 sec 445 MBytes 372 Mbits/sec 0 sender
331 | CACHO: update [ 65] 0.00-10.04 sec 443 MBytes 370 Mbits/sec receiver
332 | */
333 |
334 | private fun buildAndSaveMeasurmment(text: String, config: IPerfConfig) {
335 | if (text.contains(" receiver") == true || text.contains(" sender") == true) {
336 | var transfer = ""
337 | var bandwidth = ""
338 | if (text.contains(" sender") == true) {
339 | _senderTransfer.value =
340 | IpersOutputParser.getFinalTransferOrBwValues(text, "transfer")
341 | _senderBandwidth.value = IpersOutputParser.getFinalTransferOrBwValues(text, "bw")
342 | if (!config.download) {
343 | transfer = _senderTransfer.value
344 | bandwidth = _senderBandwidth.value
345 | }
346 | } else if (text.contains(" receiver") == true) {
347 | _receiverTransfer.value =
348 | IpersOutputParser.getFinalTransferOrBwValues(text, "transfer")
349 | _receiverBandwidth.value = IpersOutputParser.getFinalTransferOrBwValues(text, "bw")
350 | if (config.download) {
351 | transfer = _receiverTransfer.value
352 | bandwidth = _receiverBandwidth.value
353 | }
354 | }
355 |
356 | updateResultsTestConfig(
357 | ExecutedTestConfig(
358 | resultID.toInt(),
359 | config.hostname,
360 | config.port,
361 | config.duration,
362 | config.interval,
363 | config.download,
364 | "CHANGE ME",
365 | getFormattedTime(),
366 | bandwidth,
367 | transfer,
368 | config.useUDP
369 | )
370 | )
371 | }
372 | }
373 |
374 |
375 | /**
376 | * returns bw list //TODO: code better
377 | */
378 | private fun parseBwThr(iperfResultLine: String): List {
379 | if (iperfResultLine.contains("sender") || iperfResultLine.contains("receiver")) {
380 | return listOf(0f)
381 | }
382 |
383 | return try {
384 | val transfer = IpersOutputParser.getIntermediateTransferOrBwValuesInMBytes(
385 | iperfResultLine, "transfer"
386 | )
387 | val bw = IpersOutputParser.getIntermediateTransferOrBwValuesInMBytes(
388 | iperfResultLine, "bw"
389 | )
390 |
391 | // Append values (not lists of values)
392 | _transferArray.value = _transferArray.value + transfer
393 | _bwArray.value = _bwArray.value + bw
394 |
395 | Log.w("CACHO", "bw = $bw")
396 | return listOf(bw)
397 | } catch (e: IllegalStateException) {
398 | Log.wtf("CACHO","parsing error $e")
399 | throw e
400 | }
401 | }
402 |
403 |
404 | private fun getGraphVals(iperfResultLine: String) {
405 | val bw = parseBwThr(iperfResultLine)
406 | modelProducer(_bwArray.value, _transferArray.value) // build graph
407 | if (bw.isNotEmpty()) { // add point to map
408 | _mapMarkers.value =
409 | _mapMarkers.value + listOf(
410 | SpeedMapMarker(
411 | GeoPoint(locationFlow.value),
412 | bw.last()
413 | )
414 | )
415 | }
416 | }
417 |
418 | private fun clearResultsLists(){
419 | _loadedTestResults.value = emptyList()
420 | _transferArray.value = emptyList()
421 | _bwArray.value = emptyList()
422 | _mapMarkers.value = emptyList()
423 | _testResults.value = emptyList()
424 | modelProducer(listOf(0f), listOf(0f))
425 | }
426 |
427 | //used to load existing results
428 | fun loadGraphAndMapExistingResults(tid: Int?) {
429 | clearResultsLists()
430 | getExecutedTestResults(tid)
431 | }
432 |
433 | private fun getGraphMapVals(results: List) {
434 |
435 | val newMarkers = results.mapNotNull { result ->
436 | try {
437 | val bw = parseBwThr(result.measurment)
438 | if (bw.isEmpty()) return@mapNotNull null
439 |
440 | SpeedMapMarker(
441 | GeoPoint(result.latitude, result.longitude),
442 | bw.last()
443 | )
444 | } catch (e: IllegalStateException) {
445 | Log.wtf("CACHO", e.message ?: "Unknown error")
446 | null
447 | }
448 | }
449 |
450 | // Perform a single atomic update instead of inside the loop
451 | _mapMarkers.value = _mapMarkers.value + newMarkers
452 |
453 | // Build graph only once at the end
454 | if (_bwArray.value.isNotEmpty() || _transferArray.value.isNotEmpty()) {
455 | modelProducer(_bwArray.value, _transferArray.value)
456 | }
457 | }
458 |
459 |
460 | fun resetTestConfig() {
461 | _uiStateFlow.value = newTestConfig()
462 | }
463 |
464 | private fun newTestConfig(): TestUiState {
465 | return TestUiState(null, "111.111.111.111", 1000, 10, 1, true, "CHANGE ME", false, false)
466 | }
467 |
468 | //////////////////////////////////////////////////////////////////////////////////////////////////
469 | private fun saveResultsTestConfig(testConfig: ExecutedTestConfig) {
470 | viewModelScope.launch(ioDispatcher) { //this: CoroutineScope
471 | resultID = resultsRepository.createTestConfig(testConfig)
472 | }
473 | }
474 |
475 | private fun updateResultsTestConfig(testConfig: ExecutedTestConfig) {
476 | viewModelScope.launch(ioDispatcher) { //this: CoroutineScope
477 | resultsRepository.updateTestConfigTestConfig(testConfig)
478 | }
479 | }
480 |
481 | private fun saveMeasurement(resultID: Long, measurment: String) {
482 |
483 | viewModelScope.launch(ioDispatcher) {
484 | println("CACHO: ${networkInfoRepository.getNetworkType()}")
485 | println("CACHO: ${locationFlow.value.latitude} ${locationFlow.value.longitude} ${locationFlow.value.altitude}")
486 | resultsRepository.addResult(
487 | resultID,
488 | measurment,
489 | locationFlow.value.latitude,
490 | locationFlow.value.longitude,
491 | locationFlow.value.altitude,
492 | networkInfoRepository.getNetworkType()
493 | )
494 | }
495 | }
496 |
497 | fun getExecutedTests() {
498 | viewModelScope.launch(ioDispatcher) {
499 | _executedTestsList.value = emptyList()
500 |
501 | _executedTestsList.value =
502 | _executedTestsList.value + resultsRepository.getExecutedTests(
503 | _filterStateFlow.value.tcp,
504 | _filterStateFlow.value.udp,
505 | _filterStateFlow.value.upload,
506 | _filterStateFlow.value.download
507 | )
508 | }
509 | }
510 |
511 | fun getExecutedTestResults(testID: Int?) {
512 | viewModelScope.launch(ioDispatcher) {
513 | val results = resultsRepository.getExecutedTestResults(testID)
514 | _loadedTestResults.value = _loadedTestResults.value + results
515 | Log.d("CACHO", "Results size: ${results.size}")
516 | // Build a new list from measurement values
517 | val measurements = results.map { it.measurment }
518 | _testResults.value = _testResults.value + measurements
519 |
520 | getGraphMapVals(_loadedTestResults.value)
521 | }
522 | }
523 |
524 |
525 | fun deleteExecutedTestsWithResults(executedTestId: ExecutedTestConfig) {
526 | viewModelScope.launch(ioDispatcher) {
527 | resultsRepository.deleteExecutedTestsWithResults(executedTestId)
528 | _executedTestsList.value = resultsRepository.getExecutedTests(
529 | _filterStateFlow.value.tcp,
530 | _filterStateFlow.value.udp,
531 | _filterStateFlow.value.upload,
532 | _filterStateFlow.value.download
533 | )
534 | }
535 | }
536 |
537 | fun getResultsCount() {
538 | viewModelScope.launch(ioDispatcher) {
539 | _resultsCount.value = resultsRepository.getResultsCount()
540 | }
541 | }
542 |
543 | private fun modelProducer(value: List, value1: List) {
544 | viewModelScope.launch(defaultDispatcher) {
545 | _modelProducer.value.runTransaction {
546 | lineSeries {
547 | series(value)
548 | series(value1)
549 | }
550 | }
551 | }
552 | }
553 |
554 | }
555 |
556 | data class FilterState(
557 | var server: String = "",
558 | var tcp: Boolean = true,
559 | var udp: Boolean = true,
560 | var upload: Boolean = true,
561 | var download: Boolean = true
562 | )
563 |
564 | class SpeedMapMarker(val location: GeoPoint, val throughput: Float)
565 |
566 |
567 |
568 |
569 |
--------------------------------------------------------------------------------