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])