Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ internal class FakeLogViewerComponent(
)
override val model: Value<LogViewerState> 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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,26 +16,79 @@ import java.io.File

@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
internal actual object LaunchUtils {
internal actual fun shareLogs(context: PlatformContext, logs: List<LogEntity>) {
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
internal actual fun canShareLogsAsFile(): Boolean = true

internal actual fun copyLogs(context: PlatformContext, logs: List<LogEntity>) {
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<LogEntity>) {
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<String>
launcher = activity.activityResultRegistry.register(
key,
ActivityResultContracts.CreateDocument(LOG_MIME_TYPE)
) { uri ->
uri?.let {
activity.contentResolver.openOutputStream(it)?.bufferedWriter()?.use { writer ->
writer.write(text)
}
}
launcher.unregister()
}
launcher.launch(LOGS_FILE_NAME)
}

internal actual fun shareLogsAsText(context: PlatformContext, logs: List<LogEntity>) {
val androidContext = context.get()
val text = logs.joinToString(separator = "\n") { it.toLogString() }
Intent(Intent.ACTION_SEND).apply {
type = LOG_MIME_TYPE
putExtra(Intent.EXTRA_TEXT, text)
}.also { intent ->
androidContext.startActivity(Intent.createChooser(intent, SHARE_TEXT_TITLE))
}
}

internal actual fun shareLogsAsFile(context: PlatformContext, logs: List<LogEntity>) {
val androidContext = context.get()
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"))
androidContext.startActivity(Intent.createChooser(intent, SHARE_FILE_TITLE))
}
}

private fun writeLogsToFile(directory: File, fileName: String, logs: List<LogEntity>): File {
val file = File(directory, fileName)
file.bufferedWriter().use { writer ->
logs.forEach { item ->
writer.appendLine(item.toLogString())
}
}
return file
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogViewerState>

Expand All @@ -14,6 +15,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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()) {
Expand All @@ -65,38 +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 = { 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"
}
Icon(imageVector = icon, contentDescription = contentDescription)
}
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) {
Expand All @@ -118,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")) {
Expand Down
Loading