diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 4e2854e..4d598f9 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -37,6 +37,15 @@ + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt index 016b129..515329b 100644 --- a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt @@ -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 { AndroidNetworkMonitor(androidContext()) } single { AndroidAppInfo(androidContext()) } + single { AndroidShareLog(androidContext()) } } diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.android.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.android.kt new file mode 100644 index 0000000..118383c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.android.kt @@ -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() + } + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/res/xml/file_paths.xml b/composeApp/src/androidMain/res/xml/file_paths.xml new file mode 100644 index 0000000..8711378 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt index 15a2293..0dd8b63 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt @@ -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>(emptyList()) + val logs: StateFlow> = _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> = - dbQueries.getAllLogs() - .asFlow() - .mapToList(Dispatchers.IO) - - suspend fun getLogsPaged(limit: Long, lastTimestamp: Long?, lastId: Long?): List { - return if (lastTimestamp == null || lastId == null) { - dbQueries.getLogsFirstPage(limit).executeAsList() - } else { - dbQueries.getLogsNextPage(lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList() + _logs.update { current -> + (listOf(newLog) + current).take(MAX_LOGS) } } - - suspend fun getLogsByFilterPaged(filter: LogFilter, limit: Long, lastTimestamp: Long?, lastId: Long?): List { - return when (filter) { - LogFilter.All -> getLogsPaged(limit, lastTimestamp, lastId) - LogFilter.System -> { - if (lastTimestamp == null || lastId == null) { - dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList() - } else { - dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList() - } - } - is LogFilter.Device -> { - if (filter.deviceId != null) { - getLogsByDevicePaged(filter.deviceId, limit, lastTimestamp, lastId) - } else { - if (lastTimestamp == null || lastId == null) { - dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList() + fun getAllLogs(): Flow> = logs + fun getLogsWithFilter(filter: LogFilter): Flow> = + logs.map { list -> + when (filter) { + LogFilter.All -> list + LogFilter.System -> list.filter { it.source == LogFilter.SYSTEM } + is LogFilter.Device -> { + if (filter.deviceId != null) { + list.filter { it.deviceId == filter.deviceId } } else { - dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList() + list.filter { it.source == LogFilter.DEVICE } } } } } - } - suspend fun getLogsByDevicePaged(deviceId: String, limit: Long, lastTimestamp: Long?, lastId: Long?): List { - 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() } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt index b735990..ca05c84 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt @@ -36,7 +36,7 @@ val appModule = module { val storageModule = module { single { AppDatabase(get()) } single { get().appDatabaseQueries } - single { LogRepository(get()) } + single { LogRepository() } single { DeviceRepository(get()) } single { ScannerRepository(get()) } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogScreen.kt index 9b76b3e..daaa9a5 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogScreen.kt @@ -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, activeFilter: LogFilter, - isLoading: Boolean, onFilterSelected: (LogFilter) -> Unit, onDeleteAllLogs: () -> Unit, - onLoadMore: () -> Unit, - onRefresh: () -> Unit + txtContent: String, + csvContent: String ) { val snackbarHostState = remember { SnackbarHostState() } - var showConfirmDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) } + var showShareDialog by remember { mutableStateOf(false) } + val shareLog: ShareLog = koinInject() val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() - // Infinite Scroll logic - val shouldLoadMore = remember { - derivedStateOf { - val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 - lastVisibleItemIndex >= logs.size - 5 && logs.isNotEmpty() - } - } - LaunchedEffect(logs) { if (logs.isNotEmpty()) { listState.scrollToItem(0) } } - LaunchedEffect(shouldLoadMore) { - snapshotFlow { shouldLoadMore.value } - .distinctUntilChanged() - .filter { it } - .collect { - onLoadMore() - } - } - Scaffold ( modifier = Modifier.imePadding(), topBar = { AppBar( title = {Text(stringResource(Res.string.log_title))}, actions = { - IconButton(onClick = {showConfirmDialog = true}){ + IconButton(onClick = { showShareDialog = true }){ + Icon( + Icons.Rounded.Share, + contentDescription = "Share logs", + tint = Color.White + ) + } + IconButton(onClick = { showDeleteDialog = true }){ Icon( Icons.Default.DeleteForever, contentDescription = "Delete all log", @@ -142,17 +136,33 @@ internal fun LogContent( ) }, floatingActionButton = { - FloatingActionButton( - onClick = { - onRefresh() - coroutineScope.launch { - listState.animateScrollToItem(0) - } - }, - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) { - Icon(Icons.Rounded.KeyboardArrowUp, contentDescription = "Go to top") + Column { + FloatingActionButton( + onClick = { + coroutineScope.launch { + listState.animateScrollToItem(0) + } + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon(Icons.Rounded.KeyboardArrowUp, contentDescription = "Go to top") + } + Spacer(modifier = Modifier.height(4.dp)) + FloatingActionButton( + onClick = { + coroutineScope.launch { + val lastIndex = logs.size - 1 + if (lastIndex >= 0) { + listState.animateScrollToItem(lastIndex) + } + } + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = "Go to end") + } } }, snackbarHost = { SnackbarHost(snackbarHostState) }) @@ -160,10 +170,8 @@ internal fun LogContent( val pullToRefreshState = rememberPullToRefreshState() PullToRefreshBox( - isRefreshing = isLoading && logs.isEmpty(), - onRefresh = { - onRefresh() - }, + isRefreshing = false, + onRefresh = {}, state = pullToRefreshState, modifier = Modifier .fillMaxSize() @@ -171,52 +179,33 @@ internal fun LogContent( .padding(padding) .padding(horizontal = 16.dp, vertical = 12.dp), ){ - if (logs.isEmpty() && !isLoading) { + if (logs.isEmpty()) { EmptyLogView() } else { LazyColumn(state = listState) { items(items = logs, key = { it.id }) { log -> LogItem(log) } - if (isLoading && logs.isNotEmpty()) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - } } } } - if (showConfirmDialog) { - AlertDialog( - onDismissRequest = { showConfirmDialog = false }, - title = { Text("Delete Logs") }, - text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") }, - confirmButton = { - TextButton(onClick = { - onDeleteAllLogs() - showConfirmDialog = false - }) { - Text("Delete", color = Color.Red) - } - }, - dismissButton = { - TextButton(onClick = { showConfirmDialog = false }) { - Text("Cancel", color = Color.Gray) - } - } - ) - } - if (showBottomSheet) { - FilterBottomSheet( - activeFilter = activeFilter, - onFilterSelected = onFilterSelected, - onDismissRequest = {showBottomSheet = false} - ) - } + ShareLogDialog(show = showShareDialog, onDismiss = { showShareDialog = false }, onConfirm = { format -> + when (format) { + LogFormat.PlainText -> { shareLog.exportTextFile(txtContent) } + LogFormat.CSV -> { shareLog.exportCSVFile(csvContent) } + } + showShareDialog = false + }) + DeleteLogDialog(show = showDeleteDialog, onDismiss = { showDeleteDialog = false }, onConfirm = { + onDeleteAllLogs() + showDeleteDialog = false + }) + FilterBottomSheet( + show = showBottomSheet, + activeFilter = activeFilter, + onFilterSelected = onFilterSelected, + onDismissRequest = {showBottomSheet = false} + ) + } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt index af0e288..86137bc 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt @@ -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>(emptyList()) - val logs: StateFlow> = _logs.asStateFlow() - private val _activeFilter = MutableStateFlow(LogFilter.All) val activeFilter: StateFlow = _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> = _activeFilter + .flatMapLatest { filter -> + logRepository.getLogsWithFilter(filter) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) fun setFilter(filter: LogFilter) { if (_activeFilter.value == filter) return _activeFilter.value = filter - refresh() - } - - fun loadNextPage() { - if (isLoading.value || isLastPage) return - - viewModelScope.launch { - isLoading.value = true - val newLogs = logRepository.getLogsByFilterPaged( - filter = _activeFilter.value, - limit = pageSize, - lastTimestamp = lastTimestamp, - lastId = lastId - ) - - if (newLogs.size < pageSize) { - isLastPage = true - } - - if (newLogs.isNotEmpty()) { - val lastLog = newLogs.last() - lastTimestamp = lastLog.timestamp - lastId = lastLog.id - _logs.value += newLogs - } - - isLoading.value = false - } - } - - fun refresh() { - viewModelScope.launch { - lastTimestamp = null - lastId = null - isLastPage = false - _logs.value = emptyList() - loadNextPage() - } } fun clearAllLogs() { - viewModelScope.launch { - logRepository.clearLogs() - _logs.value = emptyList() - lastTimestamp = null - lastId = null - isLastPage = true + logRepository.clearLogs() + } + + fun formatTimestamp(epochMillis: Long): String { + val instant = Instant.fromEpochMilliseconds(epochMillis) + val tz = TimeZone.currentSystemDefault() + val localDateTime = instant.toLocalDateTime(tz) + + // Format pattern ex: "Sat Jun 27 10:19:21 GMT+07:00 2026" + val dayOfWeek = localDateTime.dayOfWeek.name.take(3).lowercase().replaceFirstChar { it.uppercase() } + val month = localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() } + val day = localDateTime.day + val year = localDateTime.year + val hour = localDateTime.hour.toString().padStart(2, '0') + val minute = localDateTime.minute.toString().padStart(2, '0') + val second = localDateTime.second.toString().padStart(2, '0') + + val offset = tz.offsetAt(instant) // Example: GMT+07:00 + + return "$dayOfWeek $month $day $hour:$minute:$second $offset $year" + } + + fun escapeCsv(value: String?): String { + if (value == null) return "" + val escaped = value.replace("\"", "\"\"") // escape " + return "\"$escaped\"" + } + + fun exportLogsAsText(): String { + val logs = logRepository.logs.value + return logs.joinToString(separator = "\n") { log -> + "${formatTimestamp(log.timestamp)}: [${log.deviceId}] - ${log.title} - ${log.content}" } } + + fun exportLogsAsCSV(): String { + val logs = logRepository.logs.value + val header = "type,deviceId,time,title,content" + val rows = logs.joinToString(separator = "\n") { log -> + "${log.source},${log.deviceId},${formatTimestamp(log.timestamp)},${log.title},${escapeCsv(log.content)}" + } + return "$header\n$rows" + } + } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/DeleteLogDialog.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/DeleteLogDialog.kt new file mode 100644 index 0000000..c62c4e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/DeleteLogDialog.kt @@ -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) + } + } + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt index 38c2612..0580f57 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt @@ -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), diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt index 3461fb2..0cd96bb 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt @@ -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(), diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/ShareLogDialog.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/ShareLogDialog.kt new file mode 100644 index 0000000..5680440 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/ShareLogDialog.kt @@ -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) + } + } + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt index 9160ac0..4a83576 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt @@ -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 = _autoConnect - private val _onlyWithName = MutableStateFlow(true) private var scanJob: Job? = null private var cleanupJob: Job? = null private val connectionJobs = mutableMapOf() @@ -87,7 +89,7 @@ class ScannerViewModel( /** Bonded device */ private val bondedIds = mutableSetOf() // 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() + private val intervals = mutableListOf() + 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) diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.kt new file mode 100644 index 0000000..7fa271f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.kt @@ -0,0 +1,6 @@ +package com.digitoolsolutions.app.torquevaultkmp.utils + +interface ShareLog { + fun exportTextFile(content: String) + fun exportCSVFile(content: String) +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt index 7f6ab40..276195c 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt @@ -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 { IOSNetworkMonitor() } single { IOSAppInfo() } + single { IOSShareLog() } } diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.ios.kt new file mode 100644 index 0000000..02aeb63 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.ios.kt @@ -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) + } + } + } +} \ No newline at end of file