Share logs, calibrate expiration time for removing stopped advertising devices
This commit is contained in:
@@ -37,6 +37,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+3
@@ -8,11 +8,13 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidAppInfo
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidBluetoothManager
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidNetworkMonitor
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidShareLog
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.AppInfo
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkMonitor
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.ShareLog
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
@@ -29,4 +31,5 @@ actual val platformModule = module {
|
||||
}
|
||||
single<NetworkMonitor> { AndroidNetworkMonitor(androidContext()) }
|
||||
single<AppInfo> { AndroidAppInfo(androidContext()) }
|
||||
single<ShareLog> { AndroidShareLog(androidContext()) }
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.digitoolsolutions.app.torquevaultkmp.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class AndroidShareLog(private val context: Context) : ShareLog {
|
||||
init {
|
||||
cleanOldLogFiles()
|
||||
}
|
||||
override fun exportTextFile(content: String) {
|
||||
val fileName = generateFileName("txt")
|
||||
shareFile(fileName, "text/plain", "Share plain text log", content)
|
||||
}
|
||||
|
||||
override fun exportCSVFile(content: String) {
|
||||
val fileName = generateFileName("csv")
|
||||
shareFile(fileName, "text/csv", "Share CSV log", content)
|
||||
}
|
||||
|
||||
private fun generateFileName(extension: String): String {
|
||||
val formatter = DateTimeFormatter.ofPattern("yy-MM-dd_HH-mm-ss")
|
||||
val timestamp = LocalDateTime.now().format(formatter)
|
||||
return "TV_logs_${timestamp}.$extension"
|
||||
}
|
||||
|
||||
private fun shareFile(fileName: String, mimeType: String, title: String, content: String) {
|
||||
val file = File(context.cacheDir, fileName)
|
||||
file.writeText(content)
|
||||
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
clipData = ClipData.newRawUri("", uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
val chooser = Intent.createChooser(intent, title).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
if (context !is Activity) {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
context.startActivity(chooser)
|
||||
}
|
||||
private fun cleanOldLogFiles() {
|
||||
context.cacheDir?.listFiles()
|
||||
?.filter { it.name.startsWith("TV_logs_") }
|
||||
?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path
|
||||
name="shared_logs"
|
||||
path="." />
|
||||
</paths>
|
||||
+37
-48
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
_logs.update { current ->
|
||||
(listOf(newLog) + current).take(MAX_LOGS)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
getLogsByDevicePaged(filter.deviceId, limit, lastTimestamp, lastId)
|
||||
list.filter { it.deviceId == filter.deviceId }
|
||||
} else {
|
||||
if (lastTimestamp == null || lastId == null) {
|
||||
dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList()
|
||||
} 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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()) }
|
||||
}
|
||||
|
||||
+57
-68
@@ -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,9 +136,9 @@ internal fun LogContent(
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
Column {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
onRefresh()
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
@@ -154,16 +148,30 @@ internal fun LogContent(
|
||||
) {
|
||||
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) })
|
||||
{ padding ->
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
ShareLogDialog(show = showShareDialog, onDismiss = { showShareDialog = false }, onConfirm = { format ->
|
||||
when (format) {
|
||||
LogFormat.PlainText -> { shareLog.exportTextFile(txtContent) }
|
||||
LogFormat.CSV -> { shareLog.exportCSVFile(csvContent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
showShareDialog = false
|
||||
})
|
||||
DeleteLogDialog(show = showDeleteDialog, onDismiss = { showDeleteDialog = false }, onConfirm = {
|
||||
onDeleteAllLogs()
|
||||
showConfirmDialog = false
|
||||
}) {
|
||||
Text("Delete", color = Color.Red)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showConfirmDialog = false }) {
|
||||
Text("Cancel", color = Color.Gray)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showBottomSheet) {
|
||||
showDeleteDialog = false
|
||||
})
|
||||
FilterBottomSheet(
|
||||
show = showBottomSheet,
|
||||
activeFilter = activeFilter,
|
||||
onFilterSelected = onFilterSelected,
|
||||
onDismissRequest = {showBottomSheet = false}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+59
-59
@@ -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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+31
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+2
@@ -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),
|
||||
|
||||
+2
-2
@@ -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(),
|
||||
|
||||
+78
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+85
-17
@@ -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)
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.digitoolsolutions.app.torquevaultkmp.utils
|
||||
|
||||
interface ShareLog {
|
||||
fun exportTextFile(content: String)
|
||||
fun exportCSVFile(content: String)
|
||||
}
|
||||
+3
@@ -9,10 +9,12 @@ import com.digitoolsolutions.app.torquevaultkmp.utils.AppInfo
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.IOSAppInfo
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.IOSNetworkMonitor
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.IOSShareLog
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.IosBluetoothManager
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkMonitor
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform
|
||||
import com.digitoolsolutions.app.torquevaultkmp.utils.ShareLog
|
||||
import org.koin.dsl.module
|
||||
|
||||
actual val platformModule = module {
|
||||
@@ -27,4 +29,5 @@ actual val platformModule = module {
|
||||
}
|
||||
single<NetworkMonitor> { IOSNetworkMonitor() }
|
||||
single<AppInfo> { IOSAppInfo() }
|
||||
single<ShareLog> { IOSShareLog() }
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.digitoolsolutions.app.torquevaultkmp.utils
|
||||
|
||||
import kotlinx.cinterop.BetaInteropApi
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.readValue
|
||||
import platform.CoreGraphics.CGRectZero
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSDateFormatter
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSLocale
|
||||
import platform.Foundation.NSString
|
||||
import platform.Foundation.NSTemporaryDirectory
|
||||
import platform.Foundation.NSURL
|
||||
import platform.Foundation.NSUTF8StringEncoding
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataUsingEncoding
|
||||
import platform.Foundation.localeWithLocaleIdentifier
|
||||
import platform.Foundation.writeToURL
|
||||
import platform.UIKit.UIActivityViewController
|
||||
import platform.UIKit.UIApplication
|
||||
import platform.UIKit.popoverPresentationController
|
||||
|
||||
class IOSShareLog : ShareLog {
|
||||
init {
|
||||
cleanOldLogFiles()
|
||||
}
|
||||
|
||||
override fun exportTextFile(content: String) {
|
||||
val fileName = generateFileName("txt")
|
||||
shareFile(content, fileName)
|
||||
}
|
||||
|
||||
override fun exportCSVFile(content: String) {
|
||||
val fileName = generateFileName("csv")
|
||||
shareFile(content, fileName)
|
||||
}
|
||||
private fun generateFileName(extension: String): String {
|
||||
val formatter = NSDateFormatter().apply {
|
||||
dateFormat = "yy-MM-dd_HH-mm-ss"
|
||||
locale = NSLocale.localeWithLocaleIdentifier("en_US_POSIX")
|
||||
}
|
||||
val timestamp = formatter.stringFromDate(NSDate())
|
||||
return "TV_logs_${timestamp}.$extension"
|
||||
}
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
private fun shareFile(content: String, fileName: String) {
|
||||
val nsString = NSString.create(string = content)
|
||||
val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
|
||||
val tempDir = NSTemporaryDirectory()
|
||||
val fileURL = NSURL.fileURLWithPath(tempDir).URLByAppendingPathComponent(fileName)
|
||||
|
||||
if ((fileURL != null) && (data != null)) {
|
||||
data.writeToURL(url = fileURL, atomically = true)
|
||||
|
||||
val activityViewController = UIActivityViewController(
|
||||
activityItems = listOf(fileURL),
|
||||
applicationActivities = null,
|
||||
)
|
||||
|
||||
val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||
|
||||
// For iPad, we need to provide a sourceView or barButtonItem for the popover
|
||||
activityViewController.popoverPresentationController()?.let { popover ->
|
||||
val rootView = rootViewController?.view
|
||||
if (rootView != null) {
|
||||
popover.sourceView = rootView
|
||||
popover.sourceRect = rootView.bounds
|
||||
} else {
|
||||
popover.sourceRect = CGRectZero.readValue()
|
||||
}
|
||||
}
|
||||
|
||||
rootViewController?.presentViewController(activityViewController, true, null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun cleanOldLogFiles() {
|
||||
val tempDir = NSTemporaryDirectory()
|
||||
val fm = NSFileManager.defaultManager
|
||||
val files = fm.contentsOfDirectoryAtPath(tempDir, null) ?: return
|
||||
|
||||
for (item in files) {
|
||||
val name = item as? String ?: continue
|
||||
if (name.startsWith("TV_logs_")) {
|
||||
val path = tempDir + name
|
||||
fm.removeItemAtPath(path, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user