diff --git a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl index a440b7ef..a33b3800 100644 --- a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl +++ b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl @@ -61,6 +61,9 @@ interface IMeshService { /// Return my unique user ID string String getMyId(); + /// Return a unique packet ID + int getPacketId(); + /* Send a packet to a specified node name diff --git a/app/src/main/java/com/geeksville/mesh/DataPacket.kt b/app/src/main/java/com/geeksville/mesh/DataPacket.kt index 556987df..8be25e31 100644 --- a/app/src/main/java/com/geeksville/mesh/DataPacket.kt +++ b/app/src/main/java/com/geeksville/mesh/DataPacket.kt @@ -55,6 +55,13 @@ data class DataPacket( else null + constructor(to: String?, channel: Int, waypoint: MeshProtos.Waypoint) : this( + to = to, + bytes = waypoint.toByteArray(), + dataType = Portnums.PortNum.WAYPOINT_APP_VALUE, + channel = channel + ) + val waypoint: MeshProtos.Waypoint? get() = if (dataType == Portnums.PortNum.WAYPOINT_APP_VALUE) MeshProtos.Waypoint.parseFrom(bytes) @@ -149,6 +156,7 @@ data class DataPacket( const val NODENUM_BROADCAST = (0xffffffff).toInt() fun nodeNumToDefaultId(n: Int): String = "!%08x".format(n) + fun idToDefaultNodeNum(id: String?): Int? = id?.toLong(16)?.toInt() override fun createFromParcel(parcel: Parcel): DataPacket { return DataPacket(parcel) diff --git a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt index 33fbfefa..9775bb0d 100644 --- a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt @@ -45,6 +45,11 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz suspend fun deleteMessages(uuidList: List) = withContext(Dispatchers.IO) { packetDao.deleteMessages(uuidList) } + + suspend fun deleteWaypoint(id: Int) = withContext(Dispatchers.IO) { + packetDao.deleteWaypoint(id) + } + suspend fun delete(packet: Packet) = withContext(Dispatchers.IO) { packetDao.delete(packet) } diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt index 29625f91..85cfabf5 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt @@ -59,4 +59,13 @@ interface PacketDao { @Transaction fun getQueuedPackets(): List? = getDataPackets().filter { it.status in setOf(MessageStatus.ENROUTE, MessageStatus.QUEUED) } + + @Query("Select * from packet where port_num = 8 order by received_time asc") + fun getAllWaypoints(): List + + @Transaction + fun deleteWaypoint(id: Int) { + val uuidList = getAllWaypoints().filter { it.data.waypoint?.id == id }.map { it.uuid } + deleteMessages(uuidList) + } } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 590d1e95..8ef09757 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -162,12 +162,34 @@ class UIViewModel @Inject constructor( } }.asLiveData() + fun generatePacketId(): Int? { + return try { + meshService?.packetId + } catch (ex: RemoteException) { + errormsg("RemoteException: ${ex.message}") + return null + } + } + fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) val channel = contactKey[0].digitToIntOrNull() val dest = if (channel != null) contactKey.substring(1) else contactKey val p = DataPacket(dest, channel ?: 0, str) + sendDataPacket(p) + } + + fun sendWaypoint(wpt: MeshProtos.Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { + // contactKey: unique contact key filter (channel)+(nodeId) + val channel = contactKey[0].digitToIntOrNull() + val dest = if (channel != null) contactKey.substring(1) else contactKey + + val p = DataPacket(dest, channel ?: 0, wpt) + if (wpt.id != 0) sendDataPacket(p.copy(id = wpt.id)) + } + + private fun sendDataPacket(p: DataPacket) { try { meshService?.send(p) } catch (ex: RemoteException) { @@ -195,6 +217,10 @@ class UIViewModel @Inject constructor( packetRepository.deleteMessages(uuidList) } + fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { + packetRepository.deleteWaypoint(id) + } + companion object { fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE) @@ -210,7 +236,7 @@ class UIViewModel @Inject constructor( private val _connectionState = MutableLiveData(MeshService.ConnectionState.DISCONNECTED) val connectionState: LiveData get() = _connectionState - fun isConnected() = _connectionState.value == MeshService.ConnectionState.CONNECTED + fun isConnected() = _connectionState.value != MeshService.ConnectionState.DISCONNECTED fun setConnectionState(connectionState: MeshService.ConnectionState) { _connectionState.value = connectionState 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 25319795..f61adf19 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -1627,6 +1627,8 @@ class MeshService : Service(), Logging { override fun getMyId() = toRemoteExceptions { myNodeID } + override fun getPacketId() = toRemoteExceptions { generatePacketId() } + override fun setOwner(myId: String?, longName: String, shortName: String, isLicensed: Boolean) = toRemoteExceptions { this@MeshService.setOwner(myId, longName, shortName, isLicensed) diff --git a/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt index 9abc6950..44c98c19 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt @@ -8,11 +8,19 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.Rect import android.os.Bundle -import android.view.* -import android.widget.* +import android.view.HapticFeedbackConstants +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import android.widget.Toast import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.DataPacket import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging @@ -23,10 +31,13 @@ import com.geeksville.mesh.model.map.CustomOverlayManager import com.geeksville.mesh.model.map.CustomTileSource import com.geeksville.mesh.util.SqlTileWriterExt import com.geeksville.mesh.util.formatAgo +import com.geeksville.mesh.waypoint import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.switchmaterial.SwitchMaterial import dagger.hilt.android.AndroidEntryPoint import org.osmdroid.api.IMapController import org.osmdroid.config.Configuration +import org.osmdroid.events.MapEventsReceiver import org.osmdroid.events.MapListener import org.osmdroid.events.ScrollEvent import org.osmdroid.events.ZoomEvent @@ -40,8 +51,12 @@ import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.* +import org.osmdroid.views.overlay.CopyrightOverlay +import org.osmdroid.views.overlay.MapEventsOverlay +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polygon import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2 +import org.osmdroid.views.overlay.infowindow.InfoWindow import java.io.File import kotlin.math.log2 @@ -141,6 +156,10 @@ class MapFragment : ScreenFragment("Map Fragment"), Logging, View.OnClickListene } } + private fun performHapticFeedback() = requireView().performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS, + HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING + ) private fun showCacheManagerDialog() { val alertDialogBuilder = AlertDialog.Builder( @@ -249,6 +268,22 @@ class MapFragment : ScreenFragment("Map Fragment"), Logging, View.OnClickListene }.start() } + fun showMarkerLongPressDialog(id: Int) { + debug("marker long pressed id=${id}") + MaterialAlertDialogBuilder(requireContext()) + .setTitle("${getString(R.string.delete)}?") + .setNeutralButton(R.string.cancel) { _, _ -> + debug("User canceled marker edit dialog") + } +// .setNegativeButton(R.string.edit) { _, _ -> +// debug("Negative button pressed") // TODO add Edit option +// } + .setPositiveButton(getString(R.string.delete)) { _, _ -> + debug("User deleted local waypoint $id") + model.deleteWaypoint(id) + } + .show() + } private fun downloadJobAlert() { //prompt for input params . @@ -423,6 +458,9 @@ class MapFragment : ScreenFragment("Map Fragment"), Logging, View.OnClickListene } } + private fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL) getString(R.string.you) + else model.nodeDB.nodes.value?.get(id)?.user?.longName ?: getString(R.string.unknown_username) + private fun onWaypointChanged(wayPt: Collection) { /** @@ -430,17 +468,19 @@ class MapFragment : ScreenFragment("Map Fragment"), Logging, View.OnClickListene */ // Find all waypoints fun getCurrentWayPoints(): List { + debug("Showing on map: ${wayPt.size} waypoints") val wayPoint = wayPt.map { pt -> - debug("Showing on map: $pt") lateinit var marker: MarkerWithLabel pt.data.waypoint?.let { - val label = it.name + " " + formatAgo(it.expire) - marker = MarkerWithLabel(map, label, String(Character.toChars(it.icon))) - marker.title = it.name + val lock = if (it.lockedTo != 0) "\uD83D\uDD12" else "" + val label = it.name + " " + formatAgo((pt.received_time / 1000).toInt()) + val emoji = String(Character.toChars(if (it.icon == 0) 128205 else it.icon)) + marker = MarkerWithLabel(map, label, emoji) + marker.id = "${it.id}" + marker.title = "${it.name} (${getUsername(pt.data.from)}$lock)" marker.snippet = it.description - marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - marker.position = GeoPoint(it.latitudeI.toDouble(), it.longitudeI.toDouble()) - marker.icon = android.graphics.drawable.ColorDrawable(Color.TRANSPARENT) + marker.position = GeoPoint(it.latitudeI * 1e-7, it.longitudeI * 1e-7) + marker.setVisible(false) } marker } @@ -516,6 +556,46 @@ class MapFragment : ScreenFragment("Map Fragment"), Logging, View.OnClickListene createLatLongGrid(false) map.overlayManager.addAll(nodeLayer, nodePositions) map.overlayManager.addAll(nodeLayer, wayPoints) + map.overlayManager.add(nodeLayer, MapEventsOverlay(object : MapEventsReceiver { + override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { + InfoWindow.closeAllInfoWindowsOn(map) + return true + } + + override fun longPressHelper(p: GeoPoint): Boolean { + performHapticFeedback() + if (!model.isConnected()) return true + + val layout = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_add_waypoint, null) + + val nameInput: EditText = layout.findViewById(R.id.waypointName) + val descriptionInput: EditText= layout.findViewById(R.id.waypointDescription) + val lockedInput: SwitchMaterial = layout.findViewById(R.id.waypointLocked) + + MaterialAlertDialogBuilder(requireContext()) + .setView(layout) + .setNeutralButton(R.string.cancel) { _, _ -> + debug("User canceled marker create dialog") + } + .setPositiveButton(getString(R.string.save)) { _, _ -> + debug("User created waypoint") + model.sendWaypoint(waypoint { + name = nameInput.text.toString().ifEmpty { return@setPositiveButton } + description = descriptionInput.text.toString() + id = model.generatePacketId() ?: return@setPositiveButton + latitudeI = (p.latitude * 1e7).toInt() + longitudeI = (p.longitude * 1e7).toInt() + expire = Int.MAX_VALUE // TODO add expire picker + icon = 0 // TODO add emoji picker + lockedTo = if (!lockedInput.isChecked) 0 + else model.myNodeInfo.value?.myNodeNum ?: 0 + }) + } + .show() + return true + } + })) map.invalidate() } @@ -671,6 +751,15 @@ class MapFragment : ScreenFragment("Map Fragment"), Logging, View.OnClickListene ) } + override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean { + val touched = hitTest(event, mapView) + if (touched && this.id != null) { + performHapticFeedback() + this.id.toIntOrNull()?.run(::showMarkerLongPressDialog) + } + return super.onLongPress(event, mapView) + } + override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) { super.draw(c, osmv, false) val p = mPositionPixels 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 8ee7cd8d..008cb16a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -26,7 +26,6 @@ import com.geeksville.mesh.database.entity.QuickChatAction import com.geeksville.mesh.databinding.AdapterMessageLayoutBinding import com.geeksville.mesh.databinding.MessagesFragmentBinding import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.service.MeshService import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint @@ -298,9 +297,9 @@ class MessagesFragment : Fragment(), Logging { } // If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages - model.connectionState.observe(viewLifecycleOwner) { connectionState -> + model.connectionState.observe(viewLifecycleOwner) { // If we don't know our node ID and we are offline don't let user try to send - isConnected = connectionState != MeshService.ConnectionState.DISCONNECTED + isConnected = model.isConnected() binding.textInputLayout.isEnabled = isConnected binding.sendButton.isEnabled = isConnected for (subView: View in binding.quickChatLayout.allViews) { diff --git a/app/src/main/res/layout/dialog_add_waypoint.xml b/app/src/main/res/layout/dialog_add_waypoint.xml new file mode 100644 index 00000000..ec69e334 --- /dev/null +++ b/app/src/main/res/layout/dialog_add_waypoint.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f30e797a..282dddbb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,8 +18,10 @@ Connection status application icon Unknown Username + Send Send Text You haven\'t yet paired a Meshtastic compatible radio with this phone. Please pair a device and set your username.\n\nThis open-source application is in development, if you find problems please post on our forum: meshtastic.discourse.group.\n\nFor more information see our web page - www.meshtastic.org. + You Your Name Anonymous usage statistics and crash reports. Looking for Meshtastic devices… @@ -114,6 +116,8 @@ Style Selection Download Region Name + Description + Locked Save Language System default