diff --git a/.gitmodules b/.gitmodules
index 5b0a38e1e..8ce701868 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,6 +1,9 @@
-[submodule "app/src/main/proto"]
+[submodule "app proto submodule"]
path = app/src/main/proto
url = https://github.com/meshtastic/protobufs.git
+[submodule "mesh_service_example proto submodule"]
+ path = mesh_service_example/src/main/proto
+ url = https://github.com/meshtastic/protobufs.git
[submodule "design"]
path = design
url = https://github.com/meshtastic/design.git
diff --git a/config/detekt/detekt-baseline-meshserviceexample.xml b/config/detekt/detekt-baseline-meshserviceexample.xml
new file mode 100644
index 000000000..b577057f4
--- /dev/null
+++ b/config/detekt/detekt-baseline-meshserviceexample.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+ CommentSpacing:NodeInfo.kt$NodeInfo$/// @return a nice human readable string for the distance, or null for unknown
+ CommentSpacing:NodeInfo.kt$NodeInfo$/// @return bearing to the other position in degrees
+ CommentSpacing:NodeInfo.kt$NodeInfo$/// @return distance in meters to some other node (or null if unknown)
+ CommentSpacing:NodeInfo.kt$NodeInfo$/// return the position if it is valid, else null
+ CommentSpacing:NodeInfo.kt$Position$/// @return bearing to the other position in degrees
+ CommentSpacing:NodeInfo.kt$Position$/// @return distance in meters to some other node (or null if unknown)
+ CommentSpacing:NodeInfo.kt$Position.Companion$/// Convert to a double representation of degrees
+ FinalNewline:DataPacket.kt$com.geeksville.mesh.DataPacket.kt
+ FinalNewline:MyNodeInfo.kt$com.geeksville.mesh.MyNodeInfo.kt
+ FinalNewline:NodeInfo.kt$com.geeksville.mesh.NodeInfo.kt
+ FunctionParameterNaming:LocationUtils.kt$_degIn: Double
+ FunctionParameterNaming:LocationUtils.kt$lat_a: Double
+ FunctionParameterNaming:LocationUtils.kt$lat_b: Double
+ FunctionParameterNaming:LocationUtils.kt$lng_a: Double
+ FunctionParameterNaming:LocationUtils.kt$lng_b: Double
+ ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format( "%s%s %.6s %.7s", UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing )
+ ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format( "%s%s %s%s %05d %05d", MGRS.zone, MGRS.band, MGRS.column, MGRS.row, MGRS.easting, MGRS.northing )
+ ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format("%.5f %.5f", p.latitude, p.longitude)
+ ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3])
+ ImplicitDefaultLocale:NodeInfo.kt$NodeInfo$String.format("%d%%", batteryLevel)
+ MagicNumber:DataPacket.kt$DataPacket.CREATOR$16
+ MagicNumber:Extensions.kt$1000
+ MagicNumber:Extensions.kt$1440000
+ MagicNumber:Extensions.kt$24
+ MagicNumber:Extensions.kt$2880
+ MagicNumber:Extensions.kt$60
+ MagicNumber:LocationUtils.kt$0.8
+ MagicNumber:LocationUtils.kt$110540
+ MagicNumber:LocationUtils.kt$111320
+ MagicNumber:LocationUtils.kt$180
+ MagicNumber:LocationUtils.kt$1e-7
+ MagicNumber:LocationUtils.kt$360
+ MagicNumber:LocationUtils.kt$360.0
+ MagicNumber:LocationUtils.kt$3600.0
+ MagicNumber:LocationUtils.kt$60
+ MagicNumber:LocationUtils.kt$60.0
+ MagicNumber:LocationUtils.kt$6366000
+ MagicNumber:LocationUtils.kt$GPSFormat$3
+ MagicNumber:NodeInfo.kt$DeviceMetrics.Companion$1000
+ MagicNumber:NodeInfo.kt$EnvironmentMetrics.Companion$1000
+ MagicNumber:NodeInfo.kt$NodeInfo$0.114
+ MagicNumber:NodeInfo.kt$NodeInfo$0.299
+ MagicNumber:NodeInfo.kt$NodeInfo$0.587
+ MagicNumber:NodeInfo.kt$NodeInfo$0x0000FF
+ MagicNumber:NodeInfo.kt$NodeInfo$0x00FF00
+ MagicNumber:NodeInfo.kt$NodeInfo$0xFF0000
+ MagicNumber:NodeInfo.kt$NodeInfo$1000
+ MagicNumber:NodeInfo.kt$NodeInfo$1000.0
+ MagicNumber:NodeInfo.kt$NodeInfo$15
+ MagicNumber:NodeInfo.kt$NodeInfo$16
+ MagicNumber:NodeInfo.kt$NodeInfo$1609
+ MagicNumber:NodeInfo.kt$NodeInfo$1609.34
+ MagicNumber:NodeInfo.kt$NodeInfo$255
+ MagicNumber:NodeInfo.kt$NodeInfo$3.281
+ MagicNumber:NodeInfo.kt$NodeInfo$60
+ MagicNumber:NodeInfo.kt$NodeInfo$8
+ MagicNumber:NodeInfo.kt$Position$180
+ MagicNumber:NodeInfo.kt$Position$90
+ MagicNumber:NodeInfo.kt$Position$90.0
+ MagicNumber:NodeInfo.kt$Position.Companion$1000
+ MagicNumber:NodeInfo.kt$Position.Companion$1e-7
+ MagicNumber:NodeInfo.kt$Position.Companion$1e7
+ MatchingDeclarationName:LocationUtils.kt$GPSFormat
+ MaxLineLength:DataPacket.kt$DataPacket$val dataType: Int
+ MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist < 1609 -> "%.0f ft".format(dist.toDouble()*3.281)
+ MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 -> "%.1f mi".format(dist / 1609.34)
+ MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 -> "%.0f m".format(dist.toDouble())
+ MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 -> "%.1f km".format(dist / 1000.0)
+ MaxLineLength:NodeInfo.kt$Position$/**
+ MaxLineLength:NodeInfo.kt$Position$return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})"
+ MultiLineIfElse:NodeInfo.kt$MeshUser$hwModel.name.replace('_', '-').replace('p', '.').lowercase()
+ MultiLineIfElse:NodeInfo.kt$MeshUser$null
+ NewLineAtEndOfFile:DataPacket.kt$com.geeksville.mesh.DataPacket.kt
+ NewLineAtEndOfFile:MyNodeInfo.kt$com.geeksville.mesh.MyNodeInfo.kt
+ NewLineAtEndOfFile:NodeInfo.kt$com.geeksville.mesh.NodeInfo.kt
+ NoConsecutiveBlankLines:NodeInfo.kt$
+ SpacingAroundOperators:NodeInfo.kt$NodeInfo$*
+ StringTemplate:NodeInfo.kt$Position$${time}
+ TooManyFunctions:LocationUtils.kt$com.geeksville.mesh.util.LocationUtils.kt
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 06b229a80..1774a20a4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -50,6 +50,7 @@ zxing-core = "3.5.3"
[libraries]
agp = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
+activity = { group = "androidx.activity", name = "activity" }
actvity-ktx = { group = "androidx.activity", name = "activity-ktx" }
activity-compose = { group = "androidx.activity", name = "activity-compose" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
diff --git a/mesh_service_example/.gitignore b/mesh_service_example/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/mesh_service_example/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/mesh_service_example/build.gradle.kts b/mesh_service_example/build.gradle.kts
new file mode 100644
index 000000000..d6ef127ac
--- /dev/null
+++ b/mesh_service_example/build.gradle.kts
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.parcelize)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.compose)
+ alias(libs.plugins.protobuf)
+ alias(libs.plugins.detekt)
+}
+
+android {
+ namespace = "com.meshtastic.android.meshserviceexample"
+ compileSdk = Configs.COMPILE_SDK
+
+ defaultConfig {
+ applicationId = "com.meshtastic.android.meshserviceexample"
+ minSdk = 26
+ targetSdk = Configs.TARGET_SDK
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11" // match Java 11
+ }
+ buildFeatures {
+ aidl = true
+ }
+}
+
+// per protobuf-gradle-plugin docs, this is recommended for android
+protobuf {
+ protoc {
+ artifact = libs.protobuf.protoc.get().toString()
+ }
+ generateProtoTasks {
+ all().forEach { task ->
+ task.builtins {
+ create("java")
+ create("kotlin")
+ }
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.appcompat)
+ implementation(libs.material)
+ implementation(libs.activity)
+ implementation(libs.constraintlayout)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.ext.junit)
+ androidTestImplementation(libs.espresso.core)
+
+ implementation(libs.bundles.androidx)
+ implementation(libs.bundles.protobuf)
+
+ implementation(libs.kotlinx.serialization.json)
+
+ // OSM
+ implementation(libs.bundles.osm)
+ implementation(libs.osmdroid.geopackage) {
+ exclude(group = "com.j256.ormlite")
+ }
+ detektPlugins(libs.detekt.formatting)
+}
+
+detekt {
+ config.setFrom("../config/detekt/detekt.yml")
+ baseline = file("../config/detekt/detekt-baseline-meshserviceexample.xml")
+}
diff --git a/mesh_service_example/proguard-rules.pro b/mesh_service_example/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/mesh_service_example/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/mesh_service_example/src/androidTest/java/com/meshtastic/android/meshserviceexample/ExampleInstrumentedTest.java b/mesh_service_example/src/androidTest/java/com/meshtastic/android/meshserviceexample/ExampleInstrumentedTest.java
new file mode 100644
index 000000000..d895cb7c1
--- /dev/null
+++ b/mesh_service_example/src/androidTest/java/com/meshtastic/android/meshserviceexample/ExampleInstrumentedTest.java
@@ -0,0 +1,26 @@
+package com.meshtastic.android.meshserviceexample;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.*;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see Testing documentation
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+ @Test
+ public void useAppContext() {
+ // Context of the app under test.
+ Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ assertEquals("com.meshtastic.android.meshserviceexample", appContext.getPackageName());
+ }
+}
\ No newline at end of file
diff --git a/mesh_service_example/src/main/AndroidManifest.xml b/mesh_service_example/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..58fcd216b
--- /dev/null
+++ b/mesh_service_example/src/main/AndroidManifest.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/mesh_service_example/src/main/aidl/com/geeksville/mesh/IMeshService.aidl
new file mode 100644
index 000000000..a73a9a14c
--- /dev/null
+++ b/mesh_service_example/src/main/aidl/com/geeksville/mesh/IMeshService.aidl
@@ -0,0 +1,171 @@
+// com.geeksville.mesh.IMeshService.aidl
+package com.geeksville.mesh;
+
+// Declare any non-default types here with import statements
+parcelable DataPacket;
+parcelable NodeInfo;
+parcelable MeshUser;
+parcelable Position;
+parcelable MyNodeInfo;
+
+/**
+This is the public android API for talking to meshtastic radios.
+
+To connect to meshtastic you should bind to it per https://developer.android.com/guide/components/bound-services
+
+The intent you use to reach the service should look like this:
+
+ val intent = Intent().apply {
+ setClassName(
+ "com.geeksville.mesh",
+ "com.geeksville.mesh.service.MeshService"
+ )
+ }
+
+In Android 11+ you *may* need to add the following to the client app's manifest to allow binding of the mesh service:
+
+
+
+For additional information, see https://developer.android.com/guide/topics/manifest/queries-element
+
+
+Once you have bound to the service you should register your broadcast receivers per https://developer.android.com/guide/components/broadcasts#context-registered-receivers
+
+ // com.geeksville.mesh.x broadcast intents, where x is:
+
+ // 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, payload will contain a message ID and a MessageStatus)
+
+Note - these calls might throw RemoteException to indicate mesh error states
+*/
+interface IMeshService {
+ /// Tell the service where to send its broadcasts of received packets
+ /// This call is only required for manifest declared receivers. If your receiver is context-registered
+ /// you don't need this.
+ void subscribeReceiver(String packageName, String receiverName);
+
+ /**
+ * Set the user info for this node
+ */
+ void setOwner(in MeshUser user);
+
+ void setRemoteOwner(in int requestId, in byte []payload);
+ void getRemoteOwner(in int requestId, in int destNum);
+
+ /// Return my unique user ID string
+ String getMyId();
+
+ /// Return a unique packet ID
+ int getPacketId();
+
+ /*
+ Send a packet to a specified node name
+
+ typ is defined in mesh.proto Data.Type. For now juse use 0 to mean opaque bytes.
+
+ destId can be null to indicate "broadcast message"
+
+ messageStatus and id of the provided message will be updated by this routine to indicate
+ message send status and the ID that can be used to locate the message in the future
+ */
+ void send(inout DataPacket packet);
+
+ /**
+ Get the IDs of everyone on the mesh. You should also subscribe for NODE_CHANGE broadcasts.
+ */
+ List getNodes();
+
+ /// This method is only intended for use in our GUI, so the user can set radio options
+ /// It returns a DeviceConfig protobuf.
+ byte []getConfig();
+ /// It sets a Config protobuf via admin packet
+ void setConfig(in byte []payload);
+
+ /// Set and get a Config protobuf via admin packet
+ 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 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
+ void setRingtone(in int destNum, in String ringtone);
+ void getRingtone(in int requestId, in int destNum);
+
+ /// Set and get the Canned Message Messages string via admin packet
+ void setCannedMessages(in int destNum, in String messages);
+ void getCannedMessages(in int requestId, in int destNum);
+
+ /// This method is only intended for use in our GUI, so the user can set radio options
+ /// It sets a Channel protobuf via admin packet
+ void setChannel(in byte []payload);
+
+ /// Set and get a Channel protobuf via admin packet
+ 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
+ void beginEditSettings();
+
+ /// 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);
+
+ /// Send Shutdown admin packet to nodeNum
+ void requestShutdown(in int requestId, in int destNum);
+
+ /// Send Reboot admin packet to nodeNum
+ void requestReboot(in int requestId, in int destNum);
+
+ /// Send FactoryReset admin packet to nodeNum
+ void requestFactoryReset(in int requestId, in int destNum);
+
+ /// Send NodedbReset admin packet to nodeNum
+ void requestNodedbReset(in int requestId, in int destNum);
+
+ /// Returns a ChannelSet protobuf
+ byte []getChannelSet();
+
+ /**
+ Is the packet radio currently connected to the phone? Returns a ConnectionState string.
+ */
+ String connectionState();
+
+ /// If a macaddress we will try to talk to our device, if null we will be idle.
+ /// Any current connection will be dropped (even if the device address is the same) before reconnecting.
+ /// Users should not call this directly, only used internally by the MeshUtil activity
+ /// Returns true if the device address actually changed, or false if no change was needed
+ boolean setDeviceAddress(String deviceAddr);
+
+ /// Get basic device hardware info about our connected radio. Will never return NULL. Will return NULL
+ /// if no my node info is available (i.e. it will not throw an exception)
+ MyNodeInfo getMyNodeInfo();
+
+ /// Start updating the radios firmware
+ void startFirmwareUpdate();
+
+ /// Return a number 0-100 for firmware update progress. -1 for completed and success, -2 for failure
+ int getUpdateStatus();
+
+ /// Start providing location (from phone GPS) to mesh
+ void startProvideLocation();
+
+ /// Stop providing location (from phone GPS) to mesh
+ void stopProvideLocation();
+}
diff --git a/mesh_service_example/src/main/java/com/geeksville/mesh/DataPacket.kt b/mesh_service_example/src/main/java/com/geeksville/mesh/DataPacket.kt
new file mode 100644
index 000000000..a6f8b7461
--- /dev/null
+++ b/mesh_service_example/src/main/java/com/geeksville/mesh/DataPacket.kt
@@ -0,0 +1,216 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+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.
+ */
+private inline fun Parcel.readParcelableCompat(loader: ClassLoader?): T? {
+ return if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
+ @Suppress("DEPRECATION")
+ readParcelable(loader)
+ } else {
+ readParcelable(loader, T::class.java)
+ }
+}
+
+@Parcelize
+enum class MessageStatus : Parcelable {
+ UNKNOWN, // Not set for this message
+ RECEIVED, // Came in from the mesh
+ QUEUED, // Waiting to send to the mesh as soon as we connect to the device
+ ENROUTE, // Delivered to the radio, but no ACK or NAK received
+ DELIVERED, // We received an ack
+ ERROR // We received back a nak, message not delivered
+}
+
+/**
+ * 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 wantAck: Boolean = true, // If true, the receiver should send an ack back
+) : 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?, 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
+ }
+
+ val alert: String?
+ get() = if (dataType == Portnums.PortNum.ALERT_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.readInt() == 1,
+ )
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as 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
+ if (!bytes!!.contentEquals(other.bytes!!)) return false
+ if (status != other.status) return false
+ if (hopLimit != other.hopLimit) return false
+ if (wantAck != other.wantAck) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = from.hashCode()
+ result = 31 * result + to.hashCode()
+ result = 31 * result + time.hashCode()
+ result = 31 * result + id
+ result = 31 * result + dataType
+ result = 31 * result + bytes!!.contentHashCode()
+ result = 31 * result + status.hashCode()
+ result = 31 * result + hopLimit
+ result = 31 * result + channel
+ result = 31 * result + wantAck.hashCode()
+ return result
+ }
+
+ override fun writeToParcel(parcel: Parcel, flags: Int) {
+ parcel.writeString(to)
+ parcel.writeByteArray(bytes)
+ parcel.writeInt(dataType)
+ parcel.writeString(from)
+ parcel.writeLong(time)
+ parcel.writeInt(id)
+ parcel.writeParcelable(status, flags)
+ parcel.writeInt(hopLimit)
+ parcel.writeInt(channel)
+ parcel.writeInt(if (wantAck) 1 else 0)
+ }
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ // Update our object from our parcel (used for inout parameters
+ fun readFromParcel(parcel: Parcel) {
+ to = parcel.readString()
+ parcel.createByteArray()
+ parcel.readInt()
+ from = parcel.readString()
+ time = parcel.readLong()
+ id = parcel.readInt()
+ status = parcel.readParcelableCompat(MessageStatus::class.java.classLoader)
+ hopLimit = parcel.readInt()
+ channel = parcel.readInt()
+ wantAck = parcel.readInt() == 1
+ }
+
+ companion object CREATOR : Parcelable.Creator {
+ // Special node IDs that can be used for sending messages
+
+ /** the Node ID for broadcast destinations */
+ const val ID_BROADCAST = "^all"
+
+ /** The Node ID for the local node - used for from when sender doesn't know our local node ID */
+ const val ID_LOCAL = "^local"
+
+ // 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? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()
+
+ override fun createFromParcel(parcel: Parcel): DataPacket {
+ return DataPacket(parcel)
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+ }
+}
\ No newline at end of file
diff --git a/mesh_service_example/src/main/java/com/geeksville/mesh/MyNodeInfo.kt b/mesh_service_example/src/main/java/com/geeksville/mesh/MyNodeInfo.kt
new file mode 100644
index 000000000..15e265cc9
--- /dev/null
+++ b/mesh_service_example/src/main/java/com/geeksville/mesh/MyNodeInfo.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.geeksville.mesh
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+// MyNodeInfo sent via special protobuf from radio
+@Parcelize
+data class MyNodeInfo(
+ val myNodeNum: Int,
+ val hasGPS: Boolean,
+ val model: String?,
+ val firmwareVersion: String?,
+ val couldUpdate: Boolean, // this application contains a software load we _could_ install if you want
+ val shouldUpdate: Boolean, // this device has old firmware
+ val currentPacketId: Long,
+ val messageTimeoutMsec: Int,
+ val minAppVersion: Int,
+ val maxChannels: Int,
+ val hasWifi: Boolean,
+ val channelUtilization: Float,
+ val airUtilTx: Float,
+ val deviceId: String?,
+) : Parcelable {
+ /** A human readable description of the software/hardware version */
+ val firmwareString: String get() = "$model $firmwareVersion"
+}
\ No newline at end of file
diff --git a/mesh_service_example/src/main/java/com/geeksville/mesh/NodeInfo.kt b/mesh_service_example/src/main/java/com/geeksville/mesh/NodeInfo.kt
new file mode 100644
index 000000000..f1570782e
--- /dev/null
+++ b/mesh_service_example/src/main/java/com/geeksville/mesh/NodeInfo.kt
@@ -0,0 +1,241 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+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
+
+//
+// model objects that directly map to the corresponding protobufs
+//
+
+@Parcelize
+data class MeshUser(
+ val id: String,
+ val longName: String,
+ val shortName: String,
+ val hwModel: MeshProtos.HardwareModel,
+ val isLicensed: Boolean = false,
+ val role: Int = 0,
+) : Parcelable {
+
+ override fun toString(): String {
+ return "MeshUser(id=${id.anonymize}, " +
+ "longName=${longName.anonymize}, " +
+ "shortName=${shortName.anonymize}, " +
+ "hwModel=$hwModelString, " +
+ "isLicensed=$isLicensed, " +
+ "role=$role)"
+ }
+
+ /** Create our model object from a protobuf.
+ */
+ constructor(p: MeshProtos.User) : this(
+ p.id,
+ p.longName,
+ p.shortName,
+ p.hwModel,
+ p.isLicensed,
+ p.roleValue
+ )
+
+ /** a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot
+ * or null if unset
+ * */
+ val hwModelString: String?
+ get() =
+ if (hwModel == MeshProtos.HardwareModel.UNSET) null
+ else hwModel.name.replace('_', '-').replace('p', '.').lowercase()
+}
+
+@Parcelize
+data class Position(
+ val latitude: Double,
+ val longitude: Double,
+ val altitude: Int,
+ val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
+ val satellitesInView: Int = 0,
+ val groundSpeed: Int = 0,
+ val groundTrack: Int = 0, // "heading"
+ val precisionBits: Int = 0,
+) : Parcelable {
+
+ companion object {
+ /// Convert to a double representation of degrees
+ fun degD(i: Int) = i * 1e-7
+ fun degI(d: Double) = (d * 1e7).toInt()
+
+ fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
+ }
+
+ /** Create our model object from a protobuf. If time is unspecified in the protobuf, the provided default time will be used.
+ */
+ constructor(position: MeshProtos.Position, defaultTime: Int = currentTime()) : this(
+ // We prefer the int version of lat/lon but if not available use the depreciated legacy version
+ degD(position.latitudeI),
+ degD(position.longitudeI),
+ position.altitude,
+ if (position.time != 0) position.time else defaultTime,
+ position.satsInView,
+ position.groundSpeed,
+ position.groundTrack,
+ 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 &&
+ (latitude >= -90 && latitude <= 90.0) &&
+ (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.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})"
+ }
+}
+
+
+@Parcelize
+data class DeviceMetrics(
+ val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
+ val batteryLevel: Int = 0,
+ val voltage: Float,
+ val channelUtilization: Float,
+ val airUtilTx: Float,
+ val uptimeSeconds: Int,
+) : Parcelable {
+ companion object {
+ fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
+ }
+
+ /** Create our model object from a protobuf.
+ */
+ constructor(p: TelemetryProtos.DeviceMetrics, telemetryTime: Int = currentTime()) : this(
+ telemetryTime,
+ p.batteryLevel,
+ p.voltage,
+ p.channelUtilization,
+ p.airUtilTx,
+ p.uptimeSeconds,
+ )
+}
+
+@Parcelize
+data class EnvironmentMetrics(
+ val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
+ val temperature: Float,
+ val relativeHumidity: Float,
+ val barometricPressure: Float,
+ val gasResistance: Float,
+ val voltage: Float,
+ val current: Float,
+ val iaq: Int,
+) : Parcelable {
+ companion object {
+ fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
+ }
+}
+
+@Parcelize
+data class NodeInfo(
+ val num: Int, // This is immutable, and used as a key
+ var user: MeshUser? = null,
+ var position: Position? = null,
+ 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 deviceMetrics: DeviceMetrics? = null,
+ var channel: Int = 0,
+ var environmentMetrics: EnvironmentMetrics? = null,
+ var hopsAway: Int = 0
+) : Parcelable {
+
+ val colors: Pair
+ get() { // returns foreground and background @ColorInt for each 'num'
+ val r = (num and 0xFF0000) shr 16
+ val g = (num and 0x00FF00) shr 8
+ val b = num and 0x0000FF
+ val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
+ return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
+ }
+
+ val batteryLevel get() = deviceMetrics?.batteryLevel
+ val voltage get() = deviceMetrics?.voltage
+ val batteryStr get() = if (batteryLevel in 1..100) String.format("%d%%", batteryLevel) else ""
+
+ /**
+ * true if the device was heard from recently
+ */
+ val isOnline: Boolean
+ get() {
+ val now = System.currentTimeMillis() / 1000
+ val timeout = 15 * 60
+ return (now - lastHeard <= timeout)
+ }
+
+ /// return the position if it is valid, else null
+ val validPosition: Position?
+ 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/mesh_service_example/src/main/java/com/geeksville/mesh/util/Extensions.kt b/mesh_service_example/src/main/java/com/geeksville/mesh/util/Extensions.kt
new file mode 100644
index 000000000..917794dad
--- /dev/null
+++ b/mesh_service_example/src/main/java/com/geeksville/mesh/util/Extensions.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+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() = this.toOneLineString()
+
+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/mesh_service_example/src/main/java/com/geeksville/mesh/util/LocationUtils.kt b/mesh_service_example/src/main/java/com/geeksville/mesh/util/LocationUtils.kt
new file mode 100644
index 000000000..bb4567e63
--- /dev/null
+++ b/mesh_service_example/src/main/java/com/geeksville/mesh/util/LocationUtils.kt
@@ -0,0 +1,327 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+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
+import kotlin.math.PI
+
+/*******************************************************************************
+ * 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 / PI)
+ 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) * 0.8
+}
+
+/**
+ * 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/mesh_service_example/src/main/java/com/meshtastic/android/meshserviceexample/MainActivity.java b/mesh_service_example/src/main/java/com/meshtastic/android/meshserviceexample/MainActivity.java
new file mode 100644
index 000000000..16f0282e0
--- /dev/null
+++ b/mesh_service_example/src/main/java/com/meshtastic/android/meshserviceexample/MainActivity.java
@@ -0,0 +1,184 @@
+package com.meshtastic.android.meshserviceexample;
+
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.ServiceConnection;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.Log;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.activity.EdgeToEdge;
+import androidx.annotation.RequiresApi;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+
+import com.geeksville.mesh.IMeshService;
+import com.geeksville.mesh.MessageStatus;
+import com.geeksville.mesh.NodeInfo;
+
+import java.util.Objects;
+
+public class MainActivity extends AppCompatActivity {
+
+ private static final String TAG = "MeshServiceExample";
+ private IMeshService meshService;
+ private ServiceConnection serviceConnection;
+ private BroadcastReceiver meshtasticReceiver;
+ private boolean isMeshServiceBound = false;
+
+ @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ EdgeToEdge.enable(this);
+ setContentView(R.layout.activity_main);
+ ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
+ Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
+ return insets;
+ });
+
+ TextView mainTextView = findViewById(R.id.mainTextView);
+ ImageView statusImageView = findViewById(R.id.statusImageView);
+
+ // Now you can call methods on meshService
+ serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ meshService = IMeshService.Stub.asInterface(service);
+ Log.i(TAG, "Connected to MeshService");
+ isMeshServiceBound = true;
+ statusImageView.setImageResource(android.R.color.holo_green_light);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ meshService = null;
+ isMeshServiceBound = false;
+ }
+ };
+
+ meshtasticReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || intent.getAction() == null) {
+ Log.w(TAG, "Received null intent or action");
+ return;
+ }
+ // Handle the received broadcast
+ String action = intent.getAction();
+ Log.d(TAG, "Received broadcast: " + action);
+
+ switch (Objects.requireNonNull(action)) {
+ case "com.geeksville.mesh.NODE_CHANGE":
+ // handle node changed
+ try {
+ NodeInfo ni = intent.getParcelableExtra("com.geeksville.mesh.NodeInfo");
+ Log.d(TAG, "NodeInfo: " + ni);
+ mainTextView.setText("NodeInfo: " + ni);
+ } catch (Exception e) {
+ e.printStackTrace();
+ return;
+ }
+ break;
+ case "com.geeksville.mesh.MESSAGE_STATUS":
+ int id = intent.getIntExtra("com.geeksville.mesh.PacketId", 0);
+ MessageStatus status = intent.getParcelableExtra("com.geeksville.mesh.Status");
+ Log.d(TAG, "Message Status ID: " + id + " Status: " + status);
+ break;
+ case "com.geeksville.mesh.MESH_CONNECTED": {
+ String extraConnected = intent.getStringExtra("com.geeksville.mesh.Connected");
+ boolean connected = extraConnected.equals("CONNECTED");
+ Log.d(TAG, "Received ACTION_MESH_CONNECTED: " + extraConnected);
+ if (connected) {
+ statusImageView.setImageResource(android.R.color.holo_green_light);
+ }
+ break;
+ }
+ case "com.geeksville.mesh.MESH_DISCONNECTED": {
+ String extraConnected = intent.getStringExtra("com.geeksville.mesh.Disconnected");
+ boolean disconnected = extraConnected.equals("DISCONNECTED");
+ Log.d(TAG, "Received ACTION_MESH_DISTCONNECTED: " + extraConnected);
+ if (disconnected) {
+ statusImageView.setImageResource(android.R.color.holo_red_light);
+ }
+ break;
+ }
+ case "com.geeksville.mesh.RECEIVED.POSITION_APP": {
+ // handle position app data
+ try {
+ NodeInfo ni = intent.getParcelableExtra("com.geeksville.mesh.NodeInfo");
+ Log.d(TAG, "Position App NodeInfo: " + ni);
+ mainTextView.setText("Position App NodeInfo: " + ni);
+ } catch (Exception e) {
+ e.printStackTrace();
+ return;
+ }
+ break;
+ }
+ default:
+ Log.w(TAG, "Unknown action: " + action);
+ }
+ }
+ };
+
+ IntentFilter filter = new IntentFilter();
+ filter.addAction("com.geeksville.mesh.NODE_CHANGE");
+ filter.addAction("com.geeksville.mesh.RECEIVED.NODEINFO_APP");
+ filter.addAction("com.geeksville.mesh.RECEIVED.POSITION_APP");
+ filter.addAction("com.geeksville.mesh.MESH_CONNECTED");
+ filter.addAction("com.geeksville.mesh.MESH_DISCONNECTED");
+ registerReceiver(meshtasticReceiver, filter, Context.RECEIVER_EXPORTED);
+ Log.d(TAG, "Registered meshtasticPacketReceiver");
+
+ while (!bindMeshService()) {
+ try {
+ // Wait for the service to bind
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ Log.e(TAG, "Binding interrupted", e);
+ break;
+ }
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ unbindMeshService();
+ }
+
+ private boolean bindMeshService() {
+ try {
+ Log.i(TAG, "Attempting to bind to Mesh Service...");
+ Intent intent = new Intent("com.geeksville.mesh.Service");
+ intent.setClassName("com.geeksville.mesh", "com.geeksville.mesh.service.MeshService");
+ return bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to bind", e);
+ }
+ return false;
+ }
+
+ private void unbindMeshService() {
+ if (isMeshServiceBound) {
+ try {
+ unbindService(serviceConnection);
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG, "MeshService not registered or already unbound: " + e.getMessage());
+ }
+ isMeshServiceBound = false;
+ meshService = null;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/mesh_service_example/src/main/proto b/mesh_service_example/src/main/proto
new file mode 160000
index 000000000..24c7a3d28
--- /dev/null
+++ b/mesh_service_example/src/main/proto
@@ -0,0 +1 @@
+Subproject commit 24c7a3d287a4bd269ce191827e5dabd8ce8f57a7
diff --git a/mesh_service_example/src/main/res/drawable/ic_launcher_background.xml b/mesh_service_example/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 000000000..07d5da9cb
--- /dev/null
+++ b/mesh_service_example/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mesh_service_example/src/main/res/drawable/ic_launcher_foreground.xml b/mesh_service_example/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 000000000..2b068d114
--- /dev/null
+++ b/mesh_service_example/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/layout/activity_main.xml b/mesh_service_example/src/main/res/layout/activity_main.xml
new file mode 100644
index 000000000..7b1f2aa1d
--- /dev/null
+++ b/mesh_service_example/src/main/res/layout/activity_main.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 000000000..6f3b755bf
--- /dev/null
+++ b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 000000000..6f3b755bf
--- /dev/null
+++ b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/mipmap-hdpi/ic_launcher.webp b/mesh_service_example/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 000000000..c209e78ec
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/mesh_service_example/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/mesh_service_example/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..b2dfe3d1b
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/mesh_service_example/src/main/res/mipmap-mdpi/ic_launcher.webp b/mesh_service_example/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 000000000..4f0f1d64e
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/mesh_service_example/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/mesh_service_example/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..62b611da0
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/mesh_service_example/src/main/res/mipmap-xhdpi/ic_launcher.webp b/mesh_service_example/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 000000000..948a3070f
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/mesh_service_example/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/mesh_service_example/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..1b9a6956b
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/mesh_service_example/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/mesh_service_example/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..28d4b77f9
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/mesh_service_example/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/mesh_service_example/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9287f5083
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/mesh_service_example/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/mesh_service_example/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..aa7d6427e
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/mesh_service_example/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/mesh_service_example/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9126ae37c
Binary files /dev/null and b/mesh_service_example/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/mesh_service_example/src/main/res/values-night/themes.xml b/mesh_service_example/src/main/res/values-night/themes.xml
new file mode 100644
index 000000000..42a68d940
--- /dev/null
+++ b/mesh_service_example/src/main/res/values-night/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/values/colors.xml b/mesh_service_example/src/main/res/values/colors.xml
new file mode 100644
index 000000000..c8524cd96
--- /dev/null
+++ b/mesh_service_example/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/values/strings.xml b/mesh_service_example/src/main/res/values/strings.xml
new file mode 100644
index 000000000..fcec4106c
--- /dev/null
+++ b/mesh_service_example/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ MeshServiceExample
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/values/themes.xml b/mesh_service_example/src/main/res/values/themes.xml
new file mode 100644
index 000000000..e8f8fe799
--- /dev/null
+++ b/mesh_service_example/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/xml/backup_rules.xml b/mesh_service_example/src/main/res/xml/backup_rules.xml
new file mode 100644
index 000000000..4df925582
--- /dev/null
+++ b/mesh_service_example/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/xml/data_extraction_rules.xml b/mesh_service_example/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 000000000..9ee9997b0
--- /dev/null
+++ b/mesh_service_example/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mesh_service_example/src/test/java/com/meshtastic/android/meshserviceexample/ExampleUnitTest.java b/mesh_service_example/src/test/java/com/meshtastic/android/meshserviceexample/ExampleUnitTest.java
new file mode 100644
index 000000000..05e743cd1
--- /dev/null
+++ b/mesh_service_example/src/test/java/com/meshtastic/android/meshserviceexample/ExampleUnitTest.java
@@ -0,0 +1,17 @@
+package com.meshtastic.android.meshserviceexample;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() {
+ assertEquals(4, 2 + 2);
+ }
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 972e2ae23..99a0dcc1c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -15,5 +15,5 @@
* along with this program. If not, see .
*/
-include(":app", ":network")
+include(":app", ":network", ":mesh_service_example")
rootProject.name = "Meshtastic Android"