From c686a8f6c04b64d90352e540d281dc3b474f641f Mon Sep 17 00:00:00 2001 From: Artem Bazhanov Date: Thu, 19 Feb 2026 23:51:57 +0300 Subject: [PATCH 1/2] Use ACTION_CREATE_DOCUMENT directly for Android save --- .../presentation/FakeLogViewerComponent.kt | 24 ++++++- .../logging/feature/table/util/LaunchUtils.kt | 67 +++++++++++++++++-- .../presentation/DefaultLogViewerComponent.kt | 16 ++++- .../table/presentation/LogViewerComponent.kt | 5 +- .../table/presentation/LogViewerContent.kt | 63 +++++++++++++---- .../logging/feature/table/util/LaunchUtils.kt | 10 ++- .../presentation/FakeLogViewerComponent.kt | 24 ++++++- .../feature/table/util/LaunchUtils.ios.kt | 51 +++++++++++++- .../table/presentation/LogViewerIosUiTest.kt | 5 +- .../logging/feature/table/util/LaunchUtils.kt | 35 +++++++--- .../table/presentation/LogViewerUiTest.kt | 26 +++++-- .../logging/feature/table/util/LaunchUtils.kt | 13 +++- .../table/presentation/LogViewerWasmUiTest.kt | 8 +-- 13 files changed, 297 insertions(+), 50 deletions(-) diff --git a/module/logging/logging/src/androidInstrumentedTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt b/module/logging/logging/src/androidInstrumentedTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt index 04f29ef1..5f36fa68 100644 --- a/module/logging/logging/src/androidInstrumentedTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt +++ b/module/logging/logging/src/androidInstrumentedTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt @@ -25,7 +25,13 @@ internal class FakeLogViewerComponent( ) override val model: Value get() = _model - var shareInvoked: Boolean = false + var copyInvoked: Boolean = false + private set + var saveInvoked: Boolean = false + private set + var shareAsTextInvoked: Boolean = false + private set + var shareAsFileInvoked: Boolean = false private set override fun onBackPressed() = Unit @@ -63,8 +69,20 @@ internal class FakeLogViewerComponent( recalc() } - override fun onShareClick(context: PlatformContext) { - shareInvoked = true + override fun onCopyClick(context: PlatformContext) { + copyInvoked = true + } + + override fun onSaveToFileClick(context: PlatformContext) { + saveInvoked = true + } + + override fun onShareAsTextClick(context: PlatformContext) { + shareAsTextInvoked = true + } + + override fun onShareAsFileClick(context: PlatformContext) { + shareAsFileInvoked = true } override fun onLabelClick(label: String) { diff --git a/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index 5a26dd51..bc21b330 100644 --- a/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -1,6 +1,12 @@ package ru.bartwell.kick.module.logging.feature.table.util +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.FileProvider import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.core.data.get @@ -10,15 +16,52 @@ import java.io.File @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal actual object LaunchUtils { - internal actual fun shareLogs(context: PlatformContext, logs: List) { + internal actual fun canCopyLogs(): Boolean = true + internal actual fun canSaveLogsToFile(): Boolean = true + internal actual fun canShareLogsAsText(): Boolean = true + internal actual fun canShareLogsAsFile(): Boolean = true + + internal actual fun copyLogs(context: PlatformContext, logs: List) { val androidContext = context.get() - val fileName = "android.log" - val file = File(androidContext.filesDir, fileName) - file.bufferedWriter().use { writer -> - logs.forEach { item -> - writer.appendLine(item.toLogString()) + val text = logs.joinToString(separator = "\n") { it.toLogString() } + val manager = androidContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(ClipData.newPlainText("logs", text)) + } + + internal actual fun saveLogsToFile(context: PlatformContext, logs: List) { + val activity = context.get() as? ComponentActivity ?: return + val text = logs.joinToString(separator = "\n") { it.toLogString() } + val key = "save_logs_${System.nanoTime()}" + + lateinit var launcher: ActivityResultLauncher + launcher = activity.activityResultRegistry.register( + key, + ActivityResultContracts.CreateDocument("text/plain") + ) { uri -> + uri?.let { + activity.contentResolver.openOutputStream(it)?.bufferedWriter()?.use { writer -> + writer.write(text) + } } + launcher.unregister() + } + launcher.launch("logs.txt") + } + + internal actual fun shareLogsAsText(context: PlatformContext, logs: List) { + val androidContext = context.get() + val text = logs.joinToString(separator = "\n") { it.toLogString() } + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + }.also { intent -> + androidContext.startActivity(Intent.createChooser(intent, "Share logs as text")) } + } + + internal actual fun shareLogsAsFile(context: PlatformContext, logs: List) { + val androidContext = context.get() + val file = writeLogsToFile(androidContext.filesDir, "android.log", logs) val uri = FileProvider.getUriForFile( androidContext, "${androidContext.packageName}.kickfileprovider", @@ -29,7 +72,17 @@ internal actual object LaunchUtils { putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }.also { intent -> - androidContext.startActivity(Intent.createChooser(intent, "Share logs")) + androidContext.startActivity(Intent.createChooser(intent, "Share logs as file")) + } + } + + private fun writeLogsToFile(directory: File, fileName: String, logs: List): File { + val file = File(directory, fileName) + file.bufferedWriter().use { writer -> + logs.forEach { item -> + writer.appendLine(item.toLogString()) + } } + return file } } diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/DefaultLogViewerComponent.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/DefaultLogViewerComponent.kt index b3eff6d0..5d6fa0f5 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/DefaultLogViewerComponent.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/DefaultLogViewerComponent.kt @@ -67,8 +67,20 @@ internal class DefaultLogViewerComponent( filterAndUpdateLog() } - override fun onShareClick(context: PlatformContext) { - LaunchUtils.shareLogs(context = context, logs = model.value.log) + override fun onCopyClick(context: PlatformContext) { + LaunchUtils.copyLogs(context = context, logs = model.value.log) + } + + override fun onSaveToFileClick(context: PlatformContext) { + LaunchUtils.saveLogsToFile(context = context, logs = model.value.log) + } + + override fun onShareAsTextClick(context: PlatformContext) { + LaunchUtils.shareLogsAsText(context = context, logs = model.value.log) + } + + override fun onShareAsFileClick(context: PlatformContext) { + LaunchUtils.shareLogsAsFile(context = context, logs = model.value.log) } override fun onLabelClick(label: String) { diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt index 42f5ea01..0ddf3d67 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt @@ -14,6 +14,9 @@ public interface LogViewerComponent : Component { public fun onFilterDialogDismiss() public fun onFilterApply() public fun onFilterTextChange(text: String) - public fun onShareClick(context: PlatformContext) + public fun onCopyClick(context: PlatformContext) + public fun onSaveToFileClick(context: PlatformContext) + public fun onShareAsTextClick(context: PlatformContext) + public fun onShareAsFileClick(context: PlatformContext) public fun onLabelClick(label: String) } diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt index 1cda333d..347cb0f7 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt @@ -9,13 +9,14 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ClearAll -import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileDownloadOff import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterListOff -import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon @@ -28,19 +29,21 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState -import ru.bartwell.kick.core.data.Platform import ru.bartwell.kick.core.data.platformContext import ru.bartwell.kick.core.presentation.BackOrCloseButton import ru.bartwell.kick.core.presentation.ErrorBox -import ru.bartwell.kick.core.util.PlatformUtils import ru.bartwell.kick.module.logging.core.data.LogLevel import ru.bartwell.kick.module.logging.core.persist.LogEntity import ru.bartwell.kick.module.logging.feature.table.extension.toLogString +import ru.bartwell.kick.module.logging.feature.table.util.LaunchUtils @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -51,6 +54,7 @@ internal fun LogViewerContent( val state by component.model.subscribeAsState() val context = platformContext() val listState = rememberLazyListState() + var isMenuExpanded by remember { mutableStateOf(false) } LaunchedEffect(state.isAutoScrollEnabled, state.log.lastOrNull()?.id) { if (state.isAutoScrollEnabled && state.log.isNotEmpty()) { @@ -87,15 +91,50 @@ internal fun LogViewerContent( IconButton(onClick = component::onClearAllClick, modifier = Modifier.testTag("clear_all")) { Icon(imageVector = Icons.Default.ClearAll, contentDescription = "Clear all") } - IconButton(onClick = { component.onShareClick(context) }, modifier = Modifier.testTag("share_copy")) { - val shouldCopy = PlatformUtils.getPlatform() == Platform.IOS || - PlatformUtils.getPlatform() == Platform.WEB - val (icon, contentDescription) = if (shouldCopy) { - Icons.Default.ContentCopy to "Copy logs" - } else { - Icons.Default.Share to "Share logs" + IconButton(onClick = { isMenuExpanded = true }, modifier = Modifier.testTag("overflow_menu")) { + Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Menu") + } + DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) { + if (LaunchUtils.canCopyLogs()) { + DropdownMenuItem( + text = { Text("Copy") }, + onClick = { + isMenuExpanded = false + component.onCopyClick(context) + }, + modifier = Modifier.testTag("copy_logs") + ) + } + if (LaunchUtils.canSaveLogsToFile()) { + DropdownMenuItem( + text = { Text("Save to file") }, + onClick = { + isMenuExpanded = false + component.onSaveToFileClick(context) + }, + modifier = Modifier.testTag("save_to_file") + ) + } + if (LaunchUtils.canShareLogsAsText()) { + DropdownMenuItem( + text = { Text("Share as text") }, + onClick = { + isMenuExpanded = false + component.onShareAsTextClick(context) + }, + modifier = Modifier.testTag("share_as_text") + ) + } + if (LaunchUtils.canShareLogsAsFile()) { + DropdownMenuItem( + text = { Text("Share as file") }, + onClick = { + isMenuExpanded = false + component.onShareAsFileClick(context) + }, + modifier = Modifier.testTag("share_as_file") + ) } - Icon(imageVector = icon, contentDescription = contentDescription) } } ) diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index 666ec3c7..8116bffa 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -5,5 +5,13 @@ import ru.bartwell.kick.module.logging.core.persist.LogEntity @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal expect object LaunchUtils { - internal fun shareLogs(context: PlatformContext, logs: List) + internal fun canCopyLogs(): Boolean + internal fun canSaveLogsToFile(): Boolean + internal fun canShareLogsAsText(): Boolean + internal fun canShareLogsAsFile(): Boolean + + internal fun copyLogs(context: PlatformContext, logs: List) + internal fun saveLogsToFile(context: PlatformContext, logs: List) + internal fun shareLogsAsText(context: PlatformContext, logs: List) + internal fun shareLogsAsFile(context: PlatformContext, logs: List) } diff --git a/module/logging/logging/src/commonTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt b/module/logging/logging/src/commonTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt index 04f29ef1..5f36fa68 100644 --- a/module/logging/logging/src/commonTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt +++ b/module/logging/logging/src/commonTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt @@ -25,7 +25,13 @@ internal class FakeLogViewerComponent( ) override val model: Value get() = _model - var shareInvoked: Boolean = false + var copyInvoked: Boolean = false + private set + var saveInvoked: Boolean = false + private set + var shareAsTextInvoked: Boolean = false + private set + var shareAsFileInvoked: Boolean = false private set override fun onBackPressed() = Unit @@ -63,8 +69,20 @@ internal class FakeLogViewerComponent( recalc() } - override fun onShareClick(context: PlatformContext) { - shareInvoked = true + override fun onCopyClick(context: PlatformContext) { + copyInvoked = true + } + + override fun onSaveToFileClick(context: PlatformContext) { + saveInvoked = true + } + + override fun onShareAsTextClick(context: PlatformContext) { + shareAsTextInvoked = true + } + + override fun onShareAsFileClick(context: PlatformContext) { + shareAsFileInvoked = true } override fun onLabelClick(label: String) { diff --git a/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt b/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt index 6ebd59b5..5f2555fd 100644 --- a/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt +++ b/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt @@ -1,15 +1,62 @@ package ru.bartwell.kick.module.logging.feature.table.util import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIApplication import platform.UIKit.UIPasteboard +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.module.logging.core.persist.LogEntity import ru.bartwell.kick.module.logging.feature.table.extension.toLogString @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal actual object LaunchUtils { + internal actual fun canCopyLogs(): Boolean = true + internal actual fun canSaveLogsToFile(): Boolean = false + internal actual fun canShareLogsAsText(): Boolean = true + internal actual fun canShareLogsAsFile(): Boolean = false + + internal actual fun copyLogs(context: PlatformContext, logs: List) { + UIPasteboard.generalPasteboard.string = logs.joinToString(separator = "\n") { it.toLogString() } + } + + internal actual fun saveLogsToFile(context: PlatformContext, logs: List) = Unit + + internal actual fun shareLogsAsText(context: PlatformContext, logs: List) { + val text = logs.joinToString(separator = "\n") { it.toLogString() } + presentShareSheet(items = listOf(text)) + } + + internal actual fun shareLogsAsFile(context: PlatformContext, logs: List) = Unit + + private fun presentShareSheet(items: List) { + dispatch_async(dispatch_get_main_queue()) { + val presenter = topViewController() ?: return@dispatch_async + val controller = UIActivityViewController( + activityItems = items, + applicationActivities = null, + ) + presenter.presentViewController(controller, animated = true, completion = null) + } + } + @OptIn(ExperimentalForeignApi::class) - internal actual fun shareLogs(context: PlatformContext, logs: List) { - UIPasteboard.generalPasteboard.string = logs.joinToString { logEntity -> logEntity.toLogString() } + private fun topViewController(): UIViewController? { + val window = topWindow() ?: return null + var current = window.rootViewController ?: return null + while (current.presentedViewController != null) { + current = current.presentedViewController!! + } + return current + } + + private fun topWindow(): UIWindow? { + val app = UIApplication.sharedApplication + app.keyWindow?.let { return it } + val windows = app.windows as? List<*> + return windows?.filterIsInstance()?.firstOrNull() } } diff --git a/module/logging/logging/src/iosTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerIosUiTest.kt b/module/logging/logging/src/iosTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerIosUiTest.kt index a0565546..2ad62f26 100644 --- a/module/logging/logging/src/iosTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerIosUiTest.kt +++ b/module/logging/logging/src/iosTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerIosUiTest.kt @@ -48,8 +48,9 @@ class LogViewerIosUiTest { onAllNodesWithTag("log_item").assertCountEquals(0) // Copy on iOS - onNodeWithContentDescription("Copy logs").performClick() - assertTrue(fake.shareInvoked) + onNodeWithContentDescription("Menu").performClick() + onNodeWithText("Copy").performClick() + assertTrue(fake.copyInvoked) } @Test diff --git a/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index 9be5701a..e315477c 100644 --- a/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -3,26 +3,45 @@ package ru.bartwell.kick.module.logging.feature.table.util import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.module.logging.core.persist.LogEntity import ru.bartwell.kick.module.logging.feature.table.extension.toLogString +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection import java.io.File import javax.swing.JFileChooser import javax.swing.SwingUtilities @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal actual object LaunchUtils { - internal actual fun shareLogs(context: PlatformContext, logs: List) { + internal actual fun canCopyLogs(): Boolean = true + internal actual fun canSaveLogsToFile(): Boolean = true + internal actual fun canShareLogsAsText(): Boolean = false + internal actual fun canShareLogsAsFile(): Boolean = false + + internal actual fun copyLogs(context: PlatformContext, logs: List) { + val text = logs.joinToString(separator = "\n") { it.toLogString() } + Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(text), null) + } + + internal actual fun saveLogsToFile(context: PlatformContext, logs: List) { SwingUtilities.invokeLater { val chooser = JFileChooser().apply { dialogTitle = "Save logs" selectedFile = File("desktop.log") } if (chooser.showSaveDialog(null) == JFileChooser.APPROVE_OPTION) { - val file = chooser.selectedFile - file.bufferedWriter().use { writer -> - logs.forEach { item -> - writer.write(item.toLogString()) - writer.newLine() - } - } + writeLogsToFile(chooser.selectedFile, logs) + } + } + } + + internal actual fun shareLogsAsText(context: PlatformContext, logs: List) = Unit + + internal actual fun shareLogsAsFile(context: PlatformContext, logs: List) = Unit + + private fun writeLogsToFile(file: File, logs: List) { + file.bufferedWriter().use { writer -> + logs.forEach { item -> + writer.write(item.toLogString()) + writer.newLine() } } } diff --git a/module/logging/logging/src/jvmTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerUiTest.kt b/module/logging/logging/src/jvmTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerUiTest.kt index a9f966b6..f8927c48 100644 --- a/module/logging/logging/src/jvmTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerUiTest.kt +++ b/module/logging/logging/src/jvmTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerUiTest.kt @@ -109,7 +109,7 @@ class LogViewerUiTest { } @Test - fun share_or_copy_action_invoked() { + fun copy_action_invoked() { val logs = listOf( LogEntity(id = 1, time = 1_000L, level = LogLevel.INFO, message = "m1"), ) @@ -117,9 +117,27 @@ class LogViewerUiTest { composeTestRule.setContent { LogViewerContent(component = fake) } - // On JVM platform the description is "Share logs" - composeTestRule.onNodeWithContentDescription("Share logs").performClick() - assertTrue(fake.shareInvoked) + composeTestRule.onNodeWithContentDescription("Menu").performClick() + composeTestRule.onNodeWithText("Copy").performClick() + assertTrue(fake.copyInvoked) + } + + @Test + fun save_to_file_action_invoked() { + val logs = listOf( + LogEntity(id = 1, time = 1_000L, level = LogLevel.INFO, message = "m1"), + ) + val fake = FakeLogViewerComponent(logs) + + composeTestRule.setContent { LogViewerContent(component = fake) } + + composeTestRule.onNodeWithContentDescription("Menu").performClick() + composeTestRule.onNodeWithText("Save to file").performClick() + assertTrue(fake.saveInvoked) + + composeTestRule.onNodeWithContentDescription("Menu").performClick() + composeTestRule.onNodeWithText("Share as text").assertDoesNotExist() + composeTestRule.onNodeWithText("Share as file").assertDoesNotExist() } @Test diff --git a/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index 49dfa806..fda0adf6 100644 --- a/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -7,7 +7,12 @@ import ru.bartwell.kick.module.logging.feature.table.extension.toLogString @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal actual object LaunchUtils { - internal actual fun shareLogs(context: PlatformContext, logs: List) { + internal actual fun canCopyLogs(): Boolean = true + internal actual fun canSaveLogsToFile(): Boolean = false + internal actual fun canShareLogsAsText(): Boolean = false + internal actual fun canShareLogsAsFile(): Boolean = false + + internal actual fun copyLogs(context: PlatformContext, logs: List) { val text = logs.joinToString(separator = "\n") { it.toLogString() } try { val clipboard = window.navigator.clipboard @@ -16,4 +21,10 @@ internal actual object LaunchUtils { kotlin.io.println(text) } } + + internal actual fun saveLogsToFile(context: PlatformContext, logs: List) = Unit + + internal actual fun shareLogsAsText(context: PlatformContext, logs: List) = Unit + + internal actual fun shareLogsAsFile(context: PlatformContext, logs: List) = Unit } diff --git a/module/logging/logging/src/wasmJsTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerWasmUiTest.kt b/module/logging/logging/src/wasmJsTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerWasmUiTest.kt index f018cc3f..aae6a744 100644 --- a/module/logging/logging/src/wasmJsTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerWasmUiTest.kt +++ b/module/logging/logging/src/wasmJsTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerWasmUiTest.kt @@ -25,7 +25,7 @@ private const val LABEL_B = "B" private const val CD_FILTER = "Filter logs" private const val CD_DISABLE_FILTER = "Disable filter" private const val CD_CLEAR_ALL = "Clear all" -private const val CD_COPY_LOGS = "Copy logs" +private const val CD_COPY_LOGS = "Copy" private const val TEXT_FILTER = "Filter" private const val COUNT_ZERO = 0 private const val COUNT_TWO = 2 @@ -59,9 +59,9 @@ class LogViewerWasmUiTest { onNodeWithContentDescription(CD_CLEAR_ALL).performClick() onAllNodesWithTag(TAG_LOG_ITEM).assertCountEquals(COUNT_ZERO) - // On Web platform compose.uiTest uses same contentDescription - onNodeWithContentDescription(CD_COPY_LOGS).performClick() - assertTrue(fake.shareInvoked) + onNodeWithContentDescription("Menu").performClick() + onNodeWithText(CD_COPY_LOGS).performClick() + assertTrue(fake.copyInvoked) } @Test From d9ca4c7c0ab2ef1ba114afb165590f9d5f498469 Mon Sep 17 00:00:00 2001 From: Artem Bazhanov Date: Fri, 20 Feb 2026 01:35:18 +0300 Subject: [PATCH 2/2] Fix detekt violations in logging menu utilities --- .../logging/feature/table/util/LaunchUtils.kt | 20 +- .../table/presentation/LogViewerComponent.kt | 1 + .../table/presentation/LogViewerContent.kt | 189 +++++++++++------- .../feature/table/util/LaunchUtils.ios.kt | 2 +- .../logging/feature/table/util/LaunchUtils.kt | 2 +- .../logging/feature/table/util/LaunchUtils.kt | 2 +- 6 files changed, 139 insertions(+), 77 deletions(-) diff --git a/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index bc21b330..786da573 100644 --- a/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -16,6 +16,12 @@ import java.io.File @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal actual object LaunchUtils { + private const val LOG_MIME_TYPE = "text/plain" + private const val LOGS_FILE_NAME = "logs.txt" + private const val SHARE_FILE_NAME = "android.log" + private const val SHARE_TEXT_TITLE = "Share logs as text" + private const val SHARE_FILE_TITLE = "Share logs as file" + internal actual fun canCopyLogs(): Boolean = true internal actual fun canSaveLogsToFile(): Boolean = true internal actual fun canShareLogsAsText(): Boolean = true @@ -36,7 +42,7 @@ internal actual object LaunchUtils { lateinit var launcher: ActivityResultLauncher launcher = activity.activityResultRegistry.register( key, - ActivityResultContracts.CreateDocument("text/plain") + ActivityResultContracts.CreateDocument(LOG_MIME_TYPE) ) { uri -> uri?.let { activity.contentResolver.openOutputStream(it)?.bufferedWriter()?.use { writer -> @@ -45,34 +51,34 @@ internal actual object LaunchUtils { } launcher.unregister() } - launcher.launch("logs.txt") + launcher.launch(LOGS_FILE_NAME) } internal actual fun shareLogsAsText(context: PlatformContext, logs: List) { val androidContext = context.get() val text = logs.joinToString(separator = "\n") { it.toLogString() } Intent(Intent.ACTION_SEND).apply { - type = "text/plain" + type = LOG_MIME_TYPE putExtra(Intent.EXTRA_TEXT, text) }.also { intent -> - androidContext.startActivity(Intent.createChooser(intent, "Share logs as text")) + androidContext.startActivity(Intent.createChooser(intent, SHARE_TEXT_TITLE)) } } internal actual fun shareLogsAsFile(context: PlatformContext, logs: List) { val androidContext = context.get() - val file = writeLogsToFile(androidContext.filesDir, "android.log", logs) + val file = writeLogsToFile(androidContext.filesDir, SHARE_FILE_NAME, logs) val uri = FileProvider.getUriForFile( androidContext, "${androidContext.packageName}.kickfileprovider", file ) Intent(Intent.ACTION_SEND).apply { - type = "text/plain" + type = LOG_MIME_TYPE putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }.also { intent -> - androidContext.startActivity(Intent.createChooser(intent, "Share logs as file")) + androidContext.startActivity(Intent.createChooser(intent, SHARE_FILE_TITLE)) } } diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt index 0ddf3d67..c910b036 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.value.Value import ru.bartwell.kick.core.component.Component import ru.bartwell.kick.core.data.PlatformContext +@Suppress("TooManyFunctions") public interface LogViewerComponent : Component { public val model: Value diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt index 347cb0f7..e7ca3f73 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt @@ -69,73 +69,19 @@ internal fun LogViewerContent( BackOrCloseButton(onBack = component::onBackPressed) }, actions = { - IconButton(onClick = component::onFilterClick, modifier = Modifier.testTag("filter_toggle")) { - val (icon, description) = if (state.isFilterActive) { - Icons.Default.FilterListOff to "Disable filter" - } else { - Icons.Default.FilterList to "Filter logs" - } - Icon(imageVector = icon, contentDescription = description) - } - IconButton( - onClick = component::onAutoScrollToggleClick, - modifier = Modifier.testTag("auto_scroll_toggle") - ) { - val (icon, description) = if (state.isAutoScrollEnabled) { - Icons.Filled.FileDownload to "Disable auto-scroll" - } else { - Icons.Filled.FileDownloadOff to "Enable auto-scroll" - } - Icon(imageVector = icon, contentDescription = description) - } - IconButton(onClick = component::onClearAllClick, modifier = Modifier.testTag("clear_all")) { - Icon(imageVector = Icons.Default.ClearAll, contentDescription = "Clear all") - } - IconButton(onClick = { isMenuExpanded = true }, modifier = Modifier.testTag("overflow_menu")) { - Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Menu") - } - DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) { - if (LaunchUtils.canCopyLogs()) { - DropdownMenuItem( - text = { Text("Copy") }, - onClick = { - isMenuExpanded = false - component.onCopyClick(context) - }, - modifier = Modifier.testTag("copy_logs") - ) - } - if (LaunchUtils.canSaveLogsToFile()) { - DropdownMenuItem( - text = { Text("Save to file") }, - onClick = { - isMenuExpanded = false - component.onSaveToFileClick(context) - }, - modifier = Modifier.testTag("save_to_file") - ) - } - if (LaunchUtils.canShareLogsAsText()) { - DropdownMenuItem( - text = { Text("Share as text") }, - onClick = { - isMenuExpanded = false - component.onShareAsTextClick(context) - }, - modifier = Modifier.testTag("share_as_text") - ) - } - if (LaunchUtils.canShareLogsAsFile()) { - DropdownMenuItem( - text = { Text("Share as file") }, - onClick = { - isMenuExpanded = false - component.onShareAsFileClick(context) - }, - modifier = Modifier.testTag("share_as_file") - ) - } - } + TopBarActions( + state = state, + isMenuExpanded = isMenuExpanded, + onFilterClick = component::onFilterClick, + onAutoScrollToggleClick = component::onAutoScrollToggleClick, + onClearAllClick = component::onClearAllClick, + onMenuOpen = { isMenuExpanded = true }, + onMenuDismiss = { isMenuExpanded = false }, + onCopyClick = { component.onCopyClick(context) }, + onSaveToFileClick = { component.onSaveToFileClick(context) }, + onShareAsTextClick = { component.onShareAsTextClick(context) }, + onShareAsFileClick = { component.onShareAsFileClick(context) }, + ) } ) if (state.isFilterDialogVisible) { @@ -157,6 +103,115 @@ internal fun LogViewerContent( } } +@Composable +private fun TopBarActions( + state: LogViewerState, + isMenuExpanded: Boolean, + onFilterClick: () -> Unit, + onAutoScrollToggleClick: () -> Unit, + onClearAllClick: () -> Unit, + onMenuOpen: () -> Unit, + onMenuDismiss: () -> Unit, + onCopyClick: () -> Unit, + onSaveToFileClick: () -> Unit, + onShareAsTextClick: () -> Unit, + onShareAsFileClick: () -> Unit, +) { + IconButton(onClick = onFilterClick, modifier = Modifier.testTag("filter_toggle")) { + val (icon, description) = if (state.isFilterActive) { + Icons.Default.FilterListOff to "Disable filter" + } else { + Icons.Default.FilterList to "Filter logs" + } + Icon(imageVector = icon, contentDescription = description) + } + IconButton( + onClick = onAutoScrollToggleClick, + modifier = Modifier.testTag("auto_scroll_toggle") + ) { + val (icon, description) = if (state.isAutoScrollEnabled) { + Icons.Filled.FileDownload to "Disable auto-scroll" + } else { + Icons.Filled.FileDownloadOff to "Enable auto-scroll" + } + Icon(imageVector = icon, contentDescription = description) + } + IconButton(onClick = onClearAllClick, modifier = Modifier.testTag("clear_all")) { + Icon(imageVector = Icons.Default.ClearAll, contentDescription = "Clear all") + } + IconButton(onClick = onMenuOpen, modifier = Modifier.testTag("overflow_menu")) { + Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Menu") + } + OverflowMenu( + isExpanded = isMenuExpanded, + onDismiss = onMenuDismiss, + onCopyClick = onCopyClick, + onSaveToFileClick = onSaveToFileClick, + onShareAsTextClick = onShareAsTextClick, + onShareAsFileClick = onShareAsFileClick, + ) +} + +@Composable +private fun OverflowMenu( + isExpanded: Boolean, + onDismiss: () -> Unit, + onCopyClick: () -> Unit, + onSaveToFileClick: () -> Unit, + onShareAsTextClick: () -> Unit, + onShareAsFileClick: () -> Unit, +) { + DropdownMenu(expanded = isExpanded, onDismissRequest = onDismiss) { + MenuItem( + isVisible = LaunchUtils.canCopyLogs(), + title = "Copy", + tag = "copy_logs", + onDismiss = onDismiss, + onClick = onCopyClick, + ) + MenuItem( + isVisible = LaunchUtils.canSaveLogsToFile(), + title = "Save to file", + tag = "save_to_file", + onDismiss = onDismiss, + onClick = onSaveToFileClick, + ) + MenuItem( + isVisible = LaunchUtils.canShareLogsAsText(), + title = "Share as text", + tag = "share_as_text", + onDismiss = onDismiss, + onClick = onShareAsTextClick, + ) + MenuItem( + isVisible = LaunchUtils.canShareLogsAsFile(), + title = "Share as file", + tag = "share_as_file", + onDismiss = onDismiss, + onClick = onShareAsFileClick, + ) + } +} + +@Composable +private fun MenuItem( + isVisible: Boolean, + title: String, + tag: String, + onDismiss: () -> Unit, + onClick: () -> Unit, +) { + if (!isVisible) return + DropdownMenuItem( + text = { Text(title) }, + onClick = { + onDismiss() + onClick() + }, + modifier = Modifier.testTag(tag), + ) +} + @Composable private fun LabelsBar(component: LogViewerComponent, state: LogViewerState) { LazyRow(modifier = Modifier.testTag("label_chips")) { diff --git a/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt b/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt index 5f2555fd..bd3d6db4 100644 --- a/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt +++ b/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt @@ -12,7 +12,7 @@ import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.module.logging.core.persist.LogEntity import ru.bartwell.kick.module.logging.feature.table.extension.toLogString -@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "OptionalUnit") internal actual object LaunchUtils { internal actual fun canCopyLogs(): Boolean = true internal actual fun canSaveLogsToFile(): Boolean = false diff --git a/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index e315477c..330cc6b7 100644 --- a/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -9,7 +9,7 @@ import java.io.File import javax.swing.JFileChooser import javax.swing.SwingUtilities -@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "OptionalUnit") internal actual object LaunchUtils { internal actual fun canCopyLogs(): Boolean = true internal actual fun canSaveLogsToFile(): Boolean = true diff --git a/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index fda0adf6..29085a72 100644 --- a/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -5,7 +5,7 @@ import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.module.logging.core.persist.LogEntity import ru.bartwell.kick.module.logging.feature.table.extension.toLogString -@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "OptionalUnit") internal actual object LaunchUtils { internal actual fun canCopyLogs(): Boolean = true internal actual fun canSaveLogsToFile(): Boolean = false