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 @@ + + + +