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
@@ -37,6 +37,15 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>
@@ -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.AndroidAppInfo
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidBluetoothManager import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidBluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidNetworkMonitor 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.AppInfo
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkMonitor import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkMonitor
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform
import com.digitoolsolutions.app.torquevaultkmp.utils.ShareLog
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
@@ -29,4 +31,5 @@ actual val platformModule = module {
} }
single<NetworkMonitor> { AndroidNetworkMonitor(androidContext()) } single<NetworkMonitor> { AndroidNetworkMonitor(androidContext()) }
single<AppInfo> { AndroidAppInfo(androidContext()) } single<AppInfo> { AndroidAppInfo(androidContext()) }
single<ShareLog> { AndroidShareLog(androidContext()) }
} }
@@ -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>
@@ -1,73 +1,62 @@
package com.digitoolsolutions.app.torquevaultkmp.data.repository 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 com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow 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.time.Clock
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class LogRepository( data class Log(
private val dbQueries: AppDatabaseQueries val id: String,
) { val title: String,
fun addLog(title: String, content: String, deviceId: String? = null) { 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 val source = if (deviceId == null) LogFilter.SYSTEM else LogFilter.DEVICE
dbQueries.insertLog( val newLog = Log(
id = Uuid.generateV4().toString(),
title = title, title = title,
content = content, content = content,
timestamp = Clock.System.now().toEpochMilliseconds(), timestamp = Clock.System.now().toEpochMilliseconds(),
deviceId = deviceId, deviceId = deviceId,
source = source source = source
) )
} _logs.update { current ->
(listOf(newLog) + current).take(MAX_LOGS)
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()
} }
} }
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 -> { is LogFilter.Device -> {
if (filter.deviceId != null) { if (filter.deviceId != null) {
getLogsByDevicePaged(filter.deviceId, limit, lastTimestamp, lastId) list.filter { it.deviceId == filter.deviceId }
} else { } else {
if (lastTimestamp == null || lastId == null) { list.filter { it.source == LogFilter.DEVICE }
dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList()
} else {
dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
}
} }
} }
} }
} }
suspend fun getLogsByDevicePaged(deviceId: String, limit: Long, lastTimestamp: Long?, lastId: Long?): List<Log> { fun clearLogs() {
return if (lastTimestamp == null || lastId == null) { _logs.value = emptyList()
dbQueries.getLogsByDeviceFirstPage(deviceId, limit).executeAsList()
} else {
dbQueries.getLogsByDeviceNextPage(deviceId = deviceId, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
} }
}
suspend fun clearLogs() = dbQueries.clearAllLogs()
} }
@@ -36,7 +36,7 @@ val appModule = module {
val storageModule = module { val storageModule = module {
single { AppDatabase(get()) } single { AppDatabase(get()) }
single { get<AppDatabase>().appDatabaseQueries } single { get<AppDatabase>().appDatabaseQueries }
single { LogRepository(get()) } single { LogRepository() }
single { DeviceRepository(get()) } single { DeviceRepository(get()) }
single { ScannerRepository(get()) } single { ScannerRepository(get()) }
} }
@@ -1,8 +1,11 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.logs package com.digitoolsolutions.app.torquevaultkmp.screens.logs
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.Icons
import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp 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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@@ -23,7 +27,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -41,15 +44,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.AppBar 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.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.EmptyLogView
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.FilterBottomSheet 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.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.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.log_title import torquevaultkmp.composeapp.generated.resources.log_title
@@ -61,19 +69,14 @@ fun LogScreen(
) { ) {
val logs by viewModel.logs.collectAsState() val logs by viewModel.logs.collectAsState()
val activeFilter by viewModel.activeFilter.collectAsState() val activeFilter by viewModel.activeFilter.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
}
LogContent( LogContent(
logs = logs, logs = logs,
activeFilter = activeFilter, activeFilter = activeFilter,
isLoading = isLoading,
onFilterSelected = { viewModel.setFilter(it) }, onFilterSelected = { viewModel.setFilter(it) },
onDeleteAllLogs = { viewModel.clearAllLogs() }, onDeleteAllLogs = { viewModel.clearAllLogs() },
onLoadMore = { viewModel.loadNextPage() }, txtContent = viewModel.exportLogsAsText(),
onRefresh = { viewModel.refresh() } csvContent = viewModel.exportLogsAsCSV()
) )
} }
@@ -82,49 +85,40 @@ fun LogScreen(
internal fun LogContent( internal fun LogContent(
logs: List<Log>, logs: List<Log>,
activeFilter: LogFilter, activeFilter: LogFilter,
isLoading: Boolean,
onFilterSelected: (LogFilter) -> Unit, onFilterSelected: (LogFilter) -> Unit,
onDeleteAllLogs: () -> Unit, onDeleteAllLogs: () -> Unit,
onLoadMore: () -> Unit, txtContent: String,
onRefresh: () -> Unit csvContent: String
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var showConfirmDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showShareDialog by remember { mutableStateOf(false) }
val shareLog: ShareLog = koinInject()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope() 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) { LaunchedEffect(logs) {
if (logs.isNotEmpty()) { if (logs.isNotEmpty()) {
listState.scrollToItem(0) listState.scrollToItem(0)
} }
} }
LaunchedEffect(shouldLoadMore) {
snapshotFlow { shouldLoadMore.value }
.distinctUntilChanged()
.filter { it }
.collect {
onLoadMore()
}
}
Scaffold ( Scaffold (
modifier = Modifier.imePadding(), modifier = Modifier.imePadding(),
topBar = { topBar = {
AppBar( AppBar(
title = {Text(stringResource(Res.string.log_title))}, title = {Text(stringResource(Res.string.log_title))},
actions = { actions = {
IconButton(onClick = {showConfirmDialog = true}){ IconButton(onClick = { showShareDialog = true }){
Icon(
Icons.Rounded.Share,
contentDescription = "Share logs",
tint = Color.White
)
}
IconButton(onClick = { showDeleteDialog = true }){
Icon( Icon(
Icons.Default.DeleteForever, Icons.Default.DeleteForever,
contentDescription = "Delete all log", contentDescription = "Delete all log",
@@ -142,9 +136,9 @@ internal fun LogContent(
) )
}, },
floatingActionButton = { floatingActionButton = {
Column {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
onRefresh()
coroutineScope.launch { coroutineScope.launch {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
} }
@@ -154,16 +148,30 @@ internal fun LogContent(
) { ) {
Icon(Icons.Rounded.KeyboardArrowUp, contentDescription = "Go to top") 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) }) snackbarHost = { SnackbarHost(snackbarHostState) })
{ padding -> { padding ->
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isLoading && logs.isEmpty(), isRefreshing = false,
onRefresh = { onRefresh = {},
onRefresh()
},
state = pullToRefreshState, state = pullToRefreshState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -171,52 +179,33 @@ internal fun LogContent(
.padding(padding) .padding(padding)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
){ ){
if (logs.isEmpty() && !isLoading) { if (logs.isEmpty()) {
EmptyLogView() EmptyLogView()
} else { } else {
LazyColumn(state = listState) { LazyColumn(state = listState) {
items(items = logs, key = { it.id }) { log -> items(items = logs, key = { it.id }) { log ->
LogItem(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) }
} }
} showShareDialog = false
} })
if (showConfirmDialog) { DeleteLogDialog(show = showDeleteDialog, onDismiss = { showDeleteDialog = false }, onConfirm = {
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() onDeleteAllLogs()
showConfirmDialog = false showDeleteDialog = false
}) { })
Text("Delete", color = Color.Red)
}
},
dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) {
Text("Cancel", color = Color.Gray)
}
}
)
}
if (showBottomSheet) {
FilterBottomSheet( FilterBottomSheet(
show = showBottomSheet,
activeFilter = activeFilter, activeFilter = activeFilter,
onFilterSelected = onFilterSelected, onFilterSelected = onFilterSelected,
onDismissRequest = {showBottomSheet = false} onDismissRequest = {showBottomSheet = false}
) )
}
} }
} }
@@ -2,84 +2,84 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.logs
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.repository.LogRepository
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log
import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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( class LogViewModel(
private val logRepository: LogRepository private val logRepository: LogRepository
) : ViewModel() { ) : ViewModel() {
private val _logs = MutableStateFlow<List<Log>>(emptyList())
val logs: StateFlow<List<Log>> = _logs.asStateFlow()
private val _activeFilter = MutableStateFlow<LogFilter>(LogFilter.All) private val _activeFilter = MutableStateFlow<LogFilter>(LogFilter.All)
val activeFilter: StateFlow<LogFilter> = _activeFilter.asStateFlow() val activeFilter: StateFlow<LogFilter> = _activeFilter.asStateFlow()
private var lastTimestamp: Long? = null @OptIn(ExperimentalCoroutinesApi::class)
private var lastId: Long? = null val logs: StateFlow<List<Log>> = _activeFilter
private val pageSize = 20L .flatMapLatest { filter ->
private var isLastPage = false logRepository.getLogsWithFilter(filter)
var isLoading = MutableStateFlow(false)
private set
init {
loadNextPage()
} }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun setFilter(filter: LogFilter) { fun setFilter(filter: LogFilter) {
if (_activeFilter.value == filter) return if (_activeFilter.value == filter) return
_activeFilter.value = filter _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() { fun clearAllLogs() {
viewModelScope.launch {
logRepository.clearLogs() logRepository.clearLogs()
_logs.value = emptyList() }
lastTimestamp = null
lastId = null fun formatTimestamp(epochMillis: Long): String {
isLastPage = true 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FilterBottomSheet( fun FilterBottomSheet(
show: Boolean = false,
activeFilter: LogFilter, activeFilter: LogFilter,
onFilterSelected: (LogFilter) -> Unit, onFilterSelected: (LogFilter) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
if(!show) return
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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 com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -20,7 +20,7 @@ import kotlinx.datetime.toLocalDateTime
fun LogItem(log: Log, modifier: Modifier = Modifier) { fun LogItem(log: Log, modifier: Modifier = Modifier) {
val dateTime = Instant.fromEpochMilliseconds(log.timestamp) val dateTime = Instant.fromEpochMilliseconds(log.timestamp)
.toLocalDateTime(TimeZone.currentSystemDefault()) .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( Column(
modifier = modifier.fillMaxWidth(), 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.TimeoutCancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlin.math.ceil
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
@@ -58,7 +61,7 @@ class ScannerViewModel(
private val scannerRepository: ScannerRepository private val scannerRepository: ScannerRepository
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
private val ADV_EXPIRATION_TIME = 5.seconds private var ADV_EXPIRATION_TIME = 3.seconds
private val CONNECTION_TIMEOUT = 5.seconds private val CONNECTION_TIMEOUT = 5.seconds
private const val MAX_LOG_ENTRIES = 50 private const val MAX_LOG_ENTRIES = 50
} }
@@ -78,7 +81,6 @@ class ScannerViewModel(
.stateIn(viewModelScope, SharingStarted.Eagerly, false) .stateIn(viewModelScope, SharingStarted.Eagerly, false)
val isAutoConnect: StateFlow<Boolean> = _autoConnect val isAutoConnect: StateFlow<Boolean> = _autoConnect
private val _onlyWithName = MutableStateFlow(true)
private var scanJob: Job? = null private var scanJob: Job? = null
private var cleanupJob: Job? = null private var cleanupJob: Job? = null
private val connectionJobs = mutableMapOf<String, Job>() private val connectionJobs = mutableMapOf<String, Job>()
@@ -87,7 +89,7 @@ class ScannerViewModel(
/** Bonded device */ /** Bonded device */
private val bondedIds = mutableSetOf<String>() // Cache private val bondedIds = mutableSetOf<String>() // Cache
suspend fun preloadBondedIds() { fun preloadBondedIds() {
viewModelScope.launch { viewModelScope.launch {
deviceRepository.getAllDeviceIdsFlow().collect { ids -> deviceRepository.getAllDeviceIdsFlow().collect { ids ->
bondedIds.clear() bondedIds.clear()
@@ -98,17 +100,74 @@ class ScannerViewModel(
fun isBonded(id: String): Boolean = bondedIds.contains(id) fun isBonded(id: String): Boolean = bondedIds.contains(id)
init { init {
logRepository.appendLog("Scanner - Calibration", "Initial advertising expiration time: $ADV_EXPIRATION_TIME")
viewModelScope.launch { viewModelScope.launch {
preloadBondedIds() 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() { fun startScan() {
if (_isScanning.value) return if (_isScanning.value) return
scanJob = viewModelScope.launch { scanJob = viewModelScope.launch {
_isScanning.value = true _isScanning.value = true
_devices.value = emptyList() _devices.value = emptyList()
advertisementMap.clear() advertisementMap.clear()
calibrateCleanupTime.clear()
intervals.clear()
stabilized = false
bleManager.scanDevices() bleManager.scanDevices()
.catch { .catch {
@@ -126,24 +185,33 @@ class ScannerViewModel(
if (entry != null) { if (entry != null) {
advertisementMap[bleId] = entry.copy(advertisement = advertisement, timeMark = now) advertisementMap[bleId] = entry.copy(advertisement = advertisement, timeMark = now)
} else { } else {
logRepository.appendLog(title="Scanner status", content="${advertisement.name} ($bleId) peripheral discovered")
advertisementMap[bleId] = AdvertisementMark(advertisement, now) advertisementMap[bleId] = AdvertisementMark(advertisement, now)
} }
calibrateCleanupTime(bleId, now)
startCleanupJob() startCleanupJob()
updateDeviceList() 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() { private fun startCleanupJob() {
if (cleanupJob?.isActive == true) return if (cleanupJob?.isActive == true) return
cleanupJob = viewModelScope.launch { cleanupJob = viewModelScope.launch {
while (advertisementMap.isNotEmpty() && isActive) { while (advertisementMap.isNotEmpty() && isActive) {
delay(5000) delay(1000)
val keysToRemove = advertisementMap.filter { it.value.timeMark.elapsedNow() > ADV_EXPIRATION_TIME }.keys val keysToRemove = advertisementMap.filter { it.value.timeMark.elapsedNow() > ADV_EXPIRATION_TIME }.keys
if (keysToRemove.isNotEmpty()) { if (keysToRemove.isNotEmpty()) {
keysToRemove.forEach { advertisementMap.remove(it) } keysToRemove.forEach {
advertisementMap.remove(it)
resetCalibrate(it)
}
updateDeviceList() updateDeviceList()
} }
} }
@@ -219,14 +287,14 @@ class ScannerViewModel(
viewModelScope.launch { viewModelScope.launch {
bleManager.sendCommand(peripheral, Helper.buildUartCommand(command)) bleManager.sendCommand(peripheral, Helper.buildUartCommand(command))
appendHistory(deviceId, "Sent: $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) { fun connect(adv: Advertisement) {
val deviceIdentifier = adv.identifier.toString() val deviceIdentifier = adv.identifier.toString()
if (connectionJobs[deviceIdentifier]?.isActive == true) return 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 { val job = viewModelScope.launch {
try { try {
val peripheral = bleManager.connect(adv) val peripheral = bleManager.connect(adv)
@@ -241,7 +309,7 @@ class ScannerViewModel(
/** Add bonded device */ /** Add bonded device */
addBondedDevice(deviceIdentifier, adv.name ?: "No name") addBondedDevice(deviceIdentifier, adv.name ?: "No name")
_connectionReasons.value -= deviceIdentifier _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 (state is State.Disconnected && hasEmitted) {
if (_connectionReasons.value[deviceIdentifier] == null) { if (_connectionReasons.value[deviceIdentifier] == null) {
@@ -269,10 +337,10 @@ class ScannerViewModel(
if (e is TimeoutCancellationException) { if (e is TimeoutCancellationException) {
Napier.e(">>>>> Connection timed out for $deviceIdentifier") Napier.e(">>>>> Connection timed out for $deviceIdentifier")
_connectionReasons.value += (deviceIdentifier to ConnectionReason.TIMEOUT) _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) { } else if (e !is CancellationException) {
Napier.e(">>>>> Connection failed for $deviceIdentifier", e) 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 { } finally {
_connectedDevices.value -= deviceIdentifier _connectedDevices.value -= deviceIdentifier
@@ -301,7 +369,7 @@ class ScannerViewModel(
sendWorkOrdersToDevice(peripheral,orders) sendWorkOrdersToDevice(peripheral,orders)
} catch (e: Exception) { } catch (e: Exception) {
Napier.e("Failed to load work orders", e) 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) { private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) {
appendHistory(deviceIdentifier, "Received: $msg") 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 decoded = Helper.decodeRxData(msg) ?: return
val data = decoded.data val data = decoded.data
val woId = decoded.id val woId = decoded.id
@@ -342,7 +410,7 @@ class ScannerViewModel(
} catch (e: Exception) { } catch (e: Exception) {
Napier.e(">>>>> Failed to handle message: $msg", e) Napier.e(">>>>> Failed to handle message: $msg", e)
sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO) 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 -> { else -> {
@@ -363,7 +431,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto) scannerRepository.sendMeasurementResult(woId, dto)
} catch (e: Exception) { } catch (e: Exception) {
Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel") 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 { } finally {
// Currently, always send UPLOADED_WO regardless of success or failure // Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO) sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -374,7 +442,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto, true) scannerRepository.sendMeasurementResult(woId, dto, true)
} catch (e: Exception) { } catch (e: Exception) {
Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel") 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 { } finally {
// Currently, always send UPLOADED_WO regardless of success or failure // Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO) sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -385,7 +453,7 @@ class ScannerViewModel(
scannerRepository.sendCancelWorkOrder(woId, dto) scannerRepository.sendCancelWorkOrder(woId, dto)
} catch (e: Exception) { } catch (e: Exception) {
Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel") 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 { } finally {
// Currently, always send UPLOADED_WO regardless of success or failure // Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO) 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)
}
@@ -9,10 +9,12 @@ import com.digitoolsolutions.app.torquevaultkmp.utils.AppInfo
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.IOSAppInfo import com.digitoolsolutions.app.torquevaultkmp.utils.IOSAppInfo
import com.digitoolsolutions.app.torquevaultkmp.utils.IOSNetworkMonitor 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.IosBluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkMonitor import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkMonitor
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform
import com.digitoolsolutions.app.torquevaultkmp.utils.ShareLog
import org.koin.dsl.module import org.koin.dsl.module
actual val platformModule = module { actual val platformModule = module {
@@ -27,4 +29,5 @@ actual val platformModule = module {
} }
single<NetworkMonitor> { IOSNetworkMonitor() } single<NetworkMonitor> { IOSNetworkMonitor() }
single<AppInfo> { IOSAppInfo() } single<AppInfo> { IOSAppInfo() }
single<ShareLog> { IOSShareLog() }
} }
@@ -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)
}
}
}
}