Share logs, calibrate expiration time for removing stopped advertising devices
This commit is contained in:
@@ -37,6 +37,15 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<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>
|
||||||
+3
@@ -8,11 +8,13 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase
|
|||||||
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidAppInfo
|
import com.digitoolsolutions.app.torquevaultkmp.utils.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()) }
|
||||||
}
|
}
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
package com.digitoolsolutions.app.torquevaultkmp.utils
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class AndroidShareLog(private val context: Context) : ShareLog {
|
||||||
|
init {
|
||||||
|
cleanOldLogFiles()
|
||||||
|
}
|
||||||
|
override fun exportTextFile(content: String) {
|
||||||
|
val fileName = generateFileName("txt")
|
||||||
|
shareFile(fileName, "text/plain", "Share plain text log", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exportCSVFile(content: String) {
|
||||||
|
val fileName = generateFileName("csv")
|
||||||
|
shareFile(fileName, "text/csv", "Share CSV log", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateFileName(extension: String): String {
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("yy-MM-dd_HH-mm-ss")
|
||||||
|
val timestamp = LocalDateTime.now().format(formatter)
|
||||||
|
return "TV_logs_${timestamp}.$extension"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareFile(fileName: String, mimeType: String, title: String, content: String) {
|
||||||
|
val file = File(context.cacheDir, fileName)
|
||||||
|
file.writeText(content)
|
||||||
|
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = mimeType
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
clipData = ClipData.newRawUri("", uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
val chooser = Intent.createChooser(intent, title).apply {
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
if (context !is Activity) {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.startActivity(chooser)
|
||||||
|
}
|
||||||
|
private fun cleanOldLogFiles() {
|
||||||
|
context.cacheDir?.listFiles()
|
||||||
|
?.filter { it.name.startsWith("TV_logs_") }
|
||||||
|
?.forEach {
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path
|
||||||
|
name="shared_logs"
|
||||||
|
path="." />
|
||||||
|
</paths>
|
||||||
+39
-50
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun getAllLogs(): Flow<List<Log>> = logs
|
||||||
suspend fun getLogsByFilterPaged(filter: LogFilter, limit: Long, lastTimestamp: Long?, lastId: Long?): List<Log> {
|
fun getLogsWithFilter(filter: LogFilter): Flow<List<Log>> =
|
||||||
return when (filter) {
|
logs.map { list ->
|
||||||
LogFilter.All -> getLogsPaged(limit, lastTimestamp, lastId)
|
when (filter) {
|
||||||
LogFilter.System -> {
|
LogFilter.All -> list
|
||||||
if (lastTimestamp == null || lastId == null) {
|
LogFilter.System -> list.filter { it.source == LogFilter.SYSTEM }
|
||||||
dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList()
|
is LogFilter.Device -> {
|
||||||
} else {
|
if (filter.deviceId != null) {
|
||||||
dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
|
list.filter { it.deviceId == filter.deviceId }
|
||||||
}
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
} else {
|
} else {
|
||||||
dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
|
list.filter { it.source == LogFilter.DEVICE }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLogsByDevicePaged(deviceId: String, limit: Long, lastTimestamp: Long?, lastId: Long?): List<Log> {
|
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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()) }
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-85
@@ -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,17 +136,33 @@ internal fun LogContent(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
Column {
|
||||||
onClick = {
|
FloatingActionButton(
|
||||||
onRefresh()
|
onClick = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
) {
|
) {
|
||||||
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) })
|
||||||
@@ -160,10 +170,8 @@ internal fun LogContent(
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showConfirmDialog) {
|
ShareLogDialog(show = showShareDialog, onDismiss = { showShareDialog = false }, onConfirm = { format ->
|
||||||
AlertDialog(
|
when (format) {
|
||||||
onDismissRequest = { showConfirmDialog = false },
|
LogFormat.PlainText -> { shareLog.exportTextFile(txtContent) }
|
||||||
title = { Text("Delete Logs") },
|
LogFormat.CSV -> { shareLog.exportCSVFile(csvContent) }
|
||||||
text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") },
|
}
|
||||||
confirmButton = {
|
showShareDialog = false
|
||||||
TextButton(onClick = {
|
})
|
||||||
onDeleteAllLogs()
|
DeleteLogDialog(show = showDeleteDialog, onDismiss = { showDeleteDialog = false }, onConfirm = {
|
||||||
showConfirmDialog = false
|
onDeleteAllLogs()
|
||||||
}) {
|
showDeleteDialog = false
|
||||||
Text("Delete", color = Color.Red)
|
})
|
||||||
}
|
FilterBottomSheet(
|
||||||
},
|
show = showBottomSheet,
|
||||||
dismissButton = {
|
activeFilter = activeFilter,
|
||||||
TextButton(onClick = { showConfirmDialog = false }) {
|
onFilterSelected = onFilterSelected,
|
||||||
Text("Cancel", color = Color.Gray)
|
onDismissRequest = {showBottomSheet = false}
|
||||||
}
|
)
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showBottomSheet) {
|
|
||||||
FilterBottomSheet(
|
|
||||||
activeFilter = activeFilter,
|
|
||||||
onFilterSelected = onFilterSelected,
|
|
||||||
onDismissRequest = {showBottomSheet = false}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-61
@@ -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
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
init {
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
loadNextPage()
|
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
|
fun formatTimestamp(epochMillis: Long): String {
|
||||||
lastId = null
|
val instant = Instant.fromEpochMilliseconds(epochMillis)
|
||||||
isLastPage = true
|
val tz = TimeZone.currentSystemDefault()
|
||||||
|
val localDateTime = instant.toLocalDateTime(tz)
|
||||||
|
|
||||||
|
// Format pattern ex: "Sat Jun 27 10:19:21 GMT+07:00 2026"
|
||||||
|
val dayOfWeek = localDateTime.dayOfWeek.name.take(3).lowercase().replaceFirstChar { it.uppercase() }
|
||||||
|
val month = localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }
|
||||||
|
val day = localDateTime.day
|
||||||
|
val year = localDateTime.year
|
||||||
|
val hour = localDateTime.hour.toString().padStart(2, '0')
|
||||||
|
val minute = localDateTime.minute.toString().padStart(2, '0')
|
||||||
|
val second = localDateTime.second.toString().padStart(2, '0')
|
||||||
|
|
||||||
|
val offset = tz.offsetAt(instant) // Example: GMT+07:00
|
||||||
|
|
||||||
|
return "$dayOfWeek $month $day $hour:$minute:$second $offset $year"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun escapeCsv(value: String?): String {
|
||||||
|
if (value == null) return ""
|
||||||
|
val escaped = value.replace("\"", "\"\"") // escape "
|
||||||
|
return "\"$escaped\""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportLogsAsText(): String {
|
||||||
|
val logs = logRepository.logs.value
|
||||||
|
return logs.joinToString(separator = "\n") { log ->
|
||||||
|
"${formatTimestamp(log.timestamp)}: [${log.deviceId}] - ${log.title} - ${log.content}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun exportLogsAsCSV(): String {
|
||||||
|
val logs = logRepository.logs.value
|
||||||
|
val header = "type,deviceId,time,title,content"
|
||||||
|
val rows = logs.joinToString(separator = "\n") { log ->
|
||||||
|
"${log.source},${log.deviceId},${formatTimestamp(log.timestamp)},${log.title},${escapeCsv(log.content)}"
|
||||||
|
}
|
||||||
|
return "$header\n$rows"
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.digitoolsolutions.app.torquevaultkmp.screens.logs.view
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteLogDialog(
|
||||||
|
show: Boolean = false,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: () -> Unit
|
||||||
|
) {
|
||||||
|
if(!show) return
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss ,
|
||||||
|
title = { Text("Delete Logs") },
|
||||||
|
text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text("Delete", color = Color.Red)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss ) {
|
||||||
|
Text("Cancel", color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
@@ -53,10 +53,12 @@ import torquevaultkmp.composeapp.generated.resources.clear
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@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),
|
||||||
|
|||||||
+2
-2
@@ -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(),
|
||||||
|
|||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
package com.digitoolsolutions.app.torquevaultkmp.screens.logs.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
enum class LogFormat { PlainText, CSV }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShareLogDialog(
|
||||||
|
show: Boolean = false,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (LogFormat) -> Unit
|
||||||
|
) {
|
||||||
|
var selectedFormat by remember { mutableStateOf(LogFormat.PlainText) }
|
||||||
|
if(!show) return
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Export Logs") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable {
|
||||||
|
selectedFormat = LogFormat.PlainText
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedFormat == LogFormat.PlainText,
|
||||||
|
onClick = { selectedFormat = LogFormat.PlainText }
|
||||||
|
)
|
||||||
|
Text("Plain text")
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable {
|
||||||
|
selectedFormat = LogFormat.CSV
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedFormat == LogFormat.CSV,
|
||||||
|
onClick = { selectedFormat = LogFormat.CSV }
|
||||||
|
)
|
||||||
|
Text("CSV file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onConfirm(selectedFormat) }) {
|
||||||
|
Text("OK", color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel", color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
+85
-17
@@ -37,7 +37,10 @@ import kotlinx.coroutines.withTimeout
|
|||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.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)
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package com.digitoolsolutions.app.torquevaultkmp.utils
|
||||||
|
|
||||||
|
interface ShareLog {
|
||||||
|
fun exportTextFile(content: String)
|
||||||
|
fun exportCSVFile(content: String)
|
||||||
|
}
|
||||||
+3
@@ -9,10 +9,12 @@ import com.digitoolsolutions.app.torquevaultkmp.utils.AppInfo
|
|||||||
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
|
import com.digitoolsolutions.app.torquevaultkmp.utils.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() }
|
||||||
}
|
}
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
package com.digitoolsolutions.app.torquevaultkmp.utils
|
||||||
|
|
||||||
|
import kotlinx.cinterop.BetaInteropApi
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.readValue
|
||||||
|
import platform.CoreGraphics.CGRectZero
|
||||||
|
import platform.Foundation.NSDate
|
||||||
|
import platform.Foundation.NSDateFormatter
|
||||||
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSLocale
|
||||||
|
import platform.Foundation.NSString
|
||||||
|
import platform.Foundation.NSTemporaryDirectory
|
||||||
|
import platform.Foundation.NSURL
|
||||||
|
import platform.Foundation.NSUTF8StringEncoding
|
||||||
|
import platform.Foundation.create
|
||||||
|
import platform.Foundation.dataUsingEncoding
|
||||||
|
import platform.Foundation.localeWithLocaleIdentifier
|
||||||
|
import platform.Foundation.writeToURL
|
||||||
|
import platform.UIKit.UIActivityViewController
|
||||||
|
import platform.UIKit.UIApplication
|
||||||
|
import platform.UIKit.popoverPresentationController
|
||||||
|
|
||||||
|
class IOSShareLog : ShareLog {
|
||||||
|
init {
|
||||||
|
cleanOldLogFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exportTextFile(content: String) {
|
||||||
|
val fileName = generateFileName("txt")
|
||||||
|
shareFile(content, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exportCSVFile(content: String) {
|
||||||
|
val fileName = generateFileName("csv")
|
||||||
|
shareFile(content, fileName)
|
||||||
|
}
|
||||||
|
private fun generateFileName(extension: String): String {
|
||||||
|
val formatter = NSDateFormatter().apply {
|
||||||
|
dateFormat = "yy-MM-dd_HH-mm-ss"
|
||||||
|
locale = NSLocale.localeWithLocaleIdentifier("en_US_POSIX")
|
||||||
|
}
|
||||||
|
val timestamp = formatter.stringFromDate(NSDate())
|
||||||
|
return "TV_logs_${timestamp}.$extension"
|
||||||
|
}
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
private fun shareFile(content: String, fileName: String) {
|
||||||
|
val nsString = NSString.create(string = content)
|
||||||
|
val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
|
||||||
|
val tempDir = NSTemporaryDirectory()
|
||||||
|
val fileURL = NSURL.fileURLWithPath(tempDir).URLByAppendingPathComponent(fileName)
|
||||||
|
|
||||||
|
if ((fileURL != null) && (data != null)) {
|
||||||
|
data.writeToURL(url = fileURL, atomically = true)
|
||||||
|
|
||||||
|
val activityViewController = UIActivityViewController(
|
||||||
|
activityItems = listOf(fileURL),
|
||||||
|
applicationActivities = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||||
|
|
||||||
|
// For iPad, we need to provide a sourceView or barButtonItem for the popover
|
||||||
|
activityViewController.popoverPresentationController()?.let { popover ->
|
||||||
|
val rootView = rootViewController?.view
|
||||||
|
if (rootView != null) {
|
||||||
|
popover.sourceView = rootView
|
||||||
|
popover.sourceRect = rootView.bounds
|
||||||
|
} else {
|
||||||
|
popover.sourceRect = CGRectZero.readValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootViewController?.presentViewController(activityViewController, true, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun cleanOldLogFiles() {
|
||||||
|
val tempDir = NSTemporaryDirectory()
|
||||||
|
val fm = NSFileManager.defaultManager
|
||||||
|
val files = fm.contentsOfDirectoryAtPath(tempDir, null) ?: return
|
||||||
|
|
||||||
|
for (item in files) {
|
||||||
|
val name = item as? String ?: continue
|
||||||
|
if (name.startsWith("TV_logs_")) {
|
||||||
|
val path = tempDir + name
|
||||||
|
fm.removeItemAtPath(path, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user