diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2139c4d5f..447ef2228 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -62,6 +62,16 @@
+
+
+
+
diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt
index 4b1481efa..12b259bf1 100644
--- a/app/src/main/java/to/bitkit/env/Env.kt
+++ b/app/src/main/java/to/bitkit/env/Env.kt
@@ -7,6 +7,7 @@ import to.bitkit.ext.ensureDir
import to.bitkit.models.LnPeer
import to.bitkit.models.blocktank.BlocktankNotificationType
import to.bitkit.utils.Logger
+import java.io.File
import kotlin.io.path.Path
@Suppress("ConstPropertyName")
@@ -17,6 +18,8 @@ internal object Env {
val defaultWalletWordCount = 12
val walletSyncIntervalSecs = 10_uL // TODO review
val ldkNodeSyncIntervalSecs = 60_uL // TODO review
+
+ // TODO: remove this to load from BT API instead
val trustedLnPeers
get() = when (network) {
Network.REGTEST -> listOf(
@@ -83,11 +86,11 @@ internal object Env {
appStoragePath = path
}
- fun ldkLogFilePath(walletIndex: Int): String {
- val logPath = Path(ldkStoragePath(walletIndex), "ldk_node_latest.log").toFile().absolutePath
- Logger.info("LDK-node log path: $logPath")
- return logPath
- }
+ val logDir: String
+ get() {
+ require(::appStoragePath.isInitialized)
+ return File(appStoragePath).resolve("logs").ensureDir().path
+ }
val ldkLogLevel = LogLevel.TRACE
@@ -95,13 +98,13 @@ internal object Env {
fun bitkitCoreStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "core")
private fun storagePathOf(walletIndex: Int, network: String, dir: String): String {
- require(::appStoragePath.isInitialized) { "App storage path should be init as context.filesDir.absolutePath." }
- val absolutePath = Path(appStoragePath, network, "wallet$walletIndex", dir)
+ require(::appStoragePath.isInitialized) { "App storage path should be 'context.filesDir.absolutePath'." }
+ val path = Path(appStoragePath, network, "wallet$walletIndex", dir)
.toFile()
.ensureDir()
- .absolutePath
- Logger.debug("Using ${dir.uppercase()} storage path: $absolutePath")
- return absolutePath
+ .path
+ Logger.debug("Using ${dir.uppercase()} storage path: $path")
+ return path
}
object Peers {
@@ -114,4 +117,5 @@ internal object Env {
const val PIN_LENGTH = 4
const val PIN_ATTEMPTS = 8
const val DEFAULT_INVOICE_MESSAGE = "Bitkit"
+ const val FILE_PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.fileprovider"
}
diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt
index 3aaa6a3fc..37435fce1 100644
--- a/app/src/main/java/to/bitkit/ext/DateTime.kt
+++ b/app/src/main/java/to/bitkit/ext/DateTime.kt
@@ -18,4 +18,5 @@ object DatePattern {
const val DATE_TIME = "dd/MM/yyyy, HH:mm"
const val INVOICE_EXPIRY = "MMM dd, h:mm a"
const val ACTIVITY_ITEM = "MMMM d yyyy, HH:mm"
+ const val LOG_FILE = "yyyy-MM-dd_HH-mm-ss"
}
diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt
index 02a5f2e6f..962750f68 100644
--- a/app/src/main/java/to/bitkit/services/LightningService.kt
+++ b/app/src/main/java/to/bitkit/services/LightningService.kt
@@ -35,6 +35,7 @@ import to.bitkit.async.ServiceQueue
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
+import to.bitkit.ext.DatePattern
import to.bitkit.ext.millis
import to.bitkit.ext.toHex
import to.bitkit.ext.toSha256
@@ -44,6 +45,11 @@ import to.bitkit.models.LnPeer.Companion.toLnPeer
import to.bitkit.utils.LdkError
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.io.path.Path
@@ -79,7 +85,8 @@ class LightningService @Inject constructor(
)
})
.apply {
- setFilesystemLogger(Env.ldkLogFilePath(walletIndex), Env.ldkLogLevel)
+ setFilesystemLogger(generateLogFilePath(), Env.ldkLogLevel)
+
setChainSourceEsplora(
serverUrl = Env.esploraServerUrl,
config = EsploraSyncConfig(
@@ -354,7 +361,7 @@ class LightningService @Inject constructor(
return true
}
- //TODO: get feeRate from real source
+ // TODO: get feeRate from real source
suspend fun send(address: Address, sats: ULong, satKwu: ULong = 250uL * 5uL): Txid {
val node = this.node ?: throw ServiceError.NodeNotSetup
@@ -480,6 +487,18 @@ class LightningService @Inject constructor(
}
}.flowOn(bgDispatcher)
// endregion
+
+ private fun generateLogFilePath(): String {
+ val dateFormatter = SimpleDateFormat(DatePattern.LOG_FILE, Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+ val timestamp = dateFormatter.format(Date())
+
+ val sessionLogFilePath = File(Env.logDir).resolve("ldk_$timestamp.log").path
+
+ Logger.debug("Generated LDK log file path: $sessionLogFilePath")
+ return sessionLogFilePath
+ }
}
// region helpers
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index eaa08cddb..61dc068ca 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -76,16 +76,18 @@ import to.bitkit.ui.settings.DefaultUnitSettingsScreen
import to.bitkit.ui.settings.GeneralSettingsScreen
import to.bitkit.ui.settings.LightningSettingsScreen
import to.bitkit.ui.settings.LocalCurrencySettingsScreen
+import to.bitkit.ui.settings.LogDetailScreen
+import to.bitkit.ui.settings.LogsScreen
import to.bitkit.ui.settings.OrderDetailScreen
import to.bitkit.ui.settings.SecuritySettingsScreen
import to.bitkit.ui.settings.SettingsScreen
import to.bitkit.ui.settings.backups.BackupWalletScreen
import to.bitkit.ui.settings.backups.RestoreWalletScreen
import to.bitkit.ui.settings.pin.ChangePinConfirmScreen
-import to.bitkit.ui.settings.pin.ChangePinScreen
-import to.bitkit.ui.settings.pin.DisablePinScreen
import to.bitkit.ui.settings.pin.ChangePinNewScreen
import to.bitkit.ui.settings.pin.ChangePinResultScreen
+import to.bitkit.ui.settings.pin.ChangePinScreen
+import to.bitkit.ui.settings.pin.DisablePinScreen
import to.bitkit.ui.utils.screenScaleIn
import to.bitkit.ui.utils.screenScaleOut
import to.bitkit.ui.utils.screenSlideIn
@@ -262,6 +264,7 @@ fun ContentView(
activityItem(activityListViewModel, navController)
qrScanner(appViewModel, navController)
authCheck(navController)
+ logs(navController)
// TODO extract transferNavigation
navigation(
@@ -692,6 +695,21 @@ private fun NavGraphBuilder.authCheck(
)
}
}
+
+private fun NavGraphBuilder.logs(
+ navController: NavHostController,
+) {
+ composableWithDefaultTransitions {
+ LogsScreen(navController)
+ }
+ composableWithDefaultTransitions { navBackEntry ->
+ val route = navBackEntry.toRoute()
+ LogDetailScreen(
+ navController = navController,
+ fileName = route.fileName,
+ )
+ }
+}
// endregion
/**
@@ -850,6 +868,14 @@ fun NavController.navigateToActivityItem(id: String) = navigate(
fun NavController.navigateToQrScanner() = navigate(
route = Routes.QrScanner,
)
+
+fun NavController.navigateToLogs() = navigate(
+ route = Routes.Logs,
+)
+
+fun NavController.navigateToLogDetail(fileName: String) = navigate(
+ route = Routes.LogDetail(fileName),
+)
// endregion
object Routes {
@@ -909,6 +935,12 @@ object Routes {
@Serializable
data object ChannelOrdersSettings
+ @Serializable
+ data object Logs
+
+ @Serializable
+ data class LogDetail(val fileName: String)
+
@Serializable
data class OrderDetail(val id: String)
diff --git a/app/src/main/java/to/bitkit/ui/settings/LogsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/LogsScreen.kt
new file mode 100644
index 000000000..616861093
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/LogsScreen.kt
@@ -0,0 +1,207 @@
+package to.bitkit.ui.settings
+
+import android.content.Intent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+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.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import to.bitkit.ui.components.BodyMSB
+import to.bitkit.ui.components.Caption
+import to.bitkit.ui.navigateToLogDetail
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.shared.util.clickableAlpha
+import to.bitkit.ui.theme.Colors
+import to.bitkit.viewmodels.LogsViewModel
+
+@Composable
+fun LogsScreen(
+ navController: NavController,
+ viewModel: LogsViewModel = hiltViewModel(),
+) {
+ val logs by viewModel.logs.collectAsState()
+ var showDeleteConfirmation by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ viewModel.loadLogs()
+ }
+
+ ScreenColumn {
+ AppTopBar(
+ titleText = "Log Files",
+ onBackClick = { navController.popBackStack() },
+ actions = {
+ IconButton(
+ onClick = { showDeleteConfirmation = true },
+ enabled = logs.isNotEmpty()
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Delete logs",
+ )
+ }
+ }
+ )
+
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(logs) { logFile ->
+ ListItem(
+ headlineContent = {
+ BodyMSB(text = logFile.displayName)
+ },
+ supportingContent = {
+ Caption(
+ text = logFile.fileName,
+ color = Colors.White64,
+ )
+ },
+ modifier = Modifier.clickableAlpha {
+ navController.navigateToLogDetail(logFile.fileName)
+ }
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+
+ if (showDeleteConfirmation) {
+ AlertDialog(
+ shape = MaterialTheme.shapes.large,
+ onDismissRequest = { showDeleteConfirmation = false },
+ title = { Text("Delete All Logs") },
+ text = { Text("Are you sure you want to delete all log files? This action cannot be undone.") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ viewModel.deleteAllLogs()
+ showDeleteConfirmation = false
+ }
+ ) {
+ Text("Delete", color = Colors.Red)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteConfirmation = false }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun LogDetailScreen(
+ navController: NavController,
+ fileName: String,
+ viewModel: LogsViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val logs by viewModel.logs.collectAsState()
+ val logContent by viewModel.selectedLogContent.collectAsState()
+ var isLoading by remember { mutableStateOf(true) }
+ val listState = rememberLazyListState()
+
+ LaunchedEffect(Unit) {
+ viewModel.loadLogs()
+ }
+
+ LaunchedEffect(logs, fileName) {
+ isLoading = true
+ logs.find { it.fileName == fileName }?.let {
+ viewModel.loadLogContent(it)
+ }
+ }
+
+ // Auto scroll to bottom when content changes
+ LaunchedEffect(logContent) {
+ if (logContent.isNotEmpty()) {
+ isLoading = false
+ listState.animateScrollToItem(logContent.size - 1)
+ }
+ }
+
+ ScreenColumn {
+ AppTopBar(
+ titleText = logs.find { it.fileName == fileName }?.displayName ?: "Log Content",
+ onBackClick = { navController.popBackStack() },
+ actions = {
+ IconButton(
+ onClick = {
+ logs.find { it.fileName == fileName }?.let { file ->
+ viewModel.prepareLogForSharing(file) { tempFile ->
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_STREAM, tempFile)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "Share Log File"))
+ }
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = null,
+ )
+ }
+ }
+ )
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ } else {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 8.dp)
+ ) {
+ items(logContent) { line ->
+ Text(
+ text = line,
+ color = when {
+ line.contains("ERROR", ignoreCase = true) -> Colors.Red
+ else -> Colors.Green
+ },
+ fontSize = 8.sp,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.padding(vertical = 2.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt
index a183de1df..f2dbbce56 100644
--- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt
@@ -24,6 +24,7 @@ import to.bitkit.ui.navigateToGeneralSettings
import to.bitkit.ui.navigateToLightning
import to.bitkit.ui.navigateToRegtestSettings
import to.bitkit.ui.navigateToSecuritySettings
+import to.bitkit.ui.navigateToLogs
import to.bitkit.ui.scaffold.AppTopBar
import to.bitkit.ui.scaffold.ScreenColumn
@@ -46,6 +47,7 @@ fun SettingsScreen(
NavButton(stringResource(R.string.button_backup_settings)) { navController.navigateToBackupSettings() }
NavButton("Lightning") { navController.navigateToLightning() }
NavButton("Channel Orders") { navController.navigateToChannelOrdersSettings() }
+ NavButton("Logs") { navController.navigateToLogs() }
if (Env.network == Network.REGTEST) {
LabelText("REGTEST ONLY")
NavButton("Dev Settings") { navController.navigateToDevSettings() }
diff --git a/app/src/main/java/to/bitkit/utils/Logger.kt b/app/src/main/java/to/bitkit/utils/Logger.kt
index 7ab59beb3..1566bc859 100644
--- a/app/src/main/java/to/bitkit/utils/Logger.kt
+++ b/app/src/main/java/to/bitkit/utils/Logger.kt
@@ -2,9 +2,18 @@ package to.bitkit.utils
import android.util.Log
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
+import to.bitkit.env.Env
+import to.bitkit.ext.DatePattern
+import java.io.File
+import java.io.FileOutputStream
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
import java.util.concurrent.Executors
object Logger {
@@ -15,6 +24,22 @@ object Logger {
.asCoroutineDispatcher()
private val queue = CoroutineScope(singleThreadDispatcher + SupervisorJob())
+ private val sessionLogFile: String by lazy {
+ val dateFormatter = SimpleDateFormat(DatePattern.LOG_FILE, Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+ val timestamp = dateFormatter.format(Date())
+ val sessionLogFilePath = File(Env.logDir).resolve("bitkit_$timestamp.log").path
+
+ // Run cleanup in background
+ CoroutineScope(Dispatchers.IO).launch {
+ cleanupOldLogFiles()
+ }
+
+ Log.i(TAG, "Bitkit logger initialized with session log: $sessionLogFilePath")
+ return@lazy sessionLogFilePath
+ }
+
fun info(
msg: String?,
context: String = "",
@@ -23,7 +48,7 @@ object Logger {
) {
val message = format("INFOℹ️: $msg", context, file, line)
Log.i(TAG, message)
- saveLog(message)
+ saveToFile(message)
}
fun debug(
@@ -34,7 +59,7 @@ object Logger {
) {
val message = format("DEBUG: $msg", context, file, line)
Log.d(TAG, message)
- saveLog(message)
+ saveToFile(message)
}
fun warn(
@@ -47,7 +72,7 @@ object Logger {
val errMsg = e?.message?.let { " (err: '$it')" } ?: ""
val message = format("WARN⚠️: $msg$errMsg", context, file, line)
Log.w(TAG, message, e)
- saveLog(message)
+ saveToFile(message)
}
fun error(
@@ -60,7 +85,7 @@ object Logger {
val errMsg = e?.message?.let { " (err: '$it')" } ?: ""
val message = format("ERROR❌️: $msg$errMsg", context, file, line)
Log.e(TAG, message, e)
- saveLog(message)
+ saveToFile(message)
}
fun verbose(
@@ -71,7 +96,7 @@ object Logger {
) {
val message = format("VERBOSE: $msg", context, file, line)
Log.v(TAG, message)
- saveLog(message)
+ saveToFile(message)
}
fun performance(
@@ -82,7 +107,7 @@ object Logger {
) {
val message = format("PERF: $msg", context, file, line)
Log.v(TAG, message)
- saveLog(message)
+ saveToFile(message)
}
private fun format(message: Any, context: String, file: String, line: Int): String {
@@ -97,9 +122,48 @@ object Logger {
return Thread.currentThread().stackTrace.getOrNull(4)?.lineNumber ?: -1
}
- private fun saveLog(message: String) {
+ private fun saveToFile(message: String) {
queue.launch {
- // TODO: save log to file
+ try {
+ val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+ val timestamp = dateFormatter.format(Date())
+ val logMessage = "[$timestamp UTC] $message\n"
+
+ FileOutputStream(File(sessionLogFile), true).use { stream ->
+ stream.write(logMessage.toByteArray())
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, "Failed to write to log file", e)
+ }
+ }
+ }
+
+ // Cleans up both bitkit and ldk log files
+ private fun cleanupOldLogFiles(maxTotalSizeMB: Int = 20) {
+ val baseDir = File(Env.logDir)
+ if (!baseDir.exists()) return
+
+ val logFiles = baseDir
+ .listFiles { file -> file.extension == "log" }
+ ?.map { file -> Triple(file, file.length(), file.lastModified()) }
+ ?: return
+
+ var totalSize = logFiles.sumOf { it.second }
+ val maxSizeBytes = maxTotalSizeMB * 1024L * 1024L
+
+ // Sort by creation date (oldest first)
+ logFiles.sortedBy { it.third }.forEach { (file, size, _) ->
+ if (totalSize <= maxSizeBytes) return
+
+ try {
+ if (file.delete()) {
+ totalSize -= size
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, "Failed to cleanup log file:", e)
+ }
}
}
}
diff --git a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt
new file mode 100644
index 000000000..eb5defc20
--- /dev/null
+++ b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt
@@ -0,0 +1,139 @@
+package to.bitkit.viewmodels
+
+import android.app.Application
+import android.net.Uri
+import androidx.core.content.FileProvider
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import to.bitkit.env.Env
+import to.bitkit.utils.Logger
+import java.io.BufferedReader
+import java.io.File
+import java.io.FileReader
+import javax.inject.Inject
+
+@HiltViewModel
+class LogsViewModel @Inject constructor(
+ private val application: Application,
+) : AndroidViewModel(application) {
+ private val _logs = MutableStateFlow>(emptyList())
+ val logs: StateFlow> = _logs.asStateFlow()
+
+ private val _selectedLogContent = MutableStateFlow>(emptyList())
+ val selectedLogContent: StateFlow> = _selectedLogContent.asStateFlow()
+
+ fun loadLogs() {
+ viewModelScope.launch {
+ try {
+ val logDir = File(Env.logDir)
+ if (!logDir.exists()) {
+ _logs.value = emptyList()
+ return@launch
+ }
+
+ val logFiles = logDir
+ .listFiles { file -> file.extension == "log" }
+ ?.map { file ->
+ val fileName = file.name
+ val components = fileName.split("_")
+
+ val serviceName = components.firstOrNull()
+ ?.let { c -> c.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } }
+ ?: "Unknown"
+ val timestamp = if (components.size >= 3) components[components.size - 2] else ""
+ val displayName = "$serviceName Log: $timestamp"
+
+ LogFile(
+ displayName = displayName,
+ file = file,
+ )
+ }
+ ?.sortedByDescending { it.file.lastModified() }
+ ?: emptyList()
+
+ _logs.value = logFiles
+ } catch (e: Exception) {
+ _logs.value = emptyList()
+ Logger.error("Failed to load logs", e)
+ }
+ }
+ }
+
+ fun loadLogContent(logFile: LogFile) {
+ viewModelScope.launch {
+ try {
+ if (!logFile.file.exists()) {
+ _selectedLogContent.value = listOf("Log file not found")
+ return@launch
+ }
+
+ val lines = mutableListOf()
+ BufferedReader(FileReader(logFile.file)).use { reader ->
+ reader.forEachLine { line ->
+ lines.add(line.trim())
+ }
+ }
+ _selectedLogContent.value = lines
+ } catch (e: Exception) {
+ _selectedLogContent.value = listOf("Error loading log: ${e.message}")
+ Logger.error("Failed to load log content", e)
+ }
+ }
+ }
+
+ fun prepareLogForSharing(logFile: LogFile, onReady: (Uri) -> Unit) {
+ viewModelScope.launch {
+ try {
+ withContext(Dispatchers.IO) {
+ val tempDir = application.externalCacheDir?.resolve("logs")?.apply { mkdirs() }
+ ?: error("External cache dir is not available")
+ val tempFile = File(tempDir, logFile.fileName)
+
+ logFile.file.copyTo(tempFile, overwrite = true)
+
+ val contentUri = FileProvider.getUriForFile(
+ application,
+ Env.FILE_PROVIDER_AUTHORITY,
+ tempFile
+ )
+
+ withContext(Dispatchers.Main) {
+ onReady(contentUri)
+ }
+ }
+ } catch (e: Exception) {
+ Logger.error("Error preparing file for sharing", e)
+ }
+ }
+ }
+
+ fun deleteAllLogs() {
+ viewModelScope.launch {
+ try {
+ val logDir = File(Env.logDir)
+ logDir.listFiles { file ->
+ file.extension == "log"
+ }?.forEach { file ->
+ file.delete()
+ }
+ loadLogs()
+ } catch (e: Exception) {
+ Logger.error("Failed to delete logs", e)
+ }
+ }
+ }
+}
+
+data class LogFile(
+ val displayName: String,
+ val file: File,
+) {
+ val fileName: String get() = file.name
+}
diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml
new file mode 100644
index 000000000..5c36b66d6
--- /dev/null
+++ b/app/src/main/res/xml/provider_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+