diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 72ee1fff..59bfc41b 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -124,7 +124,13 @@
android:label="@string/p__location"
android:parentActivityName=".PrivacyPrefs"
android:launchMode="singleTop"
- android:configChanges="orientation|keyboardHidden|screenSize" />
+ android:configChanges="orientation|keyboardHidden|screenSize" />
+
+
-
+
+
+
+
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 145b6bde..2f38c270 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -607,4 +607,20 @@
Extends PTT to ensure full packet TX
Enter the tail time [ms]
+
+
+Device Identification
+Manage APRS device database
+Automatically update on startup
+Database URL
+URL to fetch tocalls.yaml from
+Reset to defaults
+Restore the database URL to the official source
+Update now
+Download the latest device database
+https://raw.githubusercontent.com/aprsorg/aprs-deviceid/main/tocalls.yaml
+Device database updated
+Update failed: %s
+URL reset to default
+
diff --git a/res/xml/device_id_prefs.xml b/res/xml/device_id_prefs.xml
new file mode 100644
index 00000000..29d68816
--- /dev/null
+++ b/res/xml/device_id_prefs.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/xml/preferences.xml b/res/xml/preferences.xml
index 97b7b257..5bd65bf6 100644
--- a/res/xml/preferences.xml
+++ b/res/xml/preferences.xml
@@ -220,7 +220,22 @@
-
+
+
+
+
+
+
+
+
+
replaceAct(classOf[HubActivity])
case "map" => replaceAct(mapmode.viewClass)
diff --git a/src/AprsPacket.scala b/src/AprsPacket.scala
index 33d5279f..96c1f118 100644
--- a/src/AprsPacket.scala
+++ b/src/AprsPacket.scala
@@ -339,32 +339,40 @@ object AprsPacket {
}
}
+ def micEDeviceInfo(comment: String): Option[Map[String, String]] = {
+ if (comment == null || comment.length < 2) None
+ else COMMENT_DATA.get(comment.takeRight(2))
+ }
+
+ def kenwoodDeviceInfo(comment: String): Option[Map[String, String]] = {
+ if (comment == null || comment.isEmpty) None
+ else KENWOOD_COMMENT_DATA.get(comment.takeRight(1))
+ }
+
+ def packetKenwoodDeviceInfo(packet: String): Option[Map[String, String]] = {
+ val colonIndex = packet.indexOf(':')
+ if (colonIndex == -1 || colonIndex + 10 >= packet.length) {
+ None
+ } else if (packet(colonIndex + 1) == '\'') {
+ KENWOOD_COMMENT_DATA.get(packet(colonIndex + 10).toString)
+ } else {
+ None
+ }
+ }
+
// Function to check if the last 2 characters of the comment match anything in COMMENT_DATA
def micetocall(comment: String): Option[String] = {
- val lastTwoChars = comment.takeRight(2) // Get the last 2 characters of the comment
- COMMENT_DATA.get(lastTwoChars).flatMap(_.get("model"))
+ micEDeviceInfo(comment).flatMap(_.get("model"))
}
// Function to check if the last character of the comment matches anything in KENWOOD_COMMENT_DATA
def kenwoodtocall(comment: String): Option[String] = {
- val lastChar = comment.takeRight(1) // Get the last character of the comment
- KENWOOD_COMMENT_DATA.get(lastChar).flatMap(_.get("model"))
+ kenwoodDeviceInfo(comment).flatMap(_.get("model"))
}
// Function to check old Kenwood calls
def oldkenwoodtocall(packet: String): Option[String] = {
- val colonIndex = packet.indexOf(':')
-
- if (colonIndex == -1 || colonIndex + 10 >= packet.length) {
- None
- } else {
- if (packet(colonIndex + 1) == '\'') {
- val keyChar = packet(colonIndex + 10).toString
- KENWOOD_COMMENT_DATA.get(keyChar).flatMap(_.get("model"))
- } else {
- None
- }
- }
+ packetKenwoodDeviceInfo(packet).flatMap(_.get("model"))
}
def parseComment(comment: String): String = {
diff --git a/src/DeviceDbUpdater.scala b/src/DeviceDbUpdater.scala
new file mode 100644
index 00000000..5e202e98
--- /dev/null
+++ b/src/DeviceDbUpdater.scala
@@ -0,0 +1,91 @@
+package org.aprsdroid.app
+
+import _root_.android.content.Context
+import _root_.android.util.Log
+import _root_.android.widget.Toast
+
+import java.io.{BufferedInputStream, FileOutputStream}
+import java.net.URL
+
+// Downloads tocalls.yaml from a configurable URL and saves it to internal storage.
+// All network I/O runs on a background thread.
+object DeviceDbUpdater {
+ val TAG = "APRSdroid.DeviceDbUpdater"
+
+ val DEFAULT_TOCALLS_URL =
+ "https://raw.githubusercontent.com/aprsorg/aprs-deviceid/main/tocalls.yaml"
+
+ // Returns the URL from preferences, falling back to the default.
+ def getUrl(prefs: PrefsWrapper): String = {
+ val url = prefs.getString("device_id_url", "").trim
+ if (url.isEmpty) DEFAULT_TOCALLS_URL else url
+ }
+
+ // Downloads tocalls.yaml if the preferences allow it right now.
+ def updateIfAllowed(context: Context, prefs: PrefsWrapper): Unit = {
+ val autoUpdate = prefs.getBoolean("device_id_auto_update", true)
+ if (!autoUpdate) return
+ update(context, prefs, silent = true)
+ }
+
+ // Downloads tocalls.yaml unconditionally and saves it to internal storage.
+ // Pass silent=false to show a Toast when done (for manual "Update now" taps).
+ def update(context: Context, prefs: PrefsWrapper, silent: Boolean = false): Unit = {
+ val appContext = context.getApplicationContext
+ val urlString = getUrl(prefs)
+
+ new Thread(new Runnable {
+ override def run(): Unit = {
+ try {
+ Log.i(TAG, "Downloading tocalls.yaml from " + urlString)
+ val url = new URL(urlString)
+ val connection = url.openConnection()
+ connection.setConnectTimeout(15000)
+ connection.setReadTimeout(30000)
+ connection.connect()
+
+ val outFile = DeviceIdentifier.tocallsFile(appContext)
+ val tmpFile = new java.io.File(outFile.getParent, "tocalls.yaml.tmp")
+
+ val input = new BufferedInputStream(connection.getInputStream)
+ val output = new FileOutputStream(tmpFile)
+ try {
+ val buf = new Array[Byte](8192)
+ var len = input.read(buf)
+ while (len != -1) {
+ output.write(buf, 0, len)
+ len = input.read(buf)
+ }
+ } finally {
+ output.close()
+ input.close()
+ }
+
+ // Atomic replace: rename tmp â final
+ tmpFile.renameTo(outFile)
+ DeviceIdentifier.invalidate()
+ Log.i(TAG, "tocalls.yaml updated successfully")
+
+ if (!silent) {
+ new android.os.Handler(appContext.getMainLooper).post(new Runnable {
+ override def run(): Unit =
+ Toast.makeText(appContext, R.string.device_id_update_success,
+ Toast.LENGTH_SHORT).show()
+ })
+ }
+ } catch {
+ case e: Exception =>
+ Log.e(TAG, "Failed to download tocalls.yaml", e)
+ if (!silent) {
+ new android.os.Handler(appContext.getMainLooper).post(new Runnable {
+ override def run(): Unit =
+ Toast.makeText(appContext,
+ appContext.getString(R.string.device_id_update_failed, e.getMessage),
+ Toast.LENGTH_LONG).show()
+ })
+ }
+ }
+ }
+ }).start()
+ }
+}
diff --git a/src/DeviceIdPrefs.scala b/src/DeviceIdPrefs.scala
new file mode 100644
index 00000000..1c0d57b5
--- /dev/null
+++ b/src/DeviceIdPrefs.scala
@@ -0,0 +1,61 @@
+package org.aprsdroid.app
+
+import android.content.SharedPreferences
+import android.os.Bundle
+import android.preference.{EditTextPreference, PreferenceActivity}
+import android.widget.Toast
+
+class DeviceIdPrefs extends PreferenceActivity with SharedPreferences.OnSharedPreferenceChangeListener {
+
+ lazy val prefs = new PrefsWrapper(this)
+
+ override def onCreate(savedInstanceState: Bundle) {
+ super.onCreate(savedInstanceState)
+ addPreferencesFromResource(R.xml.device_id_prefs)
+ getPreferenceScreen().getSharedPreferences()
+ .registerOnSharedPreferenceChangeListener(this)
+ updateUrlSummary()
+
+ // "Update now" â download tocalls.yaml immediately
+ findPreference("device_id_update_now").setOnPreferenceClickListener(
+ new android.preference.Preference.OnPreferenceClickListener {
+ override def onPreferenceClick(pref: android.preference.Preference): Boolean = {
+ DeviceDbUpdater.update(DeviceIdPrefs.this, prefs, silent = false)
+ true
+ }
+ }
+ )
+
+ // "Reset to defaults" â restore the URL to the official source
+ findPreference("device_id_reset_url").setOnPreferenceClickListener(
+ new android.preference.Preference.OnPreferenceClickListener {
+ override def onPreferenceClick(pref: android.preference.Preference): Boolean = {
+ val editor = getPreferenceScreen().getSharedPreferences().edit()
+ editor.putString("device_id_url", DeviceDbUpdater.DEFAULT_TOCALLS_URL)
+ editor.apply()
+ updateUrlSummary()
+ Toast.makeText(DeviceIdPrefs.this, R.string.device_id_url_reset,
+ Toast.LENGTH_SHORT).show()
+ true
+ }
+ }
+ )
+ }
+
+ override def onDestroy() {
+ super.onDestroy()
+ getPreferenceScreen().getSharedPreferences()
+ .unregisterOnSharedPreferenceChangeListener(this)
+ }
+
+ override def onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String): Unit = {
+ if (key == "device_id_url") updateUrlSummary()
+ }
+
+ // Show the current URL as the summary so the user can see what's set.
+ private def updateUrlSummary(): Unit = {
+ val urlPref = findPreference("device_id_url").asInstanceOf[EditTextPreference]
+ val current = prefs.getString("device_id_url", DeviceDbUpdater.DEFAULT_TOCALLS_URL)
+ urlPref.setSummary(if (current.isEmpty) DeviceDbUpdater.DEFAULT_TOCALLS_URL else current)
+ }
+}
diff --git a/src/DeviceIdentifier.scala b/src/DeviceIdentifier.scala
new file mode 100644
index 00000000..721dda13
--- /dev/null
+++ b/src/DeviceIdentifier.scala
@@ -0,0 +1,114 @@
+package org.aprsdroid.app
+
+import _root_.android.content.Context
+import _root_.android.util.Log
+
+import java.io.File
+import scala.collection.mutable.ListBuffer
+import scala.io.Source
+import scala.util.matching.Regex
+
+// Loads tocalls.yaml and matches a tocall string to a device name.
+// The file is parsed once and cached in memory; it reloads automatically
+// if the file on disk has changed (e.g. after an update).
+object DeviceIdentifier {
+ val TAG = "APRSdroid.DeviceIdentifier"
+
+ type DeviceInfo = Map[String, String]
+
+ // Each entry is a compiled pattern paired with its parsed device info.
+ private var patterns: Seq[(Regex, DeviceInfo)] = Seq.empty
+ private var loadedFileTime: Long = -1
+
+ def tocallsFile(context: Context): File =
+ new File(context.getFilesDir, "tocalls.yaml")
+
+ def getDeviceInfo(context: Context, tocall: String): Option[DeviceInfo] = {
+ if (tocall == null || tocall.isEmpty) return None
+ reloadIfStale(context)
+ patterns.find { case (regex, _) => regex.pattern.matcher(tocall).matches() }
+ .map { case (_, info) => info }
+ }
+
+ // Backward-compatible helper.
+ def getDevice(context: Context, tocall: String): Option[String] =
+ getDeviceInfo(context, tocall).flatMap(_.get("model"))
+
+ // Force a reload on the next call to getDevice.
+ def invalidate(): Unit = { loadedFileTime = -1 }
+
+ // ---- private helpers ----
+
+ private def reloadIfStale(context: Context): Unit = {
+ val file = tocallsFile(context)
+ if (!file.exists()) return
+ if (file.lastModified() == loadedFileTime) return // already up to date
+ reload(file)
+ }
+
+ private def reload(file: File): Unit = {
+ Log.i(TAG, "Loading tocalls from " + file.getAbsolutePath)
+ val buf = ListBuffer[(Regex, DeviceInfo)]()
+
+ // Parse the YAML conservatively without a full YAML dependency.
+ // We only care about the tocalls list entries.
+ var inTocalls = false
+ var currentKey: Option[String] = None
+ var currentInfo = Map[String, String]()
+
+ val lines =
+ try { Source.fromFile(file, "UTF-8").getLines().toSeq }
+ catch { case e: Exception => Log.e(TAG, "Failed to read " + file, e); return }
+
+ def flushCurrent(): Unit = {
+ if (currentKey.isDefined && currentInfo.get("model").exists(_.nonEmpty))
+ buf += ((patternToRegex(currentKey.get), currentInfo))
+ currentKey = None
+ currentInfo = Map.empty
+ }
+
+ for (rawLine <- lines) {
+ val line = rawLine.replace("\t", " ")
+ val trimmed = line.trim
+
+ if (trimmed == "tocalls:") {
+ inTocalls = true
+ flushCurrent()
+ } else if (inTocalls && !trimmed.isEmpty && !trimmed.startsWith("#") && !line.startsWith(" ")) {
+ flushCurrent()
+ inTocalls = false
+ } else if (inTocalls) {
+ if (trimmed.startsWith("- tocall:")) {
+ flushCurrent()
+ val key = trimmed.substring("- tocall:".length).trim
+ if (key.nonEmpty) currentKey = Some(key)
+ } else if (currentKey.isDefined) {
+ Seq("vendor", "model", "class", "os").foreach(field => {
+ val prefix = field + ":"
+ if (trimmed.startsWith(prefix)) {
+ val value = trimmed.substring(prefix.length).trim.stripPrefix("\"").stripSuffix("\"")
+ if (value.nonEmpty) currentInfo += (field -> value)
+ }
+ })
+ }
+ }
+ }
+
+ flushCurrent()
+ patterns = buf.toSeq
+ loadedFileTime = file.lastModified()
+ Log.i(TAG, "Loaded %d device patterns".format(patterns.size))
+ }
+
+ // Converts a tocall glob pattern (using ? and *) to a Regex.
+ // All other regex metacharacters are escaped first.
+ private def patternToRegex(pattern: String): Regex = {
+ val sb = new StringBuilder
+ for (ch <- pattern) ch match {
+ case '?' => sb.append('.')
+ case '*' => sb.append(".*")
+ case c => sb.append(Regex.quote(c.toString))
+ }
+ ("(?i)" + sb.toString).r // case-insensitive: tocalls can be mixed case
+ }
+}
diff --git a/src/IgateService.scala b/src/IgateService.scala
index 983d996a..aeea4dfe 100644
--- a/src/IgateService.scala
+++ b/src/IgateService.scala
@@ -17,7 +17,7 @@ class IgateService(service: AprsService, prefs: PrefsWrapper) extends Connection
val TAG = "IgateService"
val hostport = prefs.getString("p.igserver", "rotate.aprs2.net")
- val (host, port) = parseHostPort(hostport)
+ val (host, port) = parseHostPort(hostport)
val so_timeout = prefs.getStringInt("p.igsotimeout", 120)
val connectretryinterval = prefs.getStringInt("p.igconnectretry", 30)
var conn: TcpSocketThread = _
@@ -62,10 +62,10 @@ class IgateService(service: AprsService, prefs: PrefsWrapper) extends Connection
Log.d(TAG, "stop() - Waiting for connection thread to join.")
conn.join(50)
conn.shutdown() // Make sure the socket is cleanly closed
- conn = null
+ conn = null
Log.d(TAG, "stop() - Connection shutdown.")
service.addPost(StorageDatabase.Post.TYPE_INFO, "APRS-IS", "IGate Stopped")
-
+
} else {
Log.d(TAG, "stop() - No connection to stop.")
}
@@ -85,36 +85,36 @@ class IgateService(service: AprsService, prefs: PrefsWrapper) extends Connection
// External reconnect logic
def reconnect(): Unit = {
Log.d(TAG, "reconnect() - Initiating reconnect.")
-
+
// Check if the service is already running (get the value of the "service_running" preference)
val service_running = prefs.getBoolean("service_running", false) // Default to false if not set
-
+
// If the service is already running, don't proceed
if (!service_running || !prefs.isIgateEnabled) {
Log.d(TAG, "start() - Service is not running, skipping connection.")
reconnecting = false
return
}
-
+
if (reconnecting) {
Log.d(TAG, "reconnect() - Already in reconnecting process, skipping.")
return
}
-
+
reconnecting = true
-
+
service.addPost(StorageDatabase.Post.TYPE_INFO, "APRS-IS", s"Connection lost... Reconnecting in $connectretryinterval seconds")
// Step 1: Stop the current connection
stop()
-
+
// Step 2: Wait for a while before reconnecting
Thread.sleep(connectretryinterval * 1000) // Wait for 5 seconds before reconnect attempt (can be adjusted)
-
+
// Step 3: Create a new connection
Log.d(TAG, "reconnect() - Attempting to create a new connection.")
createConnection()
-
+
reconnecting = false
}
@@ -134,18 +134,18 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
private var socket: Socket = _
private var reader: BufferedReader = _
private var writer: PrintWriter = _
-
+
// Track the time of the last sent packets
private val sentPackets1Min: mutable.Queue[Long] = mutable.Queue()
private val sentPackets5Min: mutable.Queue[Long] = mutable.Queue()
- private val mspMap: mutable.HashMap[String, Int] = mutable.HashMap[String, Int]()
+ private val mspMap: mutable.HashMap[String, Int] = mutable.HashMap[String, Int]()
// Assuming we have a Map to store the source calls and their last heard timestamps
val lastHeardCalls: mutable.Map[String, Long] = mutable.Map()
override def run(): Unit = {
Log.d("IgateService", s"run() - Starting TCP connection to $host with timeout $timeout")
- service.addPost(StorageDatabase.Post.TYPE_INFO, "APRS-IS", "Starting IGate...")
+ service.addPost(StorageDatabase.Post.TYPE_INFO, "APRS-IS", "Starting IGate...")
service.addPost(StorageDatabase.Post.TYPE_INFO, "APRS-IS", s"Connecting to $host:$port")
while (running) {
@@ -165,10 +165,10 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
val message = reader.readLine()
if (message != null) {
Log.d("IgateService", s"run() - Received message: $message")
-
- handleMessage(message)
- handleAprsTrafficPost(message)
-
+
+ handleAprsTrafficPost(message)
+ handleMessage(message)
+
} else {
Log.d("IgateService", "run() - Server disconnected. Attempting to reconnect.")
running = false
@@ -225,7 +225,7 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
Log.d("IgateService", s"modifyData() - RFONLY or TCPIP found: $data")
return null // Return null if the packet contains "RFONLY" or "TCPIP"
}
-
+
// Find the index of the first colon
val colonIndex = data.indexOf(":")
Log.d("IgateService", s"modifyData() - Colon index: $colonIndex")
@@ -281,7 +281,7 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
// Send data to the server
def sendData(data: String): Unit = {
Log.d("IgateService", s"sendData() - Sending data: $data")
-
+
// Run the task in a new thread
new Thread(new Runnable {
override def run(): Unit = {
@@ -305,10 +305,10 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
}
}
}
-
+
def handleAprsTrafficPost(message: String): Unit = {
val aprsIstrafficDisabled = prefs.getBoolean("p.aprsistraffic", false)
-
+
if (aprsIstrafficDisabled) {
Log.d("IgateService", "APRS-IS traffic disabled, skipping the post.")
@@ -337,20 +337,20 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
service.addPost(StorageDatabase.Post.TYPE_IG, "APRS-IS Received", message)
Log.d("IgateService", s"APRS-IS traffic enabled, post added: $message")
}
- }
-
+ }
+
def processMessage(payloadString: String): String = {
//Check if payload is actually a message and not telemetry
if (payloadString.startsWith(":")) {
- if (payloadString.length < 11 ||
- payloadString.length >= 16 &&
+ if (payloadString.length < 11 ||
+ payloadString.length >= 16 &&
(payloadString.substring(10).startsWith(":PARM.") ||
payloadString.substring(10).startsWith(":UNIT.") ||
payloadString.substring(10).startsWith(":EQNS.") ||
payloadString.substring(10).startsWith(":BITS."))) {
return null // Ignore this payload
}
- if (payloadString.length >= 4 &&
+ if (payloadString.length >= 4 &&
(payloadString.substring(1, 4) == "BLN" ||
payloadString.substring(1, 4) == "NWS" ||
payloadString.substring(1, 4) == "SKY" ||
@@ -373,17 +373,17 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
val lastUsedDigi = fap.getDigiString() // Last used digipeater
val payload = fap.getAprsInformation() // Payload of the message
val payloadString = if (payload != null) payload.toString else ""
- val digipath = prefs.getString("igpath", "WIDE1-1")
+ val digipath = prefs.getString("igpath", "WIDE1-1")
val formattedDigipath = if (digipath.nonEmpty) s",$digipath" else ""
val version = service.APP_VERSION // Version information
// If targetedCallsign is null, check MSP for sourceCall
- if (mspMap.getOrElse(sourceCall, 0) == 1) {
+ if (mspMap.getOrElse(sourceCall, 0) == 1) {
Log.d("IgateService", s"MSP entry found and is 1 for $sourceCall, pass packet")
-
+
// If MSP for sourceCall is 1, process the packet and remove the entry
- mspMap.remove(sourceCall)
-
+ mspMap.remove(sourceCall)
+
// Process and create the packet
val igatedPacket = s"$callssid>$version$formattedDigipath:}$sourceCall>$destinationCall,TCPIP,$callssid*:$payload"
Log.d("IgateService", s"Processed packet: $igatedPacket")
@@ -394,23 +394,23 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
Log.d("IgateService", "Rate limit exceeded, skipping this packet.")
return null // Skip sending this packet if rate limit exceeded
}
-
+
// Handle rate limiting to update the queues
handleRateLimiting()
-
+
return igatedPacket
-
+
} else {
Log.d("IgateService", s"Station not MSP, skipping processing.")
return null
}
-
+
} catch {
case e: Exception =>
Log.e("IgateService", s"processPacketPostion() - Error processing packet", e)
return null
}
- }
+ }
def processPacketMessage(fap: APRSPacket): String = {
//Process APRS-IS Packet for RF destination
@@ -422,13 +422,13 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
val lastUsedDigi = fap.getDigiString() // Last used digipeater
val payload = fap.getAprsInformation() // Payload of the message
val payloadString = if (payload != null) payload.toString else ""
- val digipath = prefs.getString("igpath", "WIDE1-1")
+ val digipath = prefs.getString("igpath", "WIDE1-1")
val formattedDigipath = if (digipath.nonEmpty) s",$digipath" else ""
val version = service.APP_VERSION // Version information
-
+
val targetedCallsign = processMessage(payloadString)
Log.d("IgateService", s"Targeted Callsign: $targetedCallsign")
-
+
// If the targetedCallsign is null, return immediately and skip further processing
if (targetedCallsign == null) {
Log.d("IgateService", "Target station not found or not a message packet, skipping packet processing.")
@@ -440,14 +440,14 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
//val lastHeardTime = lastHeardCalls.getOrElse(Option(targetedCallsign).getOrElse(sourceCall), 0L) //Checks RF station first, then checks APRS-IS station
val lastHeardTime = lastHeardCalls.getOrElse(targetedCallsign, 0L)
val timeElapsed = currentTime - lastHeardTime
-
+
Log.d("IgateService", s"processPacketMessage() - $targetedCallsign, last heard at $lastHeardTime, time elapsed: $timeElapsed ms.")
-
+
if (timeElapsed <= timelastheard * 60 * 1000) { // If it was heard within the last 30 minutes
//Set MSP for originating sourceCall that is messaging the targetedCallsign
mspMap.getOrElseUpdate(sourceCall, 1)
Log.d("IgateService", s"MSP set to 1 for $sourceCall")
-
+
// Process and create the packet
val igatedPacket = s"$callssid>$version$formattedDigipath:}$sourceCall>$destinationCall,TCPIP,$callssid*:$payload"
Log.d("IgateService", s"Processed packet: $igatedPacket")
@@ -458,28 +458,28 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
Log.d("IgateService", "Rate limit exceeded, skipping this packet.")
return null // Skip sending this packet if rate limit exceeded
}
-
+
// Handle rate limiting to update the queues
handleRateLimiting()
-
+
return igatedPacket
-
+
} else {
Log.d("IgateService", s"Station not heard recently, skipping processing.")
return null
}
-
+
} catch {
case e: Exception =>
Log.e("IgateService", s"processPacketMessage() - Error processing packet", e)
return null
}
- }
+ }
// Function to handle adding time to the queues and enforcing rate limits
def handleRateLimiting(): Unit = {
val currentTime = System.currentTimeMillis()
-
+
// Log before adding the current time to the queues
Log.d("IgateService", s"Adding current time to queues: $currentTime")
@@ -503,7 +503,7 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
Log.d("IgateService", s"sentPackets1Min size after enqueue: ${sentPackets1Min.size}")
Log.d("IgateService", s"sentPackets5Min size after enqueue: ${sentPackets5Min.size}")
}
-
+
def checkRateLimit(): Boolean = {
val currentTime = System.currentTimeMillis() // Get the current time in milliseconds
@@ -516,7 +516,7 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
Log.d("IgateService", s"sentPackets1Min size after cleanup: ${sentPackets1Min.size}")
// Remove packets older than 5 minutes (300,000 ms)
- sentPackets5Min.dequeueAll(packetTime => currentTime - packetTime > 300000)
+ sentPackets5Min.dequeueAll(packetTime => currentTime - packetTime > 300000)
// Log the size of the queue after dequeuing old packets
Log.d("IgateService", s"sentPackets5Min size after cleanup: ${sentPackets5Min.size}")
@@ -548,64 +548,35 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic
}
def handleMessage(message: String): Unit = {
- // Early return if message starts with '#'
if (message.startsWith("#")) {
Log.d("IgateService", "Message starts with '#', skipping processing.")
return
- }
- Log.d("IgateService", s"handleMessage() - Handling incoming message: $message")
-
- // Check if bidirectional gate is enabled in preferences
+ }
+
val bidirectionalGate = prefs.getBoolean("p.aprsistorf", false)
-
if (!bidirectionalGate) {
- Log.d("IgateService", "Bidirectional IGate disabled.")
+ Log.d("IgateService", "Bidirectional IGate disabled.")
return
- }
-
- // Attempt to parse the message
+ }
+
try {
- // Attempt to parse the incoming message using the Parser
- val fap = Parser.parse(message)
- Log.d("IgateService", s"Packet type: ${fap.getAprsInformation.getClass.getSimpleName}")
-
- // Check the type of the parsed packet
- fap.getAprsInformation() match {
- case msg: MessagePacket =>
- // Process MessagePacket
+ val fap = Parser.parse(message)
+ val aprsInfo = fap.getAprsInformation()
+ if (aprsInfo == null) return
+
+ aprsInfo match {
+ case _: MessagePacket =>
try {
- val igatedPacket = processPacketMessage(fap) // Process and create the igated packet
-
- if (igatedPacket != null) {
- Log.d("IgateService", s"Sending igated packet: $igatedPacket")
- service.sendThirdPartyPacket(igatedPacket) // Send the packet to the third-party service
- } else {
- Log.d("IgateService", "Packet not processed, skipping send.")
- }
+ val igatedPacket = processPacketMessage(fap)
+ if (igatedPacket != null)
+ service.sendThirdPartyPacket(igatedPacket)
} catch {
case e: Exception =>
Log.e("IgateService", s"Error processing MessagePacket: ${e.getMessage}")
}
-
- case msg: PositionPacket =>
- // Process PositionPacket
- try {
- val igatedPacket = processPacketPosition(fap) // Process and create the igated packet
-
- if (igatedPacket != null) {
- Log.d("IgateService", s"Sending igated packet: $igatedPacket")
- service.sendThirdPartyPacket(igatedPacket) // Send the packet to the third-party service
- } else {
- Log.d("IgateService", "Packet not processed, skipping send.")
- }
- } catch {
- case e: Exception =>
- Log.e("IgateService", s"Error processing PositionPacket: ${e.getMessage}")
- }
-
+
case _ =>
- // If it's not a MessagePacket or PositionPacket, skip processing
- Log.d("IgateService", s"handleMessage() - Not a MessagePacket or PositionPacket, skipping processing.")
+ // Do not process ordinary APRS-IS traffic through the message gating path.
}
} catch {
case e: Exception =>
diff --git a/src/StationActivity.scala b/src/StationActivity.scala
index 28aa6a95..29a83f07 100644
--- a/src/StationActivity.scala
+++ b/src/StationActivity.scala
@@ -54,13 +54,18 @@ class StationActivity extends StationHelper(R.string.app_sta)
//super.onListItemClick(l, v, position, id)
val c = getListView().getItemAtPosition(position).asInstanceOf[Cursor]
val call = c.getString(StorageDatabase.Station.COLUMN_CALL)
- Log.d("StationActivity", "onListItemClick: %s".format(call))
+ val origin = c.getString(StorageDatabase.Station.COLUMN_ORIGIN)
+ Log.d("StationActivity", "onListItemClick: %s origin=%s".format(call, origin))
if (targetcall == call) {
- // click on own callssid
+ // click on own callssid or object detail row
trackOnMap(call)
} else {
- openDetails(call)
+ // For object/item rows, opening another nested StationActivity can be fragile
+ // because CALL is the object name while ORIGIN is the sender. Prefer the
+ // real station details when origin is present, otherwise fall back to call.
+ val nextTarget = if (origin != null && origin.trim.length() > 0) origin else call
+ openDetails(nextTarget)
finish()
}
}
diff --git a/src/StationListAdapter.scala b/src/StationListAdapter.scala
index 95cf340c..95d74093 100644
--- a/src/StationListAdapter.scala
+++ b/src/StationListAdapter.scala
@@ -73,6 +73,7 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper,
val course = cursor.getFloat(COLUMN_COURSE)
val dist = Array[Float](0, 0)
val comment = cursor.getString(COLUMN_COMMENT) // Retrieve COMMENT data
+ val tocall = cursor.getString(COLUMN_TOCALL)
if (call == mycall) {
view.setBackgroundColor(0x4020ff20)
@@ -130,7 +131,37 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper,
// Set visibility based on the course value (only show if valid)
courseTextView.setVisibility(if (course > 0) View.VISIBLE else View.GONE)
if (course > 0) courseTextView.setText(f"Course: $course%.1f°") // Assuming course is in degrees
-
+
+ // Show device name when the packet has a recognized tocall. Keep the
+ // implementation explicit to avoid Scala inference oddities in Android builds.
+ val deviceTextView = view.findViewById(R.id.station_device).asInstanceOf[TextView]
+ deviceTextView.setVisibility(View.GONE)
+ try {
+ val yamlDeviceOpt = DeviceIdentifier.getDeviceInfo(context, tocall)
+ val storedDevice = if (cursor.getColumnCount > COLUMN_TOCALL + 1) cursor.getString(StorageDatabase.Station.COLUMN_DEVICE) else null
+ val deviceTextOpt = if (yamlDeviceOpt.isDefined) {
+ yamlDeviceOpt.map(info => {
+ val vendor = info.getOrElse("vendor", "").trim
+ val model = info.getOrElse("model", "").trim
+ val clazz = info.getOrElse("class", "").trim
+ val os = info.getOrElse("os", "").trim
+ val head = if (vendor.nonEmpty && model.nonEmpty) vendor + ": " + model
+ else if (model.nonEmpty) model
+ else vendor
+ val parts = Seq(clazz, os).filter(_.nonEmpty)
+ if (parts.nonEmpty) head + " (" + parts.mkString(", ") + ")" else head
+ })
+ } else Option(storedDevice).filter(_.nonEmpty)
+ if (deviceTextOpt.isDefined) {
+ deviceTextView.setText(deviceTextOpt.get)
+ deviceTextView.setVisibility(View.VISIBLE)
+ }
+ } catch {
+ case e: Exception =>
+ Log.e("APRSdroid.StationListAdapter", "Device lookup failed", e)
+ deviceTextView.setVisibility(View.GONE)
+ }
+
super.bindView(view, context, cursor)
}
diff --git a/src/StorageDatabase.scala b/src/StorageDatabase.scala
index 97bba720..220d9a4b 100644
--- a/src/StorageDatabase.scala
+++ b/src/StorageDatabase.scala
@@ -15,7 +15,7 @@ import _root_.scala.math.{cos, Pi}
object StorageDatabase {
val TAG = "APRSdroid.Storage"
- val DB_VERSION = 4
+ val DB_VERSION = 5
val DB_NAME = "storage.db"
val TSS_COL = "DATETIME(TS/1000, 'unixepoch', 'localtime') as TSS"
@@ -63,16 +63,18 @@ object StorageDatabase {
val ORIGIN = "origin" // originator call for object/item
val QRG = "qrg" // voice frequency
val FLAGS = "flags" // bitmask for attributes like "messaging capable"
+ val TOCALL = "tocall" // destination address (device identification)
+ val DEVICE = "device" // derived device label from parse-time packet metadata
lazy val TABLE_CREATE = """CREATE TABLE %s (%s INTEGER PRIMARY KEY AUTOINCREMENT, %s LONG,
%s TEXT UNIQUE, %s INTEGER, %s INTEGER,
%s INTEGER, %s INTEGER, %s INTEGER,
- %s TEXT, %s TEXT, %s TEXT, %s TEXT, %s INTEGER)"""
+ %s TEXT, %s TEXT, %s TEXT, %s TEXT, %s INTEGER, %s TEXT, %s TEXT)"""
.format(TABLE, _ID, TS,
CALL, LAT, LON,
SPEED, COURSE, ALT,
- SYMBOL, COMMENT, ORIGIN, QRG, FLAGS)
+ SYMBOL, COMMENT, ORIGIN, QRG, FLAGS, TOCALL, DEVICE)
lazy val TABLE_DROP = "DROP TABLE %s".format(TABLE)
- lazy val COLUMNS = Array(_ID, TS, CALL, LAT, LON, SYMBOL, COMMENT, SPEED, COURSE, ALT, ORIGIN, QRG)
+ lazy val COLUMNS = Array(_ID, TS, CALL, LAT, LON, SYMBOL, COMMENT, SPEED, COURSE, ALT, ORIGIN, QRG, TOCALL, DEVICE)
lazy val COL_DIST = "((lat - %d)*(lat - %d) + (lon - %d)*(lon - %d)*%d/100) as dist"
val COLUMN_TS = 1
@@ -87,6 +89,8 @@ object StorageDatabase {
val COLUMN_ORIGIN = 10
val COLUMN_QRG = 11
val COLUMN_FLAGS = 12
+ val COLUMN_TOCALL = 12 // index 12 in COLUMNS array (FLAGS is not in COLUMNS)
+ val COLUMN_DEVICE = 13
lazy val COLUMNS_MAP = Array(_ID, CALL, LAT, LON, SYMBOL, ORIGIN, QRG, COMMENT, SPEED, COURSE)
val COLUMN_MAP_CALL = 1
@@ -216,6 +220,12 @@ class StorageDatabase(context : Context) extends
Array(Position.TABLE, Station.TABLE).map(tab => db.execSQL(TABLE_INDEX.format(tab, "ts", "ts")))
Array("call", "type").map(col => db.execSQL(TABLE_INDEX.format(Message.TABLE, col, col)))
}
+ if (from <= 4) {
+ db.execSQL("ALTER TABLE %s ADD COLUMN %s TEXT".format(Station.TABLE, Station.TOCALL))
+ }
+ if (from <= 5) {
+ db.execSQL("ALTER TABLE %s ADD COLUMN %s TEXT".format(Station.TABLE, Station.DEVICE))
+ }
}
def trimPosts(ts : Long) = Benchmark("trimPosts") {
@@ -240,7 +250,19 @@ class StorageDatabase(context : Context) extends
val lat = (pos.getLatitude()*1000000).asInstanceOf[Int]
val lon = (pos.getLongitude()*1000000).asInstanceOf[Int]
val sym = "%s%s".format(pos.getSymbolTable(), pos.getSymbolCode())
- val comment = AprsPacket.parseComment(ap.getAprsInformation().getComment())
+ val rawComment = ap.getAprsInformation().getComment()
+ val derivedDevice = AprsPacket.micEDeviceInfo(rawComment).orElse(AprsPacket.kenwoodDeviceInfo(rawComment)).map(info => {
+ val vendor = info.getOrElse("vendor", "").trim
+ val model = info.getOrElse("model", "").trim
+ val clazz = info.getOrElse("class", "").trim
+ val os = info.getOrElse("os", "").trim
+ val head = if (vendor.nonEmpty && model.nonEmpty) vendor + ": " + model
+ else if (model.nonEmpty) model
+ else vendor
+ val parts = Seq(clazz, os).filter(_.nonEmpty)
+ if (parts.nonEmpty) head + " (" + parts.mkString(", ") + ")" else head
+ }).orNull
+ val comment = AprsPacket.parseComment(rawComment)
val qrg = AprsPacket.parseQrg(comment)
cv.put(TS, ts.asInstanceOf[java.lang.Long])
cv.put(CALL, if (objectname != null) objectname else call)
@@ -254,6 +276,8 @@ class StorageDatabase(context : Context) extends
cv.put(SYMBOL, sym)
cv.put(COMMENT, comment)
cv.put(QRG, qrg)
+ cv.put(TOCALL, ap.getDestinationCall())
+ cv.put(DEVICE, derivedDevice)
if (cse != null) {
cv.put(SPEED, cse.getSpeed().asInstanceOf[java.lang.Integer])
cv.put(COURSE, cse.getCourse().asInstanceOf[java.lang.Integer])