feat: add dialog for message status information

This commit is contained in:
andrekir 2024-09-24 19:39:20 -03:00 committed by Andre K
parent 056f6b28cf
commit a075dfbd3a
16 changed files with 803 additions and 103 deletions

View file

@ -36,8 +36,9 @@ import com.geeksville.mesh.database.entity.QuickChatAction
AutoMigration (from = 7, to = 8),
AutoMigration (from = 8, to = 9),
AutoMigration (from = 9, to = 10),
AutoMigration (from = 10, to = 11),
],
version = 10,
version = 11,
exportSchema = true,
)
@TypeConverters(Converters::class)

View file

@ -50,8 +50,8 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz
packetDao.updateMessageId(d, id)
}
suspend fun getDataPacketById(requestId: Int) = withContext(Dispatchers.IO) {
packetDao.getDataPacketById(requestId)
suspend fun getPacketById(requestId: Int) = withContext(Dispatchers.IO) {
packetDao.getPacketById(requestId)
}
suspend fun deleteMessages(uuidList: List<Long>) = withContext(Dispatchers.IO) {

View file

@ -130,10 +130,15 @@ interface PacketDao {
)
fun getDataPackets(): List<DataPacket>
@Transaction
fun getDataPacketById(requestId: Int): DataPacket? {
return getDataPackets().lastOrNull { it.id == requestId }
}
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo))
AND packet_id = :requestId
ORDER BY received_time DESC
"""
)
fun getPacketById(requestId: Int): Packet?
@Transaction
fun getQueuedPackets(): List<DataPacket>? =

View file

@ -22,7 +22,9 @@ data class Packet(
@ColumnInfo(name = "contact_key") val contact_key: String,
@ColumnInfo(name = "received_time") val received_time: Long,
@ColumnInfo(name = "read", defaultValue = "1") val read: Boolean,
@ColumnInfo(name = "data") val data: DataPacket
@ColumnInfo(name = "data") val data: DataPacket,
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
@ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1,
)
@Entity(tableName = "contact_settings")

View file

@ -0,0 +1,52 @@
package com.geeksville.mesh.model
import com.geeksville.mesh.MeshProtos.Routing
import com.geeksville.mesh.MeshUser
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.R
val Routing.Error.stringRes: Int
get() = when (this) {
Routing.Error.NONE -> R.string.routing_error_none
Routing.Error.NO_ROUTE -> R.string.routing_error_no_route
Routing.Error.GOT_NAK -> R.string.routing_error_got_nak
Routing.Error.TIMEOUT -> R.string.routing_error_timeout
Routing.Error.NO_INTERFACE -> R.string.routing_error_no_interface
Routing.Error.MAX_RETRANSMIT -> R.string.routing_error_max_retransmit
Routing.Error.NO_CHANNEL -> R.string.routing_error_no_channel
Routing.Error.TOO_LARGE -> R.string.routing_error_too_large
Routing.Error.NO_RESPONSE -> R.string.routing_error_no_response
Routing.Error.DUTY_CYCLE_LIMIT -> R.string.routing_error_duty_cycle_limit
Routing.Error.BAD_REQUEST -> R.string.routing_error_bad_request
Routing.Error.NOT_AUTHORIZED -> R.string.routing_error_not_authorized
Routing.Error.PKI_FAILED -> R.string.routing_error_pki_failed
Routing.Error.PKI_UNKNOWN_PUBKEY -> R.string.routing_error_pki_unknown_pubkey
else -> R.string.unrecognized
}
data class Message(
val uuid: Long,
val receivedTime: Long,
val user: MeshUser,
val text: String,
val time: Long,
val read: Boolean,
val status: MessageStatus?,
val routingError: Int,
) {
private fun getStatusStringRes(value: Int): Int {
val error = Routing.Error.forNumber(value) ?: Routing.Error.UNRECOGNIZED
return error.stringRes
}
fun getStatusStringRes(): Pair<Int, Int> {
val title = if (routingError > 0) R.string.error else R.string.message_delivery_status
val text = when (status) {
MessageStatus.RECEIVED -> R.string.delivery_confirmed
MessageStatus.QUEUED -> R.string.message_status_queued
MessageStatus.ENROUTE -> R.string.message_status_enroute
else -> getStatusStringRes(routingError)
}
return title to text
}
}

View file

@ -463,7 +463,7 @@ class RadioConfigViewModel @Inject constructor(
val parsed = MeshProtos.Routing.parseFrom(data.payload)
debug(debugMsg.format(parsed.errorReason.name))
if (parsed.errorReason != MeshProtos.Routing.Error.NONE) {
setResponseStateError(parsed.errorReason.name)
setResponseStateError(app.getString(parsed.errorReason.stringRes))
} else if (packet.from == destNum && route.isEmpty()) {
requestIds.update { it.apply { remove(data.requestId) } }
if (requestIds.value.isEmpty()) setResponseStateSuccess()

View file

@ -130,16 +130,6 @@ data class Contact(
val isMuted: Boolean,
)
data class Message(
val uuid: Long,
val receivedTime: Long,
val user: MeshUser,
val text: String,
val time: Long,
val read: Boolean,
val status: MessageStatus?,
)
// return time if within 24 hours, otherwise date
internal fun getShortDateTime(time: Long): String? {
val date = if (time != 0L) Date(time) else return null
@ -181,7 +171,6 @@ class UIViewModel @Inject constructor(
private val _channels = MutableStateFlow(channelSet {})
val channels: StateFlow<AppOnlyProtos.ChannelSet> get() = _channels
val channelSet get() = channels.value
private val _quickChatActions = MutableStateFlow<List<QuickChatAction>>(emptyList())
val quickChatActions: StateFlow<List<QuickChatAction>> = _quickChatActions
@ -339,6 +328,7 @@ class UIViewModel @Inject constructor(
time = it.data.time,
read = it.read,
status = it.data.status,
routingError = it.routingError,
)
}
}

View file

@ -601,13 +601,14 @@ class MeshService : Service(), Logging {
val contactKey = "${dataPacket.channel}$contactId"
val packetToSave = Packet(
0L, // autoGenerated
myNodeNum,
dataPacket.dataType,
contactKey,
System.currentTimeMillis(),
fromLocal,
dataPacket
uuid = 0L, // autoGenerated
myNodeNum = myNodeNum,
packetId = dataPacket.id,
port_num = dataPacket.dataType,
contact_key = contactKey,
received_time = System.currentTimeMillis(),
read = fromLocal,
data = dataPacket
)
serviceScope.handledLaunch {
packetRepository.get().apply {
@ -686,13 +687,12 @@ class MeshService : Service(), Logging {
// We always send ACKs to other apps, because they might care about the messages they sent
shouldBroadcast = true
val u = MeshProtos.Routing.parseFrom(data.payload)
val isAck = u.errorReasonValue == MeshProtos.Routing.Error.NONE_VALUE
if (u.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) {
radioConfigRepository.setErrorMessage(getString(R.string.error_duty_cycle))
}
handleAckNak(isAck, fromId, data.requestId)
handleAckNak(data.requestId, fromId, u.errorReasonValue)
queueResponse.remove(data.requestId)?.complete(true)
}
@ -1011,7 +1011,7 @@ class MeshService : Service(), Logging {
private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1000) {
var dataPacket: DataPacket? = null
while (dataPacket == null) {
dataPacket = packetRepository.get().getDataPacketById(packetId)
dataPacket = packetRepository.get().getPacketById(packetId)?.data
if (dataPacket == null) delay(100)
}
dataPacket
@ -1031,14 +1031,21 @@ class MeshService : Service(), Logging {
/**
* Handle an ack/nak packet by updating sent message status
*/
private fun handleAckNak(isAck: Boolean, fromId: String, requestId: Int) {
private fun handleAckNak(requestId: Int, fromId: String, routingError: Int) {
serviceScope.handledLaunch {
val p = getDataPacketById(requestId)
val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE
val p = packetRepository.get().getPacketById(requestId)
// distinguish real ACKs coming from the intended receiver
val m = if (isAck && fromId == p?.to) MessageStatus.RECEIVED
else if (isAck) MessageStatus.DELIVERED else MessageStatus.ERROR
if (p != null && p.status != MessageStatus.RECEIVED)
packetRepository.get().updateMessageStatus(p, m)
val m = when {
isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED
isAck -> MessageStatus.DELIVERED
else -> MessageStatus.ERROR
}
if (p != null && p.data.status != MessageStatus.RECEIVED) {
p.data.status = m
p.routingError = routingError
packetRepository.get().update(p)
}
serviceBroadcasts.broadcastMessageStatus(requestId, m)
}
}

View file

@ -33,6 +33,7 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.twotone.KeyboardArrowRight
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -42,7 +43,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -686,15 +686,15 @@ private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Un
horizontalArrangement = Arrangement.Center,
) {
Icon(
painterResource(R.drawable.ic_twotone_warning_24),
"warning",
imageVector = Icons.TwoTone.Warning,
contentDescription = "warning",
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = "${stringResource(title)}?\n")
Icon(
painterResource(R.drawable.ic_twotone_warning_24),
"warning",
imageVector = Icons.TwoTone.Warning,
contentDescription = "warning",
modifier = Modifier.padding(start = 8.dp)
)
}

View file

@ -3,6 +3,7 @@ package com.geeksville.mesh.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -20,14 +21,19 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Cloud
import androidx.compose.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
import androidx.compose.material.icons.twotone.CloudUpload
import androidx.compose.material.icons.twotone.HowToReg
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -54,6 +60,7 @@ internal fun MessageItem(
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onChipClick: () -> Unit = {},
onStatusClick: () -> Unit = {},
) {
val fromLocal = shortName == null
val messageColor = if (fromLocal) R.color.colorMyMsg else R.color.colorMsg
@ -134,18 +141,19 @@ internal fun MessageItem(
fontSize = MaterialTheme.typography.caption.fontSize,
)
AnimatedVisibility(visible = fromLocal) {
val icon = when (messageStatus) {
MessageStatus.RECEIVED -> R.drawable.ic_twotone_how_to_reg_24
MessageStatus.QUEUED -> R.drawable.ic_twotone_cloud_upload_24
MessageStatus.DELIVERED -> R.drawable.cloud_on
MessageStatus.ENROUTE -> R.drawable.ic_twotone_cloud_24
MessageStatus.ERROR -> R.drawable.cloud_off
else -> R.drawable.ic_twotone_warning_24
}
Icon(
imageVector = ImageVector.vectorResource(id = icon),
imageVector = when (messageStatus) {
MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg
MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload
MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone
MessageStatus.ENROUTE -> Icons.TwoTone.Cloud
MessageStatus.ERROR -> Icons.TwoTone.CloudOff
else -> Icons.TwoTone.Warning
},
contentDescription = stringResource(R.string.message_delivery_status),
modifier = Modifier.padding(start = 8.dp),
modifier = Modifier
.padding(start = 8.dp)
.clickable { onStatusClick() },
)
}
}

View file

@ -9,11 +9,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.ui.components.SimpleAlertDialog
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
@ -34,6 +37,13 @@ internal fun MessageListView(
AutoScrollToBottom(listState, messages)
UpdateUnreadCount(listState, messages, onUnreadChanged)
var showStatusDialog by remember { mutableStateOf<Message?>(null) }
if (showStatusDialog != null) {
val msg = showStatusDialog ?: return
val (title, text) = msg.getStatusStringRes()
SimpleAlertDialog(title = title, text = text) { showStatusDialog = null }
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
@ -52,6 +62,7 @@ internal fun MessageListView(
onClick = { onClick(msg) },
onLongClick = { onLongClick(msg) },
onChipClick = { onChipClick(msg) },
onStatusClick = { showStatusDialog = msg }
)
}
}