feat: add MeshServiceExample project to repo (#2038)

Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com>
This commit is contained in:
James Rich 2025-06-06 20:43:32 +00:00 committed by GitHub
parent 833e6f04dd
commit c757224269
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1874 additions and 2 deletions

5
.gitmodules vendored
View file

@ -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

View file

@ -0,0 +1,102 @@
<?xml version="1.0" ?>
<!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CommentSpacing:NodeInfo.kt$NodeInfo$/// @return a nice human readable string for the distance, or null for unknown</ID>
<ID>CommentSpacing:NodeInfo.kt$NodeInfo$/// @return bearing to the other position in degrees</ID>
<ID>CommentSpacing:NodeInfo.kt$NodeInfo$/// @return distance in meters to some other node (or null if unknown)</ID>
<ID>CommentSpacing:NodeInfo.kt$NodeInfo$/// return the position if it is valid, else null</ID>
<ID>CommentSpacing:NodeInfo.kt$Position$/// @return bearing to the other position in degrees</ID>
<ID>CommentSpacing:NodeInfo.kt$Position$/// @return distance in meters to some other node (or null if unknown)</ID>
<ID>CommentSpacing:NodeInfo.kt$Position.Companion$/// Convert to a double representation of degrees</ID>
<ID>FinalNewline:DataPacket.kt$com.geeksville.mesh.DataPacket.kt</ID>
<ID>FinalNewline:MyNodeInfo.kt$com.geeksville.mesh.MyNodeInfo.kt</ID>
<ID>FinalNewline:NodeInfo.kt$com.geeksville.mesh.NodeInfo.kt</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$_degIn: Double</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$lat_a: Double</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$lat_b: Double</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$lng_a: Double</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$lng_b: Double</ID>
<ID>ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format( "%s%s %.6s %.7s", UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing )</ID>
<ID>ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format( "%s%s %s%s %05d %05d", MGRS.zone, MGRS.band, MGRS.column, MGRS.row, MGRS.easting, MGRS.northing )</ID>
<ID>ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format("%.5f %.5f", p.latitude, p.longitude)</ID>
<ID>ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3])</ID>
<ID>ImplicitDefaultLocale:NodeInfo.kt$NodeInfo$String.format("%d%%", batteryLevel)</ID>
<ID>MagicNumber:DataPacket.kt$DataPacket.CREATOR$16</ID>
<ID>MagicNumber:Extensions.kt$1000</ID>
<ID>MagicNumber:Extensions.kt$1440000</ID>
<ID>MagicNumber:Extensions.kt$24</ID>
<ID>MagicNumber:Extensions.kt$2880</ID>
<ID>MagicNumber:Extensions.kt$60</ID>
<ID>MagicNumber:LocationUtils.kt$0.8</ID>
<ID>MagicNumber:LocationUtils.kt$110540</ID>
<ID>MagicNumber:LocationUtils.kt$111320</ID>
<ID>MagicNumber:LocationUtils.kt$180</ID>
<ID>MagicNumber:LocationUtils.kt$1e-7</ID>
<ID>MagicNumber:LocationUtils.kt$360</ID>
<ID>MagicNumber:LocationUtils.kt$360.0</ID>
<ID>MagicNumber:LocationUtils.kt$3600.0</ID>
<ID>MagicNumber:LocationUtils.kt$60</ID>
<ID>MagicNumber:LocationUtils.kt$60.0</ID>
<ID>MagicNumber:LocationUtils.kt$6366000</ID>
<ID>MagicNumber:LocationUtils.kt$GPSFormat$3</ID>
<ID>MagicNumber:NodeInfo.kt$DeviceMetrics.Companion$1000</ID>
<ID>MagicNumber:NodeInfo.kt$EnvironmentMetrics.Companion$1000</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$0.114</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$0.299</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$0.587</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$0x0000FF</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$0x00FF00</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$0xFF0000</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$1000</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$1000.0</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$15</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$16</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$1609</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$1609.34</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$255</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$3.281</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$60</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$8</ID>
<ID>MagicNumber:NodeInfo.kt$Position$180</ID>
<ID>MagicNumber:NodeInfo.kt$Position$90</ID>
<ID>MagicNumber:NodeInfo.kt$Position$90.0</ID>
<ID>MagicNumber:NodeInfo.kt$Position.Companion$1000</ID>
<ID>MagicNumber:NodeInfo.kt$Position.Companion$1e-7</ID>
<ID>MagicNumber:NodeInfo.kt$Position.Companion$1e7</ID>
<ID>MatchingDeclarationName:LocationUtils.kt$GPSFormat</ID>
<ID>MaxLineLength:DataPacket.kt$DataPacket$val dataType: Int</ID>
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE &amp;&amp; dist &lt; 1609 -&gt; "%.0f ft".format(dist.toDouble()*3.281)</ID>
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE &amp;&amp; dist &gt;= 1609 -&gt; "%.1f mi".format(dist / 1609.34)</ID>
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE &amp;&amp; dist &lt; 1000 -&gt; "%.0f m".format(dist.toDouble())</ID>
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE &amp;&amp; dist &gt;= 1000 -&gt; "%.1f km".format(dist / 1000.0)</ID>
<ID>MaxLineLength:NodeInfo.kt$Position$/**</ID>
<ID>MaxLineLength:NodeInfo.kt$Position$return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})"</ID>
<ID>MultiLineIfElse:NodeInfo.kt$MeshUser$hwModel.name.replace('_', '-').replace('p', '.').lowercase()</ID>
<ID>MultiLineIfElse:NodeInfo.kt$MeshUser$null</ID>
<ID>NewLineAtEndOfFile:DataPacket.kt$com.geeksville.mesh.DataPacket.kt</ID>
<ID>NewLineAtEndOfFile:MyNodeInfo.kt$com.geeksville.mesh.MyNodeInfo.kt</ID>
<ID>NewLineAtEndOfFile:NodeInfo.kt$com.geeksville.mesh.NodeInfo.kt</ID>
<ID>NoConsecutiveBlankLines:NodeInfo.kt$ </ID>
<ID>SpacingAroundOperators:NodeInfo.kt$NodeInfo$*</ID>
<ID>StringTemplate:NodeInfo.kt$Position$${time}</ID>
<ID>TooManyFunctions:LocationUtils.kt$com.geeksville.mesh.util.LocationUtils.kt</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -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" }

1
mesh_service_example/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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")
}

21
mesh_service_example/proguard-rules.pro vendored Normal file
View file

@ -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

View file

@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@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());
}
}

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="com.geeksville.mesh.permission.BIND_MESH_SERVICE" />
<queries>
<package android:name="com.geeksville.mesh" />
</queries>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MeshServiceExample"
tools:targetApi="31"
tools:replace="android:allowBackup">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -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:
<queries>
<package android:name="com.geeksville.mesh" />
</queries>
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.<portnumm> - 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<NodeInfo> 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();
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 <reified T : Parcelable> 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<DataPacket> {
// 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<DataPacket?> {
return arrayOfNulls(size)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int, Int>
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
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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>) = 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<String>) = "%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<String> {
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<String> {
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<String> {
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
)
}

View file

@ -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;
}
}
}

@ -0,0 +1 @@
Subproject commit 24c7a3d287a4bd269ce191827e5dabd8ce8f57a7

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Meshtastic Node Info"
android:textSize="24sp"
app:layout_constraintBottom_toTopOf="@+id/mainTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/statusImageView"
android:layout_width="25px"
android:layout_height="25px"
android:src="@android:color/holo_red_light"
app:layout_constraintBottom_toTopOf="@+id/titleTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/mainTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.MeshServiceExample" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">MeshServiceExample</string>
</resources>

View file

@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.MeshServiceExample" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.MeshServiceExample" parent="Base.Theme.MeshServiceExample" />
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View file

@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}

View file

@ -15,5 +15,5 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
include(":app", ":network")
include(":app", ":network", ":mesh_service_example")
rootProject.name = "Meshtastic Android"