Merge branch 'meshtastic:master' into master

This commit is contained in:
Wojciech Kawecki
2022-04-07 15:34:36 +02:00
committed by GitHub
15 changed files with 589 additions and 57 deletions

View File

@@ -3,6 +3,7 @@
[![Open in Visual Studio Code](https://open.vscode.dev/badges/open-in-vscode.svg)](https://open.vscode.dev/meshtastic/Meshtastic-Android)
[![Android CI](https://github.com/meshtastic/Meshtastic-Android/actions/workflows/android.yml/badge.svg)](https://github.com/meshtastic/Meshtastic-Android/actions/workflows/android.yml)
![GitHub all releases](https://img.shields.io/github/downloads/meshtastic/meshtastic-android/total)
[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/Meshtastic-Android)](https://cla-assistant.io/meshtastic/Meshtastic-Android)
This is a tool for using Android with open-source mesh radios. For more information see our webpage: [meshtastic.org](https://www.meshtastic.org). If you are looking for the the device side code, see [here](https://github.com/meshtastic/Meshtastic-esp32).
@@ -28,7 +29,7 @@ However, if you must use 'raw' APKs you can get them from our [github releases](
If you would like to develop this application we'd love your help! These build instructions are brief
and should be improved, please send a PR if you can.
- Use Android Studio 4.1.2 to build/debug (other versions might work but no promises)
- Use Android Studio to build/debug
- Use "git submodule update --init --recursive" to pull in the various submodules we depend on
- There are a few config files which you'll need to copy from templates included in the project.
Run the following commands to do so:

View File

@@ -29,7 +29,9 @@ data class DataPacket(
var time: Long = System.currentTimeMillis(), // msecs since 1970
var id: Int = 0, // 0 means unassigned
var status: MessageStatus? = MessageStatus.UNKNOWN,
var hopLimit: Int = 0
var hopLimit: Int = 0,
var channel: Int = 0, // channel index
var delayed: Int = 0 // S&F MeshProtos.MeshPacket.Delayed.(...)_VALUE
) : Parcelable {
/**
@@ -64,6 +66,8 @@ data class DataPacket(
parcel.readLong(),
parcel.readInt(),
parcel.readParcelable(MessageStatus::class.java.classLoader),
parcel.readInt(),
parcel.readInt(),
parcel.readInt()
)
@@ -75,6 +79,7 @@ data class DataPacket(
if (from != other.from) return false
if (to != other.to) return false
if (channel != other.channel) return false
if (time != other.time) return false
if (id != other.id) return false
if (dataType != other.dataType) return false
@@ -94,6 +99,8 @@ data class DataPacket(
result = 31 * result + bytes!!.contentHashCode()
result = 31 * result + status.hashCode()
result = 31 * result + hopLimit
result = 31 * result + channel
result = 31 * result + delayed
return result
}
@@ -106,6 +113,8 @@ data class DataPacket(
parcel.writeInt(id)
parcel.writeParcelable(status, flags)
parcel.writeInt(hopLimit)
parcel.writeInt(channel)
parcel.writeInt(delayed)
}
override fun describeContents(): Int {
@@ -122,6 +131,8 @@ data class DataPacket(
id = parcel.readInt()
status = parcel.readParcelable(MessageStatus::class.java.classLoader)
hopLimit = parcel.readInt()
channel = parcel.readInt()
delayed = parcel.readInt()
}
companion object CREATOR : Parcelable.Creator<DataPacket> {
@@ -145,7 +156,7 @@ data class DataPacket(
override fun newArray(size: Int): Array<DataPacket?> {
return arrayOfNulls(size)
}
val utf8 = Charset.forName("UTF-8")
val utf8: Charset = Charset.forName("UTF-8")
}

View File

@@ -13,6 +13,7 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.RemoteException
import android.text.method.LinkMovementMethod
import android.view.Menu
@@ -144,7 +145,7 @@ class MainActivity : BaseActivity(), Logging,
TabInfo(
"Messages",
R.drawable.ic_twotone_message_24,
MessagesFragment()
ContactsFragment()
),
TabInfo(
"Users",
@@ -1010,7 +1011,7 @@ class MainActivity : BaseActivity(), Logging,
}
val handler: Handler by lazy {
Handler(mainLooper)
Handler(Looper.getMainLooper())
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {

View File

@@ -81,7 +81,7 @@ data class Position(
@Serializable
@Parcelize
data class Telemetry(
data class DeviceMetrics(
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val batteryLevel: Int = 0,
val voltage: Float,
@@ -94,16 +94,16 @@ data class Telemetry(
/** Create our model object from a protobuf.
*/
constructor(p: TelemetryProtos.Telemetry, defaultTime: Int = currentTime()) : this(
if (p.time != 0) p.time else defaultTime,
p.deviceMetrics.batteryLevel,
p.deviceMetrics.voltage,
p.deviceMetrics.channelUtilization,
p.deviceMetrics.airUtilTx
constructor(p: TelemetryProtos.DeviceMetrics, telemetryTime: Int = currentTime()) : this(
telemetryTime,
p.batteryLevel,
p.voltage,
p.channelUtilization,
p.airUtilTx
)
override fun toString(): String {
return "Telemetry(time=${time}, batteryLevel=${batteryLevel}, voltage=${voltage}, channelUtilization=${channelUtilization}, airUtilTx=${airUtilTx})"
return "DeviceMetrics(time=${time}, batteryLevel=${batteryLevel}, voltage=${voltage}, channelUtilization=${channelUtilization}, airUtilTx=${airUtilTx})"
}
}
@@ -117,10 +117,10 @@ data class NodeInfo(
var snr: Float = Float.MAX_VALUE,
var rssi: Int = Int.MAX_VALUE,
var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
var telemetry: Telemetry? = null
var deviceMetrics: DeviceMetrics? = null
) : Parcelable {
val batteryPctLevel get() = telemetry?.batteryLevel
val batteryPctLevel get() = deviceMetrics?.batteryLevel
/**
* true if the device was heard from recently

View File

@@ -30,10 +30,40 @@ class MessagesState(private val ui: UIViewModel) : Logging {
}
private var contactsList = emptyMap<String?, DataPacket>().toMutableMap()
val contacts = object : MutableLiveData<MutableMap<String?, DataPacket>>() {
}
private fun emptyDataPacket(to: String? = DataPacket.ID_BROADCAST): DataPacket {
return DataPacket(to, null, 1, DataPacket.ID_LOCAL, 0L)
}
// Map each contactId to last DataPacket message sent or received
// Broadcast: it.to == DataPacket.ID_BROADCAST; Direct Messages: it.to != DataPacket.ID_BROADCAST
private fun buildContacts() {
contactsList = messagesList.associateBy {
if (it.from == DataPacket.ID_LOCAL || it.to == DataPacket.ID_BROADCAST)
it.to else it.from
}.toMutableMap()
val all = DataPacket.ID_BROADCAST // always show contacts, even when empty
if (contactsList[all] == null)
contactsList[all] = emptyDataPacket()
val nodes = ui.nodeDB.nodes.value!!
nodes.keys.forEachIndexed { index, node ->
if (index != 0 && contactsList[node] == null)
contactsList[node] = emptyDataPacket(node)
}
contacts.value = contactsList
}
fun setMessages(m: List<DataPacket>) {
messagesList.clear()
messagesList.addAll(m)
messages.value = messagesList
buildContacts()
}
/// add a message our GUI list of past msgs
@@ -44,6 +74,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
messagesList.add(m)
messages.value = messagesList
buildContacts()
}
fun removeMessage(m: DataPacket) {
@@ -51,6 +82,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
messagesList.remove(m)
messages.value = messagesList
buildContacts()
}
private fun removeAllMessages() {
@@ -58,6 +90,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
messagesList.clear()
messages.value = messagesList
buildContacts()
}
fun updateStatus(id: Int, status: MessageStatus) {

View File

@@ -696,7 +696,9 @@ class MeshService : Service(), Logging {
id = packet.id,
dataType = data.portnumValue,
bytes = bytes,
hopLimit = hopLimit
hopLimit = hopLimit,
channel = packet.channel,
delayed = packet.delayedValue
)
}
}
@@ -719,7 +721,7 @@ class MeshService : Service(), Logging {
// we only care about old text messages, we just store those...
if (dataPacket.dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) {
// discard old messages if needed then add the new one
while (recentDataPackets.size > 50)
while (recentDataPackets.size > 100)
recentDataPackets.removeAt(0)
// FIXME - possible kotlin bug in 1.3.72 - it seems that if we start with the (globally shared) emptyList,
@@ -900,14 +902,17 @@ class MeshService : Service(), Logging {
}
}
/// Update our DB of users based on someone sending out a User subpacket
/// Update our DB of users based on someone sending out a Telemetry subpacket
private fun handleReceivedTelemetry(
fromNum: Int,
p: TelemetryProtos.Telemetry,
defaultTime: Long = System.currentTimeMillis()
) {
updateNodeInfo(fromNum) {
it.telemetry = Telemetry(p, (defaultTime / 1000L).toInt())
it.deviceMetrics = DeviceMetrics(
p.deviceMetrics,
if (p.time != 0) p.time else (defaultTime / 1000L).toInt()
)
}
}
@@ -1298,10 +1303,8 @@ class MeshService : Service(), Logging {
it.position = Position(info.position)
}
if (info.hasTelemetry()) {
// For the local node, it might not be able to update its times because it doesn't have a valid GPS reading yet
// so if the info is for _our_ node we always assume time is current
it.telemetry = Telemetry(info.telemetry)
if (info.hasDeviceMetrics()) {
it.deviceMetrics = DeviceMetrics(info.deviceMetrics)
}
it.lastHeard = info.lastHeard
@@ -1309,7 +1312,7 @@ class MeshService : Service(), Logging {
}
private fun handleNodeInfo(info: MeshProtos.NodeInfo) {
debug("Received nodeinfo num=${info.num}, hasUser=${info.hasUser()}, hasPosition=${info.hasPosition()}, hasTelemetry=${info.hasTelemetry()}")
debug("Received nodeinfo num=${info.num}, hasUser=${info.hasUser()}, hasPosition=${info.hasPosition()}, hasDeviceMetrics=${info.hasDeviceMetrics()}")
val packetToSave = Packet(
UUID.randomUUID().toString(),

View File

@@ -0,0 +1,354 @@
package com.geeksville.mesh.ui
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.*
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.android.Logging
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
import com.geeksville.mesh.databinding.AdapterContactLayoutBinding
import com.geeksville.mesh.databinding.FragmentContactsBinding
import com.geeksville.mesh.model.UIViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import java.text.DateFormat
import java.util.*
@AndroidEntryPoint
class ContactsFragment : ScreenFragment("Messages"), Logging {
private var actionMode: ActionMode? = null
private var _binding: FragmentContactsBinding? = null
// This property is only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
private val model: UIViewModel by activityViewModels()
private val dateTimeFormat: DateFormat =
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
private val timeFormat: DateFormat =
DateFormat.getTimeInstance(DateFormat.SHORT)
private fun getShortDateTime(time: Date): String {
// return time if within 24 hours, otherwise date/time
val oneDayMsec = 60 * 60 * 24 * 1000L
return if (System.currentTimeMillis() - time.time > oneDayMsec) {
dateTimeFormat.format(time)
} else timeFormat.format(time)
}
// Provide a direct reference to each of the views within a data item
// Used to cache the views within the item layout for fast access
class ViewHolder(itemView: AdapterContactLayoutBinding) :
RecyclerView.ViewHolder(itemView.root) {
val shortName = itemView.shortName
val longName = itemView.longName
val lastMessageTime = itemView.lastMessageTime
val lastMessageText = itemView.lastMessageText
}
private val contactsAdapter = object : RecyclerView.Adapter<ViewHolder>() {
/**
* Called when RecyclerView needs a new [ViewHolder] of the given type to represent
* an item.
*
*
* This new ViewHolder should be constructed with a new View that can represent the items
* of the given type. You can either create a new View manually or inflate it from an XML
* layout file.
*
*
* The new ViewHolder will be used to display items of the adapter using
* [.onBindViewHolder]. Since it will be re-used to display
* different items in the data set, it is a good idea to cache references to sub views of
* the View to avoid unnecessary [View.findViewById] calls.
*
* @param parent The ViewGroup into which the new View will be added after it is bound to
* an adapter position.
* @param viewType The view type of the new View.
*
* @return A new ViewHolder that holds a View of the given view type.
* @see .getItemViewType
* @see .onBindViewHolder
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(requireContext())
// Inflate the custom layout
val contactsView = AdapterContactLayoutBinding.inflate(inflater, parent, false)
// Return a new holder instance
return ViewHolder(contactsView)
}
private var messages = arrayOf<DataPacket>()
private var contacts = arrayOf<DataPacket>()
private var selectedList = ArrayList<String>()
/**
* Returns the total number of items in the data set held by the adapter.
*
* @return The total number of items in this adapter.
*/
override fun getItemCount(): Int = contacts.size
/**
* Called by RecyclerView to display the data at the specified position. This method should
* update the contents of the [ViewHolder.itemView] to reflect the item at the given
* position.
*
*
* Note that unlike [android.widget.ListView], RecyclerView will not call this method
* again if the position of the item changes in the data set unless the item itself is
* invalidated or the new position cannot be determined. For this reason, you should only
* use the `position` parameter while acquiring the related data item inside
* this method and should not keep a copy of it. If you need the position of an item later
* on (e.g. in a click listener), use [ViewHolder.getAdapterPosition] which will
* have the updated adapter position.
*
* Override [.onBindViewHolder] instead if Adapter can
* handle efficient partial bind.
*
* @param holder The ViewHolder which should be updated to represent the contents of the
* item at the given position in the data set.
* @param position The position of the item within the adapter's data set.
*/
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = contacts[position]
// Determine if this is my message (originated on this device)
val isLocal = contact.from == DataPacket.ID_LOCAL
val isBroadcast = contact.to == DataPacket.ID_BROADCAST
val contactId = if (isLocal || isBroadcast) contact.to else contact.from
// grab usernames from NodeInfo
val nodes = model.nodeDB.nodes.value!!
val node = nodes[if (isLocal) contact.to else contact.from]
//grab channel names from RadioConfig
val channels = model.channels.value
val primaryChannel = channels?.primaryChannel
val shortName = node?.user?.shortName ?: "???"
val longName =
if (isBroadcast) primaryChannel?.name ?: getString(R.string.channel_name)
else node?.user?.longName ?: getString(R.string.unknown_username)
holder.shortName.text = if (isBroadcast) "All" else shortName
holder.longName.text = longName
val text = if (isLocal) contact.text else "$shortName: ${contact.text}"
holder.lastMessageText.text = text
if (contact.time != 0L) {
holder.lastMessageTime.visibility = View.VISIBLE
holder.lastMessageTime.text = getShortDateTime(Date(contact.time))
} else holder.lastMessageTime.visibility = View.INVISIBLE
holder.itemView.setOnLongClickListener {
if (actionMode == null) {
actionMode =
(activity as MainActivity).startActionMode(object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.menu_messages, menu)
mode.title = "1"
return true
}
override fun onPrepareActionMode(
mode: ActionMode,
menu: Menu
): Boolean {
clickItem(holder, contactId)
return true
}
override fun onActionItemClicked(
mode: ActionMode,
item: MenuItem
): Boolean {
when (item.itemId) {
R.id.deleteButton -> {
val messagesByContactId = ArrayList<DataPacket>()
selectedList.forEach { contactId ->
messagesByContactId += messages.filter {
if (contactId == DataPacket.ID_BROADCAST)
it.to == DataPacket.ID_BROADCAST
else
it.from == contactId && it.to != DataPacket.ID_BROADCAST
|| it.from == DataPacket.ID_LOCAL && it.to == contactId
}
}
val deleteMessagesString = resources.getQuantityString(
R.plurals.delete_messages,
messagesByContactId.size,
messagesByContactId.size
)
MaterialAlertDialogBuilder(requireContext())
.setMessage(deleteMessagesString)
.setPositiveButton(getString(R.string.delete)) { _, _ ->
debug("User clicked deleteButton")
// all items selected --> deleteAllMessages()
if (messagesByContactId.size == messages.size) {
model.messagesState.deleteAllMessages()
} else {
messagesByContactId.forEach {
model.messagesState.deleteMessage(it)
}
}
mode.finish()
}
.setNeutralButton(R.string.cancel) { _, _ ->
}
.show()
}
R.id.selectAllButton -> {
// if all selected -> unselect all
if (selectedList.size == contacts.size) {
selectedList.clear()
mode.finish()
} else {
// else --> select all
selectedList.clear()
contacts.forEach {
if (it.from == DataPacket.ID_LOCAL || it.to == DataPacket.ID_BROADCAST)
selectedList.add(it.to!!) else selectedList.add(it.from!!)
}
}
actionMode?.title = selectedList.size.toString()
notifyDataSetChanged()
}
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
selectedList.clear()
notifyDataSetChanged()
actionMode = null
}
})
} else {
// when action mode is enabled
clickItem(holder, contactId)
}
true
}
holder.itemView.setOnClickListener {
if (actionMode != null) clickItem(holder, contactId)
else {
debug("calling MessagesFragment filter:$contactId")
setFragmentResult(
"requestKey",
bundleOf("contactId" to contactId, "contactName" to longName)
)
parentFragmentManager.beginTransaction()
.replace(R.id.mainActivityLayout, MessagesFragment())
.addToBackStack(null)
.commit()
}
}
if (selectedList.contains(contactId)) {
holder.itemView.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 32f
setColor(Color.rgb(127, 127, 127))
}
} else {
holder.itemView.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 32f
setColor(
ContextCompat.getColor(
holder.itemView.context,
R.color.colorAdvancedBackground
)
)
}
}
}
private fun clickItem(
holder: ViewHolder,
contactId: String? = DataPacket.ID_BROADCAST
) {
val position = holder.bindingAdapterPosition
if (contactId != null && !selectedList.contains(contactId)) {
selectedList.add(contactId)
} else {
selectedList.remove(contactId)
}
if (selectedList.isEmpty()) {
// finish action mode when no items selected
actionMode?.finish()
} else {
// show total items selected on action mode title
actionMode?.title = selectedList.size.toString()
}
notifyItemChanged(position)
}
/// Called when our contacts DB changes
fun onContactsChanged(contactsIn: Collection<DataPacket>) {
contacts = contactsIn.sortedByDescending { it.time }.toTypedArray()
notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes
}
/// Called when our message DB changes
fun onMessagesChanged(msgIn: Collection<DataPacket>) {
messages = msgIn.toTypedArray()
}
fun onChannelsChanged() {
val oldBroadcast = contacts.find { it.to == DataPacket.ID_BROADCAST }
if (oldBroadcast != null) {
notifyItemChanged(contacts.indexOf(oldBroadcast))
}
}
}
override fun onPause() {
actionMode?.finish()
super.onPause()
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentContactsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.contactsView.adapter = contactsAdapter
binding.contactsView.layoutManager = LinearLayoutManager(requireContext())
model.channels.observe(viewLifecycleOwner) {
contactsAdapter.onChannelsChanged()
}
model.messagesState.contacts.observe(viewLifecycleOwner) {
debug("New contacts received: ${it.size}")
contactsAdapter.onContactsChanged(it.values)
}
model.messagesState.messages.observe(viewLifecycleOwner) {
contactsAdapter.onMessagesChanged(it)
}
}
}

View File

@@ -11,7 +11,9 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResultListener
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.android.Logging
@@ -41,13 +43,15 @@ fun EditText.on(actionId: Int, func: () -> Unit) {
}
@AndroidEntryPoint
class MessagesFragment : ScreenFragment("Messages"), Logging {
class MessagesFragment : Fragment(), Logging {
private var actionMode: ActionMode? = null
private var _binding: MessagesFragmentBinding? = null
// This property is only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
private var contactId: String = DataPacket.ID_BROADCAST
private var contactName: String = DataPacket.ID_BROADCAST
private val model: UIViewModel by activityViewModels()
@@ -158,14 +162,27 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
val msg = messages[position]
val nodes = model.nodeDB.nodes.value!!
val node = nodes.get(msg.from)
// Determine if this is my message (originated on this device).
// val isMe = model.myNodeInfo.value?.myNodeNum == node?.num
val isMe = msg.from == "^local"
// Determine if this is my message (originated on this device)
val isLocal = msg.from == DataPacket.ID_LOCAL
val isBroadcast = (msg.to == DataPacket.ID_BROADCAST
|| msg.delayed == 1) // MeshProtos.MeshPacket.Delayed.DELAYED_BROADCAST_VALUE == 1
// Filter messages by contactId
if (contactId == DataPacket.ID_BROADCAST) {
if (isBroadcast) {
holder.card.visibility = View.VISIBLE
} else holder.card.visibility = View.GONE
} else {
if (msg.from == contactId && msg.to != DataPacket.ID_BROADCAST
|| msg.from == DataPacket.ID_LOCAL && msg.to == contactId) {
holder.card.visibility = View.VISIBLE
} else holder.card.visibility = View.GONE
}
// Set cardview offset and color.
val marginParams = holder.card.layoutParams as ViewGroup.MarginLayoutParams
val messageOffset = resources.getDimensionPixelOffset(R.dimen.message_offset)
if (isMe) {
if (isLocal) {
marginParams.leftMargin = messageOffset
marginParams.rightMargin = 0
holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_END
@@ -191,7 +208,7 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
}
}
// Hide the username chip for my messages
if (isMe) {
if (isLocal) {
holder.username.visibility = View.GONE
} else {
holder.username.visibility = View.VISIBLE
@@ -259,22 +276,30 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
selectedList.forEach {
model.messagesState.deleteMessage(it)
}
mode.finish()
}
mode.finish()
}
.setNeutralButton(R.string.cancel) { _, _ ->
}
.show()
}
R.id.selectAllButton -> {
// filter messages by ContactId
val messagesByContactId = messages.filter {
if (contactId == DataPacket.ID_BROADCAST)
it.to == DataPacket.ID_BROADCAST
else
it.from == contactId && it.to != DataPacket.ID_BROADCAST
|| it.from == DataPacket.ID_LOCAL && it.to == contactId
}
// if all selected -> unselect all
if (selectedList.size == messages.size) {
if (selectedList.size == messagesByContactId.size) {
selectedList.clear()
mode.finish()
} else {
// else --> select all
selectedList.clear()
selectedList.addAll(messages)
selectedList.addAll(messagesByContactId)
}
actionMode?.title = selectedList.size.toString()
notifyDataSetChanged()
@@ -326,7 +351,7 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
actionMode?.finish()
} else {
// show total items selected on action mode title
actionMode?.title = "${selectedList.size}"
actionMode?.title = selectedList.size.toString()
}
notifyItemChanged(position)
}
@@ -357,12 +382,20 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setFragmentResultListener("requestKey") { _, bundle->
// get the result from bundle
contactId = bundle.getString("contactId").toString()
contactName = bundle.getString("contactName").toString()
binding.messageTitle.text = contactName
}
binding.sendButton.setOnClickListener {
debug("User clicked sendButton")
val str = binding.messageInputText.text.toString().trim()
if (str.isNotEmpty())
model.messagesState.sendMessage(str)
model.messagesState.sendMessage(str, contactId)
binding.messageInputText.setText("") // blow away the string the user just entered
// requireActivity().hideKeyboard()

View File

@@ -136,7 +136,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
} else {
holder.distanceView.visibility = View.INVISIBLE
}
renderBattery(n.batteryPctLevel, n.telemetry?.voltage, holder)
renderBattery(n.batteryPctLevel, n.deviceMetrics?.voltage, holder)
holder.lastTime.text = formatAgo(n.lastHeard)
@@ -146,8 +146,8 @@ class UsersFragment : ScreenFragment("Users"), Logging {
val text =
String.format(
"ChUtil %.1f%% AirUtilTX %.1f%%",
n.telemetry?.channelUtilization ?: info.channelUtilization,
n.telemetry?.airUtilTx ?: info.airUtilTx
n.deviceMetrics?.channelUtilization ?: info.channelUtilization,
n.deviceMetrics?.airUtilTx ?: info.airUtilTx
)
holder.signalView.text = text
holder.signalView.visibility = View.VISIBLE

View File

@@ -62,11 +62,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabIconTint="@color/tab_color_selector"
app:tabIndicatorColor="@color/selectedColor"
>
</com.google.android.material.tabs.TabLayout>
app:tabIndicatorColor="@color/selectedColor" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
@@ -74,10 +70,5 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</FrameLayout>
</FrameLayout>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.card.MaterialCardView
style="@style/Widget.App.CardView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardUseCompatPadding="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.chip.Chip
android:id="@+id/shortName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/some_username"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/longName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/unknown_username"
app:layout_constraintStart_toEndOf="@+id/shortName"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/lastMessageText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:autoLink="all"
android:maxLines="2"
android:text="@string/sample_message"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/shortName"
app:layout_constraintTop_toBottomOf="@id/longName" />
<TextView
android:id="@+id/lastMessageTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:contentDescription="@string/message_reception_time"
android:text="3 minutes ago"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>

View File

@@ -61,9 +61,9 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:contentDescription="@string/message_reception_time"
android:text="3 minutes ago"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/messageStatusIcon"
app:layout_constraintTop_toBottomOf="@id/messageText" />

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/contactsView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -2,21 +2,45 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:background="@color/colorAdvancedBackground">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/MyToolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/colorPrimary"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/messageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/channel_name"
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.MaterialToolbar>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/messageListView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:layout_margin="8dp"
android:contentDescription="@string/text_messages"
app:layout_constraintBottom_toTopOf="@+id/textInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textInputLayout"