Share logs, calibrate expiration time for removing stopped advertising devices

This commit is contained in:
2026-07-07 17:46:18 +07:00
parent 99c17b1169
commit 8e7b5d7569
16 changed files with 551 additions and 216 deletions
@@ -1,73 +1,62 @@
package com.digitoolsolutions.app.torquevaultkmp.data.repository
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabaseQueries
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log
import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlin.collections.plus
import kotlin.time.Clock
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class LogRepository(
private val dbQueries: AppDatabaseQueries
) {
fun addLog(title: String, content: String, deviceId: String? = null) {
data class Log(
val id: String,
val title: String,
val content: String,
val timestamp: Long,
val deviceId: String?,
val source: String,
)
class LogRepository() {
companion object {
private const val MAX_LOGS = 1000
}
private val _logs = MutableStateFlow<List<Log>>(emptyList())
val logs: StateFlow<List<Log>> = _logs
@OptIn(ExperimentalUuidApi::class)
fun appendLog(title: String, content: String, deviceId: String? = null) {
val source = if (deviceId == null) LogFilter.SYSTEM else LogFilter.DEVICE
dbQueries.insertLog(
val newLog = Log(
id = Uuid.generateV4().toString(),
title = title,
content = content,
timestamp = Clock.System.now().toEpochMilliseconds(),
deviceId = deviceId,
source = source
)
}
fun getAllLogs(): Flow<List<Log>> =
dbQueries.getAllLogs()
.asFlow()
.mapToList(Dispatchers.IO)
suspend fun getLogsPaged(limit: Long, lastTimestamp: Long?, lastId: Long?): List<Log> {
return if (lastTimestamp == null || lastId == null) {
dbQueries.getLogsFirstPage(limit).executeAsList()
} else {
dbQueries.getLogsNextPage(lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
_logs.update { current ->
(listOf(newLog) + current).take(MAX_LOGS)
}
}
suspend fun getLogsByFilterPaged(filter: LogFilter, limit: Long, lastTimestamp: Long?, lastId: Long?): List<Log> {
return when (filter) {
LogFilter.All -> getLogsPaged(limit, lastTimestamp, lastId)
LogFilter.System -> {
if (lastTimestamp == null || lastId == null) {
dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList()
} else {
dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
}
}
is LogFilter.Device -> {
if (filter.deviceId != null) {
getLogsByDevicePaged(filter.deviceId, limit, lastTimestamp, lastId)
} else {
if (lastTimestamp == null || lastId == null) {
dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList()
fun getAllLogs(): Flow<List<Log>> = logs
fun getLogsWithFilter(filter: LogFilter): Flow<List<Log>> =
logs.map { list ->
when (filter) {
LogFilter.All -> list
LogFilter.System -> list.filter { it.source == LogFilter.SYSTEM }
is LogFilter.Device -> {
if (filter.deviceId != null) {
list.filter { it.deviceId == filter.deviceId }
} else {
dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
list.filter { it.source == LogFilter.DEVICE }
}
}
}
}
}
suspend fun getLogsByDevicePaged(deviceId: String, limit: Long, lastTimestamp: Long?, lastId: Long?): List<Log> {
return if (lastTimestamp == null || lastId == null) {
dbQueries.getLogsByDeviceFirstPage(deviceId, limit).executeAsList()
} else {
dbQueries.getLogsByDeviceNextPage(deviceId = deviceId, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
}
fun clearLogs() {
_logs.value = emptyList()
}
suspend fun clearLogs() = dbQueries.clearAllLogs()
}
@@ -36,7 +36,7 @@ val appModule = module {
val storageModule = module {
single { AppDatabase(get()) }
single { get<AppDatabase>().appDatabaseQueries }
single { LogRepository(get()) }
single { LogRepository() }
single { DeviceRepository(get()) }
single { ScannerRepository(get()) }
}
@@ -1,8 +1,11 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.logs
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@@ -11,8 +14,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.AlertDialog
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
@@ -23,7 +27,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
@@ -41,15 +44,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.AppBar
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log
import com.digitoolsolutions.app.torquevaultkmp.data.repository.Log
import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.DeleteLogDialog
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.EmptyLogView
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.FilterBottomSheet
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.LogFormat
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.LogItem
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.ShareLogDialog
import com.digitoolsolutions.app.torquevaultkmp.utils.ShareLog
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.log_title
@@ -61,19 +69,14 @@ fun LogScreen(
) {
val logs by viewModel.logs.collectAsState()
val activeFilter by viewModel.activeFilter.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
}
LogContent(
logs = logs,
activeFilter = activeFilter,
isLoading = isLoading,
onFilterSelected = { viewModel.setFilter(it) },
onDeleteAllLogs = { viewModel.clearAllLogs() },
onLoadMore = { viewModel.loadNextPage() },
onRefresh = { viewModel.refresh() }
txtContent = viewModel.exportLogsAsText(),
csvContent = viewModel.exportLogsAsCSV()
)
}
@@ -82,49 +85,40 @@ fun LogScreen(
internal fun LogContent(
logs: List<Log>,
activeFilter: LogFilter,
isLoading: Boolean,
onFilterSelected: (LogFilter) -> Unit,
onDeleteAllLogs: () -> Unit,
onLoadMore: () -> Unit,
onRefresh: () -> Unit
txtContent: String,
csvContent: String
) {
val snackbarHostState = remember { SnackbarHostState() }
var showConfirmDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showBottomSheet by remember { mutableStateOf(false) }
var showShareDialog by remember { mutableStateOf(false) }
val shareLog: ShareLog = koinInject()
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
// Infinite Scroll logic
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisibleItemIndex >= logs.size - 5 && logs.isNotEmpty()
}
}
LaunchedEffect(logs) {
if (logs.isNotEmpty()) {
listState.scrollToItem(0)
}
}
LaunchedEffect(shouldLoadMore) {
snapshotFlow { shouldLoadMore.value }
.distinctUntilChanged()
.filter { it }
.collect {
onLoadMore()
}
}
Scaffold (
modifier = Modifier.imePadding(),
topBar = {
AppBar(
title = {Text(stringResource(Res.string.log_title))},
actions = {
IconButton(onClick = {showConfirmDialog = true}){
IconButton(onClick = { showShareDialog = true }){
Icon(
Icons.Rounded.Share,
contentDescription = "Share logs",
tint = Color.White
)
}
IconButton(onClick = { showDeleteDialog = true }){
Icon(
Icons.Default.DeleteForever,
contentDescription = "Delete all log",
@@ -142,17 +136,33 @@ internal fun LogContent(
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
onRefresh()
coroutineScope.launch {
listState.animateScrollToItem(0)
}
},
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Rounded.KeyboardArrowUp, contentDescription = "Go to top")
Column {
FloatingActionButton(
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(0)
}
},
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Rounded.KeyboardArrowUp, contentDescription = "Go to top")
}
Spacer(modifier = Modifier.height(4.dp))
FloatingActionButton(
onClick = {
coroutineScope.launch {
val lastIndex = logs.size - 1
if (lastIndex >= 0) {
listState.animateScrollToItem(lastIndex)
}
}
},
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = "Go to end")
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) })
@@ -160,10 +170,8 @@ internal fun LogContent(
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = isLoading && logs.isEmpty(),
onRefresh = {
onRefresh()
},
isRefreshing = false,
onRefresh = {},
state = pullToRefreshState,
modifier = Modifier
.fillMaxSize()
@@ -171,52 +179,33 @@ internal fun LogContent(
.padding(padding)
.padding(horizontal = 16.dp, vertical = 12.dp),
){
if (logs.isEmpty() && !isLoading) {
if (logs.isEmpty()) {
EmptyLogView()
} else {
LazyColumn(state = listState) {
items(items = logs, key = { it.id }) { log ->
LogItem(log)
}
if (isLoading && logs.isNotEmpty()) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}
}
if (showConfirmDialog) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
title = { Text("Delete Logs") },
text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") },
confirmButton = {
TextButton(onClick = {
onDeleteAllLogs()
showConfirmDialog = false
}) {
Text("Delete", color = Color.Red)
}
},
dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) {
Text("Cancel", color = Color.Gray)
}
}
)
}
if (showBottomSheet) {
FilterBottomSheet(
activeFilter = activeFilter,
onFilterSelected = onFilterSelected,
onDismissRequest = {showBottomSheet = false}
)
}
ShareLogDialog(show = showShareDialog, onDismiss = { showShareDialog = false }, onConfirm = { format ->
when (format) {
LogFormat.PlainText -> { shareLog.exportTextFile(txtContent) }
LogFormat.CSV -> { shareLog.exportCSVFile(csvContent) }
}
showShareDialog = false
})
DeleteLogDialog(show = showDeleteDialog, onDismiss = { showDeleteDialog = false }, onConfirm = {
onDeleteAllLogs()
showDeleteDialog = false
})
FilterBottomSheet(
show = showBottomSheet,
activeFilter = activeFilter,
onFilterSelected = onFilterSelected,
onDismissRequest = {showBottomSheet = false}
)
}
}
@@ -2,84 +2,84 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.logs
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.digitoolsolutions.app.torquevaultkmp.data.repository.Log
import com.digitoolsolutions.app.torquevaultkmp.data.repository.LogRepository
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log
import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
class LogViewModel(
private val logRepository: LogRepository
) : ViewModel() {
private val _logs = MutableStateFlow<List<Log>>(emptyList())
val logs: StateFlow<List<Log>> = _logs.asStateFlow()
private val _activeFilter = MutableStateFlow<LogFilter>(LogFilter.All)
val activeFilter: StateFlow<LogFilter> = _activeFilter.asStateFlow()
private var lastTimestamp: Long? = null
private var lastId: Long? = null
private val pageSize = 20L
private var isLastPage = false
var isLoading = MutableStateFlow(false)
private set
init {
loadNextPage()
}
@OptIn(ExperimentalCoroutinesApi::class)
val logs: StateFlow<List<Log>> = _activeFilter
.flatMapLatest { filter ->
logRepository.getLogsWithFilter(filter)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun setFilter(filter: LogFilter) {
if (_activeFilter.value == filter) return
_activeFilter.value = filter
refresh()
}
fun loadNextPage() {
if (isLoading.value || isLastPage) return
viewModelScope.launch {
isLoading.value = true
val newLogs = logRepository.getLogsByFilterPaged(
filter = _activeFilter.value,
limit = pageSize,
lastTimestamp = lastTimestamp,
lastId = lastId
)
if (newLogs.size < pageSize) {
isLastPage = true
}
if (newLogs.isNotEmpty()) {
val lastLog = newLogs.last()
lastTimestamp = lastLog.timestamp
lastId = lastLog.id
_logs.value += newLogs
}
isLoading.value = false
}
}
fun refresh() {
viewModelScope.launch {
lastTimestamp = null
lastId = null
isLastPage = false
_logs.value = emptyList()
loadNextPage()
}
}
fun clearAllLogs() {
viewModelScope.launch {
logRepository.clearLogs()
_logs.value = emptyList()
lastTimestamp = null
lastId = null
isLastPage = true
logRepository.clearLogs()
}
fun formatTimestamp(epochMillis: Long): String {
val instant = Instant.fromEpochMilliseconds(epochMillis)
val tz = TimeZone.currentSystemDefault()
val localDateTime = instant.toLocalDateTime(tz)
// Format pattern ex: "Sat Jun 27 10:19:21 GMT+07:00 2026"
val dayOfWeek = localDateTime.dayOfWeek.name.take(3).lowercase().replaceFirstChar { it.uppercase() }
val month = localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }
val day = localDateTime.day
val year = localDateTime.year
val hour = localDateTime.hour.toString().padStart(2, '0')
val minute = localDateTime.minute.toString().padStart(2, '0')
val second = localDateTime.second.toString().padStart(2, '0')
val offset = tz.offsetAt(instant) // Example: GMT+07:00
return "$dayOfWeek $month $day $hour:$minute:$second $offset $year"
}
fun escapeCsv(value: String?): String {
if (value == null) return ""
val escaped = value.replace("\"", "\"\"") // escape "
return "\"$escaped\""
}
fun exportLogsAsText(): String {
val logs = logRepository.logs.value
return logs.joinToString(separator = "\n") { log ->
"${formatTimestamp(log.timestamp)}: [${log.deviceId}] - ${log.title} - ${log.content}"
}
}
fun exportLogsAsCSV(): String {
val logs = logRepository.logs.value
val header = "type,deviceId,time,title,content"
val rows = logs.joinToString(separator = "\n") { log ->
"${log.source},${log.deviceId},${formatTimestamp(log.timestamp)},${log.title},${escapeCsv(log.content)}"
}
return "$header\n$rows"
}
}
@@ -0,0 +1,31 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.logs.view
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@Composable
fun DeleteLogDialog(
show: Boolean = false,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
if(!show) return
AlertDialog(
onDismissRequest = onDismiss ,
title = { Text("Delete Logs") },
text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Delete", color = Color.Red)
}
},
dismissButton = {
TextButton(onClick = onDismiss ) {
Text("Cancel", color = Color.Gray)
}
}
)
}
@@ -53,10 +53,12 @@ import torquevaultkmp.composeapp.generated.resources.clear
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterBottomSheet(
show: Boolean = false,
activeFilter: LogFilter,
onFilterSelected: (LogFilter) -> Unit,
onDismissRequest: () -> Unit,
) {
if(!show) return
ModalBottomSheet(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
@@ -10,7 +10,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log
import com.digitoolsolutions.app.torquevaultkmp.data.repository.Log
import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
import kotlin.time.Instant
import kotlinx.datetime.TimeZone
@@ -20,7 +20,7 @@ import kotlinx.datetime.toLocalDateTime
fun LogItem(log: Log, modifier: Modifier = Modifier) {
val dateTime = Instant.fromEpochMilliseconds(log.timestamp)
.toLocalDateTime(TimeZone.currentSystemDefault())
val formattedTime = "${dateTime.date} ${dateTime.hour}:${dateTime.minute}:${dateTime.second}"
val formattedTime = "${dateTime.date} ${dateTime.hour.toString().padStart(2, '0')}:${dateTime.minute.toString().padStart(2, '0')}:${dateTime.second.toString().padStart(2, '0')}"
Column(
modifier = modifier.fillMaxWidth(),
@@ -0,0 +1,78 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.logs.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
enum class LogFormat { PlainText, CSV }
@Composable
fun ShareLogDialog(
show: Boolean = false,
onDismiss: () -> Unit,
onConfirm: (LogFormat) -> Unit
) {
var selectedFormat by remember { mutableStateOf(LogFormat.PlainText) }
if(!show) return
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Export Logs") },
text = {
Column {
Row(
modifier = Modifier.fillMaxWidth().clickable {
selectedFormat = LogFormat.PlainText
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedFormat == LogFormat.PlainText,
onClick = { selectedFormat = LogFormat.PlainText }
)
Text("Plain text")
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
selectedFormat = LogFormat.CSV
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedFormat == LogFormat.CSV,
onClick = { selectedFormat = LogFormat.CSV }
)
Text("CSV file")
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(selectedFormat) }) {
Text("OK", color = MaterialTheme.colorScheme.primary)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.Gray)
}
}
)
}
@@ -37,7 +37,10 @@ import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlin.math.ceil
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
@@ -58,7 +61,7 @@ class ScannerViewModel(
private val scannerRepository: ScannerRepository
) : ViewModel() {
companion object {
private val ADV_EXPIRATION_TIME = 5.seconds
private var ADV_EXPIRATION_TIME = 3.seconds
private val CONNECTION_TIMEOUT = 5.seconds
private const val MAX_LOG_ENTRIES = 50
}
@@ -78,7 +81,6 @@ class ScannerViewModel(
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val isAutoConnect: StateFlow<Boolean> = _autoConnect
private val _onlyWithName = MutableStateFlow(true)
private var scanJob: Job? = null
private var cleanupJob: Job? = null
private val connectionJobs = mutableMapOf<String, Job>()
@@ -87,7 +89,7 @@ class ScannerViewModel(
/** Bonded device */
private val bondedIds = mutableSetOf<String>() // Cache
suspend fun preloadBondedIds() {
fun preloadBondedIds() {
viewModelScope.launch {
deviceRepository.getAllDeviceIdsFlow().collect { ids ->
bondedIds.clear()
@@ -98,17 +100,74 @@ class ScannerViewModel(
fun isBonded(id: String): Boolean = bondedIds.contains(id)
init {
logRepository.appendLog("Scanner - Calibration", "Initial advertising expiration time: $ADV_EXPIRATION_TIME")
viewModelScope.launch {
preloadBondedIds()
}
}
private val calibrateCleanupTime = mutableMapOf<String, TimeMark>()
private val intervals = mutableListOf<Duration>()
private var stabilized = false
/**
* Record advertisement: collect (20 times) intervals between consecutive ads
* to calibrate the cleanup expiration time when a device stops advertising.
*/
private fun calibrateCleanupTime(deviceId: String, now: TimeMark) {
if (stabilized) return
val firstDeviceId = calibrateCleanupTime.keys.firstOrNull()
if (firstDeviceId != null && deviceId != firstDeviceId) {
return
}
if (firstDeviceId == null) {
logRepository.appendLog("Scanner Calibration", "Starting calibration with device $deviceId")
}
calibrateCleanupTime[deviceId]?.let { last ->
val delta = last.elapsedNow()
// Only consider reasonable intervals between consecutive ads of the same device
if (delta.inWholeMilliseconds < 5000) { // ms
intervals.add(delta)
if (intervals.size > 20) intervals.removeAt(0)
if (intervals.size >= 20) {
val maxMs = intervals.maxOf { it.inWholeMilliseconds }
val rounded = ceil((maxMs * 1.2) / 1000.0).toLong().seconds // *1.2: margin 20%
ADV_EXPIRATION_TIME = rounded.coerceAtLeast(1.seconds)
stabilized = true
logRepository.appendLog("Scanner Calibration", "Expiration time for removing stopped advertising devices set to: $ADV_EXPIRATION_TIME")
}
}
}
calibrateCleanupTime[deviceId] = now
}
/**
* Reset calibration when the currently calibrated device is removed
* (i.e., it stopped advertising and was cleaned up).
*/
private fun resetCalibrate(id: String) {
val firstDeviceId = calibrateCleanupTime.keys.firstOrNull()
if(!stabilized && id == firstDeviceId) {
calibrateCleanupTime.clear()
intervals.clear()
logRepository.appendLog(
"Scanner Calibration",
"Calibration cancelled for $id — device stopped advertising and was cleaned up"
)
}
}
fun startScan() {
if (_isScanning.value) return
scanJob = viewModelScope.launch {
_isScanning.value = true
_devices.value = emptyList()
advertisementMap.clear()
calibrateCleanupTime.clear()
intervals.clear()
stabilized = false
bleManager.scanDevices()
.catch {
@@ -126,24 +185,33 @@ class ScannerViewModel(
if (entry != null) {
advertisementMap[bleId] = entry.copy(advertisement = advertisement, timeMark = now)
} else {
logRepository.appendLog(title="Scanner status", content="${advertisement.name} ($bleId) peripheral discovered")
advertisementMap[bleId] = AdvertisementMark(advertisement, now)
}
calibrateCleanupTime(bleId, now)
startCleanupJob()
updateDeviceList()
}
}
}
logRepository.addLog(title="Scanner status", content="Scanning ...")
logRepository.appendLog(title="Scanner status", content="Scanning ...")
}
/**
* Cleanup job: runs periodically to remove devices that stopped advertising.
* Also resets calibration if the calibrated device is removed.
*/
private fun startCleanupJob() {
if (cleanupJob?.isActive == true) return
cleanupJob = viewModelScope.launch {
while (advertisementMap.isNotEmpty() && isActive) {
delay(5000)
delay(1000)
val keysToRemove = advertisementMap.filter { it.value.timeMark.elapsedNow() > ADV_EXPIRATION_TIME }.keys
if (keysToRemove.isNotEmpty()) {
keysToRemove.forEach { advertisementMap.remove(it) }
keysToRemove.forEach {
advertisementMap.remove(it)
resetCalibrate(it)
}
updateDeviceList()
}
}
@@ -219,14 +287,14 @@ class ScannerViewModel(
viewModelScope.launch {
bleManager.sendCommand(peripheral, Helper.buildUartCommand(command))
appendHistory(deviceId, "Sent: $command")
logRepository.addLog(title = "Communicate - Sent", content = command, deviceId)
logRepository.appendLog(title = "Communicate - Sent", content = command, deviceId)
}
}
fun connect(adv: Advertisement) {
val deviceIdentifier = adv.identifier.toString()
if (connectionJobs[deviceIdentifier]?.isActive == true) return
logRepository.addLog(title = "Connection - Status", deviceId = deviceIdentifier ,content = "Device [${adv.name}] is connecting...")
logRepository.appendLog(title = "Connection - Status", deviceId = deviceIdentifier ,content = "Device [${adv.name}] is connecting...")
val job = viewModelScope.launch {
try {
val peripheral = bleManager.connect(adv)
@@ -241,7 +309,7 @@ class ScannerViewModel(
/** Add bonded device */
addBondedDevice(deviceIdentifier, adv.name ?: "No name")
_connectionReasons.value -= deviceIdentifier
logRepository.addLog(title = "Connection - Status", deviceId = deviceIdentifier ,content = "Device [${adv.name}] has been connected.")
logRepository.appendLog(title = "Connection - Status", deviceId = deviceIdentifier ,content = "Device [${adv.name}] has been connected.")
}
if (state is State.Disconnected && hasEmitted) {
if (_connectionReasons.value[deviceIdentifier] == null) {
@@ -269,10 +337,10 @@ class ScannerViewModel(
if (e is TimeoutCancellationException) {
Napier.e(">>>>> Connection timed out for $deviceIdentifier")
_connectionReasons.value += (deviceIdentifier to ConnectionReason.TIMEOUT)
logRepository.addLog(title = "Connection - Status", content = "Connect to device [${adv.name}] failed. ${e.message}")
logRepository.appendLog(title = "Connection - Status", content = "Connect to device [${adv.name}] failed. ${e.message}")
} else if (e !is CancellationException) {
Napier.e(">>>>> Connection failed for $deviceIdentifier", e)
logRepository.addLog(title = "Connection - Status", content = "Connect to device [${adv.name}] failed. ${e.message}")
logRepository.appendLog(title = "Connection - Status", content = "Connect to device [${adv.name}] failed. ${e.message}")
}
} finally {
_connectedDevices.value -= deviceIdentifier
@@ -301,7 +369,7 @@ class ScannerViewModel(
sendWorkOrdersToDevice(peripheral,orders)
} catch (e: Exception) {
Napier.e("Failed to load work orders", e)
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
}
}
}
@@ -323,7 +391,7 @@ class ScannerViewModel(
}
private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) {
appendHistory(deviceIdentifier, "Received: $msg")
logRepository.addLog(title = "Communicate - Received", content = msg, deviceId = deviceIdentifier)
logRepository.appendLog(title = "Communicate - Received", content = msg, deviceId = deviceIdentifier)
val decoded = Helper.decodeRxData(msg) ?: return
val data = decoded.data
val woId = decoded.id
@@ -342,7 +410,7 @@ class ScannerViewModel(
} catch (e: Exception) {
Napier.e(">>>>> Failed to handle message: $msg", e)
sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO)
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
}
}
else -> {
@@ -363,7 +431,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto)
} catch (e: Exception) {
Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel")
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -374,7 +442,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto, true)
} catch (e: Exception) {
Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel")
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -385,7 +453,7 @@ class ScannerViewModel(
scannerRepository.sendCancelWorkOrder(woId, dto)
} catch (e: Exception) {
Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel")
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -0,0 +1,6 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
interface ShareLog {
fun exportTextFile(content: String)
fun exportCSVFile(content: String)
}