feat: add logged files viewer in settings

This commit is contained in:
2026-06-12 10:58:25 +01:00
parent bb47d61eec
commit 9838378933
3 changed files with 305 additions and 0 deletions
@@ -1,11 +1,21 @@
package com.example.esp32aldldashboard.repository
import android.content.ContentUris
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.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
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")
@@ -73,4 +83,123 @@ class SettingsRepository(private val context: Context) {
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
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.esp32aldldashboard.repository.LoggedFile
import com.example.esp32aldldashboard.repository.SettingsRepository
import kotlinx.coroutines.launch
@@ -20,6 +23,9 @@ fun SettingsScreen(
val recordRawData by settingsRepository.recordRawDataFlow.collectAsStateWithLifecycle(initialValue = false)
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()
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
@@ -87,6 +93,31 @@ fun SettingsScreen(
}
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
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
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 }
)
}
}