diff --git a/app/build.gradle b/app/build.gradle
index 14401c5..c86fc17 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -184,6 +184,7 @@ dependencies {
implementation 'org.bouncycastle:bcprov-jdk15to18:1.70'
implementation 'org.apache.commons:commons-csv:1.9.0'
implementation 'io.github.azhon:appupdate:4.3.2'
+ implementation 'androidx.documentfile:documentfile:1.0.1'
// Analytics
implementation "com.microsoft.appcenter:appcenter-analytics:${appCenterSdkVersion}"
diff --git a/app/schemas/com.lcl.lclmeasurementtool.database.db.AppDatabase/1.json b/app/schemas/com.lcl.lclmeasurementtool.database.db.AppDatabase/1.json
new file mode 100644
index 0000000..6174b29
--- /dev/null
+++ b/app/schemas/com.lcl.lclmeasurementtool.database.db.AppDatabase/1.json
@@ -0,0 +1,150 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "0c7a8a929af48ae178cd610468177570",
+ "entities": [
+ {
+ "tableName": "signal_strength_table",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `time_stamp` TEXT NOT NULL, `cellId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `signal_strength` INTEGER NOT NULL, `signal_strength_level` INTEGER NOT NULL, `reported` INTEGER NOT NULL, PRIMARY KEY(`time_stamp`))",
+ "fields": [
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "time_stamp",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cellId",
+ "columnName": "cellId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dbm",
+ "columnName": "signal_strength",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "levelCode",
+ "columnName": "signal_strength_level",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reported",
+ "columnName": "reported",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "time_stamp"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "connectivity_table",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `time_stamp` TEXT NOT NULL, `cellId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `upload_speed` REAL NOT NULL, `download_speed` REAL NOT NULL, `ping` REAL NOT NULL, `package_loss` REAL NOT NULL, `reported` INTEGER NOT NULL, PRIMARY KEY(`time_stamp`))",
+ "fields": [
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "time_stamp",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cellId",
+ "columnName": "cellId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploadSpeed",
+ "columnName": "upload_speed",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "downloadSpeed",
+ "columnName": "download_speed",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ping",
+ "columnName": "ping",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetLoss",
+ "columnName": "package_loss",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reported",
+ "columnName": "reported",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "time_stamp"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0c7a8a929af48ae178cd610468177570')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 19120ea..380670d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,6 +19,7 @@
+
@@ -51,7 +52,7 @@
android:grantUriPermissions="true">
+ android:resource="@xml/file_paths" />
run {
if (!allGranted) {
diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/MainActivityViewModel.kt b/app/src/main/java/com/lcl/lclmeasurementtool/MainActivityViewModel.kt
index 05fae3c..c88143e 100644
--- a/app/src/main/java/com/lcl/lclmeasurementtool/MainActivityViewModel.kt
+++ b/app/src/main/java/com/lcl/lclmeasurementtool/MainActivityViewModel.kt
@@ -7,13 +7,8 @@ import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.protobuf.ByteString
-import com.lcl.lclmeasurementtool.constants.NetworkConstants
import com.lcl.lclmeasurementtool.features.mlab.MLabRunner
import com.lcl.lclmeasurementtool.features.mlab.MLabTestStatus
-import com.lcl.lclmeasurementtool.features.ping.Ping
-import com.lcl.lclmeasurementtool.features.ping.PingError
-import com.lcl.lclmeasurementtool.features.ping.PingErrorCase
-import com.lcl.lclmeasurementtool.features.ping.PingResult
import com.lcl.lclmeasurementtool.location.LocationService
import com.lcl.lclmeasurementtool.model.datamodel.*
import com.lcl.lclmeasurementtool.model.repository.ConnectivityRepository
@@ -69,11 +64,11 @@ class MainActivityViewModel @Inject constructor(
// Network Testing
private val _isMLabTestActive = MutableStateFlow(false)
- private var _mLabPingResult = MutableStateFlow(PingResultState())
+ private var _mlabRttResult = MutableStateFlow(ConnectivityTestResult())
private var _mLabUploadResult = MutableStateFlow(ConnectivityTestResult())
private var _mLabDownloadResult = MutableStateFlow(ConnectivityTestResult())
- var mLabPingResult = _mLabPingResult.asStateFlow()
+ var mlabRttResult = _mlabRttResult.asStateFlow()
var mlabUploadResult = _mLabUploadResult.asStateFlow()
var mlabDownloadResult = _mLabDownloadResult.asStateFlow()
val isMLabTestActive = _isMLabTestActive.asStateFlow()
@@ -250,33 +245,7 @@ class MainActivityViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000)
)
- private suspend fun runMLabPing() {
- try {
- Ping.cancellableStart(address = NetworkConstants.PING_TEST_ADDRESS, timeout = 1000)
- .onStart {
- Log.d(TAG, "isActive = true")
- _isMLabTestActive.value = true
- }
- .onCompletion {
- if (it != null) {
- Log.d(TAG, "Error is ${it.message}")
- _isMLabTestActive.value = false
- }
- }
- .collect {
- _mLabPingResult.value = when(it.error.code) {
- PingErrorCase.OK -> PingResultState.Success(it)
- else -> {
- _isMLabTestActive.value = false
- PingResultState.Error(it.error)
- }
- }
- }
- } catch (e: IllegalArgumentException) {
- _mLabPingResult.value = PingResultState.Error(PingError(PingErrorCase.OTHER, e.message))
- Log.e(TAG, "Ping Config error")
- }
- }
+ // The runMLabPing method is removed as we now use ndt7's TCPInfo.RTT instead of custom ping
fun cancelMLabTest() {
Log.d(TAG, "cancellation: the test job is $mlabTestJob")
@@ -287,47 +256,82 @@ class MainActivityViewModel @Inject constructor(
private suspend fun getMLabTestResult() {
try {
+ Log.d(TAG, "Starting MLab test (DOWNLOAD_AND_UPLOAD)")
MLabRunner.runTest(NDTTest.TestType.DOWNLOAD_AND_UPLOAD)
.onStart {
_isMLabTestActive.value = true
+ Log.d(TAG, "MLab test started")
}
.onCompletion {
if (it != null) {
- Log.d(TAG, "Error is ${it.message}")
+ Log.e(TAG, "Error in MLab test: ${it.message}", it)
_isMLabTestActive.value = false
+ } else {
+ Log.d(TAG, "MLab test completed normally")
}
}
.collect{
when(it.type) {
NDTTest.TestType.UPLOAD -> {
- _mLabUploadResult.value = when(it.status) {
- MLabTestStatus.RUNNING -> { ConnectivityTestResult.Result(it.speed!!, Color.LightGray) }
-
- MLabTestStatus.FINISHED -> { ConnectivityTestResult.Result(it.speed!!, Color.Black) }
-
- MLabTestStatus.ERROR -> {
- _isMLabTestActive.value = false
- ConnectivityTestResult.Error(it.errorMsg!!)
+ if (it.speed != null) {
+ Log.d(TAG, "Upload speed update: ${it.speed}, status: ${it.status}")
+ _mLabUploadResult.value = when(it.status) {
+ MLabTestStatus.RUNNING -> {
+ ConnectivityTestResult.Result(it.speed, Color.LightGray)
+ }
+ MLabTestStatus.FINISHED -> {
+ Log.d(TAG, "Upload test finished with speed: ${it.speed}")
+ ConnectivityTestResult.Result(it.speed, Color.Black)
+ }
+ MLabTestStatus.ERROR -> {
+ Log.e(TAG, "Upload test error: ${it.errorMsg}")
+ _isMLabTestActive.value = false
+ ConnectivityTestResult.Error(it.errorMsg ?: "Unknown error")
+ }
}
+ } else if (it.status == MLabTestStatus.ERROR) {
+ Log.e(TAG, "Upload test error with null speed: ${it.errorMsg}")
+ _mLabUploadResult.value = ConnectivityTestResult.Error(it.errorMsg ?: "Unknown error")
+ _isMLabTestActive.value = false
}
}
NDTTest.TestType.DOWNLOAD -> {
- _mLabDownloadResult.value = when(it.status) {
- MLabTestStatus.RUNNING -> { ConnectivityTestResult.Result(it.speed!!, Color.LightGray) }
-
- MLabTestStatus.FINISHED -> { ConnectivityTestResult.Result(it.speed!!, Color.Black) }
-
- MLabTestStatus.ERROR -> {
- _isMLabTestActive.value = false
- ConnectivityTestResult.Error(it.errorMsg!!)
+ // Use RTT directly from MLabResult if available
+ it.rttMs?.let { rttMs ->
+ _mlabRttResult.value = ConnectivityTestResult.Result(rttMs.toString(), Color.Black)
+ Log.d(TAG, "RTT from Download test: $rttMs ms")
+ }
+
+ if (it.speed != null) {
+ Log.d(TAG, "Download speed update: ${it.speed}, status: ${it.status}")
+ _mLabDownloadResult.value = when(it.status) {
+ MLabTestStatus.RUNNING -> {
+ ConnectivityTestResult.Result(it.speed, Color.LightGray)
+ }
+ MLabTestStatus.FINISHED -> {
+ Log.d(TAG, "Download test finished with speed: ${it.speed}")
+ ConnectivityTestResult.Result(it.speed, Color.Black)
+ }
+ MLabTestStatus.ERROR -> {
+ Log.e(TAG, "Download test error: ${it.errorMsg}")
+ _isMLabTestActive.value = false
+ ConnectivityTestResult.Error(it.errorMsg ?: "Unknown error")
+ }
}
+ } else if (it.status == MLabTestStatus.ERROR) {
+ Log.e(TAG, "Download test error with null speed: ${it.errorMsg}")
+ _mLabDownloadResult.value = ConnectivityTestResult.Error(it.errorMsg ?: "Unknown error")
+ _isMLabTestActive.value = false
}
}
- else -> { }
+ else -> {
+ Log.d(TAG, "Other test type: ${it.type}, status: ${it.status}, speed: ${it.speed}")
+ }
}
}
} catch (e: Exception) {
- Log.d(TAG, "catch $e")
+ Log.e(TAG, "Exception during MLab test", e)
+ _isMLabTestActive.value = false
}
}
@@ -341,12 +345,7 @@ class MainActivityViewModel @Inject constructor(
try {
resetMLabTestResult()
- runMLabPing()
- if (_mLabPingResult.value is PingResultState.Error) {
- this.cancel("Ping Test Failed")
- }
- ensureActive()
-
+ // Run ndt7 test (which includes RTT measurement)
getMLabTestResult()
if (_mLabUploadResult.value is ConnectivityTestResult.Error || _mLabDownloadResult.value is ConnectivityTestResult.Error) {
Log.d(TAG, "mlab test job is cancelled")
@@ -361,7 +360,7 @@ class MainActivityViewModel @Inject constructor(
ensureActive()
_isMLabTestActive.value = false
- Log.d(TAG, "ping, upload, download are finished. isMLabTestActive.value=${isMLabTestActive.value}")
+ Log.d(TAG, "upload, download are finished. isMLabTestActive.value=${isMLabTestActive.value}")
val curTime = TimeUtil.getCurrentTime()
val cellID = signalStrengthMonitor.getCellID()
@@ -387,8 +386,8 @@ class MainActivityViewModel @Inject constructor(
it.second.deviceID,
(_mLabUploadResult.value as ConnectivityTestResult.Result).result.toDouble(),
(_mLabDownloadResult.value as ConnectivityTestResult.Result).result.toDouble(),
- (_mLabPingResult.value as PingResultState.Success).result.avg!!.toDouble(),
- (_mLabPingResult.value as PingResultState.Success).result.numLoss!!.toDouble(),
+ (_mlabRttResult.value as ConnectivityTestResult.Result).result.toDouble(),
+ 0.0, // No packet loss information available from ndt7, defaulting to 0
)
saveToDB(signalStrengthReportModel, connectivityReportModel)
@@ -446,9 +445,11 @@ class MainActivityViewModel @Inject constructor(
}
private fun resetMLabTestResult() {
- _mLabPingResult.value = PingResultState.Error(PingError(PingErrorCase.OK, null))
+ Log.d(TAG, "Resetting MLab test results")
+ _mlabRttResult.value = ConnectivityTestResult.Result("0.0", Color.LightGray)
_mLabUploadResult.value = ConnectivityTestResult.Result("0.0", Color.LightGray)
_mLabDownloadResult.value = ConnectivityTestResult.Result("0.0", Color.LightGray)
+ _isMLabTestActive.value = false
}
}
@@ -462,9 +463,6 @@ open class ConnectivityTestResult {
data class Error(val error: String): ConnectivityTestResult()
}
-open class PingResultState {
- data class Success(val result: PingResult): PingResultState()
- data class Error(val error: PingError): PingResultState()
-}
+// PingResultState removed as we now use ndt7's TCPInfo.RTT instead
data class SignalStrengthResult(val dbm: Int, val level: SignalStrengthLevelEnum)
\ No newline at end of file
diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabCallback.kt b/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabCallback.kt
index 7b6ff41..126b9da 100644
--- a/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabCallback.kt
+++ b/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabCallback.kt
@@ -2,9 +2,12 @@ package com.lcl.lclmeasurementtool.features.mlab
import net.measurementlab.ndt7.android.NDTTest
import net.measurementlab.ndt7.android.models.ClientResponse
+import net.measurementlab.ndt7.android.models.Measurement
interface MLabCallback {
fun onDownloadProgress(clientResponse: ClientResponse)
fun onUploadProgress(clientResponse: ClientResponse)
+ fun onMeasurementDownloadProgress(measurement: Measurement)
+ fun onMeasurementUploadProgress(measurement: Measurement)
fun onFinish(clientResponse: ClientResponse?, error: Throwable?, testType: NDTTest.TestType)
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabResult.kt b/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabResult.kt
index 6273c80..cf3d9cb 100644
--- a/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabResult.kt
+++ b/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabResult.kt
@@ -6,7 +6,8 @@ data class MLabResult(
val speed: String?,
val type: NDTTest.TestType,
val errorMsg: String?,
- val status: MLabTestStatus
+ val status: MLabTestStatus,
+ val rttMs: Double? = null
)
enum class MLabTestStatus {
diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabRunner.kt b/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabRunner.kt
index 1e79caf..56d1ea6 100644
--- a/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabRunner.kt
+++ b/app/src/main/java/com/lcl/lclmeasurementtool/features/mlab/MLabRunner.kt
@@ -4,7 +4,9 @@ import android.util.Log
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import net.measurementlab.ndt7.android.NDTTest
+import net.measurementlab.ndt7.android.models.AppInfo
import net.measurementlab.ndt7.android.models.ClientResponse
+import net.measurementlab.ndt7.android.models.Measurement
import net.measurementlab.ndt7.android.utils.DataConverter
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
@@ -20,6 +22,16 @@ class MLabRunner(httpClient: OkHttpClient, private val callback: MLabCallback):
callback.onUploadProgress(clientResponse)
}
+ override fun onMeasurementDownloadProgress(measurement: Measurement) {
+ super.onMeasurementDownloadProgress(measurement)
+ callback.onMeasurementDownloadProgress(measurement)
+ }
+
+ override fun onMeasurementUploadProgress(measurement: Measurement) {
+ super.onMeasurementUploadProgress(measurement)
+ callback.onMeasurementUploadProgress(measurement)
+ }
+
override fun onFinished(
clientResponse: ClientResponse?,
error: Throwable?,
@@ -36,14 +48,36 @@ class MLabRunner(httpClient: OkHttpClient, private val callback: MLabCallback):
val callback = object : MLabCallback {
override fun onDownloadProgress(clientResponse: ClientResponse) {
val speed = DataConverter.convertToMbps(clientResponse)
+ val speedValue = speed?.toFloatOrNull() ?: 0f
Log.d(TAG, "client download is $speed")
- channel.trySend(MLabResult(speed, TestType.DOWNLOAD, null, MLabTestStatus.RUNNING))
+ if (speedValue > 0.1f) {
+ channel.trySend(MLabResult(speed, TestType.DOWNLOAD, null, MLabTestStatus.RUNNING, null))
+ }
}
override fun onUploadProgress(clientResponse: ClientResponse) {
val speed = DataConverter.convertToMbps(clientResponse)
+ val speedValue = speed?.toFloatOrNull() ?: 0f
Log.d(TAG, "client upload is $speed")
- channel.trySend(MLabResult(speed, TestType.UPLOAD, null, MLabTestStatus.RUNNING))
+ if (speedValue > 0.1f) {
+ channel.trySend(MLabResult(speed, TestType.UPLOAD, null, MLabTestStatus.RUNNING, null))
+ }
+ }
+
+ override fun onMeasurementDownloadProgress(measurement: Measurement) {
+ Log.d(TAG, "on measurement download")
+ val tcpInfo = measurement.tcpInfo
+ val rttMs = tcpInfo?.rtt?.toDouble()?.div(1000.0) // Convert microseconds to milliseconds
+
+ channel.trySend(MLabResult(null, TestType.DOWNLOAD, null, MLabTestStatus.RUNNING, rttMs))
+ }
+
+ override fun onMeasurementUploadProgress(measurement: Measurement) {
+ Log.d(TAG, "on measurement upload")
+ val tcpInfo = measurement.tcpInfo
+ val rttMs = tcpInfo?.rtt?.toDouble()?.div(1000.0) // Convert microseconds to milliseconds
+
+ channel.trySend(MLabResult(null, TestType.UPLOAD, null, MLabTestStatus.RUNNING, rttMs))
}
override fun onFinish(
@@ -52,13 +86,21 @@ class MLabRunner(httpClient: OkHttpClient, private val callback: MLabCallback):
testType: TestType
) {
if (clientResponse != null) {
- Log.d(TAG, "client finish test $testType")
- channel.trySend(MLabResult(DataConverter.convertToMbps(clientResponse), testType, null, MLabTestStatus.FINISHED))
+ val speed = DataConverter.convertToMbps(clientResponse)
+ val speedValue = speed?.toFloatOrNull() ?: 0f
+ Log.d(TAG, "client finish test $testType with speed $speed")
+ // For finished tests, we report all results regardless of value
+ channel.trySend(MLabResult(speed, testType, null, MLabTestStatus.FINISHED, null))
} else {
- channel.trySend(MLabResult(null, testType, error?.message, MLabTestStatus.ERROR))
+ Log.e(TAG, "Error during $testType test: ${error?.message}")
+ channel.trySend(MLabResult(null, testType, error?.message, MLabTestStatus.ERROR, null))
}
- if (testType == TestType.UPLOAD) channel.close()
+ // Only close the channel after both download and upload tests are complete
+ if (testType == TestType.UPLOAD || testType == TestType.DOWNLOAD_AND_UPLOAD) {
+ Log.d(TAG, "Closing channel after $testType test")
+ channel.close()
+ }
}
}
@@ -72,7 +114,7 @@ class MLabRunner(httpClient: OkHttpClient, private val callback: MLabCallback):
}
}
- private fun createHttpClient(connectTimeout: Long = 10, readTimeout: Long = 10, writeTimeout: Long = 10): OkHttpClient {
+ private fun createHttpClient(connectTimeout: Long = 30, readTimeout: Long = 30, writeTimeout: Long = 30): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(connectTimeout, TimeUnit.SECONDS)
.readTimeout(readTimeout, TimeUnit.SECONDS)
@@ -81,4 +123,3 @@ class MLabRunner(httpClient: OkHttpClient, private val callback: MLabCallback):
}
}
}
-
diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/model/viewmodels/SettingsViewModel.kt b/app/src/main/java/com/lcl/lclmeasurementtool/model/viewmodels/SettingsViewModel.kt
index 860582b..654a57a 100644
--- a/app/src/main/java/com/lcl/lclmeasurementtool/model/viewmodels/SettingsViewModel.kt
+++ b/app/src/main/java/com/lcl/lclmeasurementtool/model/viewmodels/SettingsViewModel.kt
@@ -1,11 +1,19 @@
package com.lcl.lclmeasurementtool.model.viewmodels
-import androidx.lifecycle.ViewModel
+import android.app.Application
+import android.content.Intent
+import android.net.Uri
+import android.widget.Toast
+import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
+import com.lcl.lclmeasurementtool.model.repository.ConnectivityRepository
+import com.lcl.lclmeasurementtool.model.repository.SignalStrengthRepository
import com.lcl.lclmeasurementtool.model.repository.UserDataRepository
+import com.lcl.lclmeasurementtool.util.CsvExporter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -14,7 +22,10 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val userDataRepository: UserDataRepository,
-) : ViewModel() {
+ private val signalStrengthRepository: SignalStrengthRepository,
+ private val connectivityRepository: ConnectivityRepository,
+ application: Application
+) : AndroidViewModel(application) {
val shouldShowData: StateFlow = userDataRepository
.userData
@@ -25,6 +36,9 @@ class SettingsViewModel @Inject constructor(
initialValue = false
)
+ // Holds the URI of the exported file after a successful export
+ private var _exportedFileUri: Uri? = null
+ val exportedFileUri get() = _exportedFileUri
fun toggleShowData(showData: Boolean) {
viewModelScope.launch {
@@ -32,10 +46,94 @@ class SettingsViewModel @Inject constructor(
}
}
- fun logout() {
+ fun logout() {
viewModelScope.launch {
userDataRepository.logout()
}
}
+ /**
+ * Export signal strength data to a CSV file
+ */
+ fun exportSignalStrengthData(onComplete: (Uri?) -> Unit) {
+ viewModelScope.launch {
+ try {
+ val signalData = signalStrengthRepository.getAll().first()
+ if (signalData.isEmpty()) {
+ showToast("No signal strength data to export")
+ onComplete(null)
+ return@launch
+ }
+
+ val uri = CsvExporter.exportSignalStrengthToCsv(getApplication(), signalData)
+ _exportedFileUri = uri
+
+ if (uri != null) {
+ showToast("Signal strength data exported successfully")
+ shareFile(uri, "Signal Strength Data", "text/csv")
+ } else {
+ showToast("Failed to export signal strength data")
+ }
+
+ onComplete(uri)
+ } catch (e: Exception) {
+ showToast("Error exporting signal strength data: ${e.message}")
+ onComplete(null)
+ }
+ }
+ }
+
+ /**
+ * Export connectivity data to a CSV file
+ */
+ fun exportConnectivityData(onComplete: (Uri?) -> Unit) {
+ viewModelScope.launch {
+ try {
+ val connectivityData = connectivityRepository.getAll().first()
+ if (connectivityData.isEmpty()) {
+ showToast("No connectivity data to export")
+ onComplete(null)
+ return@launch
+ }
+
+ val uri = CsvExporter.exportConnectivityToCsv(getApplication(), connectivityData)
+ _exportedFileUri = uri
+
+ if (uri != null) {
+ showToast("Connectivity data exported successfully")
+ shareFile(uri, "Speed Test Data", "text/csv")
+ } else {
+ showToast("Failed to export connectivity data")
+ }
+
+ onComplete(uri)
+ } catch (e: Exception) {
+ showToast("Error exporting connectivity data: ${e.message}")
+ onComplete(null)
+ }
+ }
+ }
+
+ /**
+ * Share a file using Android's share functionality
+ */
+ private fun shareFile(uri: Uri, subject: String, mimeType: String) {
+ val context = getApplication()
+ val shareIntent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_SUBJECT, subject)
+ type = mimeType
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ val chooserIntent = Intent.createChooser(shareIntent, "Share $subject")
+ chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(chooserIntent)
+ }
+
+ private fun showToast(message: String) {
+ Toast.makeText(getApplication(), message, Toast.LENGTH_SHORT).show()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt b/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt
index 5e616ad..d17a8a0 100644
--- a/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt
+++ b/app/src/main/java/com/lcl/lclmeasurementtool/ui/HomeScreen.kt
@@ -30,10 +30,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.lcl.lclmeasurementtool.BuildConfig
import com.lcl.lclmeasurementtool.ConnectivityTestResult
import com.lcl.lclmeasurementtool.MainActivityViewModel
-import com.lcl.lclmeasurementtool.PingResultState
import com.lcl.lclmeasurementtool.SignalStrengthResult
-import com.lcl.lclmeasurementtool.features.ping.PingError
-import com.lcl.lclmeasurementtool.features.ping.PingErrorCase
import kotlinx.coroutines.cancel
@Composable
@@ -48,7 +45,7 @@ fun HomeScreen(modifier: Modifier = Modifier, isOffline: Boolean, mainActivityVi
val snackbarHostState = remember { SnackbarHostState() }
val isMLabTestActive = mainActivityViewModel.isMLabTestActive.collectAsStateWithLifecycle()
- val mlabPingResult = mainActivityViewModel.mLabPingResult.collectAsStateWithLifecycle()
+ val mlabRttResult = mainActivityViewModel.mlabRttResult.collectAsStateWithLifecycle()
val mlabUploadResult = mainActivityViewModel.mlabUploadResult.collectAsStateWithLifecycle()
val mlabDownloadResult = mainActivityViewModel.mlabDownloadResult.collectAsStateWithLifecycle()
val signalStrength = mainActivityViewModel.signalStrengthResult.collectAsStateWithLifecycle()
@@ -64,7 +61,7 @@ fun HomeScreen(modifier: Modifier = Modifier, isOffline: Boolean, mainActivityVi
ConnectivityCard(
label = "MLab",
modifier = modifier,
- pingResult = mlabPingResult.value,
+ rttValue = mlabRttResult.value,
uploadResult = mlabUploadResult.value,
downloadResult = mlabDownloadResult.value
)
@@ -150,7 +147,7 @@ private fun SignalStrengthCard(
private fun ConnectivityCard(
label: String,
modifier: Modifier = Modifier,
- pingResult: PingResultState,
+ rttValue: ConnectivityTestResult,
uploadResult: ConnectivityTestResult,
downloadResult: ConnectivityTestResult,
) {
@@ -188,26 +185,20 @@ private fun ConnectivityCard(
}
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
- var pingNum = "0"
- var pingLoss = "0"
- when(pingResult) {
- is PingResultState.Success -> {
- pingNum = pingResult.result.avg!!
- pingLoss = pingResult.result.numLoss!!
+ val formattedRtt = when (rttValue) {
+ is ConnectivityTestResult.Result -> {
+ val numeric = rttValue.result.toDoubleOrNull() ?: 0.0
+ if (numeric > 0) String.format("%.1f", numeric) else "0.0"
}
- is PingResultState.Error -> {
- pingNum = "0"
- pingLoss = "0"
- if (pingResult.error.code != PingErrorCase.OK) {
- Log.d("HOMEScreen", pingResult.error.message ?: "Error occurred")
- // TODO: show error message
- }
+ else -> {
+ "0.0" // or rttValue.error if you want to display the error message
}
}
- DataEntry(icon = Rounded.NetworkPing, text = "$pingNum ms")
- DataEntry(icon = Rounded.Cancel, text = "$pingLoss % loss")
+ DataEntry(icon = Rounded.NetworkPing, text = "$formattedRtt ms")
+ DataEntry(icon = Rounded.Cancel, text = "0 % loss")
}
+
}
}
Box(modifier = Modifier.fillMaxWidth()) {
@@ -232,14 +223,16 @@ fun DataEntry(icon: ImageVector, text: String) {
fun ConnectivityCardPreview() {
Column {
- ConnectivityCard(label = "IperfRunner",
- pingResult = PingResultState.Error(PingError(PingErrorCase.OK, null)),
+ ConnectivityCard(
+ label = "IperfRunner",
+ rttValue = ConnectivityTestResult.Result("1", Color.Red),
uploadResult = ConnectivityTestResult.Result("1", Color.Blue),
downloadResult = ConnectivityTestResult.Result("1", Color.Green)
)
- ConnectivityCard(label = "MLab",
- pingResult = PingResultState.Error(PingError(PingErrorCase.OK, null)),
+ ConnectivityCard(
+ label = "MLab",
+ rttValue = ConnectivityTestResult.Result("1", Color.Red),
uploadResult = ConnectivityTestResult.Result("1", Color.Blue),
downloadResult = ConnectivityTestResult.Result("1", Color.Green)
)
diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/ui/SettingsView.kt b/app/src/main/java/com/lcl/lclmeasurementtool/ui/SettingsView.kt
index 0b31e92..af12cb5 100644
--- a/app/src/main/java/com/lcl/lclmeasurementtool/ui/SettingsView.kt
+++ b/app/src/main/java/com/lcl/lclmeasurementtool/ui/SettingsView.kt
@@ -34,13 +34,21 @@ fun SettingsDialog(
onDismiss: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
-
+ val context = LocalContext.current
val showData = viewModel.shouldShowData.collectAsStateWithLifecycle()
+
+ // Export functions that handle the result
+ val exportSignalStrength = { viewModel.exportSignalStrengthData { _ -> } }
+
+ val exportConnectivity = { viewModel.exportConnectivityData { _ -> } }
+
SettingDialog(
onDismiss = onDismiss,
toggleShowData = viewModel::toggleShowData,
logout = viewModel::logout,
- showData = showData.value
+ showData = showData.value,
+ exportSignalStrength = exportSignalStrength,
+ exportConnectivity = exportConnectivity
)
}
@@ -49,7 +57,9 @@ fun SettingDialog(
onDismiss: () -> Unit,
toggleShowData: (Boolean) -> Unit,
logout: () -> Unit,
- showData: Boolean
+ showData: Boolean,
+ exportSignalStrength: () -> Unit = {},
+ exportConnectivity: () -> Unit = {}
) {
AlertDialog(
onDismissRequest = { onDismiss() },
@@ -62,7 +72,13 @@ fun SettingDialog(
text = {
Divider()
Column(Modifier.verticalScroll(rememberScrollState())) {
- SettingsPanel(onSelectShowData = {toggleShowData(!showData)}, onLogoutClicked = {logout()}, showData = showData)
+ SettingsPanel(
+ onSelectShowData = {toggleShowData(!showData)},
+ onLogoutClicked = {logout()},
+ showData = showData,
+ exportSignalStrength = exportSignalStrength,
+ exportConnectivity = exportConnectivity
+ )
Divider(Modifier.padding(top = 8.dp))
LinksPanel()
VersionInfo()
@@ -85,7 +101,9 @@ fun SettingDialog(
private fun SettingsPanel(
onSelectShowData: (Boolean) -> Unit,
onLogoutClicked: () -> Unit,
- showData: Boolean
+ showData: Boolean,
+ exportSignalStrength: () -> Unit = {},
+ exportConnectivity: () -> Unit = {}
) {
SettingsDialogSectionTitle(text = "General")
Column(Modifier.fillMaxWidth(),
@@ -102,8 +120,8 @@ private fun SettingsPanel(
SettingsDialogSectionTitle(text = "Data Management")
Column(Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) {
- ClickableRow(text = "Export Signal Strength Data", icon = Icons.Rounded.Download, onClick = {})
- ClickableRow(text = "Export Speed Test Data", icon = Icons.Rounded.Download, onClick = {})
+ ClickableRow(text = "Export Signal Strength Data", icon = Icons.Rounded.Download, onClick = exportSignalStrength)
+ ClickableRow(text = "Export Speed Test Data", icon = Icons.Rounded.Download, onClick = exportConnectivity)
}
SettingsDialogSectionTitle(text = "Help")
Column(Modifier.fillMaxWidth(),
@@ -207,7 +225,9 @@ private fun PreviewSettingsDialog() {
onDismiss = {},
toggleShowData = {},
logout = {},
- showData = false
+ showData = false,
+ exportSignalStrength = {},
+ exportConnectivity = {}
)
}
//
diff --git a/app/src/main/java/com/lcl/lclmeasurementtool/util/CsvExporter.kt b/app/src/main/java/com/lcl/lclmeasurementtool/util/CsvExporter.kt
new file mode 100644
index 0000000..ca9d2b4
--- /dev/null
+++ b/app/src/main/java/com/lcl/lclmeasurementtool/util/CsvExporter.kt
@@ -0,0 +1,155 @@
+package com.lcl.lclmeasurementtool.util
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.documentfile.provider.DocumentFile
+import com.lcl.lclmeasurementtool.model.datamodel.ConnectivityReportModel
+import com.lcl.lclmeasurementtool.model.datamodel.SignalStrengthReportModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.FileOutputStream
+import java.io.IOException
+import java.text.SimpleDateFormat
+import java.util.*
+
+object CsvExporter {
+
+ /**
+ * Export signal strength data to a CSV file
+ */
+ suspend fun exportSignalStrengthToCsv(
+ context: Context,
+ data: List
+ ): Uri? = withContext(Dispatchers.IO) {
+ try {
+ val fileName = "signal_strength_export_${getCurrentDateTime()}.csv"
+ val csvContent = buildSignalStrengthCsv(data)
+ return@withContext saveToFile(context, fileName, csvContent)
+ } catch (e: IOException) {
+ e.printStackTrace()
+ return@withContext null
+ }
+ }
+
+ /**
+ * Export connectivity data to a CSV file
+ */
+ suspend fun exportConnectivityToCsv(
+ context: Context,
+ data: List
+ ): Uri? = withContext(Dispatchers.IO) {
+ try {
+ val fileName = "connectivity_export_${getCurrentDateTime()}.csv"
+ val csvContent = buildConnectivityCsv(data)
+ return@withContext saveToFile(context, fileName, csvContent)
+ } catch (e: IOException) {
+ e.printStackTrace()
+ return@withContext null
+ }
+ }
+
+ /**
+ * Build CSV content for signal strength data
+ */
+ private fun buildSignalStrengthCsv(data: List): String {
+ val csvBuilder = StringBuilder()
+
+ // Add CSV header
+ csvBuilder.append("Timestamp,Latitude,Longitude,Cell ID,Device ID,Signal Strength (dBm),Signal Level\n")
+
+ // Add data rows
+ data.forEach { signal ->
+ csvBuilder.append("${signal.timestamp},${signal.latitude},${signal.longitude},")
+ csvBuilder.append("${signal.cellId},${signal.deviceId},${signal.dbm},${signal.levelCode}\n")
+ }
+
+ return csvBuilder.toString()
+ }
+
+ /**
+ * Build CSV content for connectivity data
+ */
+ private fun buildConnectivityCsv(data: List): String {
+ val csvBuilder = StringBuilder()
+
+ // Add CSV header
+ csvBuilder.append("Timestamp,Latitude,Longitude,Cell ID,Device ID,Download Speed,Upload Speed,Ping,Packet Loss\n")
+
+ // Add data rows
+ data.forEach { connectivity ->
+ csvBuilder.append("${connectivity.timestamp},${connectivity.latitude},${connectivity.longitude},")
+ csvBuilder.append("${connectivity.cellId},${connectivity.deviceId},${connectivity.downloadSpeed},")
+ csvBuilder.append("${connectivity.uploadSpeed},${connectivity.ping},${connectivity.packetLoss}\n")
+ }
+
+ return csvBuilder.toString()
+ }
+
+ /**
+ * Save CSV content to a file in the Downloads directory
+ *
+ * This approach uses ContentResolver for compatibility across Android versions
+ */
+ private fun saveToFile(context: Context, fileName: String, content: String): Uri? {
+ return try {
+ // Use ContentValues and ContentResolver to create a file
+ val contentValues = android.content.ContentValues().apply {
+ put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, fileName)
+ put(android.provider.MediaStore.MediaColumns.MIME_TYPE, "text/csv")
+
+ // Using Downloads collection (available since our min SDK is 33)
+ put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, "Download")
+ put(android.provider.MediaStore.Downloads.IS_PENDING, 1)
+ }
+
+ // Use Downloads collection content URI (available since our min SDK is 33)
+ val contentUri = android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI
+
+ val uri = context.contentResolver.insert(contentUri, contentValues)
+
+ uri?.let {
+ context.contentResolver.openOutputStream(it)?.use { outputStream ->
+ outputStream.write(content.toByteArray())
+ outputStream.flush()
+ }
+
+ // Update IS_PENDING to 0 after writing is complete
+ contentValues.clear()
+ contentValues.put(android.provider.MediaStore.Downloads.IS_PENDING, 0)
+ context.contentResolver.update(uri, contentValues, null, null)
+ }
+
+ uri
+ } catch (e: Exception) {
+ Log.e("CsvExporter", "Error saving file with MediaStore approach: ${e.message}", e)
+
+ // Use a simpler fallback method if the MediaStore approach fails
+ try {
+ val downloadsDir = context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOWNLOADS)
+ val file = java.io.File(downloadsDir, fileName)
+
+ java.io.FileWriter(file).use { writer ->
+ writer.write(content)
+ }
+
+ androidx.core.content.FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file
+ )
+ } catch (e2: Exception) {
+ Log.e("CsvExporter", "Error saving file with fallback method: ${e2.message}", e2)
+ null
+ }
+ }
+ }
+
+ /**
+ * Get current date and time formatted for filenames
+ */
+ private fun getCurrentDateTime(): String {
+ val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
+ return dateFormat.format(Date())
+ }
+}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..7a105c2
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/libndt7/src/main/java/net/measurementlab/ndt7/android/models/HostnameResponse.kt b/libndt7/src/main/java/net/measurementlab/ndt7/android/models/HostnameResponse.kt
index c102003..d664ace 100644
--- a/libndt7/src/main/java/net/measurementlab/ndt7/android/models/HostnameResponse.kt
+++ b/libndt7/src/main/java/net/measurementlab/ndt7/android/models/HostnameResponse.kt
@@ -17,7 +17,9 @@ data class Result(
@SerialName("machine")
val machine: String,
@SerialName("urls")
- val urls: Urls
+ val urls: Urls,
+ @SerialName("hostname")
+ var hostname: String
)
@Serializable
diff --git a/libndt7/src/main/java/net/measurementlab/ndt7/android/models/Measurement.kt b/libndt7/src/main/java/net/measurementlab/ndt7/android/models/Measurement.kt
index 679b3d7..40991ea 100644
--- a/libndt7/src/main/java/net/measurementlab/ndt7/android/models/Measurement.kt
+++ b/libndt7/src/main/java/net/measurementlab/ndt7/android/models/Measurement.kt
@@ -81,5 +81,7 @@ data class TCPInfo(
@SerialName("BytesRetrans") val bytesRetrans: Long?,
@SerialName("DSackDups") val dSackDups: Long?,
@SerialName("ReordSeen") val reordSeen: Long?,
+ @SerialName("RcvOooPack") val rcvOooPack: Long?,
+ @SerialName("SndWnd") val sndWnd: Long?,
@SerialName("ElapsedTime") val elapsedTime: Long?
)