feat: implement telemetry persistence, data visualization, and improved bluetooth stream parsing with frame statistics #9
@@ -1,11 +1,21 @@
|
|||||||
package com.example.esp32aldldashboard.repository
|
package com.example.esp32aldldashboard.repository
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.*
|
import androidx.datastore.preferences.core.*
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
@@ -73,4 +83,123 @@ class SettingsRepository(private val context: Context) {
|
|||||||
preferences[RECORD_RAW_DATA] = recordRaw
|
preferences[RECORD_RAW_DATA] = recordRaw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries MediaStore for logged files (CSV and .bin) in Downloads/ALDLLogs
|
||||||
|
*/
|
||||||
|
suspend fun getLoggedFiles(): List<LoggedFile> = withContext(Dispatchers.IO) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
return@withContext emptyList() // Legacy not supported for this feature
|
||||||
|
}
|
||||||
|
|
||||||
|
val files = mutableListOf<LoggedFile>()
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.MediaColumns._ID,
|
||||||
|
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||||
|
MediaStore.MediaColumns.SIZE,
|
||||||
|
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||||
|
MediaStore.MediaColumns.MIME_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Query for CSV files
|
||||||
|
val csvSelection = "${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?"
|
||||||
|
val csvSelectionArgs = arrayOf("%${Environment.DIRECTORY_DOWNLOADS}/ALDLLogs%", "%.csv")
|
||||||
|
|
||||||
|
resolver.query(
|
||||||
|
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||||
|
projection,
|
||||||
|
csvSelection,
|
||||||
|
csvSelectionArgs,
|
||||||
|
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
||||||
|
)?.use { cursor ->
|
||||||
|
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
|
||||||
|
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
|
||||||
|
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||||
|
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
|
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = cursor.getLong(idColumn)
|
||||||
|
val name = cursor.getString(nameColumn)
|
||||||
|
val size = cursor.getLong(sizeColumn)
|
||||||
|
val dateModified = cursor.getLong(dateColumn)
|
||||||
|
val uri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
|
||||||
|
|
||||||
|
files.add(
|
||||||
|
LoggedFile(
|
||||||
|
uri = uri,
|
||||||
|
name = name,
|
||||||
|
size = size,
|
||||||
|
lastModified = dateModified * 1000, // Convert to milliseconds
|
||||||
|
type = FileType.CSV
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query for binary files (.bin)
|
||||||
|
val binSelection = "${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?"
|
||||||
|
val binSelectionArgs = arrayOf("%${Environment.DIRECTORY_DOWNLOADS}/ALDLLogs%", "%.bin")
|
||||||
|
|
||||||
|
resolver.query(
|
||||||
|
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||||
|
projection,
|
||||||
|
binSelection,
|
||||||
|
binSelectionArgs,
|
||||||
|
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
||||||
|
)?.use { cursor ->
|
||||||
|
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
|
||||||
|
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
|
||||||
|
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||||
|
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
|
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = cursor.getLong(idColumn)
|
||||||
|
val name = cursor.getString(nameColumn)
|
||||||
|
val size = cursor.getLong(sizeColumn)
|
||||||
|
val dateModified = cursor.getLong(dateColumn)
|
||||||
|
val uri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
|
||||||
|
|
||||||
|
files.add(
|
||||||
|
LoggedFile(
|
||||||
|
uri = uri,
|
||||||
|
name = name,
|
||||||
|
size = size,
|
||||||
|
lastModified = dateModified * 1000,
|
||||||
|
type = FileType.BINARY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date descending
|
||||||
|
files.sortByDescending { it.lastModified }
|
||||||
|
files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LoggedFile(
|
||||||
|
val uri: Uri,
|
||||||
|
val name: String,
|
||||||
|
val size: Long,
|
||||||
|
val lastModified: Long,
|
||||||
|
val type: FileType
|
||||||
|
) {
|
||||||
|
fun getFormattedSize(): String {
|
||||||
|
return when {
|
||||||
|
size < 1024 -> "$size B"
|
||||||
|
size < 1024 * 1024 -> "${size / 1024} KB"
|
||||||
|
else -> "${size / (1024 * 1024)} MB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFormattedDate(): String {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
|
||||||
|
return sdf.format(Date(lastModified))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class FileType {
|
||||||
|
CSV, BINARY
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.example.esp32aldldashboard.ui.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.example.esp32aldldashboard.repository.FileType
|
||||||
|
import com.example.esp32aldldashboard.repository.LoggedFile
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LogFilesDialog(
|
||||||
|
files: List<LoggedFile>,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Logged Files (${files.size})") },
|
||||||
|
text = {
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No log files found in Downloads/ALDLLogs",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(files) { file ->
|
||||||
|
FileListItem(
|
||||||
|
file = file,
|
||||||
|
onOpen = {
|
||||||
|
try {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(file.uri, when(file.type) {
|
||||||
|
FileType.CSV -> "text/csv"
|
||||||
|
FileType.BINARY -> "application/octet-stream"
|
||||||
|
})
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// No app available to open file
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShare = {
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = when(file.type) {
|
||||||
|
FileType.CSV -> "text/csv"
|
||||||
|
FileType.BINARY -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
putExtra(Intent.EXTRA_STREAM, file.uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, file.name)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(shareIntent, "Share ${file.name}"))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FileListItem(
|
||||||
|
file: LoggedFile,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onShare: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
onClick = onOpen
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.InsertDriveFile,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = when(file.type) {
|
||||||
|
FileType.CSV -> MaterialTheme.colorScheme.primary
|
||||||
|
FileType.BINARY -> MaterialTheme.colorScheme.tertiary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = file.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${file.getFormattedDate()} • ${file.getFormattedSize()}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onShare) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Share,
|
||||||
|
contentDescription = "Share"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
package com.example.esp32aldldashboard.ui.settings
|
package com.example.esp32aldldashboard.ui.settings
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.example.esp32aldldashboard.repository.LoggedFile
|
||||||
import com.example.esp32aldldashboard.repository.SettingsRepository
|
import com.example.esp32aldldashboard.repository.SettingsRepository
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -20,6 +23,9 @@ fun SettingsScreen(
|
|||||||
val recordRawData by settingsRepository.recordRawDataFlow.collectAsStateWithLifecycle(initialValue = false)
|
val recordRawData by settingsRepository.recordRawDataFlow.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val coolantThreshold by settingsRepository.coolantAlertThresholdFlow.collectAsStateWithLifecycle(initialValue = 100f)
|
val coolantThreshold by settingsRepository.coolantAlertThresholdFlow.collectAsStateWithLifecycle(initialValue = 100f)
|
||||||
|
|
||||||
|
var showLogFilesDialog by remember { mutableStateOf(false) }
|
||||||
|
var logFiles by remember { mutableStateOf<List<LoggedFile>>(emptyList()) }
|
||||||
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
|
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
|
||||||
@@ -87,6 +93,31 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// View Logged Files
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = "View Logged Files", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(text = "Browse CSV and binary logs", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
logFiles = settingsRepository.getLoggedFiles()
|
||||||
|
showLogFilesDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.FolderOpen, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
// Coolant Alert Threshold
|
// Coolant Alert Threshold
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||||
Text(text = "Coolant Alert Threshold", style = MaterialTheme.typography.titleMedium)
|
Text(text = "Coolant Alert Threshold", style = MaterialTheme.typography.titleMedium)
|
||||||
@@ -101,4 +132,12 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log Files Dialog
|
||||||
|
if (showLogFilesDialog) {
|
||||||
|
LogFilesDialog(
|
||||||
|
files = logFiles,
|
||||||
|
onDismiss = { showLogFilesDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user