From a65944005631364563753551bc3a0f5b77535fdb Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Wed, 2 Oct 2024 11:13:33 -0400 Subject: [PATCH] 1.0.35 Updates for new NodeInfo and protobufs Also fixed the geochat bug where the same text couldnt be shown twice --- app/build.gradle | 13 +- .../com/geeksville/mesh/IMeshService.aidl | 21 +- .../meshtastic/MeshtasticMapComponent.java | 6 +- .../meshtastic/MeshtasticReceiver.java | 8 +- .../java/com/geeksville/mesh/DataPacket.kt | 74 ++++- .../java/com/geeksville/mesh/MyNodeInfo.kt | 5 - .../main/java/com/geeksville/mesh/NodeInfo.kt | 112 +++---- .../com/geeksville/mesh/util/Extensions.kt | 57 ++++ .../com/geeksville/mesh/util/LocationUtils.kt | 309 ++++++++++++++++++ app/src/main/protobufs | 2 +- models/build.gradle | 2 +- 11 files changed, 510 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/util/Extensions.kt create mode 100644 app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt diff --git a/app/build.gradle b/app/build.gradle index 0e3de4d..36eb119 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,7 +9,7 @@ buildscript { - ext.PLUGIN_VERSION = "1.0.33" + ext.PLUGIN_VERSION = "1.0.35" ext.ATAK_VERSION = "4.10.0" def takdevVersion = '2.+' @@ -231,6 +231,17 @@ dependencies { implementation 'net.java.dev.jna:jna:5.13.0@aar' implementation 'com.alphacephei:vosk-android:0.3.47@aar' implementation project(':models') + + // Osmdroid & Maps + def osmdroid_version = '6.1.14' + implementation "org.osmdroid:osmdroid-android:$osmdroid_version" + implementation "org.osmdroid:osmdroid-wms:$osmdroid_version" + implementation("org.osmdroid:osmdroid-geopackage:$osmdroid_version") { + exclude group: 'com.j256.ormlite' + } + implementation 'com.github.MKergall:osmbonuspack:6.9.0' + implementation "mil.nga:mgrs:2.1.3" + } protobuf { diff --git a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl index a1481e3..8fd8065 100644 --- a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl +++ b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl @@ -33,17 +33,12 @@ Once you have bound to the service you should register your broadcast receivers // com.geeksville.mesh.x broadcast intents, where x is: - // RECEIVED_DATA for data received from other nodes. payload will contain a DataPacket, this action is DEPRECATED (because it sends all received data) - // far better to instead use RECEIVED. - // RECEIVED. - will **only** deliver packets for the specified port number. If a wellknown portnums.proto name for portnum is known it will be used // (i.e. com.geeksville.mesh.RECEIVED.TEXT_MESSAGE_APP) else the numeric portnum will be included as a base 10 integer (com.geeksville.mesh.RECEIVED.4403 etc...) // NODE_CHANGE for new IDs appearing or disappearing // CONNECTION_CHANGED for losing/gaining connection to the packet radio - // MESSAGE_STATUS_CHANGED for any message status changes (for sent messages only, other messages come via RECEIVED_DATA. payload will contain a message ID and a MessageStatus) - -At the very least you will probably want to receive RECEIVED_DATA. + // MESSAGE_STATUS_CHANGED for any message status changes (for sent messages only, payload will contain a message ID and a MessageStatus) Note - these calls might throw RemoteException to indicate mesh error states */ @@ -58,7 +53,7 @@ interface IMeshService { */ void setOwner(in MeshUser user); - void setRemoteOwner(in int destNum, in byte []payload); + void setRemoteOwner(in int requestId, in byte []payload); void getRemoteOwner(in int requestId, in int destNum); /// Return my unique user ID string @@ -91,11 +86,11 @@ interface IMeshService { void setConfig(in byte []payload); /// Set and get a Config protobuf via admin packet - void setRemoteConfig(in int destNum, in byte []payload); + void setRemoteConfig(in int requestId, in int destNum, in byte []payload); void getRemoteConfig(in int requestId, in int destNum, in int configTypeValue); /// Set and get a ModuleConfig protobuf via admin packet - void setModuleConfig(in int destNum, in byte []payload); + void setModuleConfig(in int requestId, in int destNum, in byte []payload); void getModuleConfig(in int requestId, in int destNum, in int moduleConfigTypeValue); /// Set and get the Ext Notification Ringtone string via admin packet @@ -111,7 +106,7 @@ interface IMeshService { void setChannel(in byte []payload); /// Set and get a Channel protobuf via admin packet - void setRemoteChannel(in int destNum, in byte []payload); + void setRemoteChannel(in int requestId, in int destNum, in byte []payload); void getRemoteChannel(in int requestId, in int destNum, in int channelIndex); /// Send beginEditSettings admin packet to nodeNum @@ -120,9 +115,15 @@ interface IMeshService { /// Send commitEditSettings admin packet to nodeNum void commitEditSettings(); + /// delete a specific nodeNum from nodeDB + void removeByNodenum(in int requestID, in int nodeNum); + /// Send position packet with wantResponse to nodeNum void requestPosition(in int destNum, in Position position); + /// Send setFixedPosition admin packet (or removeFixedPosition if Position is empty) + void setFixedPosition(in int destNum, in Position position); + /// Send traceroute packet with wantResponse to nodeNum void requestTraceroute(in int requestId, in int destNum); diff --git a/app/src/main/java/com/atakmap/android/meshtastic/MeshtasticMapComponent.java b/app/src/main/java/com/atakmap/android/meshtastic/MeshtasticMapComponent.java index 1670135..c44c74e 100644 --- a/app/src/main/java/com/atakmap/android/meshtastic/MeshtasticMapComponent.java +++ b/app/src/main/java/com/atakmap/android/meshtastic/MeshtasticMapComponent.java @@ -380,11 +380,11 @@ public void processCotEvent(CotEvent cotEvent, String[] strings) { e.printStackTrace(); return; } - +/* if (cotEvent.getUID().startsWith("!") && cotEvent.getType().equals("a-f-G-E-S")) { Log.d(TAG, "Don't forward Meshtastic Nodes"); return; - } else if (cotEvent.getUID().equals(getMapView().getSelfMarker().getUID())) { + } else */if (cotEvent.getUID().equals(getMapView().getSelfMarker().getUID())) { // self PLI report /* @@ -793,7 +793,7 @@ public void onCreate(final Context context, Intent intent, MapView view) { prefs = PreferenceManager.getDefaultSharedPreferences(MapView.getMapView().getContext()); editor = prefs.edit(); editor.putBoolean("plugin_meshtastic_file_transfer", false); - editor.putBoolean("plugin_meshtastic_chunking", true); + editor.putBoolean("plugin_meshtastic_chunking", false); editor.apply(); prefs.registerOnSharedPreferenceChangeListener(this); diff --git a/app/src/main/java/com/atakmap/android/meshtastic/MeshtasticReceiver.java b/app/src/main/java/com/atakmap/android/meshtastic/MeshtasticReceiver.java index 0328be0..2d9a72c 100644 --- a/app/src/main/java/com/atakmap/android/meshtastic/MeshtasticReceiver.java +++ b/app/src/main/java/com/atakmap/android/meshtastic/MeshtasticReceiver.java @@ -17,6 +17,7 @@ import android.os.Bundle; import android.os.Environment; import android.os.RemoteException; +import android.os.SystemClock; import android.preference.PreferenceManager; import android.speech.tts.TextToSpeech; import android.widget.Toast; @@ -472,6 +473,9 @@ public void onReceive(Context context, Intent intent) { contactDetail.setAttribute("endpoint", "0.0.0.0:4242:tcp"); cotDetail.addChild(contactDetail); + CotDetail meshDetail = new CotDetail("__meshtastic"); + cotDetail.addChild(meshDetail); + if (cotEvent.isValid()) { CotMapComponent.getInternalDispatcher().dispatch(cotEvent); if (prefs.getBoolean("plugin_meshtastic_server", false)) { @@ -848,7 +852,7 @@ else if (team.equals("DarkGreen")) String callsign = contact.getCallsign(); String deviceCallsign = contact.getDeviceCallsign(); - String msgId = callsign + "-" + deviceCallsign + "-" + geoChat.getMessage().hashCode(); + String msgId = callsign + "-" + deviceCallsign + "-" + geoChat.getMessage().hashCode() + "-" + System.currentTimeMillis(); //Bundle chatMessage = ChatDatabase.getInstance(_mapView.getContext()).getChatMessage(msgId); //if (chatMessage != null) { @@ -949,7 +953,7 @@ else if (team.equals("DarkGreen")) String to = geoChat.getTo(); String callsign = contact.getCallsign(); String deviceCallsign = contact.getDeviceCallsign(); - String msgId = callsign + "-" + deviceCallsign + "-" + geoChat.getMessage().hashCode(); + String msgId = callsign + "-" + deviceCallsign + "-" + geoChat.getMessage().hashCode() + "-" + System.currentTimeMillis(); //Bundle chatMessage = ChatDatabase.getInstance(_mapView.getContext()).getChatMessage(msgId); //if (chatMessage != null) { diff --git a/app/src/main/java/com/geeksville/mesh/DataPacket.kt b/app/src/main/java/com/geeksville/mesh/DataPacket.kt index 3f494ac..16843b5 100644 --- a/app/src/main/java/com/geeksville/mesh/DataPacket.kt +++ b/app/src/main/java/com/geeksville/mesh/DataPacket.kt @@ -3,6 +3,7 @@ package com.geeksville.mesh import android.os.Parcel import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable /** * Generic [Parcel.readParcelable] Android 13 compatibility extension. @@ -15,6 +16,7 @@ private inline fun Parcel.readParcelableCompat(loader: readParcelable(loader, T::class.java) } } + @Parcelize enum class MessageStatus : Parcelable { UNKNOWN, // Not set for this message @@ -28,16 +30,17 @@ enum class MessageStatus : Parcelable { /** * A parcelable version of the protobuf MeshPacket + Data subpacket. */ +@Serializable data class DataPacket( - var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast - val bytes: ByteArray?, - val dataType: Int, // A port number for this packet (formerly called DataType, see portnums.proto for new usage instructions) - var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost - 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 channel: Int = 0, // channel index + var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast + val bytes: ByteArray?, + val dataType: Int, // A port number for this packet (formerly called DataType, see portnums.proto for new usage instructions) + var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost + 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 channel: Int = 0, // channel index ) : Parcelable { /** @@ -45,18 +48,50 @@ data class DataPacket( */ var errorMessage: String? = null + /** + * Syntactic sugar to make it easy to create text messages + */ + constructor(to: String?, channel: Int, text: String) : this( + to = to, + bytes = text.encodeToByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + channel = channel + ) + + /** + * If this is a text message, return the string, otherwise null + */ + val text: String? + get() = if (dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + bytes?.decodeToString() + 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) + else + null + // Autogenerated comparision, because we have a byte array constructor(parcel: Parcel) : this( - parcel.readString(), - parcel.createByteArray(), - parcel.readInt(), - parcel.readString(), - parcel.readLong(), - parcel.readInt(), - parcel.readParcelableCompat(MessageStatus::class.java.classLoader), - parcel.readInt(), - parcel.readInt(), + parcel.readString(), + parcel.createByteArray(), + parcel.readInt(), + parcel.readString(), + parcel.readLong(), + parcel.readInt(), + parcel.readParcelableCompat(MessageStatus::class.java.classLoader), + parcel.readInt(), + parcel.readInt(), ) override fun equals(other: Any?): Boolean { @@ -132,6 +167,9 @@ data class DataPacket( /// special broadcast address const val NODENUM_BROADCAST = (0xffffffff).toInt() + // Public-key cryptography (PKC) channel index + const val PKC_CHANNEL_INDEX = 8 + fun nodeNumToDefaultId(n: Int): String = "!%08x".format(n) fun idToDefaultNodeNum(id: String?): Int? = id?.toLong(16)?.toInt() diff --git a/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt index 7ce2048..70dc45b 100644 --- a/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt @@ -3,11 +3,6 @@ package com.geeksville.mesh import android.os.Parcelable import kotlinx.parcelize.Parcelize -/** - * Room [Entity] and [PrimaryKey] annotations and imports can be removed when only using the API. - * For details check the AIDL interface in [com.geeksville.mesh.IMeshService] - */ - // MyNodeInfo sent via special protobuf from radio @Parcelize data class MyNodeInfo( diff --git a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt index 2c45d9b..a1185fe 100644 --- a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt @@ -2,6 +2,10 @@ package com.geeksville.mesh import android.graphics.Color import android.os.Parcelable +import com.geeksville.mesh.util.GPSFormat +import com.geeksville.mesh.util.bearing +import com.geeksville.mesh.util.latLongToMeter +import com.geeksville.mesh.util.anonymize import kotlinx.parcelize.Parcelize // @@ -19,7 +23,12 @@ data class MeshUser( ) : Parcelable { override fun toString(): String { - return "MeshUser(id=${id}, longName=${longName}, shortName=${shortName}, hwModel=${hwModelString}, isLicensed=${isLicensed})" + return "MeshUser(id=${id.anonymize}, " + + "longName=${longName.anonymize}, " + + "shortName=${shortName.anonymize}, " + + "hwModel=$hwModelString, " + + "isLicensed=$isLicensed, " + + "role=$role)" } /** Create our model object from a protobuf. @@ -30,17 +39,9 @@ data class MeshUser( p.shortName, p.hwModel, p.isLicensed, + p.roleValue ) - fun toProto(): MeshProtos.User = - MeshProtos.User.newBuilder() - .setId(id) - .setLongName(longName) - .setShortName(shortName) - .setHwModel(hwModel) - .setIsLicensed(isLicensed) - .build() - /** a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot * or null if unset * */ @@ -84,6 +85,12 @@ data class Position( position.precisionBits ) + /// @return distance in meters to some other node (or null if unknown) + fun distance(o: Position) = latLongToMeter(latitude, longitude, o.latitude, o.longitude) + + /// @return bearing to the other position in degrees + fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude) + // If GPS gives a crap position don't crash our app fun isValid(): Boolean { return latitude != 0.0 && longitude != 0.0 && @@ -91,8 +98,16 @@ data class Position( (longitude >= -180 && longitude <= 180) } + fun gpsString(gpsFormat: Int): String = when (gpsFormat) { + ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.DEC(this) + ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.DMS(this) + ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.UTM(this) + ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.MGRS(this) + else -> GPSFormat.DEC(this) + } + override fun toString(): String { - return "Position(lat=${latitude}, lon=${longitude}, alt=${altitude}, time=${time})" + return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})" } } @@ -117,12 +132,9 @@ data class DeviceMetrics( p.batteryLevel, p.voltage, p.channelUtilization, - p.airUtilTx + p.airUtilTx, + p.uptimeSeconds, ) - - override fun toString(): String { - return "DeviceMetrics(time=${time}, batteryLevel=${batteryLevel}, voltage=${voltage}, channelUtilization=${channelUtilization}, airUtilTx=${airUtilTx})" - } } @Parcelize @@ -139,48 +151,6 @@ data class EnvironmentMetrics( companion object { fun currentTime() = (System.currentTimeMillis() / 1000).toInt() } - - /** Create our model object from a protobuf. - */ - constructor(t: TelemetryProtos.EnvironmentMetrics, telemetryTime: Int = currentTime()) : this( - telemetryTime, - t.temperature, - t.relativeHumidity, - t.barometricPressure, - t.gasResistance, - t.voltage, - t.current - ) - - override fun toString(): String { - return "EnvironmentMetrics(time=${time}, temperature=${temperature}, humidity=${relativeHumidity}, pressure=${barometricPressure}), resistance=${gasResistance}, voltage=${voltage}, current=${current}" - } - - fun getDisplayString(inFahrenheit: Boolean = false): String { - val temp = if (temperature != 0f) { - if (inFahrenheit) { - val fahrenheit = temperature * 1.8F + 32 - String.format("%.1f°F", fahrenheit) - } else { - String.format("%.1f°C", temperature) - } - } else null - val humidity = if (relativeHumidity != 0f) String.format("%.0f%%", relativeHumidity) else null - val pressure = if (barometricPressure != 0f) String.format("%.1fhPa", barometricPressure) else null - val gas = if (gasResistance != 0f) String.format("%.0fMΩ", gasResistance) else null - val voltage = if (voltage != 0f) String.format("%.2fV", voltage) else null - val current = if (current != 0f) String.format("%.1fmA", current) else null - - return listOfNotNull( - temp, - humidity, - pressure, - gas, - voltage, - current - ).joinToString(" ") - } - } @Parcelize @@ -225,4 +195,30 @@ data class NodeInfo( get() { return position?.takeIf { it.isValid() } } + + /// @return distance in meters to some other node (or null if unknown) + fun distance(o: NodeInfo?): Int? { + val p = validPosition + val op = o?.validPosition + return if (p != null && op != null) p.distance(op).toInt() else null + } + + /// @return bearing to the other position in degrees + fun bearing(o: NodeInfo?): Int? { + val p = validPosition + val op = o?.validPosition + return if (p != null && op != null) p.bearing(op).toInt() else null + } + + /// @return a nice human readable string for the distance, or null for unknown + fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist -> + when { + dist == 0 -> null // same point + prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 -> "%.0f m".format(dist.toDouble()) + prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 -> "%.1f km".format(dist / 1000.0) + prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist < 1609 -> "%.0f ft".format(dist.toDouble()*3.281) + prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 -> "%.1f mi".format(dist / 1609.34) + else -> null + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/util/Extensions.kt b/app/src/main/java/com/geeksville/mesh/util/Extensions.kt new file mode 100644 index 0000000..dc3581e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/Extensions.kt @@ -0,0 +1,57 @@ +package com.geeksville.mesh.util + +import android.widget.EditText +import com.geeksville.mesh.ConfigProtos + +/** + * When printing strings to logs sometimes we want to print useful debugging information about users + * or positions. But we don't want to leak things like usernames or locations. So this function + * if given a string, will return a string which is a maximum of three characters long, taken from the tail + * of the string. Which should effectively hide real usernames and locations, + * but still let us see if values were zero, empty or different. + */ +val Any?.anonymize: String + get() = this.anonymize() + +/** + * A version of anonymize that allows passing in a custom minimum length + */ +fun Any?.anonymize(maxLen: Int = 3) = + if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null" + +/// A toString that makes sure all newlines are removed (for nice logging). +fun Any.toOneLineString() = this.toString().replace('\n', ' ') + +fun ConfigProtos.Config.toOneLineString(): String { + val redactedFields = """(wifi_psk:|public_key:|private_key:|admin_key:)\s*".*""" + return this.toString() + .replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" } + .replace('\n', ' ') +} + +/// Return a one line string version of an object (but if a release build, just say 'might be PII) +fun Any.toPIIString() = "" +fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } + +fun formatAgo(lastSeenUnix: Int, currentTimeMillis: Long = System.currentTimeMillis()): String { + val currentTime = (currentTimeMillis / 1000).toInt() + val diffMin = (currentTime - lastSeenUnix) / 60 + return when { + diffMin < 1 -> "now" + diffMin < 60 -> diffMin.toString() + " min" + diffMin < 2880 -> (diffMin / 60).toString() + " h" + diffMin < 1440000 -> (diffMin / (60 * 24)).toString() + " d" + else -> "?" + } +} + +/// Allows usage like email.onEditorAction(EditorInfo.IME_ACTION_NEXT, { confirm() }) +fun EditText.onEditorAction(actionId: Int, func: () -> Unit) { + setOnEditorActionListener { _, receivedActionId, _ -> + + if (actionId == receivedActionId) { + func() + } + true + } +} diff --git a/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt b/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt new file mode 100644 index 0000000..8fef9c5 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt @@ -0,0 +1,309 @@ +package com.geeksville.mesh.util + +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.Position +import mil.nga.grid.features.Point +import mil.nga.mgrs.MGRS +import mil.nga.mgrs.utm.UTM +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.log2 +import kotlin.math.pow +import kotlin.math.sin + +/******************************************************************************* + * Revive some of my old Gaggle source code... + * + * GNU Public License, version 2 + * All other distribution of Gaggle must conform to the terms of the GNU Public License, version 2. The full + * text of this license is included in the Gaggle source, see assets/manual/gpl-2.0.txt. + ******************************************************************************/ + +object GPSFormat { + fun DEC(p: Position): String { + return String.format("%.5f %.5f", p.latitude, p.longitude).replace(",", ".") + } + + fun DMS(p: Position): String { + val lat = degreesToDMS(p.latitude, true) + val lon = degreesToDMS(p.longitude, false) + fun string(a: Array) = String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3]) + return string(lat) + " " + string(lon) + } + + fun UTM(p: Position): String { + val UTM = UTM.from(Point.point(p.longitude, p.latitude)) + return String.format( + "%s%s %.6s %.7s", + UTM.zone, + UTM.toMGRS().band, + UTM.easting, + UTM.northing + ) + } + + fun MGRS(p: Position): String { + val MGRS = MGRS.from(Point.point(p.longitude, p.latitude)) + return String.format( + "%s%s %s%s %05d %05d", + MGRS.zone, + MGRS.band, + MGRS.column, + MGRS.row, + MGRS.easting, + MGRS.northing + ) + } + + fun toDEC(latitude: Double, longitude: Double): String { + return "%.5f %.5f".format(latitude, longitude).replace(",", ".") + } + + fun toDMS(latitude: Double, longitude: Double): String { + val lat = degreesToDMS(latitude, true) + val lon = degreesToDMS(longitude, false) + fun string(a: Array) = "%s°%s'%.5s\"%s".format(a[0], a[1], a[2], a[3]) + return string(lat) + " " + string(lon) + } + + fun toUTM(latitude: Double, longitude: Double): String { + val UTM = UTM.from(Point.point(longitude, latitude)) + return "%s%s %.6s %.7s".format(UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing) + } + + fun toMGRS(latitude: Double, longitude: Double): String { + val MGRS = MGRS.from(Point.point(longitude, latitude)) + return "%s%s %s%s %05d %05d".format( + MGRS.zone, + MGRS.band, + MGRS.column, + MGRS.row, + MGRS.easting, + MGRS.northing + ) + } +} + +/** + * Format as degrees, minutes, secs + * + * @param degIn + * @param isLatitude + * @return a string like 120deg + */ +fun degreesToDMS( + _degIn: Double, + isLatitude: Boolean +): Array { + var degIn = _degIn + val isPos = degIn >= 0 + val dirLetter = + if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W' + degIn = abs(degIn) + val degOut = degIn.toInt() + val minutes = 60 * (degIn - degOut) + val minwhole = minutes.toInt() + val seconds = (minutes - minwhole) * 60 + return arrayOf( + degOut.toString(), minwhole.toString(), + seconds.toString(), + dirLetter.toString() + ) +} + +fun degreesToDM(_degIn: Double, isLatitude: Boolean): Array { + var degIn = _degIn + val isPos = degIn >= 0 + val dirLetter = + if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W' + degIn = abs(degIn) + val degOut = degIn.toInt() + val minutes = 60 * (degIn - degOut) + val seconds = 0 + return arrayOf( + degOut.toString(), minutes.toString(), + seconds.toString(), + dirLetter.toString() + ) +} + +fun degreesToD(_degIn: Double, isLatitude: Boolean): Array { + var degIn = _degIn + val isPos = degIn >= 0 + val dirLetter = + if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W' + degIn = abs(degIn) + val degOut = degIn + val minutes = 0 + val seconds = 0 + return arrayOf( + degOut.toString(), minutes.toString(), + seconds.toString(), + dirLetter.toString() + ) +} + +/** + * A not super efficent mapping from a starting lat/long + a distance at a + * certain direction + * + * @param lat + * @param longitude + * @param distMeters + * @param theta + * in radians, 0 == north + * @return an array with lat and long + */ +fun addDistance( + lat: Double, + longitude: Double, + distMeters: Double, + theta: Double +): DoubleArray { + val dx = distMeters * sin(theta) // theta measured clockwise + // from due north + val dy = distMeters * cos(theta) // dx, dy same units as R + val dLong = dx / (111320 * cos(lat)) // dx, dy in meters + val dLat = dy / 110540 // result in degrees long/lat + return doubleArrayOf(lat + dLat, longitude + dLong) +} + +/** + * @return distance in meters along the surface of the earth (ish) + */ +fun latLongToMeter( + lat_a: Double, + lng_a: Double, + lat_b: Double, + lng_b: Double +): Double { + val pk = (180 / 3.14169) + val a1 = lat_a / pk + val a2 = lng_a / pk + val b1 = lat_b / pk + val b2 = lng_b / pk + val t1 = cos(a1) * cos(a2) * cos(b1) * cos(b2) + val t2 = cos(a1) * sin(a2) * cos(b1) * sin(b2) + val t3 = sin(a1) * sin(b1) + var tt = acos(t1 + t2 + t3) + if (java.lang.Double.isNaN(tt)) tt = 0.0 // Must have been the same point? + return 6366000 * tt +} + +// Same as above, but takes Mesh Position proto. +fun positionToMeter(a: MeshProtos.Position, b: MeshProtos.Position): Double { + return latLongToMeter( + a.latitudeI * 1e-7, + a.longitudeI * 1e-7, + b.latitudeI * 1e-7, + b.longitudeI * 1e-7 + ) +} + +/** + * Convert degrees/mins/secs to a single double + * + * @param degrees + * @param minutes + * @param seconds + * @param isPostive + * @return + */ +fun DMSToDegrees( + degrees: Int, + minutes: Int, + seconds: Float, + isPostive: Boolean +): Double { + return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0) +} + +fun DMSToDegrees( + degrees: Double, + minutes: Double, + seconds: Double, + isPostive: Boolean +): Double { + return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0) +} + +/** + * Computes the bearing in degrees between two points on Earth. + * + * @param lat1 + * Latitude of the first point + * @param lon1 + * Longitude of the first point + * @param lat2 + * Latitude of the second point + * @param lon2 + * Longitude of the second point + * @return Bearing between the two points in degrees. A value of 0 means due + * north. + */ +fun bearing( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double +): Double { + val lat1Rad = Math.toRadians(lat1) + val lat2Rad = Math.toRadians(lat2) + val deltaLonRad = Math.toRadians(lon2 - lon1) + val y = sin(deltaLonRad) * cos(lat2Rad) + val x = cos(lat1Rad) * sin(lat2Rad) - (sin(lat1Rad) * cos(lat2Rad) * cos(deltaLonRad)) + return radToBearing(atan2(y, x)) +} + +/** + * Converts an angle in radians to degrees + */ +fun radToBearing(rad: Double): Double { + return (Math.toDegrees(rad) + 360) % 360 +} + +/** + * Calculates the zoom level required to fit the entire [BoundingBox] inside the map view. + * @return The zoom level as a Double value. + */ +fun BoundingBox.requiredZoomLevel(): Double { + val topLeft = GeoPoint(this.latNorth, this.lonWest) + val bottomRight = GeoPoint(this.latSouth, this.lonEast) + val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude)) + val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude)) + val requiredLatZoom = log2(360.0 / (latLonHeight / 111320)) + val requiredLonZoom = log2(360.0 / (latLonWidth / 111320)) + return maxOf(requiredLatZoom, requiredLonZoom) +} + +/** + * Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor]. + * @return A new [BoundingBox] with added [zoomFactor]. Example: + * ``` + * // Setting the zoom level directly using setZoom() + * map.setZoom(14.0) + * val boundingBoxZoom14 = map.boundingBox + * + * // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0) + * val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0) + * ``` + */ +fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox { + val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2) + val latDiff = latNorth - latSouth + val lonDiff = lonEast - lonWest + + val newLatDiff = latDiff / (2.0.pow(zoomFactor)) + val newLonDiff = lonDiff / (2.0.pow(zoomFactor)) + + return BoundingBox( + center.latitude + newLatDiff / 2, + center.longitude + newLonDiff / 2, + center.latitude - newLatDiff / 2, + center.longitude - newLonDiff / 2 + ) +} diff --git a/app/src/main/protobufs b/app/src/main/protobufs index 86640f2..62c4b00 160000 --- a/app/src/main/protobufs +++ b/app/src/main/protobufs @@ -1 +1 @@ -Subproject commit 86640f20db7b9b5be42949d18e8d96ad10d47a68 +Subproject commit 62c4b0081c8217a139484a24a47e9b35e133ebf3 diff --git a/models/build.gradle b/models/build.gradle index ad366fc..2aa5a59 100644 --- a/models/build.gradle +++ b/models/build.gradle @@ -8,7 +8,7 @@ android { targetSdkVersion 33 } buildFeatures { - buildConfig = false + buildConfig = true } sourceSets { main {