diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt index 82c77eeea..d2d846580 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt @@ -1,31 +1,36 @@ package com.geeksville.mesh.database +import com.geeksville.mesh.CoroutineDispatchers import com.geeksville.mesh.Portnums import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.database.dao.MeshLogDao import com.geeksville.mesh.database.entity.MeshLog -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.withContext import javax.inject.Inject -class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.Lazy) { +class MeshLogRepository @Inject constructor( + private val meshLogDaoLazy: dagger.Lazy, + private val dispatchers: CoroutineDispatchers, +) { private val meshLogDao by lazy { meshLogDaoLazy.get() } - suspend fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow> = withContext(Dispatchers.IO) { - meshLogDao.getAllLogs(maxItems) - } + fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow> = meshLogDao.getAllLogs(maxItems) + .flowOn(dispatchers.io) + .conflate() - suspend fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow> = withContext(Dispatchers.IO) { + fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow> = meshLogDao.getAllLogsInReceiveOrder(maxItems) - } + .flowOn(dispatchers.io) + .conflate() private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching { Telemetry.parseFrom(log.fromRadio.packet.decoded.payload) @@ -37,7 +42,7 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L meshLogDao.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS) .distinctUntilChanged() .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } - .flowOn(Dispatchers.IO) + .flowOn(dispatchers.io) fun getLogsFrom( nodeNum: Int, @@ -45,7 +50,7 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L maxItem: Int = MAX_MESH_PACKETS, ): Flow> = meshLogDao.getLogsFrom(nodeNum, portNum, maxItem) .distinctUntilChanged() - .flowOn(Dispatchers.IO) + .flowOn(dispatchers.io) /* * Retrieves MeshPackets matching 'nodeNum' and 'portNum'. @@ -57,21 +62,21 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, ): Flow> = getLogsFrom(nodeNum, portNum) .mapLatest { list -> list.map { it.fromRadio.packet } } - .flowOn(Dispatchers.IO) + .flowOn(dispatchers.io) - suspend fun insert(log: MeshLog) = withContext(Dispatchers.IO) { + suspend fun insert(log: MeshLog) = withContext(dispatchers.io) { meshLogDao.insert(log) } - suspend fun deleteAll() = withContext(Dispatchers.IO) { + suspend fun deleteAll() = withContext(dispatchers.io) { meshLogDao.deleteAll() } - suspend fun deleteLog(uuid: String) = withContext(Dispatchers.IO) { + suspend fun deleteLog(uuid: String) = withContext(dispatchers.io) { meshLogDao.deleteLog(uuid) } - suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(Dispatchers.IO) { + suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(dispatchers.io) { meshLogDao.deleteLogs(nodeNum, portNum) } diff --git a/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt index 72d5195ea..4bca71eeb 100644 --- a/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt @@ -7,8 +7,9 @@ import com.geeksville.mesh.database.MeshLogRepository import com.geeksville.mesh.database.entity.MeshLog import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -16,15 +17,10 @@ import javax.inject.Inject class DebugViewModel @Inject constructor( private val meshLogRepository: MeshLogRepository, ) : ViewModel(), Logging { - - private val _meshLog = MutableStateFlow>(emptyList()) - val meshLog: StateFlow> = _meshLog + val meshLog: StateFlow> = meshLogRepository.getAllLogs() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) init { - viewModelScope.launch { - meshLogRepository.getAllLogs().collect { _meshLog.value = it } - } - debug("DebugViewModel created") } diff --git a/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt index c14a31e7d..e5f55454b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt @@ -6,6 +6,7 @@ import android.view.View import android.view.ViewGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -15,10 +16,17 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Button import androidx.compose.material.Card import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -26,8 +34,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString @@ -37,12 +47,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.R import com.geeksville.mesh.database.entity.MeshLog -import com.geeksville.mesh.databinding.FragmentDebugBinding import com.geeksville.mesh.model.DebugViewModel import com.geeksville.mesh.ui.theme.AppTheme import dagger.hilt.android.AndroidEntryPoint @@ -51,133 +61,138 @@ import java.util.Locale @AndroidEntryPoint class DebugFragment : Fragment() { - - private var _binding: FragmentDebugBinding? = null - - // This property is only valid between onCreateView and onDestroyView. - private val binding get() = _binding!! - - private val model: DebugViewModel by viewModels() - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentDebugBinding.inflate(inflater, container, false) - return binding.root + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground)) + setContent { + val viewModel: DebugViewModel = hiltViewModel() + + AppTheme { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.debug_panel)) }, + navigationIcon = { + IconButton(onClick = { parentFragmentManager.popBackStack() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(id = R.string.navigate_back), + ) + } + }, + actions = { + Button(onClick = viewModel::deleteAllLogs) { + Text(text = stringResource(R.string.clear)) + } + } + ) + }, + ) { innerPadding -> + DebugScreen( + viewModel = viewModel, + contentPadding = innerPadding, + ) + } + } + } + } } +} - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) +private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE) - binding.clearButton.setOnClickListener { - model.deleteAllLogs() +/** + * Transform the input [MeshLog] by enhancing the raw message with annotations. + */ +private fun annotateMeshLog(meshLog: MeshLog): MeshLog { + val annotated = when (meshLog.message_type) { + "Packet" -> meshLog.meshPacket?.let { packet -> + annotateRawMessage(meshLog.raw_message, packet.from, packet.to) } - binding.closeButton.setOnClickListener { - parentFragmentManager.popBackStack() + "NodeInfo" -> meshLog.nodeInfo?.let { nodeInfo -> + annotateRawMessage(meshLog.raw_message, nodeInfo.num) } - binding.debugListView.setContent { - val listState = rememberLazyListState() - val logs by model.meshLog.collectAsStateWithLifecycle() + "MyNodeInfo" -> meshLog.myNodeInfo?.let { nodeInfo -> + annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum) + } - val shouldAutoScroll by remember { derivedStateOf { listState.firstVisibleItemIndex < 3 } } - if (shouldAutoScroll) { - LaunchedEffect(logs) { - if (!listState.isScrollInProgress) { - listState.scrollToItem(0) - } - } - } + else -> null + } + return if (annotated == null) { + meshLog + } else { + meshLog.copy(raw_message = annotated) + } +} - AppTheme { - SelectionContainer { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - ) { - items(logs, key = { it.uuid }) { log -> DebugItem(annotateMeshLog(log)) } - } - } - } - } +/** + * Annotate the raw message string with the node IDs provided, in hex, if they are present. + */ +private fun annotateRawMessage(rawMessage: String, vararg nodeIds: Int): String { + val msg = StringBuilder(rawMessage) + var mutated = false + nodeIds.forEach { nodeId -> + mutated = mutated or msg.annotateNodeId(nodeId) + } + return if (mutated) { + return msg.toString() + } else { + rawMessage } +} - override fun onDestroyView() { - super.onDestroyView() - _binding = null +/** + * Look for a single node ID integer in the string and annotate it with the hex equivalent + * if found. + */ +private fun StringBuilder.annotateNodeId(nodeId: Int): Boolean { + val nodeIdStr = nodeId.toUInt().toString() + indexOf(nodeIdStr).takeIf { it >= 0 }?.let { idx -> + insert(idx + nodeIdStr.length, " (${nodeId.asNodeId()})") + return true } + return false +} - /** - * Transform the input [MeshLog] by enhancing the raw message with annotations. - */ - private fun annotateMeshLog(meshLog: MeshLog): MeshLog { - val annotated = when (meshLog.message_type) { - "Packet" -> { - meshLog.meshPacket?.let { packet -> - annotateRawMessage(meshLog.raw_message, packet.from, packet.to) - } - } +private fun Int.asNodeId(): String { + return "!%08x".format(Locale.getDefault(), this) +} - "NodeInfo" -> { - meshLog.nodeInfo?.let { nodeInfo -> - annotateRawMessage(meshLog.raw_message, nodeInfo.num) - } - } +@Composable +internal fun DebugScreen( + viewModel: DebugViewModel = hiltViewModel(), + contentPadding: PaddingValues, +) { + val listState = rememberLazyListState() + val logs by viewModel.meshLog.collectAsStateWithLifecycle() - "MyNodeInfo" -> { - meshLog.myNodeInfo?.let { nodeInfo -> - annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum) - } + val shouldAutoScroll by remember { derivedStateOf { listState.firstVisibleItemIndex < 3 } } + if (shouldAutoScroll) { + LaunchedEffect(logs) { + if (!listState.isScrollInProgress) { + listState.scrollToItem(0) } - - else -> null - } - return if (annotated == null) { - meshLog - } else { - meshLog.copy(raw_message = annotated) - } - } - - /** - * Annotate the raw message string with the node IDs provided, in hex, if they are present. - */ - private fun annotateRawMessage(rawMessage: String, vararg nodeIds: Int): String { - val msg = StringBuilder(rawMessage) - var mutated = false - nodeIds.forEach { nodeId -> - mutated = mutated or msg.annotateNodeId(nodeId) - } - return if (mutated) { - return msg.toString() - } else { - rawMessage } } - /** - * Look for a single node ID integer in the string and annotate it with the hex equivalent - * if found. - */ - private fun StringBuilder.annotateNodeId(nodeId: Int): Boolean { - val nodeIdStr = nodeId.toUInt().toString() - indexOf(nodeIdStr).takeIf { it >= 0 }?.let { idx -> - insert(idx + nodeIdStr.length, " (${nodeId.asNodeId()})") - return true + SelectionContainer { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = contentPadding, + ) { + items(logs, key = { it.uuid }) { log -> DebugItem(annotateMeshLog(log)) } } - return false - } - - private fun Int.asNodeId(): String { - return "!%08x".format(Locale.getDefault(), this) } } -private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE) - @Composable internal fun DebugItem(log: MeshLog) { val timeFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) @@ -205,8 +220,8 @@ internal fun DebugItem(log: MeshLog) { style = TextStyle(fontWeight = FontWeight.Bold), ) Icon( - painterResource(R.drawable.cloud_download_outline_24), - contentDescription = null, + imageVector = Icons.Outlined.CloudDownload, + contentDescription = stringResource(id = R.string.logs), tint = Color.Gray.copy(alpha = 0.6f), modifier = Modifier.padding(end = 8.dp), ) diff --git a/app/src/main/res/drawable/cloud_download_outline_24.xml b/app/src/main/res/drawable/cloud_download_outline_24.xml deleted file mode 100644 index 4301600f9..000000000 --- a/app/src/main/res/drawable/cloud_download_outline_24.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_debug.xml b/app/src/main/res/layout/fragment_debug.xml deleted file mode 100644 index ea3aeba0a..000000000 --- a/app/src/main/res/layout/fragment_debug.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - -