diff --git a/app/src/main/java/com/geeksville/mesh/DataPacket.kt b/app/src/main/java/com/geeksville/mesh/DataPacket.kt index 409e85a7f..df8e3adfc 100644 --- a/app/src/main/java/com/geeksville/mesh/DataPacket.kt +++ b/app/src/main/java/com/geeksville/mesh/DataPacket.kt @@ -24,11 +24,33 @@ data class DataPacket( val bytes: ByteArray?, val dataType: Int, // A value such as MeshProtos.Data.Type.OPAQUE_VALUE var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost - var rxTime: Long = System.currentTimeMillis(), // msecs since 1970 + var time: Long = System.currentTimeMillis(), // msecs since 1970 var id: Int = 0, // 0 means unassigned var status: MessageStatus? = MessageStatus.UNKNOWN ) : Parcelable { + /** + * If there was an error with this message, this string describes what was wrong. + */ + var errorMessage: String? = null + + /** + * Syntactic sugar to make it easy to create text messages + */ + constructor(to: String? = ID_BROADCAST, text: String) : this( + to, text.toByteArray(utf8), + MeshProtos.Data.Type.CLEAR_TEXT_VALUE + ) + + /** + * If this is a text message, return the string, otherwise null + */ + val text: String? + get() = if (dataType == MeshProtos.Data.Type.CLEAR_TEXT_VALUE) + bytes?.toString(utf8) + else + null + // Autogenerated comparision, because we have a byte array constructor(parcel: Parcel) : this( @@ -50,7 +72,7 @@ data class DataPacket( if (from != other.from) return false if (to != other.to) return false - if (rxTime != other.rxTime) return false + if (time != other.time) return false if (id != other.id) return false if (dataType != other.dataType) return false if (!bytes!!.contentEquals(other.bytes!!)) return false @@ -62,7 +84,7 @@ data class DataPacket( override fun hashCode(): Int { var result = from.hashCode() result = 31 * result + to.hashCode() - result = 31 * result + rxTime.hashCode() + result = 31 * result + time.hashCode() result = 31 * result + id result = 31 * result + dataType result = 31 * result + bytes!!.contentHashCode() @@ -75,7 +97,7 @@ data class DataPacket( parcel.writeByteArray(bytes) parcel.writeInt(dataType) parcel.writeString(from) - parcel.writeLong(rxTime) + parcel.writeLong(time) parcel.writeInt(id) parcel.writeParcelable(status, flags) } @@ -90,7 +112,7 @@ data class DataPacket( parcel.createByteArray() parcel.readInt() from = parcel.readString() - rxTime = parcel.readLong() + time = parcel.readLong() id = parcel.readInt() status = parcel.readParcelable(MessageStatus::class.java.classLoader) } diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 688d91d61..2e9207f2e 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -31,7 +31,6 @@ import com.geeksville.android.GeeksvilleApplication import com.geeksville.android.Logging import com.geeksville.android.ServiceClient import com.geeksville.mesh.model.Channel -import com.geeksville.mesh.model.TextMessage import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.service.* import com.geeksville.mesh.ui.* @@ -398,7 +397,7 @@ class MainActivity : AppCompatActivity(), Logging, else -> R.drawable.cloud_off } - connectStatusImage.setImageDrawable(getDrawable(image)) + connectStatusImage.setImageResource(image) }) askToRate() @@ -623,6 +622,15 @@ class MainActivity : AppCompatActivity(), Logging, errormsg("Unhandled dataType ${payload.dataType}") } } + + MeshService.ACTION_MESSAGE_STATUS -> { + debug("received message status from service") + val id = intent.getIntExtra(EXTRA_PACKET_ID, 0) + val status = intent.getParcelableExtra(EXTRA_STATUS) + + model.messagesState.updateStatus(id, status) + } + MeshService.ACTION_MESH_CONNECTED -> { val connected = MeshService.ConnectionState.valueOf( @@ -650,10 +658,7 @@ class MainActivity : AppCompatActivity(), Logging, registerMeshReceiver() // Init our messages table with the service's record of past text messages - model.messagesState.messages.value = service.oldMessages.map { - TextMessage(it) - } - + model.messagesState.messages.value = service.oldMessages val connectionState = MeshService.ConnectionState.valueOf(service.connectionState()) diff --git a/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt index 2f94aa5d2..ef3d37ef7 100644 --- a/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt @@ -16,7 +16,8 @@ data class MyNodeInfo( val shouldUpdate: Boolean, // this device has old firmware val currentPacketId: Long, val nodeNumBits: Int, - val packetIdBits: Int + val packetIdBits: Int, + val messageTimeoutMsec: Int ) : Parcelable { /** A human readable description of the software/hardware version */ val firmwareString: String get() = "$model $region/$firmwareVersion" @@ -31,6 +32,7 @@ data class MyNodeInfo( parcel.readByte() != 0.toByte(), parcel.readLong(), parcel.readInt(), + parcel.readInt(), parcel.readInt() ) { } @@ -46,6 +48,7 @@ data class MyNodeInfo( parcel.writeLong(currentPacketId) parcel.writeInt(nodeNumBits) parcel.writeInt(packetIdBits) + parcel.writeInt(messageTimeoutMsec) } override fun describeContents(): Int { @@ -61,4 +64,6 @@ data class MyNodeInfo( return arrayOfNulls(size) } } + + } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt b/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt index 338385196..723f93420 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt @@ -6,36 +6,17 @@ import com.geeksville.android.BuildUtils.isEmulator import com.geeksville.android.Logging import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.utf8 -import java.util.* - -/** - * the model object for a text message - * - * if errorMessage is set then we had a problem sending this message - */ -data class TextMessage( - val from: String, - val text: String, - val date: Date = Date(), - val errorMessage: String? = null -) { - /// We can auto init from data packets - constructor(payload: DataPacket) : this( - payload.from!!, - payload.bytes!!.toString(utf8), - date = Date(payload.rxTime) - ) -} class MessagesState(private val ui: UIViewModel) : Logging { private val testTexts = listOf( - TextMessage( + DataPacket( "+16508765310", "I found the cache" ), - TextMessage( + DataPacket( "+16508765311", "Help! I've fallen and I can't get up." ) @@ -44,44 +25,47 @@ class MessagesState(private val ui: UIViewModel) : Logging { // If the following (unused otherwise) line is commented out, the IDE preview window works. // if left in the preview always renders as empty. val messages = - object : MutableLiveData>(if (isEmulator) testTexts else listOf()) { + object : MutableLiveData>(if (isEmulator) testTexts else listOf()) { } /// add a message our GUI list of past msgs - private fun addMessage(m: TextMessage) { + fun addMessage(m: DataPacket) { // FIXME - don't just slam in a new list each time, it probably causes extra drawing. messages.value = messages.value!! + m } - /// Add a message that was encapsulated in a data packet - fun addMessage(payload: DataPacket) = addMessage(TextMessage(payload)) + fun updateStatus(id: Int, status: MessageStatus) { + // Super inefficent but this is rare + val msgs = messages.value!! + + msgs.find { it.id == id }?.let { p -> + if (p.status != status) { + p.status = status + // Trigger an expensive complete redraw FIXME + messages.value = msgs + } + } + } /// Send a message and added it to our GUI log fun sendMessage(str: String, dest: String = DataPacket.ID_BROADCAST) { - var error: String? = null + val service = ui.meshService + val p = DataPacket( + dest, + str.toByteArray(utf8), + MeshProtos.Data.Type.CLEAR_TEXT_VALUE + ) if (service != null) try { - service.send( - DataPacket( - dest, - str.toByteArray(utf8), - MeshProtos.Data.Type.CLEAR_TEXT_VALUE - ) - ) + service.send(p) } catch (ex: RemoteException) { - error = "Error: ${ex.message}" + p.errorMessage = "Error: ${ex.message}" } else - error = "Error: No Mesh service" + p.errorMessage = "Error: No Mesh service" - addMessage( - TextMessage( - ui.nodeDB.myId.value!!, - str, - errorMessage = error - ) - ) + addMessage(p) } } diff --git a/app/src/main/java/com/geeksville/mesh/service/Constants.kt b/app/src/main/java/com/geeksville/mesh/service/Constants.kt index 53d2594fc..4d4b68b9b 100644 --- a/app/src/main/java/com/geeksville/mesh/service/Constants.kt +++ b/app/src/main/java/com/geeksville/mesh/service/Constants.kt @@ -15,3 +15,5 @@ const val EXTRA_PERMANENT = "$prefix.Permanent" const val EXTRA_PAYLOAD = "$prefix.Payload" const val EXTRA_NODEINFO = "$prefix.NodeInfo" +const val EXTRA_PACKET_ID = "$prefix.PacketId" +const val EXTRA_STATUS = "$prefix.Status" diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 9d3913d64..23d00282f 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -9,6 +9,7 @@ import android.content.IntentFilter import android.graphics.Color import android.os.Build import android.os.IBinder +import android.os.Parcelable import android.os.RemoteException import android.widget.Toast import androidx.annotation.RequiresApi @@ -52,6 +53,7 @@ class MeshService : Service(), Logging { const val ACTION_RECEIVED_DATA = "$prefix.RECEIVED_DATA" const val ACTION_NODE_CHANGE = "$prefix.NODE_CHANGE" const val ACTION_MESH_CONNECTED = "$prefix.MESH_CONNECTED" + const val ACTION_MESSAGE_STATUS = "$prefix.MESSAGE_STATUS" class IdNotFoundException(id: String) : Exception("ID not found $id") class NodeNumNotFoundException(id: Int) : Exception("NodeNum not found $id") @@ -292,6 +294,19 @@ class MeshService : Service(), Logging { explicitBroadcast(intent) } + private fun broadcastMessageStatus(p: DataPacket) { + if (p.id == 0) { + debug("Ignoring anonymous packet status") + } else { + debug("Broadcasting message status $p") + val intent = Intent(ACTION_MESSAGE_STATUS) + + intent.putExtra(EXTRA_PACKET_ID, p.id) + intent.putExtra(EXTRA_STATUS, p.status as Parcelable) + explicitBroadcast(intent) + } + } + /// Safely access the radio service, if not connected an exception will be thrown private val connectedRadio: IRadioInterfaceService get() = (if (connectionState == ConnectionState.CONNECTED) radio.serviceP else null) @@ -703,7 +718,7 @@ class MeshService : Service(), Logging { DataPacket( from = fromId, to = toId, - rxTime = rxTime * 1000L, + time = rxTime * 1000L, id = packet.id, dataType = data.typValue, bytes = bytes @@ -800,6 +815,9 @@ class MeshService : Service(), Logging { /// If apps try to send packets when our radio is sleeping, we queue them here instead private val offlineSentPackets = mutableListOf() + /** Keep a record of recently sent packets, so we can properly handle ack/nak */ + private val sentPackets = mutableMapOf() + /// Update our model and resend as needed for a MeshPacket we just received from the radio private fun handleReceivedMeshPacket(packet: MeshPacket) { if (haveNodeDB) { @@ -820,12 +838,25 @@ class MeshService : Service(), Logging { // encapsulate our payload in the proper protobufs and fire it off val packet = toMeshPacket(p) p.status = MessageStatus.ENROUTE + p.time = + System.currentTimeMillis() // update time to the actual time we started sending sendToRadio(packet) + broadcastMessageStatus(p) } offlineSentPackets.clear() } + /** + * Handle an ack/nak packet by updating sent message status + */ + private fun handleAckNak(isAck: Boolean, id: Int) { + sentPackets.remove(id)?.let { p -> + p.status = if (isAck) MessageStatus.DELIVERED else MessageStatus.ERROR + broadcastMessageStatus(p) + } + } + /// Update our model and resend as needed for a MeshPacket we just received from the radio private fun processReceivedMeshPacket(packet: MeshPacket) { val fromNum = packet.from @@ -859,6 +890,12 @@ class MeshService : Service(), Logging { if (p.hasUser()) handleReceivedUser(fromNum, p.user) + + if (p.successId != 0) + handleAckNak(true, p.successId) + + if (p.failId != 0) + handleAckNak(false, p.failId) } private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt() @@ -1169,8 +1206,9 @@ class MeshService : Service(), Logging { firmwareUpdateFilename != null, SoftwareUpdateService.shouldUpdate(this@MeshService, firmwareVersion), currentPacketId.toLong() and 0xffffffffL, - nodeNumBits, - packetIdBits + if (nodeNumBits == 0) 8 else nodeNumBits, + if (packetIdBits == 0) 8 else packetIdBits, + if (messageTimeoutMsec == 0) 5 * 60 * 1000 else messageTimeoutMsec // constants from current device code ) } @@ -1377,7 +1415,22 @@ class MeshService : Service(), Logging { } } - private + /** + * Remove any sent packets that have been sitting around too long + * + * Note: we give each message what the timeout the device code is using, though in the normal + * case the device will fail after 3 retries much sooner than that (and it will provide a nak to us) + */ + private fun deleteOldPackets() { + myNodeInfo?.apply { + val now = System.currentTimeMillis() + sentPackets.values.forEach { p -> + if (p.status == MessageStatus.ENROUTE && p.time + messageTimeoutMsec < now) + handleAckNak(false, p.id) + } + } + } + val binder = object : IMeshService.Stub() { override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { @@ -1416,8 +1469,6 @@ class MeshService : Service(), Logging { p: DataPacket ) { toRemoteExceptions { - info("sendData dest=${p.to} <- ${p.bytes!!.size} bytes (connectionState=$connectionState)") - // Init from and id myNodeID?.let { myId -> if (p.from == DataPacket.ID_LOCAL) @@ -1427,10 +1478,16 @@ class MeshService : Service(), Logging { p.id = generatePacketId() } + info("sendData dest=${p.to}, id=${p.id} <- ${p.bytes!!.size} bytes (connectionState=$connectionState)") // Keep a record of datapackets, so GUIs can show proper chat history rememberDataPacket(p) + if (p.id != 0) { // If we have an ID we can wait for an ack or nak + deleteOldPackets() + sentPackets[p.id] = p + } + // If radio is sleeping, queue the packet when (connectionState) { ConnectionState.DEVICE_SLEEP -> { diff --git a/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt b/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt index c835b2aa1..7a8da82df 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt @@ -4,67 +4,3 @@ import com.geeksville.android.Logging object UILog : Logging -/* -val palette = lightColorPalette() // darkColorPalette() - - -@Composable -fun MeshApp() { - val (drawerState, onDrawerStateChange) = state { DrawerState.Closed } - - MaterialTheme(colors = palette) { - ModalDrawerLayout( - drawerState = drawerState, - onStateChange = onDrawerStateChange, - gesturesEnabled = drawerState == DrawerState.Opened, - drawerContent = { - - AppDrawer( - currentScreen = AppStatus.currentScreen, - closeDrawer = { onDrawerStateChange(DrawerState.Closed) } - ) - - }, bodyContent = { AppContent { onDrawerStateChange(DrawerState.Opened) } }) - } -} - -@Preview -@Composable -fun previewView() { - // It seems modaldrawerlayout not yet supported in preview - MaterialTheme(colors = palette) { - UsersContent() - } -} - - -@Composable -private fun AppContent(openDrawer: () -> Unit) { - // crossfade breaks onCommit behavior because it keeps old views around - //Crossfade(AppStatus.currentScreen) { screen -> - //Surface(color = (MaterialTheme.colors()).background) { - - Scaffold(topAppBar = { - TopAppBar( - title = { Text(text = "Meshtastic") }, - navigationIcon = { - //Container(LayoutSize(40.dp, 40.dp)) { - VectorImageButton(R.drawable.ic_launcher_new_foreground) { - openDrawer() - } - //} - } - ) - }) { - when (AppStatus.currentScreen) { - Screen.messages -> MessagesContent() - Screen.settings -> SettingsContent() - Screen.users -> UsersContent() - Screen.channel -> ChannelContent(UIState.getChannel()) - Screen.map -> MapContent() - else -> TODO() - } - } - //} -} -*/ \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt index ed41db64e..357c39d23 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -13,14 +13,16 @@ import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.geeksville.android.Logging +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R -import com.geeksville.mesh.model.TextMessage import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.service.MeshService import com.google.android.material.chip.Chip import kotlinx.android.synthetic.main.adapter_message_layout.view.* import kotlinx.android.synthetic.main.messages_fragment.* import java.text.SimpleDateFormat +import java.util.* // Allows usage like email.on(EditorInfo.IME_ACTION_NEXT, { confirm() }) fun EditText.on(actionId: Int, func: () -> Unit) { @@ -129,13 +131,27 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { holder.messageText.text = msg.text } - holder.messageTime.text = dateFormat.format(msg.date) + holder.messageTime.text = dateFormat.format(Date(msg.time)) + + val icon = when (msg.status) { + 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 -> null + } + + if (icon != null) { + holder.messageStatusIcon.setImageResource(icon) + holder.messageStatusIcon.visibility = View.VISIBLE + } else + holder.messageStatusIcon.visibility = View.INVISIBLE } - private var messages = arrayOf() + private var messages = arrayOf() /// Called when our node DB changes - fun onMessagesChanged(nodesIn: Collection) { + fun onMessagesChanged(nodesIn: Collection) { messages = nodesIn.toTypedArray() notifyDataSetChanged() // FIXME, this is super expensive and redraws all messages diff --git a/app/src/main/proto b/app/src/main/proto index d624f8cb1..e9c7f9b95 160000 --- a/app/src/main/proto +++ b/app/src/main/proto @@ -1 +1 @@ -Subproject commit d624f8cb17a2f723a51649d442791b195a18604a +Subproject commit e9c7f9b95d490aea3f0f213d4666d2dbf7e2111c