diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt b/WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt index e1199d998a75..be7dfcda0f83 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt @@ -97,7 +97,8 @@ class HESupportRepository @Inject constructor( subject: String, message: String, tags: List, - attachments: List + attachments: List, + encryptedLogIds: List, ): CreateConversationResult = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportTickets().createSupportTicket( @@ -106,6 +107,7 @@ class HESupportRepository @Inject constructor( message = message, tags = tags, attachments = attachments, + encryptedLogIds = encryptedLogIds, application = APPLICATION_ID, // Only jetpack is supported ) ) @@ -138,7 +140,7 @@ class HESupportRepository @Inject constructor( suspend fun addMessageToConversation( conversationId: Long, message: String, - attachments: List + attachments: List, ): CreateConversationResult = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportTickets().addMessageToSupportConversation( diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt index e9d4b0da8aab..bc12da0439f9 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt @@ -72,6 +72,7 @@ fun HENewTicketScreen( subject: String, messageText: String, siteAddress: String, + includeAppLogs: Boolean, ) -> Unit, userInfo: UserInfo, isSendingNewConversation: Boolean = false, @@ -99,7 +100,7 @@ fun HENewTicketScreen( isLoading = isSendingNewConversation, onClick = { selectedCategory?.let { category -> - onSubmit(category, subject, messageText, siteAddress) + onSubmit(category, subject, messageText, siteAddress, includeAppLogs) } } ) @@ -424,7 +425,7 @@ private fun HENewTicketScreenPreview() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _-> }, + onSubmit = { _, _, _, _, _ -> }, userInfo = UserInfo("Test user", "test.user@automattic.com", null), attachmentActionsListener = object : AttachmentActionsListener { override fun onAddImageClick() { @@ -446,7 +447,7 @@ private fun HENewTicketScreenPreviewDark() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _ -> }, + onSubmit = { _, _, _, _, _ -> }, userInfo = UserInfo("Test user", "test.user@automattic.com", null), attachmentActionsListener = object : AttachmentActionsListener { override fun onAddImageClick() { @@ -468,7 +469,7 @@ private fun HENewTicketScreenWordPressPreview() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _ -> }, + onSubmit = { _, _, _, _, _ -> }, userInfo = UserInfo("Test user", "test.user@automattic.com", null), attachmentActionsListener = object : AttachmentActionsListener { override fun onAddImageClick() { @@ -490,7 +491,7 @@ private fun HENewTicketScreenPreviewWordPressDark() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _ -> }, + onSubmit = { _, _, _, _, _ -> }, userInfo = UserInfo("Test user", "test.user@automattic.com", null), attachmentActionsListener = object : AttachmentActionsListener { override fun onAddImageClick() { diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt index 8fc43a3c0a60..4183d445e49f 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt @@ -193,6 +193,7 @@ class HESupportActivity : AppCompatActivity() { onSendMessage = { message, includeAppLogs -> viewModel.onAddMessageToConversation( message = message, + includeAppLogs = includeAppLogs, ) }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, @@ -237,11 +238,12 @@ class HESupportActivity : AppCompatActivity() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { viewModel.onBackClick() }, - onSubmit = { category, subject, messageText, siteAddress -> + onSubmit = { category, subject, messageText, siteAddress, includeAppLogs -> viewModel.onSendNewConversation( subject = subject, message = messageText, tags = listOf(category.key), + includeAppLogs = includeAppLogs, ) }, userInfo = userInfo, diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt index a44f610d7a04..b59d2de349bd 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt @@ -22,6 +22,8 @@ import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository import org.wordpress.android.support.he.util.TempAttachmentsUtil import org.wordpress.android.util.AppLog +import org.wordpress.android.util.EncryptedLogging +import org.wordpress.android.util.LogFileProviderWrapper import org.wordpress.android.util.NetworkUtilsWrapper import java.io.File import javax.inject.Inject @@ -32,6 +34,8 @@ class HESupportViewModel @Inject constructor( private val heSupportRepository: HESupportRepository, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, private val tempAttachmentsUtil: TempAttachmentsUtil, + private val encryptedLogging: EncryptedLogging, + private val logFileProvider: LogFileProviderWrapper, private val application: Application, accountStore: AccountStore, appLogWrapper: AppLogWrapper, @@ -70,6 +74,7 @@ class HESupportViewModel @Inject constructor( subject: String, message: String, tags: List, + includeAppLogs : Boolean, ) { viewModelScope.launch(ioDispatcher) { try { @@ -80,13 +85,20 @@ class HESupportViewModel @Inject constructor( _isSendingMessage.value = true - val files = tempAttachmentsUtil.createTempFilesFrom(_attachmentState.value.acceptedUris) + val logsIds: List = if (includeAppLogs) { + uploadLogs() + } else { + emptyList() + } + + val attachments = tempAttachmentsUtil.createTempFilesFrom(_attachmentState.value.acceptedUris) when (val result = heSupportRepository.createConversation( subject = subject, message = message, tags = tags, - attachments = files.map { it.path } + attachments = attachments.map { it.path }, + encryptedLogIds = logsIds )) { is CreateConversationResult.Success -> { val newConversation = result.conversation @@ -108,7 +120,7 @@ class HESupportViewModel @Inject constructor( } } - tempAttachmentsUtil.removeTempFiles(files) + tempAttachmentsUtil.removeTempFiles(attachments) _isSendingMessage.value = false } catch (e: Exception) { _errorMessage.value = ErrorType.GENERAL @@ -120,11 +132,26 @@ class HESupportViewModel @Inject constructor( } } + private fun uploadLogs(): List { + val encryptedLogsUuid = mutableListOf() + logFileProvider.getLogFiles().forEach { logFile -> + if (logFile.exists()) { + encryptedLogging.encryptAndUploadLogFile( + logFile = logFile, + shouldStartUploadImmediately = true + )?.let { uuid -> + encryptedLogsUuid.add(uuid) + } + } + } + return encryptedLogsUuid + } + override suspend fun getConversation(conversationId: Long): SupportConversation? = heSupportRepository.loadConversation(conversationId) @Suppress("TooGenericExceptionCaught") - fun onAddMessageToConversation(message: String) { + fun onAddMessageToConversation(message: String, includeAppLogs: Boolean,) { viewModelScope.launch(ioDispatcher) { try { if (!networkUtilsWrapper.isNetworkAvailable()) { @@ -140,12 +167,18 @@ class HESupportViewModel @Inject constructor( } _isSendingMessage.value = true - val files = tempAttachmentsUtil.createTempFilesFrom(_attachmentState.value.acceptedUris) + + if (includeAppLogs) { + // TODO: use the Id to send it within the answer + val logsId = uploadLogs() + } + + val attachments = tempAttachmentsUtil.createTempFilesFrom(_attachmentState.value.acceptedUris) when (val result = heSupportRepository.addMessageToConversation( conversationId = selectedConversation.id, message = message, - attachments = files.map { it.path } + attachments = attachments.map { it.path } )) { is CreateConversationResult.Success -> { _selectedConversation.value = result.conversation @@ -167,7 +200,7 @@ class HESupportViewModel @Inject constructor( } } - tempAttachmentsUtil.removeTempFiles(files) + tempAttachmentsUtil.removeTempFiles(attachments) _isSendingMessage.value = false } catch (e: Exception) { _errorMessage.value = ErrorType.GENERAL diff --git a/WordPress/src/main/java/org/wordpress/android/support/logs/model/LogDay.kt b/WordPress/src/main/java/org/wordpress/android/support/logs/model/LogDay.kt deleted file mode 100644 index 630c302fa58d..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/support/logs/model/LogDay.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.wordpress.android.support.logs.model - -data class LogDay( - val date: String, // e.g., "Oct-16" - val displayDate: String, // e.g., "October 16" - val logEntries: List, - val logCount: Int -) diff --git a/WordPress/src/main/java/org/wordpress/android/support/logs/model/LogFile.kt b/WordPress/src/main/java/org/wordpress/android/support/logs/model/LogFile.kt new file mode 100644 index 000000000000..e21c03208c9b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/logs/model/LogFile.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.support.logs.model + +import java.io.File + +data class LogFile( + val file: File, + val fileName: String, + val title: String, + val subtitle: String, + // Log lines could be truncated to avoid memory issues + val logLines: List? = null +) diff --git a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogDetailScreen.kt index b9e508462283..a4581aef8208 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogDetailScreen.kt @@ -25,9 +25,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.text.HtmlCompat import org.wordpress.android.R -import org.wordpress.android.support.logs.model.LogDay +import org.wordpress.android.support.logs.model.LogFile import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -35,14 +34,14 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun LogDetailScreen( - logDay: LogDay, + logFile: LogFile, onBackClick: () -> Unit, onShareClick: () -> Unit ) { Scaffold( topBar = { MainTopAppBar( - title = logDay.displayDate, + title = logFile.title, navigationIcon = NavigationIcons.BackIcon, onNavigationIconClick = onBackClick ) @@ -77,11 +76,12 @@ fun LogDetailScreen( Spacer(modifier = Modifier.height(16.dp)) } + val logLines = logFile.logLines ?: emptyList() itemsIndexed( - items = logDay.logEntries, - key = { index, _ -> "${logDay.date}_$index" } - ) { _, logEntry -> - LogEntryItem(logEntry = logEntry) + items = logLines, + key = { index, _ -> "${logFile.fileName}_$index" } + ) { _, logLine -> + LogLineItem(logLine = logLine) } item { @@ -93,19 +93,13 @@ fun LogDetailScreen( } @Composable -private fun LogEntryItem(logEntry: String) { +private fun LogLineItem(logLine: String) { Column( modifier = Modifier .fillMaxWidth() ) { - // Strip HTML tags for display in Compose - val plainText = HtmlCompat.fromHtml( - logEntry, - HtmlCompat.FROM_HTML_MODE_LEGACY - ).toString() - Text( - text = plainText, + text = logLine, style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace, fontSize = 12.sp, @@ -119,14 +113,16 @@ private fun LogEntryItem(logEntry: String) { @Preview(showBackground = true, name = "Log Detail Screen - Light") @Composable private fun LogDetailScreenPreview() { - val exampleList = getExampleLogList() + val exampleLogLines = getExampleLogLines() + val mockFile = java.io.File("") AppThemeM3(isDarkTheme = false) { LogDetailScreen( - logDay = LogDay( - date = "Oct-16", - displayDate = "October 16", - logEntries = exampleList, - logCount = exampleList.size + logFile = LogFile( + file = mockFile, + fileName = "2025-11-21T10:42:06+0100.log", + title = "November 21, 2025", + subtitle = "10:42 AM", + logLines = exampleLogLines ), onBackClick = {}, onShareClick = {} @@ -137,14 +133,16 @@ private fun LogDetailScreenPreview() { @Preview(showBackground = true, name = "Log Detail Screen - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun LogDetailScreenPreviewDark() { - val exampleList = getExampleLogList() + val exampleLogLines = getExampleLogLines() + val mockFile = java.io.File("") AppThemeM3(isDarkTheme = true) { LogDetailScreen( - logDay = LogDay( - date = "Oct-16", - displayDate = "October 16", - logEntries = exampleList, - logCount = exampleList.size + logFile = LogFile( + file = mockFile, + fileName = "2025-11-21T10:42:06+0100.log", + title = "November 21, 2025", + subtitle = "10:42 AM", + logLines = exampleLogLines ), onBackClick = {}, onShareClick = {} @@ -152,7 +150,7 @@ private fun LogDetailScreenPreviewDark() { } } -private fun getExampleLogList(): List = listOf( +private fun getExampleLogLines(): List = listOf( "[Oct-16 12:34:56.789] D/MainActivity: Activity created", "[Oct-16 12:34:57.123] I/NetworkManager: Connection established", "[Oct-16 12:34:58.456] W/ImageLoader: Cache miss for image_123", diff --git a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsActivity.kt index dce2f2185523..9e0d46ca8f00 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -27,6 +28,7 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.util.AppLog import org.wordpress.android.util.ToastUtils +import java.io.File import javax.inject.Inject @AndroidEntryPoint @@ -95,7 +97,7 @@ class LogsActivity : AppCompatActivity() { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.actionEvents.collect { event -> when (event) { - is LogsViewModel.ActionEvent.ShareLogDay -> shareLogDay(event.logDay, event.date) + is LogsViewModel.ActionEvent.ShareLogFile -> shareLogFile(event.file) } } } @@ -117,21 +119,21 @@ class LogsActivity : AppCompatActivity() { startDestination = LogsScreen.List.name ) { composable(route = LogsScreen.List.name) { - val logDays by viewModel.logDays.collectAsState() + val logFiles by viewModel.logFiles.collectAsState() LogsListScreen( - logDays = logDays, - onLogDayClick = { logDay -> viewModel.onLogDayClick(logDay) }, + logFiles = logFiles, + onLogFileClick = { logFile -> viewModel.onLogFileClick(logFile) }, onBackClick = { finish() } ) } composable(route = LogsScreen.Detail.name) { - val selectedLogDay by viewModel.selectedLogDay.collectAsState() - selectedLogDay?.let { logDay -> + val selectedLogFile by viewModel.selectedLogFile.collectAsState() + selectedLogFile?.let { logFile -> LogDetailScreen( - logDay = logDay, + logFile = logFile, onBackClick = { navController?.navigateUp() }, - onShareClick = { viewModel.onShareClick(logDay) } + onShareClick = { viewModel.onShareClick(logFile) } ) } ?: run { LaunchedEffect(Unit) { @@ -143,19 +145,26 @@ class LogsActivity : AppCompatActivity() { } } - private fun shareLogDay(logDay: String, date: String) { - val subject = "${getString(R.string.app_name)} " + - "${getString(R.string.support_screen_application_logs_title)} - $date" - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, logDay) - putExtra(Intent.EXTRA_SUBJECT, subject) - } + @Suppress("TooGenericExceptionCaught") + private fun shareLogFile(cachedFile: File) { try { + val uri = FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.provider", + cachedFile + ) + val subject = "${getString(R.string.app_name)} " + + "${getString(R.string.support_screen_application_logs_title)} - ${cachedFile.name}" + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, subject) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } startActivity(Intent.createChooser(intent, getString(R.string.reader_btn_share))) - } catch (ex: android.content.ActivityNotFoundException) { + } catch (ex: Exception) { ToastUtils.showToast(this, R.string.reader_toast_err_share_intent) - appLogWrapper.e(AppLog.T.SUPPORT, "Error sharing logs: ${ex.stackTraceToString()}") + appLogWrapper.e(AppLog.T.SUPPORT, "Error sharing log file: ${ex.stackTraceToString()}") } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsListScreen.kt index 1a4f362a28a1..7d3f7f5130d5 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsListScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.wordpress.android.R -import org.wordpress.android.support.logs.model.LogDay +import org.wordpress.android.support.logs.model.LogFile import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -34,8 +34,8 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun LogsListScreen( - logDays: List, - onLogDayClick: (LogDay) -> Unit, + logFiles: List, + onLogFileClick: (LogFile) -> Unit, onBackClick: () -> Unit ) { Scaffold( @@ -47,7 +47,7 @@ fun LogsListScreen( ) } ) { contentPadding -> - if (logDays.isEmpty()) { + if (logFiles.isEmpty()) { Column( modifier = Modifier .fillMaxSize() @@ -69,12 +69,12 @@ fun LogsListScreen( .padding(contentPadding) ) { items( - items = logDays, - key = { it.date } - ) { logDay -> - LogDayListItem( - logDay = logDay, - onClick = { onLogDayClick(logDay) } + items = logFiles, + key = { it.fileName } + ) { logFile -> + LogFileListItem( + logFile = logFile, + onClick = { onLogFileClick(logFile) } ) HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) @@ -86,8 +86,8 @@ fun LogsListScreen( } @Composable -private fun LogDayListItem( - logDay: LogDay, +private fun LogFileListItem( + logFile: LogFile, onClick: () -> Unit ) { Row( @@ -101,17 +101,19 @@ private fun LogDayListItem( modifier = Modifier.weight(1f) ) { Text( - text = logDay.displayDate, + text = logFile.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface ) - Text( - modifier = Modifier.padding(top = 4.dp), - text = stringResource(R.string.logs_screen_log_count, logDay.logCount), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (logFile.subtitle.isNotEmpty()) { + Text( + modifier = Modifier.padding(top = 4.dp), + text = logFile.subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } Icon( @@ -126,11 +128,11 @@ private fun LogDayListItem( @Preview(showBackground = true, name = "Logs List Screen - Light") @Composable private fun LogsListScreenPreview() { - val exampleList = getExampleLogDaysList() + val exampleList = getExampleLogFilesList() AppThemeM3(isDarkTheme = false) { LogsListScreen( - logDays = exampleList, - onLogDayClick = {}, + logFiles = exampleList, + onLogFileClick = {}, onBackClick = {} ) } @@ -139,11 +141,11 @@ private fun LogsListScreenPreview() { @Preview(showBackground = true, name = "Logs List Screen - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun LogsListScreenPreviewDark() { - val exampleList = getExampleLogDaysList() + val exampleList = getExampleLogFilesList() AppThemeM3(isDarkTheme = true) { LogsListScreen( - logDays = exampleList, - onLogDayClick = {}, + logFiles = exampleList, + onLogFileClick = {}, onBackClick = {} ) } @@ -154,31 +156,37 @@ private fun LogsListScreenPreviewDark() { private fun LogsListScreenPreviewEmpty() { AppThemeM3(isDarkTheme = false) { LogsListScreen( - logDays = emptyList(), - onLogDayClick = {}, + logFiles = emptyList(), + onLogFileClick = {}, onBackClick = {} ) } } @Suppress("MagicNumber") -private fun getExampleLogDaysList(): List = listOf( - LogDay( - date = "Oct-16", - displayDate = "October 16", - logEntries = List(50) { "[Oct-16 12:34:56.789] Sample log entry $it" }, - logCount = 50 - ), - LogDay( - date = "Oct-15", - displayDate = "October 15", - logEntries = List(32) { "[Oct-15 12:34:56.789] Sample log entry $it" }, - logCount = 32 - ), - LogDay( - date = "Oct-14", - displayDate = "October 14", - logEntries = List(28) { "[Oct-14 12:34:56.789] Sample log entry $it" }, - logCount = 28 +private fun getExampleLogFilesList(): List { + val mockFile = java.io.File("") + return listOf( + LogFile( + file = mockFile, + fileName = "2025-11-21T10:42:06+0100.log", + title = "November 21, 2025", + subtitle = "10:42 AM", + logLines = null + ), + LogFile( + file = mockFile, + fileName = "2025-11-20T14:30:15+0100.log", + title = "November 20, 2025", + subtitle = "02:30 PM", + logLines = null + ), + LogFile( + file = mockFile, + fileName = "2025-11-19T09:15:42+0100.log", + title = "November 19, 2025", + subtitle = "09:15 AM", + logLines = null + ) ) -) +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsViewModel.kt index 96c5a0565684..a27ad6a431f0 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsViewModel.kt @@ -15,8 +15,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.IO_THREAD -import org.wordpress.android.support.logs.model.LogDay +import org.wordpress.android.support.logs.model.LogFile import org.wordpress.android.util.AppLog +import org.wordpress.android.util.LogFileProviderWrapper +import java.io.File import java.text.SimpleDateFormat import java.util.Locale import javax.inject.Inject @@ -25,22 +27,23 @@ import javax.inject.Named @HiltViewModel class LogsViewModel @Inject constructor( private val appLogWrapper: AppLogWrapper, - @ApplicationContext private val appContext: Context, + private val logFileProvider: LogFileProviderWrapper, + @ApplicationContext private val context: Context, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { sealed class NavigationEvent { - data class NavigateToDetail(val logDay: LogDay) : NavigationEvent() + data class NavigateToDetail(val logFile: LogFile) : NavigationEvent() } sealed class ActionEvent { - data class ShareLogDay(val logDay: String, val date: String) : ActionEvent() + data class ShareLogFile(val file: java.io.File) : ActionEvent() } - private val _logDays = MutableStateFlow>(emptyList()) - val logDays: StateFlow> = _logDays.asStateFlow() + private val _logFiles = MutableStateFlow>(emptyList()) + val logFiles: StateFlow> = _logFiles.asStateFlow() - private val _selectedLogDay = MutableStateFlow(null) - val selectedLogDay: StateFlow = _selectedLogDay.asStateFlow() + private val _selectedLogFile = MutableStateFlow(null) + val selectedLogFile: StateFlow = _selectedLogFile.asStateFlow() private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() @@ -55,75 +58,110 @@ class LogsViewModel @Inject constructor( fun init() { viewModelScope.launch(ioDispatcher) { try { - val allLogs = AppLog.toHtmlList(appContext) - _logDays.value = parseLogsByDay(allLogs) + val files = logFileProvider.getLogFiles() + _logFiles.value = files + .sortedByDescending { it.lastModified() } + .map { file -> + val (title, subtitle) = formatTitleAndSubtitle(file.name) + LogFile( + file = file, + fileName = file.name, + title = title, + subtitle = subtitle, + logLines = null + ) + } } catch (throwable: Throwable) { - // If there's any error parsing the logs, better not to crash the app + // If there's any error loading log files, better not to crash the app _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error parsing logs: ${throwable.stackTraceToString()}") + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading log files: ${throwable.stackTraceToString()}") } } } - fun onLogDayClick(logDay: LogDay) { - _selectedLogDay.value = logDay - viewModelScope.launch { - _navigationEvents.emit(NavigationEvent.NavigateToDetail(logDay)) + fun onLogFileClick(logFile: LogFile) { + viewModelScope.launch(ioDispatcher) { + // Load log lines only when user clicks on the file + val logFileWithContent = if (logFile.logLines == null) { + logFile.copy(logLines = readFileContent(logFile.file)) + } else { + logFile + } + _selectedLogFile.value = logFileWithContent + _navigationEvents.emit(NavigationEvent.NavigateToDetail(logFileWithContent)) } } - fun onShareClick(logDay: LogDay) { - viewModelScope.launch { - val logs = logDay.logEntries.joinToString(separator = "\n") - _actionEvents.emit(ActionEvent.ShareLogDay(logs, logDay.displayDate)) + @Suppress("TooGenericExceptionCaught") + fun onShareClick(logFile: LogFile) { + viewModelScope.launch(ioDispatcher) { + try { + // Copy log file to cache directory for secure sharing + val cacheDir = File(context.cacheDir, "shared_logs") + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + + val cachedFile = File(cacheDir, logFile.file.name) + logFile.file.copyTo(cachedFile, overwrite = true) + + _actionEvents.emit(ActionEvent.ShareLogFile(cachedFile)) + } catch (throwable: Throwable) { + appLogWrapper.e( + AppLog.T.SUPPORT, + "Error preparing log file for sharing: ${throwable.stackTraceToString()}" + ) + _errorMessage.value = ErrorType.GENERAL + } } } @Suppress("TooGenericExceptionCaught") - private fun parseLogsByDay(logs: List): List { - val logsByDay = mutableMapOf>() - - logs.forEach { log -> - // Extract date from log entry format: [Oct-16 12:34:56.789] ... - val dateMatch = Regex("""\[([A-Z][a-z]{2}-\d{2})""").find(log) - if (dateMatch != null) { - val date = dateMatch.groupValues[1] - logsByDay.getOrPut(date) { mutableListOf() }.add(log) - } - } + private fun formatTitleAndSubtitle(fileName: String): Pair { + // Remove extension + val nameWithoutExtension = fileName.substringBeforeLast(".") - return logsByDay.map { (date, entries) -> - LogDay( - date = date, - displayDate = formatDisplayDate(date), - logEntries = entries, - logCount = entries.size - ) - }.sortedWith(compareByDescending { logDay -> - // Most recent first - try { - SimpleDateFormat("MMM-dd", Locale.getDefault()).parse(logDay.date) - } catch (e: Exception) { - appLogWrapper.e(AppLog.T.SUPPORT, "Error sorting logs: ${e.stackTraceToString()}") - null + // Try to parse timestamp format: 2025-11-21T10:42:06+0100 + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + val parsedDate = inputFormat.parse(nameWithoutExtension) + if (parsedDate != null) { + val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault()) + val timeFormat = SimpleDateFormat("hh:mm a", Locale.getDefault()) + val title = dateFormat.format(parsedDate) + val subtitle = timeFormat.format(parsedDate) + Pair(title, subtitle) + } else { + Pair(nameWithoutExtension, "") } - }) + } catch (e: Exception) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error formatting title: ${e.stackTraceToString()}") + Pair(nameWithoutExtension, "") + } } @Suppress("TooGenericExceptionCaught") - private fun formatDisplayDate(date: String): String { + private fun readFileContent(file: File): List { return try { - val inputFormat = SimpleDateFormat("MMM-dd", Locale.getDefault()) - val outputFormat = SimpleDateFormat("MMMM dd", Locale.getDefault()) - val parsedDate = inputFormat.parse(date) - if (parsedDate != null) { - outputFormat.format(parsedDate) - } else { - date + file.bufferedReader().use { reader -> + val linesList = mutableListOf() + var count = 0 + var line = reader.readLine() + while (line != null && count < MAX_LINES) { + linesList.add(line) + count++ + line = reader.readLine() + } + // Check if there are more lines + val hasMoreLines = line != null + if (hasMoreLines) { + linesList.add("... [truncated]") + } + linesList } - } catch (exception: Exception) { - appLogWrapper.e(AppLog.T.SUPPORT, "Error parsing log date: ${exception.stackTraceToString()}") - date + } catch (e: Exception) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error reading file content: ${e.stackTraceToString()}") + emptyList() } } @@ -132,4 +170,8 @@ class LogsViewModel @Inject constructor( } enum class ErrorType { GENERAL } + + companion object { + private const val MAX_LINES = 100 + } } diff --git a/WordPress/src/main/res/xml/provider_paths.xml b/WordPress/src/main/res/xml/provider_paths.xml index 7c82c7779fdb..ea86f2475947 100644 --- a/WordPress/src/main/res/xml/provider_paths.xml +++ b/WordPress/src/main/res/xml/provider_paths.xml @@ -3,4 +3,7 @@ + diff --git a/WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt index e4508fef6276..76bbb23c8b0b 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt @@ -201,7 +201,8 @@ class HESupportRepositoryTest : BaseUnitTest() { subject = subject, message = message, tags = tags, - attachments = attachments + attachments = attachments, + encryptedLogIds = emptyList() ) // Then @@ -232,7 +233,8 @@ class HESupportRepositoryTest : BaseUnitTest() { subject = "Test", message = "Test", tags = emptyList(), - attachments = emptyList() + attachments = emptyList(), + encryptedLogIds = emptyList() ) // Then @@ -256,7 +258,8 @@ class HESupportRepositoryTest : BaseUnitTest() { subject = "Test", message = "Test", tags = emptyList(), - attachments = emptyList() + attachments = emptyList(), + encryptedLogIds = emptyList() ) // Then diff --git a/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt index b076c085b95f..092856d28c5c 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt @@ -29,6 +29,8 @@ import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository import org.wordpress.android.support.he.util.TempAttachmentsUtil +import org.wordpress.android.util.EncryptedLogging +import org.wordpress.android.util.LogFileProviderWrapper import org.wordpress.android.util.NetworkUtilsWrapper import java.util.Date @@ -50,6 +52,12 @@ class HESupportViewModelTest : BaseUnitTest() { @Mock private lateinit var tempAttachmentsUtil: TempAttachmentsUtil + @Mock + private lateinit var encryptedLogging: EncryptedLogging + + @Mock + private lateinit var logFileProvider: LogFileProviderWrapper + @Mock private lateinit var application: Application @@ -92,6 +100,8 @@ class HESupportViewModelTest : BaseUnitTest() { heSupportRepository = heSupportRepository, ioDispatcher = UnconfinedTestDispatcher(), tempAttachmentsUtil = tempAttachmentsUtil, + encryptedLogging = encryptedLogging, + logFileProvider = logFileProvider, application = application, accountStore = accountStore, appLogWrapper = appLogWrapper, @@ -147,13 +157,15 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), - attachments = emptyList() + attachments = emptyList(), + encryptedLogIds = emptyList() )).thenReturn(CreateConversationResult.Success(newConversation)) viewModel.onSendNewConversation( subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() @@ -161,7 +173,8 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), - attachments = emptyList() + attachments = emptyList(), + encryptedLogIds = emptyList() ) } @@ -171,13 +184,15 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), - attachments = emptyList() + attachments = emptyList(), + encryptedLogIds = emptyList() )).thenReturn(CreateConversationResult.Error.Forbidden) viewModel.onSendNewConversation( subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() @@ -191,13 +206,15 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), - attachments = emptyList() + attachments = emptyList(), + encryptedLogIds = emptyList() )).thenReturn(CreateConversationResult.Error.GeneralError) viewModel.onSendNewConversation( subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() @@ -208,13 +225,14 @@ class HESupportViewModelTest : BaseUnitTest() { @Test fun `onSendNewConversation resets isSendingNewConversation even when error occurs`() = test { whenever(heSupportRepository.createConversation( - any(), any(), any(), any() + any(), any(), any(), any(), any() )).thenReturn(CreateConversationResult.Error.GeneralError) viewModel.onSendNewConversation( subject = "Test Subject", message = "Test Message", - tags = emptyList() + tags = emptyList(), + includeAppLogs = false, ) advanceUntilIdle() @@ -228,7 +246,8 @@ class HESupportViewModelTest : BaseUnitTest() { viewModel.onSendNewConversation( subject = "Test Subject", message = "Test Message", - tags = listOf("tag1") + tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() @@ -243,11 +262,12 @@ class HESupportViewModelTest : BaseUnitTest() { viewModel.onSendNewConversation( subject = "Test Subject", message = "Test Message", - tags = listOf("tag1") + tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() - verify(heSupportRepository, never()).createConversation(any(), any(), any(), any()) + verify(heSupportRepository, never()).createConversation(any(), any(), any(), any(), any()) } // endregion @@ -272,7 +292,8 @@ class HESupportViewModelTest : BaseUnitTest() { @Test fun `onAddMessageToConversation does nothing when no conversation is selected`() = test { viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -297,7 +318,8 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -325,7 +347,8 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -346,7 +369,8 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -368,7 +392,8 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -388,7 +413,8 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -408,7 +434,8 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -429,7 +456,8 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -449,7 +477,8 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -717,7 +746,8 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), - attachments = listOf(tempFile1.path, tempFile2.path) + attachments = listOf(tempFile1.path, tempFile2.path), + encryptedLogIds = emptyList() )).thenReturn(CreateConversationResult.Success(newConversation)) viewModel.addAttachments(listOf(uri1, uri2)) @@ -725,6 +755,7 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() @@ -733,7 +764,8 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), - attachments = listOf(tempFile1.path, tempFile2.path) + attachments = listOf(tempFile1.path, tempFile2.path), + encryptedLogIds = emptyList() ) verify(tempAttachmentsUtil).removeTempFiles(listOf(tempFile1, tempFile2)) } @@ -744,7 +776,7 @@ class HESupportViewModelTest : BaseUnitTest() { val newConversation = createTestConversation(1) whenever(heSupportRepository.createConversation( - any(), any(), any(), any() + any(), any(), any(), any(), any() )).thenReturn(CreateConversationResult.Success(newConversation)) viewModel.addAttachments(listOf(uri1)) @@ -755,6 +787,7 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() @@ -766,7 +799,7 @@ class HESupportViewModelTest : BaseUnitTest() { val uri1 = mock() whenever(heSupportRepository.createConversation( - any(), any(), any(), any() + any(), any(), any(), any(), any() )).thenReturn(CreateConversationResult.Error.GeneralError) viewModel.addAttachments(listOf(uri1)) @@ -776,6 +809,7 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() @@ -790,7 +824,7 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(tempAttachmentsUtil.createTempFilesFrom(listOf(uri1))) .thenReturn(listOf(tempFile1)) whenever(heSupportRepository.createConversation( - any(), any(), any(), any() + any(), any(), any(), any(), any() )).thenReturn(CreateConversationResult.Error.GeneralError) viewModel.addAttachments(listOf(uri1)) @@ -798,6 +832,7 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() @@ -827,7 +862,8 @@ class HESupportViewModelTest : BaseUnitTest() { viewModel.addAttachments(listOf(uri1)) viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -859,7 +895,8 @@ class HESupportViewModelTest : BaseUnitTest() { assertThat(viewModel.attachmentState.value.acceptedUris).containsExactly(uri1) viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -883,7 +920,8 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -908,7 +946,8 @@ class HESupportViewModelTest : BaseUnitTest() { viewModel.addAttachments(listOf(uri1)) viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() @@ -927,6 +966,7 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), + includeAppLogs = false, ) advanceUntilIdle() @@ -948,7 +988,8 @@ class HESupportViewModelTest : BaseUnitTest() { viewModel.addAttachments(listOf(uri1)) viewModel.onAddMessageToConversation( - message = "Test message" + message = "Test message", + includeAppLogs = false, ) advanceUntilIdle() diff --git a/WordPress/src/test/java/org/wordpress/android/support/logs/ui/LogsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/logs/ui/LogsViewModelTest.kt index d1991da66140..3c8096d5836f 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/logs/ui/LogsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/logs/ui/LogsViewModelTest.kt @@ -6,28 +6,36 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test -import org.mockito.Mock -import org.mockito.Mockito.mock +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.fluxc.utils.AppLogWrapper -import org.wordpress.android.support.logs.model.LogDay -import java.lang.reflect.Method +import org.wordpress.android.support.logs.model.LogFile +import org.wordpress.android.util.LogFileProviderWrapper +import java.io.File @ExperimentalCoroutinesApi class LogsViewModelTest : BaseUnitTest() { - @Mock - lateinit var appLogWrapper: AppLogWrapper - - @Mock - lateinit var context: Context - + private lateinit var appLogWrapper: AppLogWrapper + private lateinit var logFileProvider: LogFileProviderWrapper + private lateinit var context: Context private lateinit var viewModel: LogsViewModel @Before fun setUp() { + appLogWrapper = mock() + logFileProvider = mock() + context = mock() + + // Create a real temporary directory for cache operations to avoid mocking File constructor + val tempCacheDir = createTempDir("test-cache") + tempCacheDir.deleteOnExit() + whenever(context.cacheDir).thenReturn(tempCacheDir) + viewModel = LogsViewModel( appLogWrapper = appLogWrapper, - appContext = mock(), + logFileProvider = logFileProvider, + context = context, ioDispatcher = testDispatcher() ) } @@ -35,15 +43,15 @@ class LogsViewModelTest : BaseUnitTest() { // region Initial state tests @Test - fun `logDays is empty by default`() { + fun `logFiles is empty by default`() { // Then - assertThat(viewModel.logDays.value).isEmpty() + assertThat(viewModel.logFiles.value).isEmpty() } @Test - fun `selectedLogDay is null by default`() { + fun `selectedLogFile is null by default`() { // Then - assertThat(viewModel.selectedLogDay.value).isNull() + assertThat(viewModel.selectedLogFile.value).isNull() } @Test @@ -54,275 +62,268 @@ class LogsViewModelTest : BaseUnitTest() { // endregion - // region selectLogDay() tests + // region init() tests @Test - fun `selectLogDay updates selectedLogDay state`() = test { + fun `init loads and sorts log files by last modified date`() = test { // Given - val logDay = LogDay( - date = "Oct-16", - displayDate = "October 16", - logEntries = listOf("[Oct-16 12:34:56.789] Test log"), - logCount = 1 - ) - - // When - viewModel.navigationEvents.test { - viewModel.onLogDayClick(logDay) - - // Then - assertThat(viewModel.selectedLogDay.value).isEqualTo(logDay) - val event = awaitItem() - assertThat(event).isInstanceOf(LogsViewModel.NavigationEvent.NavigateToDetail::class.java) - assertThat((event as LogsViewModel.NavigationEvent.NavigateToDetail).logDay).isEqualTo(logDay) - } - } + val file1 = createMockFile("2025-11-21T10:42:06+0100.log", 1000L) + val file2 = createMockFile("2025-11-20T14:30:15+0100.log", 2000L) + val file3 = createMockFile("2025-11-19T09:15:42+0100.log", 500L) - @Test - fun `selectLogDay can be called multiple times and updates state each time`() = test { - // Given - val logDay1 = LogDay( - date = "Oct-16", - displayDate = "October 16", - logEntries = listOf("[Oct-16 12:34:56.789] Test log"), - logCount = 1 - ) - val logDay2 = LogDay( - date = "Oct-15", - displayDate = "October 15", - logEntries = listOf("[Oct-15 12:34:56.789] Test log"), - logCount = 1 - ) + whenever(logFileProvider.getLogFiles()).thenReturn(listOf(file1, file2, file3)) // When - viewModel.navigationEvents.test { - viewModel.onLogDayClick(logDay1) - assertThat(viewModel.selectedLogDay.value).isEqualTo(logDay1) - val event1 = awaitItem() - assertThat(event1).isInstanceOf(LogsViewModel.NavigationEvent.NavigateToDetail::class.java) - assertThat((event1 as LogsViewModel.NavigationEvent.NavigateToDetail).logDay).isEqualTo(logDay1) - - viewModel.onLogDayClick(logDay2) + viewModel.init() + testScheduler.advanceUntilIdle() - // Then - assertThat(viewModel.selectedLogDay.value).isEqualTo(logDay2) - val event2 = awaitItem() - assertThat(event2).isInstanceOf(LogsViewModel.NavigationEvent.NavigateToDetail::class.java) - assertThat((event2 as LogsViewModel.NavigationEvent.NavigateToDetail).logDay).isEqualTo(logDay2) - } + // Then + val logFiles = viewModel.logFiles.value + assertThat(logFiles).hasSize(3) + assertThat(logFiles[0].fileName).isEqualTo("2025-11-20T14:30:15+0100.log") + assertThat(logFiles[1].fileName).isEqualTo("2025-11-21T10:42:06+0100.log") + assertThat(logFiles[2].fileName).isEqualTo("2025-11-19T09:15:42+0100.log") } - // endregion - - // region clearError() tests - @Test - fun `clearError sets errorMessage to null`() { - // Given - Force error state using reflection - val errorMessageField = viewModel.javaClass.getDeclaredField("_errorMessage") - errorMessageField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val errorMessageFlow = - errorMessageField.get(viewModel) as kotlinx.coroutines.flow.MutableStateFlow - errorMessageFlow.value = LogsViewModel.ErrorType.GENERAL - - assertThat(viewModel.errorMessage.value).isEqualTo(LogsViewModel.ErrorType.GENERAL) + fun `init parses timestamp filenames into readable titles`() = test { + // Given + val file = createMockFile("2025-11-21T10:42:06+0100.log", 1000L) + whenever(logFileProvider.getLogFiles()).thenReturn(listOf(file)) // When - viewModel.clearError() + viewModel.init() + testScheduler.advanceUntilIdle() // Then - assertThat(viewModel.errorMessage.value).isNull() + val logFile = viewModel.logFiles.value.first() + assertThat(logFile.title).isEqualTo("November 21, 2025") + assertThat(logFile.subtitle).isEqualTo("10:42 AM") } - // endregion - - // region parseLogsByDay() tests (via reflection) - @Test - fun `parseLogsByDay groups logs by date correctly`() { + fun `init handles unparseable filenames gracefully`() = test { // Given - val logs = listOf( - "[Oct-16 12:34:56.789] First log entry", - "[Oct-16 13:45:00.123] Second log entry", - "[Oct-15 10:20:30.456] Third log entry", - "[Oct-15 11:30:40.789] Fourth log entry" - ) + val file = createMockFile("invalid-filename.log", 1000L) + whenever(logFileProvider.getLogFiles()).thenReturn(listOf(file)) // When - val logDays = invokeParseLogsByDay(logs) + viewModel.init() + testScheduler.advanceUntilIdle() // Then - assertThat(logDays).hasSize(2) - - // Check that logs are sorted by date (most recent first) - assertThat(logDays[0].date).isEqualTo("Oct-16") - assertThat(logDays[1].date).isEqualTo("Oct-15") - - // Check log counts - assertThat(logDays[0].logCount).isEqualTo(2) - assertThat(logDays[1].logCount).isEqualTo(2) - - // Check log entries - assertThat(logDays[0].logEntries).containsExactly( - "[Oct-16 12:34:56.789] First log entry", - "[Oct-16 13:45:00.123] Second log entry" - ) - assertThat(logDays[1].logEntries).containsExactly( - "[Oct-15 10:20:30.456] Third log entry", - "[Oct-15 11:30:40.789] Fourth log entry" - ) + val logFile = viewModel.logFiles.value.first() + assertThat(logFile.title).isEqualTo("invalid-filename") + assertThat(logFile.subtitle).isEmpty() } @Test - fun `parseLogsByDay handles logs with no date pattern`() { + fun `init does not load log content initially`() = test { // Given - val logs = listOf( - "[Oct-16 12:34:56.789] Valid log entry", - "Invalid log entry without date", - "[Oct-16 13:45:00.123] Another valid entry" - ) + val file = createMockFile("2025-11-21T10:42:06+0100.log", 1000L) + whenever(logFileProvider.getLogFiles()).thenReturn(listOf(file)) // When - val logDays = invokeParseLogsByDay(logs) + viewModel.init() + testScheduler.advanceUntilIdle() // Then - assertThat(logDays).hasSize(1) - assertThat(logDays[0].date).isEqualTo("Oct-16") - assertThat(logDays[0].logCount).isEqualTo(2) + val logFile = viewModel.logFiles.value.first() + assertThat(logFile.logLines).isNull() } @Test - fun `parseLogsByDay handles empty log list`() { + fun `init sets error state when exception occurs`() = test { // Given - val logs = emptyList() + whenever(logFileProvider.getLogFiles()).thenThrow(RuntimeException("Test error")) // When - val logDays = invokeParseLogsByDay(logs) + viewModel.init() + testScheduler.advanceUntilIdle() // Then - assertThat(logDays).isEmpty() + assertThat(viewModel.errorMessage.value).isEqualTo(LogsViewModel.ErrorType.GENERAL) } + // endregion + + // region onLogFileClick() tests + @Test - fun `parseLogsByDay sorts dates in descending order`() { + fun `onLogFileClick loads file content and updates selectedLogFile`() = test { // Given - val logs = listOf( - "[Oct-10 12:34:56.789] Log entry", - "[Oct-20 12:34:56.789] Log entry", - "[Oct-15 12:34:56.789] Log entry" + val fileContent = "Line 1\nLine 2\nLine 3" + val file = createMockFileWithContent("2025-11-21T10:42:06+0100.log", fileContent) + val logFile = LogFile( + file = file, + fileName = "2025-11-21T10:42:06+0100.log", + title = "November 21, 2025", + subtitle = "10:42 AM", + logLines = null ) // When - val logDays = invokeParseLogsByDay(logs) + viewModel.navigationEvents.test { + viewModel.onLogFileClick(logFile) + testScheduler.advanceUntilIdle() - // Then - assertThat(logDays).hasSize(3) - assertThat(logDays[0].date).isEqualTo("Oct-20") - assertThat(logDays[1].date).isEqualTo("Oct-15") - assertThat(logDays[2].date).isEqualTo("Oct-10") + // Then + assertThat(viewModel.selectedLogFile.value).isNotNull + assertThat(viewModel.selectedLogFile.value?.logLines).containsExactly("Line 1", "Line 2", "Line 3") + + val event = awaitItem() + assertThat(event).isInstanceOf(LogsViewModel.NavigationEvent.NavigateToDetail::class.java) + } } @Test - fun `parseLogsByDay handles single log entry`() { + fun `onLogFileClick does not reload content if already loaded`() = test { // Given - val logs = listOf("[Oct-16 12:34:56.789] Single log entry") + val existingLines = listOf("Line 1", "Line 2") + val file = createMockFile("2025-11-21T10:42:06+0100.log", 1000L) + val logFile = LogFile( + file = file, + fileName = "2025-11-21T10:42:06+0100.log", + title = "November 21, 2025", + subtitle = "10:42 AM", + logLines = existingLines + ) // When - val logDays = invokeParseLogsByDay(logs) + viewModel.navigationEvents.test { + viewModel.onLogFileClick(logFile) + testScheduler.advanceUntilIdle() - // Then - assertThat(logDays).hasSize(1) - assertThat(logDays[0].date).isEqualTo("Oct-16") - assertThat(logDays[0].logCount).isEqualTo(1) - assertThat(logDays[0].logEntries).containsExactly("[Oct-16 12:34:56.789] Single log entry") + // Then + assertThat(viewModel.selectedLogFile.value?.logLines).isEqualTo(existingLines) + awaitItem() + } } - // endregion - - // region formatDisplayDate() tests (via reflection) - @Test - fun `formatDisplayDate formats date correctly`() { + fun `onLogFileClick truncates content to 100 lines`() = test { // Given - val date = "Oct-16" + val fileContent = (1..150).joinToString("\n") { "Line $it" } + val file = createMockFileWithContent("2025-11-21T10:42:06+0100.log", fileContent) + val logFile = LogFile( + file = file, + fileName = "2025-11-21T10:42:06+0100.log", + title = "November 21, 2025", + subtitle = "10:42 AM", + logLines = null + ) // When - val formattedDate = invokeFormatDisplayDate(date) + viewModel.navigationEvents.test { + viewModel.onLogFileClick(logFile) + testScheduler.advanceUntilIdle() - // Then - assertThat(formattedDate).isEqualTo("October 16") + // Then + val lines = viewModel.selectedLogFile.value?.logLines + assertThat(lines).hasSize(101) // 100 lines + truncation message + assertThat(lines?.last()).isEqualTo("... [truncated]") + awaitItem() + } } - @Test - fun `formatDisplayDate handles different months`() { - // Given/When/Then - assertThat(invokeFormatDisplayDate("Jan-01")).isEqualTo("January 01") - assertThat(invokeFormatDisplayDate("Dec-31")).isEqualTo("December 31") - assertThat(invokeFormatDisplayDate("Jul-04")).isEqualTo("July 04") - } + // endregion + + // region onShareClick() tests @Test - fun `formatDisplayDate returns original string for invalid format`() { + fun `onShareClick copies file to cache and emits ShareLogFile event`() = test { // Given - val invalidDate = "Invalid-Date" + val file = createMockFile("2025-11-21T10:42:06+0100.log", 1000L) + val logFile = LogFile( + file = file, + fileName = "2025-11-21T10:42:06+0100.log", + title = "November 21, 2025", + subtitle = "10:42 AM", + logLines = null + ) // When - val formattedDate = invokeFormatDisplayDate(invalidDate) + viewModel.actionEvents.test { + viewModel.onShareClick(logFile) + testScheduler.advanceUntilIdle() - // Then - assertThat(formattedDate).isEqualTo(invalidDate) + // Then + val event = awaitItem() + assertThat(event).isInstanceOf(LogsViewModel.ActionEvent.ShareLogFile::class.java) + } } @Test - fun `formatDisplayDate returns original string for empty input`() { + fun `onShareClick sets error state when exception occurs`() = test { // Given - val emptyDate = "" + val file = createMockFile("2025-11-21T10:42:06+0100.log", 1000L) + val logFile = LogFile( + file = file, + fileName = "2025-11-21T10:42:06+0100.log", + title = "November 21, 2025", + subtitle = "10:42 AM", + logLines = null + ) + whenever(context.cacheDir).thenThrow(RuntimeException("Test error")) // When - val formattedDate = invokeFormatDisplayDate(emptyDate) + viewModel.onShareClick(logFile) + testScheduler.advanceUntilIdle() // Then - assertThat(formattedDate).isEqualTo(emptyDate) + assertThat(viewModel.errorMessage.value).isEqualTo(LogsViewModel.ErrorType.GENERAL) } // endregion - // region init() error handling tests + // region clearError() tests @Test - fun `init logs error when exception occurs`() { - // Note: We can't easily test the successful path without mocking AppLog.toHtmlList() - // which is a static method. However, we can verify that errors are logged properly - // by checking that the appLogWrapper is available for error logging. + fun `clearError sets errorMessage to null`() { + // Given - Force error state using reflection + val errorMessageField = viewModel.javaClass.getDeclaredField("_errorMessage") + errorMessageField.isAccessible = true + @Suppress("UNCHECKED_CAST") + val errorMessageFlow = + errorMessageField.get(viewModel) as kotlinx.coroutines.flow.MutableStateFlow + errorMessageFlow.value = LogsViewModel.ErrorType.GENERAL - // Given - This test verifies that the appLogWrapper dependency is properly injected - // and available for error logging + assertThat(viewModel.errorMessage.value).isEqualTo(LogsViewModel.ErrorType.GENERAL) + + // When + viewModel.clearError() - // When/Then - No exception should be thrown during construction - assertThat(viewModel).isNotNull() + // Then + assertThat(viewModel.errorMessage.value).isNull() } // endregion - // Helper methods to invoke private methods via reflection - - private fun invokeParseLogsByDay(logs: List): List { - val method: Method = viewModel.javaClass.getDeclaredMethod( - "parseLogsByDay", - List::class.java - ) - method.isAccessible = true - @Suppress("UNCHECKED_CAST") - return method.invoke(viewModel, logs) as List + // Helper methods + + @Suppress("DEPRECATION") + private fun createMockFile(name: String, lastModified: Long): File { + // Create a temporary directory with expected filename + val testDir = File(System.getProperty("java.io.tmpdir"), "test-logs") + testDir.mkdirs() + val file = File(testDir, name) + file.writeText("") + file.setLastModified(lastModified) + file.deleteOnExit() + testDir.deleteOnExit() + return file } - private fun invokeFormatDisplayDate(date: String): String { - val method: Method = viewModel.javaClass.getDeclaredMethod( - "formatDisplayDate", - String::class.java - ) - method.isAccessible = true - return method.invoke(viewModel, date) as String + @Suppress("DEPRECATION") + private fun createMockFileWithContent(name: String, content: String): File { + // Create a temporary directory with expected filename + val testDir = File(System.getProperty("java.io.tmpdir"), "test-logs") + testDir.mkdirs() + val file = File(testDir, name) + file.writeText(content) + file.setLastModified(1000L) + file.deleteOnExit() + testDir.deleteOnExit() + return file } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 83e333c7b6b1..fd919c9b04d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-3d2dafdc1f8b058b4ed9101673fdf690671da73c' +wordpress-rs = 'trunk-8670f3dab1c722d2cd7d7477b3fe7ca1e916a25d' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.1'