diff --git a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt index a6440802928..29b76a55876 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt @@ -125,7 +125,10 @@ fun String.getBaseRoute(): String { val Direction.baseRoute: String get() = (this as? Route)?.baseRoute ?: route.getBaseRoute() -fun Direction.handleNavigation(context: Context, handleOtherDirection: (Direction) -> Unit) = when (this) { +fun Direction.handleNavigation( + context: Context, + handleOtherDirection: (Direction) -> Unit +) = when (this) { is ExternalUriDirection -> CustomTabsHelper.launchUri(context, this.uri) is ExternalUriStringResDirection -> CustomTabsHelper.launchUri(context, this.getUri(context.resources)) is IntentDirection -> context.startActivity(this.intent(context)) diff --git a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt index 8e50254eef3..3ba47c3d7e3 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt @@ -28,10 +28,6 @@ import com.wire.android.R import com.wire.android.util.EmailComposer import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId -import com.wire.android.util.getMimeType -import com.wire.android.util.getUrisOfFilesInDirectory -import com.wire.android.util.logging.LogFileWriter -import com.wire.android.util.multipleFileSharingIntent import com.wire.android.util.sha256 /** @@ -111,32 +107,6 @@ object GiveFeedbackDestination : IntentDirection { get() = "wire-intent:give-feedback" } -object ReportBugDestination : IntentDirection { - override fun intent(context: Context): Intent { - val dir = LogFileWriter.logsDirectory(context) - val logsUris = context.getUrisOfFilesInDirectory(dir) - val intent = context.multipleFileSharingIntent(logsUris) - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.send_bug_report_email))) - intent.putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.send_bug_report_subject)) - intent.putExtra( - Intent.EXTRA_TEXT, - EmailComposer.reportBugEmailTemplate( - context.getDeviceIdString()?.sha256(), - context.getGitBuildId() - ) - ) - val mimeTypes = logsUris.mapNotNull { it.getMimeType(context) }.toSet() - if (mimeTypes.isNotEmpty()) { - intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray()) - } - intent.type = "*/*" - return Intent.createChooser(intent, context.getString(R.string.send_feedback_choose_email)) - } - - override val route: String - get() = "wire-intent:report-bug" -} - object WelcomeToNewAndroidAppDestination : ExternalUriStringResDirection { override val uriStringRes: Int get() = R.string.url_welcome_to_new_android diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt index d9e86cea49c..b09a38aadef 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt @@ -18,10 +18,8 @@ package com.wire.android.ui.debug -import com.wire.android.navigation.annotation.app.WireRootDestination import android.annotation.SuppressLint import android.content.Context -import android.content.Intent import android.widget.Toast import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background @@ -48,6 +46,7 @@ import com.wire.android.di.hiltViewModelScoped import com.wire.android.model.Clickable import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator +import com.wire.android.navigation.annotation.app.WireRootDestination import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType @@ -60,13 +59,9 @@ import com.wire.android.ui.home.settings.backup.BackupAndRestoreDialog import com.wire.android.ui.home.settings.backup.rememberBackUpAndRestoreStateHolder import com.wire.android.ui.theme.WireTheme import com.wire.android.util.AppNameUtil -import com.wire.android.util.getMimeType -import com.wire.android.util.getUrisOfFilesInDirectory -import com.wire.android.util.multipleFileSharingIntent +import com.wire.android.util.logging.LogShareLauncher import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred -import kotlinx.coroutines.launch import java.io.File @WireRootDestination @@ -193,14 +188,24 @@ fun rememberDebugContentState(logPath: String): DebugContentState { val clipboardManager = LocalClipboardManager.current val scrollState = rememberScrollState() val coroutineScope = rememberCoroutineScope() + val shareLogsFailureMessage = stringResource(R.string.label_share_logs_failed) + val logShareLauncher = remember(context, coroutineScope, shareLogsFailureMessage) { + LogShareLauncher( + context = context, + coroutineScope = coroutineScope, + onFailure = { + Toast.makeText(context, shareLogsFailureMessage, Toast.LENGTH_SHORT).show() + } + ) + } - return remember { + return remember(context, clipboardManager, logPath, scrollState, logShareLauncher) { DebugContentState( context, clipboardManager, logPath, scrollState, - coroutineScope + logShareLauncher ) } } @@ -210,7 +215,7 @@ data class DebugContentState( val clipboardManager: ClipboardManager, val logPath: String, val scrollState: ScrollState, - val coroutineScope: CoroutineScope + val logShareLauncher: LogShareLauncher ) { fun copyToClipboard(text: String) { clipboardManager.setText(AnnotatedString(text)) @@ -222,20 +227,12 @@ data class DebugContentState( } fun shareLogs(onFlushLogs: () -> Deferred) { - coroutineScope.launch { - // Flush any buffered logs before sharing to ensure completeness - onFlushLogs().await() - val dir = File(logPath).parentFile - val fileUris = - if (dir != null && dir.exists()) context.getUrisOfFilesInDirectory(dir) else arrayListOf() - val intent = context.multipleFileSharingIntent(fileUris) - // The first log file is simply text, not compressed. Get its mime type separately - // and set it as the mime type for the intent. - intent.type = fileUris.firstOrNull()?.getMimeType(context) ?: "text/plain" - // Get all other mime types and add them - val mimeTypes = fileUris.drop(1).mapNotNull { it.getMimeType(context) } - intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toSet().toTypedArray()) - context.startActivity(intent) + val dir = File(logPath).parentFile + if (dir != null && dir.exists()) { + logShareLauncher.shareLogs(dir) { + // Flush any buffered logs before sharing to ensure completeness. + onFlushLogs().await() + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index be324de4522..23df058339e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -121,6 +121,7 @@ fun HomeScreen( analyticsUsageViewModel: AnalyticsUsageViewModel = hiltViewModel(), ) { val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() homeViewModel.checkRequirements() @@ -147,7 +148,6 @@ fun HomeScreen( val lifecycleOwner = LocalLifecycleOwner.current val snackbarHostState = LocalSnackbarHostState.current - val coroutineScope = rememberCoroutineScope() DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt index 029ceef6731..f379cea8e36 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt @@ -35,7 +35,6 @@ import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.navigation.GiveFeedbackDestination import com.wire.android.navigation.PrivacyPolicyScreenDestination -import com.wire.android.navigation.ReportBugDestination import com.wire.android.navigation.ReportMisuseScreenDestination import com.wire.android.navigation.SupportScreenDestination import com.wire.android.navigation.TermsOfUseScreenDestination @@ -146,6 +145,11 @@ sealed class SettingsItem(open val id: String, open val title: UIText) { override val title: UIText ) : SettingsItem(id, title) + sealed class ActionItem( + override val id: String, + override val title: UIText + ) : SettingsItem(id, title) + data object AppSettings : DirectionItem( id = "general_app_settings", title = UIText.StringResource(R.string.app_settings_screen_title), @@ -242,10 +246,9 @@ sealed class SettingsItem(open val id: String, open val title: UIText) { direction = GiveFeedbackDestination ) - data object ReportBug : DirectionItem( + data object ReportBug : ActionItem( id = "report_bug", - title = UIText.StringResource(R.string.report_bug_screen_title), - direction = ReportBugDestination + title = UIText.StringResource(R.string.report_bug_screen_title) ) data class AppLock(override val switchState: SwitchState) : SwitchItem( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt index d8349ece711..0c53229a20b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt @@ -18,7 +18,7 @@ package com.wire.android.ui.home.settings -import com.wire.android.navigation.annotation.app.WireHomeDestination +import android.widget.Toast import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -26,9 +26,11 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.generated.app.destinations.SetLockCodeScreenDestination import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.appLogger @@ -36,15 +38,16 @@ import com.wire.android.model.Clickable import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.NavigationCommand +import com.wire.android.navigation.annotation.app.WireHomeDestination import com.wire.android.navigation.handleNavigation import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.ramcosta.composedestinations.generated.app.destinations.SetLockCodeScreenDestination import com.wire.android.ui.home.HomeStateHolder import com.wire.android.ui.theme.WireTheme import com.wire.android.util.debug.LocalFeatureVisibilityFlags -import com.wire.android.util.ui.sectionWithElements +import com.wire.android.util.logging.LogShareLauncher import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.sectionWithElements @WireHomeDestination @Composable @@ -65,15 +68,39 @@ fun SettingsScreen( } val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val shareLogsFailureMessage = context.getString(R.string.label_share_logs_failed) + val logShareLauncher = remember(context, coroutineScope, shareLogsFailureMessage) { + LogShareLauncher( + context = context, + coroutineScope = coroutineScope, + onFailure = { + Toast.makeText(context, shareLogsFailureMessage, Toast.LENGTH_SHORT).show() + } + ) + } SettingsScreenContent( lazyListState = homeStateHolder.lazyListStateFor(HomeDestination.Settings), settingsState = viewModel.state, - onItemClicked = remember { + onItemClicked = remember(context, homeStateHolder.navigator, logShareLauncher, viewModel) { { - it.direction.handleNavigation( - context = context, - handleOtherDirection = { homeStateHolder.navigator.navigate(NavigationCommand(it)) } - ) + item -> + when (item) { + is SettingsItem.DirectionItem -> item.direction.handleNavigation( + context = context, + handleOtherDirection = { direction -> + homeStateHolder.navigator.navigate(NavigationCommand(direction)) + } + ) + + is SettingsItem.ActionItem -> when (item) { + SettingsItem.ReportBug -> logShareLauncher.shareBugReport { + viewModel.flushLogs().await() + } + } + + is SettingsItem.SwitchItem -> Unit + } } }, onAppLockSwitchChanged = onAppLockSwitchClicked @@ -84,12 +111,11 @@ fun SettingsScreen( @Composable fun SettingsScreenContent( settingsState: SettingsState, - onItemClicked: (SettingsItem.DirectionItem) -> Unit, + onItemClicked: (SettingsItem) -> Unit, modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState(), onAppLockSwitchChanged: (Boolean) -> Unit ) { - val context = LocalContext.current val featureVisibilityFlags = LocalFeatureVisibilityFlags.current with(featureVisibilityFlags) { @@ -175,7 +201,7 @@ private fun LazyListScope.sectionWithElements( header: UIText, items: List, trailingText: ((SettingsItem) -> String?)? = null, - onItemClicked: (SettingsItem.DirectionItem) -> Unit + onItemClicked: (SettingsItem) -> Unit ) { sectionWithElements( header = header, @@ -184,17 +210,20 @@ private fun LazyListScope.sectionWithElements( SettingsItem( text = settingsItem.title.asString(), switchState = (settingsItem as? SettingsItem.SwitchItem)?.switchState ?: SwitchState.None, - onRowPressed = remember { - Clickable(enabled = settingsItem is SettingsItem.DirectionItem) { - (settingsItem as? SettingsItem.DirectionItem)?.let(onItemClicked) + onRowPressed = remember(settingsItem) { + Clickable(enabled = settingsItem.isClickable) { + onItemClicked(settingsItem) } }, - trailingIcon = if (settingsItem is SettingsItem.DirectionItem) R.drawable.ic_arrow_right else null, + trailingIcon = if (settingsItem.isClickable) R.drawable.ic_arrow_right else null, trailingText = trailingText?.invoke(settingsItem), ) } } +private val SettingsItem.isClickable: Boolean + get() = this is SettingsItem.DirectionItem || this is SettingsItem.ActionItem + @PreviewMultipleThemes @Composable fun PreviewSettingsScreen() = WireTheme { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModel.kt index 81375aad0ad..710ce9a7b28 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModel.kt @@ -25,9 +25,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.datastore.GlobalDataStore import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.logging.LogFileWriter import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppLockEditableUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn @@ -41,6 +44,7 @@ class SettingsViewModel @Inject constructor( private val observeIsAppLockEditable: ObserveIsAppLockEditableUseCase, private val getSelf: ObserveSelfUserUseCase, private val dispatchers: DispatcherProvider, + private val logFileWriter: LogFileWriter, ) : ViewModel() { var state by mutableStateOf(SettingsState()) private set @@ -69,6 +73,10 @@ class SettingsViewModel @Inject constructor( } } + fun flushLogs(): Deferred = viewModelScope.async { + logFileWriter.forceFlush() + } + private suspend fun fetchSelfUser() { viewModelScope.launch { val self = diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogSharing.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogSharing.kt new file mode 100644 index 00000000000..eb76446b5a9 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogSharing.kt @@ -0,0 +1,190 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util.logging + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import com.wire.android.R +import com.wire.android.appLogger +import com.wire.android.util.EmailComposer +import com.wire.android.util.getDeviceIdString +import com.wire.android.util.getGitBuildId +import com.wire.android.util.getProviderAuthority +import com.wire.android.util.sha256 +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +private const val LOGS_ARCHIVE_MIME_TYPE = "application/zip" +private const val LOGS_ARCHIVE_CACHE_DIRECTORY = "shared-logs" +private const val LOGS_ARCHIVE_FILE_PREFIX = "wire-logs-" +private const val LOGS_ARCHIVE_FILE_EXTENSION = ".zip" +private const val LOGS_ARCHIVE_RANDOM_SUFFIX_LENGTH = 8 +private const val LOGS_ARCHIVE_RETENTION_MILLIS = 24L * 60L * 60L * 1000L + +class LogShareLauncher( + private val context: Context, + private val coroutineScope: CoroutineScope, + private val archiveCreator: CompressedLogsArchiveCreator = CompressedLogsArchiveCreator(context.cacheDir), + private val onFailure: (Throwable) -> Unit = {} +) { + fun shareLogs( + logsDirectory: File, + flushLogs: suspend () -> Unit = {} + ) { + share( + logsDirectory = logsDirectory, + flushLogs = flushLogs, + intent = { archive -> context.logsSharingIntent(archive) } + ) + } + + fun shareBugReport( + flushLogs: suspend () -> Unit = {} + ) { + share( + logsDirectory = LogFileWriter.logsDirectory(context), + flushLogs = flushLogs, + intent = { archive -> context.bugReportLogsSharingIntent(archive) } + ) + } + + private fun share( + logsDirectory: File, + flushLogs: suspend () -> Unit, + intent: (File) -> Intent + ) { + coroutineScope.launch { + runCatching { + flushLogs() + val archive = archiveCreator.create(logsDirectory) + context.startActivity(intent(archive)) + }.onFailure { error -> + appLogger.e("Failed to prepare logs for sharing", error) + onFailure(error) + } + } + } +} + +class CompressedLogsArchiveCreator( + private val cacheDirectory: File, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val currentTimeMillis: () -> Long = System::currentTimeMillis +) { + suspend fun create(logsDirectory: File): File = withContext(dispatcher) { + val outputDirectory = File(cacheDirectory, LOGS_ARCHIVE_CACHE_DIRECTORY) + val outputFile = File(outputDirectory, compressedLogsArchiveFileName()) + deleteStaleCompressedLogsArchives( + directory = outputDirectory, + keepFile = outputFile, + currentTimeMillis = currentTimeMillis() + ) + createCompressedLogsArchive(logsDirectory, outputFile) + } +} + +fun Context.logsSharingIntent(archiveFile: File): Intent { + val archiveUri = FileProvider.getUriForFile(this, getProviderAuthority(), archiveFile) + return Intent(Intent.ACTION_SEND).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + type = LOGS_ARCHIVE_MIME_TYPE + putExtra(Intent.EXTRA_STREAM, archiveUri) + clipData = ClipData.newUri(contentResolver, archiveFile.name, archiveUri) + } +} + +fun Context.bugReportLogsSharingIntent(archiveFile: File): Intent { + val intent = logsSharingIntent(archiveFile).apply { + putExtra(Intent.EXTRA_EMAIL, arrayOf(getString(R.string.send_bug_report_email))) + putExtra(Intent.EXTRA_SUBJECT, getString(R.string.send_bug_report_subject)) + putExtra( + Intent.EXTRA_TEXT, + EmailComposer.reportBugEmailTemplate( + getDeviceIdString()?.sha256(), + getGitBuildId() + ) + ) + selector = Intent(Intent.ACTION_SENDTO).setData(Uri.parse("mailto:")) + } + return Intent.createChooser(intent, getString(R.string.send_feedback_choose_email)) +} + +internal fun deleteStaleCompressedLogsArchives( + directory: File?, + keepFile: File, + currentTimeMillis: Long = System.currentTimeMillis(), + retentionMillis: Long = LOGS_ARCHIVE_RETENTION_MILLIS +) { + directory?.listFiles() + ?.filter { file -> + file.isFile && + file != keepFile && + file.name.startsWith(LOGS_ARCHIVE_FILE_PREFIX) && + file.name.endsWith(LOGS_ARCHIVE_FILE_EXTENSION) && + file.lastModified() < currentTimeMillis - retentionMillis + } + ?.forEach(File::delete) +} + +fun createCompressedLogsArchive(logsDirectory: File, outputFile: File): File { + outputFile.parentFile?.mkdirs() + if (outputFile.exists()) { + outputFile.delete() + } + ZipOutputStream(BufferedOutputStream(outputFile.outputStream())).use { zipStream -> + logsDirectory.listFiles() + ?.filter { it.isFile && !it.name.endsWith(".tmp") } + ?.sortedBy(File::getName) + ?.forEach { file -> + zipStream.putNextEntry(ZipEntry(file.name)) + BufferedInputStream(file.inputStream()).use { input -> + input.copyTo(zipStream) + } + zipStream.closeEntry() + } + } + return outputFile +} + +fun compressedLogsArchiveFileName( + instant: Instant = Clock.System.now(), + timeZone: TimeZone = TimeZone.currentSystemDefault(), + randomSuffix: String = UUID.randomUUID().toString().take(LOGS_ARCHIVE_RANDOM_SUFFIX_LENGTH) +): String { + val dateTime = instant.toLocalDateTime(timeZone) + return "$LOGS_ARCHIVE_FILE_PREFIX${dateTime.year}-${dateTime.monthNumber.padded()}-${dateTime.dayOfMonth.padded()}" + + "_${dateTime.hour.padded()}-${dateTime.minute.padded()}-${dateTime.second.padded()}-$randomSuffix.zip" +} + +private fun Int.padded(): String = toString().padStart(2, '0') diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 39493419d3e..9f0885b6c5e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1395,6 +1395,7 @@ In group conversations, the group admin can overwrite this setting. Text copied to clipboard Logs Share Logs + Could not prepare logs for sharing Delete All Logs Restart slow sync Restart diff --git a/app/src/test/kotlin/com/wire/android/util/logging/LogSharingTest.kt b/app/src/test/kotlin/com/wire/android/util/logging/LogSharingTest.kt new file mode 100644 index 00000000000..27b992170ce --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/logging/LogSharingTest.kt @@ -0,0 +1,126 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util.logging + +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.zip.ZipFile + +class LogSharingTest { + + @get:Rule + val tempDir: TemporaryFolder = TemporaryFolder() + + @Test + fun `given log files when creating compressed logs archive then all logs are written into one zip`() { + val logsDirectory = tempDir.newFolder("logs") + File(logsDirectory, "wire_logs.txt").writeText("active logs") + File(logsDirectory, "wire_logs.1.gz").writeText("rotated logs") + File(logsDirectory, "wire_logs.2.gz.tmp").writeText("in-progress rotation") + File(logsDirectory, "nested").mkdir() + val archive = File(tempDir.root, "wire_logs.zip") + + val result = createCompressedLogsArchive(logsDirectory, archive) + + assertEquals(archive, result) + assertTrue(archive.exists()) + ZipFile(archive).use { zipFile -> + val entryNames = zipFile.entries().asSequence().map { it.name }.toList() + assertEquals(listOf("wire_logs.1.gz", "wire_logs.txt"), entryNames) + assertEquals("active logs", zipFile.readEntryText("wire_logs.txt")) + assertEquals("rotated logs", zipFile.readEntryText("wire_logs.1.gz")) + } + } + + @Test + fun `given existing compressed logs archive when creating new archive then stale content is removed`() { + val logsDirectory = tempDir.newFolder("logs") + File(logsDirectory, "wire_logs.txt").writeText("fresh logs") + val archive = File(tempDir.root, "wire_logs.zip").apply { + writeText("stale archive") + } + + createCompressedLogsArchive(logsDirectory, archive) + + ZipFile(archive).use { zipFile -> + val entryNames = zipFile.entries().asSequence().map { it.name }.toList() + assertEquals(listOf("wire_logs.txt"), entryNames) + assertEquals("fresh logs", zipFile.readEntryText("wire_logs.txt")) + } + } + + @Test + fun `given expired compressed logs archives when deleting stale archives then recent archives are kept`() { + val cacheDirectory = tempDir.newFolder("cache") + val archiveToKeep = File(cacheDirectory, "wire-logs-2026-05-22_12-34-56-current.zip").apply { + writeText("current") + } + val expiredArchive = File(cacheDirectory, "wire-logs-2026-05-21_12-34-55-stale.zip").apply { + writeText("expired") + setLastModified(EXPIRED_ARCHIVE_LAST_MODIFIED) + } + val recentArchive = File(cacheDirectory, "wire-logs-2026-05-22_12-34-55-recent.zip").apply { + writeText("recent") + setLastModified(RECENT_ARCHIVE_LAST_MODIFIED) + } + val unrelatedZip = File(cacheDirectory, "backup.zip").apply { + writeText("unrelated") + } + + deleteStaleCompressedLogsArchives( + directory = cacheDirectory, + keepFile = archiveToKeep, + currentTimeMillis = CURRENT_TIME_MILLIS, + retentionMillis = ARCHIVE_RETENTION_MILLIS + ) + + assertTrue(archiveToKeep.exists()) + assertTrue(recentArchive.exists()) + assertTrue(unrelatedZip.exists()) + assertTrue(!expiredArchive.exists()) + } + + @Test + fun `given instant when creating compressed logs archive file name then it includes local date time and random suffix`() { + val instant = Instant.parse("2026-05-22T12:34:56Z") + + val result = compressedLogsArchiveFileName( + instant = instant, + timeZone = TimeZone.UTC, + randomSuffix = "abc123ef" + ) + + assertEquals("wire-logs-2026-05-22_12-34-56-abc123ef.zip", result) + } + + private fun ZipFile.readEntryText(name: String): String = + getInputStream(getEntry(name)).bufferedReader().use { it.readText() } + + private companion object { + const val CURRENT_TIME_MILLIS = 1_000L + const val ARCHIVE_RETENTION_MILLIS = 100L + const val EXPIRED_ARCHIVE_LAST_MODIFIED = CURRENT_TIME_MILLIS - ARCHIVE_RETENTION_MILLIS - 1L + const val RECENT_ARCHIVE_LAST_MODIFIED = CURRENT_TIME_MILLIS - ARCHIVE_RETENTION_MILLIS + 1L + } +} diff --git a/kalium b/kalium index d8ec16d3d0d..76c86313219 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit d8ec16d3d0d4f5cb6a4a6bd0fb196be286c309df +Subproject commit 76c863132199c7a6c0e3da06b29f8c77676d76fd