From 174d2c9441cda6a035ab1629680baeab4e1caacb Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Tue, 7 Apr 2026 10:57:02 -0400 Subject: [PATCH 01/13] Add device identification from tocalls.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New DeviceIdentifier: parses tocalls.yaml line-by-line, matches tocall patterns (? and * globs) to device model names, caches in memory - New DeviceDbUpdater: downloads tocalls.yaml on a background thread from a user-configurable URL (default: aprsorg/aprs-deviceid on GitHub); respects auto-update and WiFi-only preferences - New DeviceIdPrefs activity: preference screen with auto-update toggle, WiFi-only toggle, editable URL, Reset to defaults, and Update now - StorageDatabase: added tocall column (DB_VERSION 4→5, migration via ALTER TABLE), stores ap.getDestinationCall() in addPosition - StationListAdapter: shows device name in yellowish text below course, hidden when tocall is unknown - APRSdroid: calls DeviceDbUpdater.updateIfAllowed() on startup Co-Authored-By: Claude Sonnet 4.6 --- AndroidManifest.xml | 8 ++- res/layout/stationview.xml | 16 +++++- res/values/strings.xml | 17 ++++++ res/xml/device_id_prefs.xml | 32 +++++++++++ res/xml/preferences.xml | 17 +++++- src/APRSdroid.scala | 5 +- src/DeviceDbUpdater.scala | 108 +++++++++++++++++++++++++++++++++++ src/DeviceIdPrefs.scala | 61 ++++++++++++++++++++ src/DeviceIdentifier.scala | 99 ++++++++++++++++++++++++++++++++ src/StationListAdapter.scala | 14 ++++- src/StorageDatabase.scala | 14 +++-- 11 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 res/xml/device_id_prefs.xml create mode 100644 src/DeviceDbUpdater.scala create mode 100644 src/DeviceIdPrefs.scala create mode 100644 src/DeviceIdentifier.scala 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..bdb95490 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -607,4 +607,21 @@ Extends PTT to ensure full packet TX Enter the tail time [ms] + + +Device Identification +Manage APRS device database +Automatically update on startup +Only update on WiFi +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..15eda2d1 --- /dev/null +++ b/res/xml/device_id_prefs.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + 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/DeviceDbUpdater.scala b/src/DeviceDbUpdater.scala new file mode 100644 index 00000000..ab098224 --- /dev/null +++ b/src/DeviceDbUpdater.scala @@ -0,0 +1,108 @@ +package org.aprsdroid.app + +import _root_.android.content.Context +import _root_.android.net.ConnectivityManager +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 + } + + // Returns true if the device is currently on a WiFi connection. + def isWifi(context: Context): Boolean = { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) + .asInstanceOf[ConnectivityManager] + val info = cm.getActiveNetworkInfo + info != null && info.isConnected && + info.getType == ConnectivityManager.TYPE_WIFI + } + + // 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 + + val wifiOnly = prefs.getBoolean("device_id_wifi_only", true) + if (wifiOnly && !isWifi(context)) { + Log.d(TAG, "Skipping update: WiFi-only mode and not on WiFi") + 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) { + 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) { + 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..f4e2eef2 --- /dev/null +++ b/src/DeviceIdentifier.scala @@ -0,0 +1,99 @@ +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" + + // Each entry is a compiled pattern paired with its device model name + private var patterns: Seq[(Regex, String)] = Seq.empty + private var loadedFileTime: Long = -1 + + def tocallsFile(context: Context): File = + new File(context.getFilesDir, "tocalls.yaml") + + // Returns the device model name for the given tocall, or None if unknown. + def getDevice(context: Context, tocall: String): Option[String] = { + if (tocall == null || tocall.isEmpty) return None + reloadIfStale(context) + patterns.find { case (regex, _) => regex.pattern.matcher(tocall).matches() } + .map { case (_, model) => 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, String)]() + + // Simple line-by-line parser for the tocalls section of tocalls.yaml. + // We only care about the "tocalls:" section and ignore mice/micelegacy. + var inTocalls = false + var currentKey: Option[String] = None + + val lines = + try { Source.fromFile(file, "UTF-8").getLines().toSeq } + catch { case e: Exception => Log.e(TAG, "Failed to read " + file, e); return } + + for (line <- lines) { + // Section headers are at column 0, e.g. "tocalls:" + if (!line.startsWith(" ") && !line.startsWith("#") && line.endsWith(":")) { + val section = line.dropRight(1).trim + inTocalls = (section == "tocalls") + currentKey = None + + } else if (inTocalls) { + // A tocall key looks like " APXXX:" (2-space indent, no leading dash) + if (line.startsWith(" ") && !line.startsWith(" ") && line.contains(":")) { + val key = line.trim.dropRight(1) // strip trailing ":" + if (key.nonEmpty && !key.startsWith("#") && !key.startsWith("-")) + currentKey = Some(key) + + // A model line looks like " model: Some Device Name" + } else if (currentKey.isDefined && line.contains("model:")) { + val colonIdx = line.indexOf("model:") + 6 + val model = line.substring(colonIdx).trim + if (model.nonEmpty) { + buf += ((patternToRegex(currentKey.get), model)) + currentKey = None // only take the first model entry per key + } + } + } + } + + 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/StationListAdapter.scala b/src/StationListAdapter.scala index 95cf340c..79c2fb22 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,18 @@ 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 if tocalls.yaml identifies the sender + val deviceTextView = view.findViewById(R.id.station_device).asInstanceOf[TextView] + val device = DeviceIdentifier.getDevice(context, tocall) + device match { + case Some(name) => + deviceTextView.setText(name) + deviceTextView.setVisibility(View.VISIBLE) + case None => + deviceTextView.setVisibility(View.GONE) + } + super.bindView(view, context, cursor) } diff --git a/src/StorageDatabase.scala b/src/StorageDatabase.scala index 97bba720..bdbf2c34 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,17 @@ 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) 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)""" .format(TABLE, _ID, TS, CALL, LAT, LON, SPEED, COURSE, ALT, - SYMBOL, COMMENT, ORIGIN, QRG, FLAGS) + SYMBOL, COMMENT, ORIGIN, QRG, FLAGS, TOCALL) 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) lazy val COL_DIST = "((lat - %d)*(lat - %d) + (lon - %d)*(lon - %d)*%d/100) as dist" val COLUMN_TS = 1 @@ -87,6 +88,7 @@ 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) lazy val COLUMNS_MAP = Array(_ID, CALL, LAT, LON, SYMBOL, ORIGIN, QRG, COMMENT, SPEED, COURSE) val COLUMN_MAP_CALL = 1 @@ -216,6 +218,9 @@ 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)) + } } def trimPosts(ts : Long) = Benchmark("trimPosts") { @@ -254,6 +259,7 @@ class StorageDatabase(context : Context) extends cv.put(SYMBOL, sym) cv.put(COMMENT, comment) cv.put(QRG, qrg) + cv.put(TOCALL, ap.getDestinationCall()) if (cse != null) { cv.put(SPEED, cse.getSpeed().asInstanceOf[java.lang.Integer]) cv.put(COURSE, cse.getCourse().asInstanceOf[java.lang.Integer]) From cfe1f1636052bad78af679adaada0bb9c3794d93 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Tue, 7 Apr 2026 11:22:46 -0400 Subject: [PATCH 02/13] fix updater issues --- src/DeviceDbUpdater.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DeviceDbUpdater.scala b/src/DeviceDbUpdater.scala index ab098224..0d1f6499 100644 --- a/src/DeviceDbUpdater.scala +++ b/src/DeviceDbUpdater.scala @@ -84,7 +84,7 @@ object DeviceDbUpdater { Log.i(TAG, "tocalls.yaml updated successfully") if (!silent) { - android.os.Handler(appContext.getMainLooper).post(new Runnable { + 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() @@ -94,7 +94,7 @@ object DeviceDbUpdater { case e: Exception => Log.e(TAG, "Failed to download tocalls.yaml", e) if (!silent) { - android.os.Handler(appContext.getMainLooper).post(new Runnable { + 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), From f8fa9925e2b1d598cdb5f996288345f2e5d56483 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Tue, 7 Apr 2026 11:34:23 -0400 Subject: [PATCH 03/13] Remove wifi only option since that requires extra permissions --- res/values/strings.xml | 1 - res/xml/device_id_prefs.xml | 5 ----- src/DeviceDbUpdater.scala | 17 ----------------- 3 files changed, 23 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index bdb95490..2f38c270 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -612,7 +612,6 @@ Device Identification Manage APRS device database Automatically update on startup -Only update on WiFi Database URL URL to fetch tocalls.yaml from Reset to defaults diff --git a/res/xml/device_id_prefs.xml b/res/xml/device_id_prefs.xml index 15eda2d1..29d68816 100644 --- a/res/xml/device_id_prefs.xml +++ b/res/xml/device_id_prefs.xml @@ -6,11 +6,6 @@ android:title="@string/p_device_id_auto_update" android:defaultValue="true" /> - - Date: Wed, 8 Apr 2026 17:42:39 -0500 Subject: [PATCH 04/13] Fix object list detail crashes --- src/StationActivity.scala | 11 ++++++++--- src/StationListAdapter.scala | 31 ++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 12 deletions(-) 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 79c2fb22..ca72fed7 100644 --- a/src/StationListAdapter.scala +++ b/src/StationListAdapter.scala @@ -73,7 +73,10 @@ 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) + val tocall = if (mode == StationListAdapter.NEIGHBORS || mode == StationListAdapter.SINGLE) + cursor.getString(COLUMN_TOCALL) + else + null if (call == mycall) { view.setBackgroundColor(0x4020ff20) @@ -132,15 +135,25 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper, 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 if tocalls.yaml identifies the sender + // Show device name only in list modes where the row reliably represents + // a station lookup with a stable tocall column. Keep object/SSID detail + // views conservative to avoid crashing on mixed row shapes. val deviceTextView = view.findViewById(R.id.station_device).asInstanceOf[TextView] - val device = DeviceIdentifier.getDevice(context, tocall) - device match { - case Some(name) => - deviceTextView.setText(name) - deviceTextView.setVisibility(View.VISIBLE) - case None => - deviceTextView.setVisibility(View.GONE) + deviceTextView.setVisibility(View.GONE) + if (mode == StationListAdapter.NEIGHBORS || mode == StationListAdapter.SINGLE) { + try { + DeviceIdentifier.getDevice(context, tocall) match { + case Some(name) => + deviceTextView.setText(name) + deviceTextView.setVisibility(View.VISIBLE) + case _ => + deviceTextView.setVisibility(View.GONE) + } + } catch { + case e: Exception => + Log.e("APRSdroid.StationListAdapter", "Device lookup failed", e) + deviceTextView.setVisibility(View.GONE) + } } super.bindView(view, context, cursor) From 7e7fccddd5a9048e56a7cb1288e5e1858cd96423 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Wed, 8 Apr 2026 18:09:19 -0500 Subject: [PATCH 05/13] Re-enable device labels safely --- src/StationListAdapter.scala | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/StationListAdapter.scala b/src/StationListAdapter.scala index ca72fed7..8532eccb 100644 --- a/src/StationListAdapter.scala +++ b/src/StationListAdapter.scala @@ -73,10 +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 = if (mode == StationListAdapter.NEIGHBORS || mode == StationListAdapter.SINGLE) - cursor.getString(COLUMN_TOCALL) - else - null + val tocall = cursor.getString(COLUMN_TOCALL) if (call == mycall) { view.setBackgroundColor(0x4020ff20) @@ -135,25 +132,20 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper, 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 only in list modes where the row reliably represents - // a station lookup with a stable tocall column. Keep object/SSID detail - // views conservative to avoid crashing on mixed row shapes. + // 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) - if (mode == StationListAdapter.NEIGHBORS || mode == StationListAdapter.SINGLE) { - try { - DeviceIdentifier.getDevice(context, tocall) match { - case Some(name) => - deviceTextView.setText(name) - deviceTextView.setVisibility(View.VISIBLE) - case _ => - deviceTextView.setVisibility(View.GONE) - } - } catch { - case e: Exception => - Log.e("APRSdroid.StationListAdapter", "Device lookup failed", e) - deviceTextView.setVisibility(View.GONE) + try { + val deviceOpt = DeviceIdentifier.getDevice(context, tocall) + if (deviceOpt.isDefined) { + deviceTextView.setText(deviceOpt.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) From 639aec997cb17729516901facfed68f320ca6f56 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Wed, 8 Apr 2026 18:21:13 -0500 Subject: [PATCH 06/13] Add device ID diagnostics --- src/DeviceIdentifier.scala | 11 ++++++++--- src/StationListAdapter.scala | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/DeviceIdentifier.scala b/src/DeviceIdentifier.scala index f4e2eef2..72475ddd 100644 --- a/src/DeviceIdentifier.scala +++ b/src/DeviceIdentifier.scala @@ -23,10 +23,15 @@ object DeviceIdentifier { // Returns the device model name for the given tocall, or None if unknown. def getDevice(context: Context, tocall: String): Option[String] = { - if (tocall == null || tocall.isEmpty) return None + if (tocall == null || tocall.isEmpty) { + Log.d(TAG, "getDevice: empty/null tocall") + return None + } reloadIfStale(context) - patterns.find { case (regex, _) => regex.pattern.matcher(tocall).matches() } - .map { case (_, model) => model } + val result = patterns.find { case (regex, _) => regex.pattern.matcher(tocall).matches() } + .map { case (_, model) => model } + Log.d(TAG, "getDevice: tocall='" + tocall + "' patterns=" + patterns.size + " result=" + result.getOrElse("")) + result } // Force a reload on the next call to getDevice. diff --git a/src/StationListAdapter.scala b/src/StationListAdapter.scala index 8532eccb..e238dedb 100644 --- a/src/StationListAdapter.scala +++ b/src/StationListAdapter.scala @@ -138,6 +138,7 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper, deviceTextView.setVisibility(View.GONE) try { val deviceOpt = DeviceIdentifier.getDevice(context, tocall) + Log.d("APRSdroid.StationListAdapter", "bindView call=" + call + " origin=" + cursor.getString(COLUMN_ORIGIN) + " tocall=" + tocall + " device=" + deviceOpt.getOrElse("")) if (deviceOpt.isDefined) { deviceTextView.setText(deviceOpt.get) deviceTextView.setVisibility(View.VISIBLE) From 9e7c0b25383c8ead15c45d6fcf4a73a11e73af25 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Wed, 8 Apr 2026 19:09:02 -0500 Subject: [PATCH 07/13] Fix tocalls YAML parsing --- src/DeviceIdentifier.scala | 39 ++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/DeviceIdentifier.scala b/src/DeviceIdentifier.scala index 72475ddd..e80910d4 100644 --- a/src/DeviceIdentifier.scala +++ b/src/DeviceIdentifier.scala @@ -50,8 +50,11 @@ object DeviceIdentifier { Log.i(TAG, "Loading tocalls from " + file.getAbsolutePath) val buf = ListBuffer[(Regex, String)]() - // Simple line-by-line parser for the tocalls section of tocalls.yaml. - // We only care about the "tocalls:" section and ignore mice/micelegacy. + // Parse the YAML conservatively without a full YAML dependency. + // We only care about the tocalls list entries: + // tocalls: + // - tocall: APXXXX + // model: Some Device var inTocalls = false var currentKey: Option[String] = None @@ -59,27 +62,27 @@ object DeviceIdentifier { try { Source.fromFile(file, "UTF-8").getLines().toSeq } catch { case e: Exception => Log.e(TAG, "Failed to read " + file, e); return } - for (line <- lines) { - // Section headers are at column 0, e.g. "tocalls:" - if (!line.startsWith(" ") && !line.startsWith("#") && line.endsWith(":")) { - val section = line.dropRight(1).trim - inTocalls = (section == "tocalls") - currentKey = None + for (rawLine <- lines) { + val line = rawLine.replace("\t", " ") + val trimmed = line.trim + if (trimmed == "tocalls:") { + inTocalls = true + currentKey = None + } else if (inTocalls && !trimmed.isEmpty && !trimmed.startsWith("#") && !line.startsWith(" ")) { + // next top-level section + inTocalls = false + currentKey = None } else if (inTocalls) { - // A tocall key looks like " APXXX:" (2-space indent, no leading dash) - if (line.startsWith(" ") && !line.startsWith(" ") && line.contains(":")) { - val key = line.trim.dropRight(1) // strip trailing ":" - if (key.nonEmpty && !key.startsWith("#") && !key.startsWith("-")) + if (trimmed.startsWith("- tocall:")) { + val key = trimmed.substring("- tocall:".length).trim + if (key.nonEmpty) currentKey = Some(key) - - // A model line looks like " model: Some Device Name" - } else if (currentKey.isDefined && line.contains("model:")) { - val colonIdx = line.indexOf("model:") + 6 - val model = line.substring(colonIdx).trim + } else if (currentKey.isDefined && trimmed.startsWith("model:")) { + val model = trimmed.substring("model:".length).trim.stripPrefix("\"").stripSuffix("\"") if (model.nonEmpty) { buf += ((patternToRegex(currentKey.get), model)) - currentKey = None // only take the first model entry per key + currentKey = None } } } From ce544b3e57056168676cef94792b133e48990449 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Wed, 8 Apr 2026 20:19:48 -0500 Subject: [PATCH 08/13] Fallback to Mic-E device detection --- src/AprsPacket.scala | 40 +++++++++++++++++++++--------------- src/StationListAdapter.scala | 20 ++++++++++++++---- 2 files changed, 40 insertions(+), 20 deletions(-) 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/StationListAdapter.scala b/src/StationListAdapter.scala index e238dedb..12a03df9 100644 --- a/src/StationListAdapter.scala +++ b/src/StationListAdapter.scala @@ -137,10 +137,22 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper, val deviceTextView = view.findViewById(R.id.station_device).asInstanceOf[TextView] deviceTextView.setVisibility(View.GONE) try { - val deviceOpt = DeviceIdentifier.getDevice(context, tocall) - Log.d("APRSdroid.StationListAdapter", "bindView call=" + call + " origin=" + cursor.getString(COLUMN_ORIGIN) + " tocall=" + tocall + " device=" + deviceOpt.getOrElse("")) - if (deviceOpt.isDefined) { - deviceTextView.setText(deviceOpt.get) + val yamlDeviceOpt = DeviceIdentifier.getDevice(context, tocall) + val commentDeviceOpt = AprsPacket.micEDeviceInfo(comment).orElse(AprsPacket.kenwoodDeviceInfo(comment)) + val deviceTextOpt = if (yamlDeviceOpt.isDefined) { + Some(yamlDeviceOpt.get) + } else { + commentDeviceOpt.map(info => { + val vendor = info.getOrElse("vendor", "") + val model = info.getOrElse("model", "") + val clazz = info.getOrElse("class", "") + val base = (vendor + " " + model).trim + if (clazz.nonEmpty) base + " (" + clazz + ")" else base + }) + } + Log.d("APRSdroid.StationListAdapter", "bindView call=" + call + " origin=" + cursor.getString(COLUMN_ORIGIN) + " tocall=" + tocall + " yaml=" + yamlDeviceOpt.getOrElse("") + " comment=" + commentDeviceOpt.flatMap(_.get("model")).getOrElse("") + " shown=" + deviceTextOpt.getOrElse("")) + if (deviceTextOpt.isDefined) { + deviceTextView.setText(deviceTextOpt.get) deviceTextView.setVisibility(View.VISIBLE) } } catch { From 04969929d7ee6087862364db87c8e3675b4795a9 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Wed, 8 Apr 2026 22:11:06 -0500 Subject: [PATCH 09/13] Format device labels with vendor and type --- src/DeviceIdentifier.scala | 58 ++++++++++++++++++++++-------------- src/StationListAdapter.scala | 27 +++++++++-------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/DeviceIdentifier.scala b/src/DeviceIdentifier.scala index e80910d4..39f3fe1f 100644 --- a/src/DeviceIdentifier.scala +++ b/src/DeviceIdentifier.scala @@ -14,26 +14,31 @@ import scala.util.matching.Regex object DeviceIdentifier { val TAG = "APRSdroid.DeviceIdentifier" - // Each entry is a compiled pattern paired with its device model name - private var patterns: Seq[(Regex, String)] = Seq.empty + 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") - // Returns the device model name for the given tocall, or None if unknown. - def getDevice(context: Context, tocall: String): Option[String] = { + def getDeviceInfo(context: Context, tocall: String): Option[DeviceInfo] = { if (tocall == null || tocall.isEmpty) { - Log.d(TAG, "getDevice: empty/null tocall") + Log.d(TAG, "getDeviceInfo: empty/null tocall") return None } reloadIfStale(context) val result = patterns.find { case (regex, _) => regex.pattern.matcher(tocall).matches() } - .map { case (_, model) => model } - Log.d(TAG, "getDevice: tocall='" + tocall + "' patterns=" + patterns.size + " result=" + result.getOrElse("")) + .map { case (_, info) => info } + Log.d(TAG, "getDeviceInfo: tocall='" + tocall + "' patterns=" + patterns.size + " result=" + result.flatMap(_.get("model")).getOrElse("")) result } + // 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 } @@ -48,46 +53,53 @@ object DeviceIdentifier { private def reload(file: File): Unit = { Log.i(TAG, "Loading tocalls from " + file.getAbsolutePath) - val buf = ListBuffer[(Regex, String)]() + val buf = ListBuffer[(Regex, DeviceInfo)]() // Parse the YAML conservatively without a full YAML dependency. - // We only care about the tocalls list entries: - // tocalls: - // - tocall: APXXXX - // model: Some Device + // 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 - currentKey = None + flushCurrent() } else if (inTocalls && !trimmed.isEmpty && !trimmed.startsWith("#") && !line.startsWith(" ")) { - // next top-level section + flushCurrent() inTocalls = false - currentKey = None } 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 && trimmed.startsWith("model:")) { - val model = trimmed.substring("model:".length).trim.stripPrefix("\"").stripSuffix("\"") - if (model.nonEmpty) { - buf += ((patternToRegex(currentKey.get), model)) - currentKey = None - } + 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)) diff --git a/src/StationListAdapter.scala b/src/StationListAdapter.scala index 12a03df9..aaead5ae 100644 --- a/src/StationListAdapter.scala +++ b/src/StationListAdapter.scala @@ -137,20 +137,21 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper, val deviceTextView = view.findViewById(R.id.station_device).asInstanceOf[TextView] deviceTextView.setVisibility(View.GONE) try { - val yamlDeviceOpt = DeviceIdentifier.getDevice(context, tocall) + val yamlDeviceOpt = DeviceIdentifier.getDeviceInfo(context, tocall) val commentDeviceOpt = AprsPacket.micEDeviceInfo(comment).orElse(AprsPacket.kenwoodDeviceInfo(comment)) - val deviceTextOpt = if (yamlDeviceOpt.isDefined) { - Some(yamlDeviceOpt.get) - } else { - commentDeviceOpt.map(info => { - val vendor = info.getOrElse("vendor", "") - val model = info.getOrElse("model", "") - val clazz = info.getOrElse("class", "") - val base = (vendor + " " + model).trim - if (clazz.nonEmpty) base + " (" + clazz + ")" else base - }) - } - Log.d("APRSdroid.StationListAdapter", "bindView call=" + call + " origin=" + cursor.getString(COLUMN_ORIGIN) + " tocall=" + tocall + " yaml=" + yamlDeviceOpt.getOrElse("") + " comment=" + commentDeviceOpt.flatMap(_.get("model")).getOrElse("") + " shown=" + deviceTextOpt.getOrElse("")) + val chosenDeviceOpt = if (yamlDeviceOpt.isDefined) yamlDeviceOpt else commentDeviceOpt + val deviceTextOpt = chosenDeviceOpt.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 + }) + Log.d("APRSdroid.StationListAdapter", "bindView call=" + call + " origin=" + cursor.getString(COLUMN_ORIGIN) + " tocall=" + tocall + " yaml=" + yamlDeviceOpt.flatMap(_.get("model")).getOrElse("") + " comment=" + commentDeviceOpt.flatMap(_.get("model")).getOrElse("") + " shown=" + deviceTextOpt.getOrElse("")) if (deviceTextOpt.isDefined) { deviceTextView.setText(deviceTextOpt.get) deviceTextView.setVisibility(View.VISIBLE) From 85e8fc6c4019a901c592d9a6d1da56e5ae2bb0a4 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Wed, 8 Apr 2026 22:19:49 -0500 Subject: [PATCH 10/13] Remove device ID diagnostics --- src/DeviceIdentifier.scala | 11 +++-------- src/StationListAdapter.scala | 1 - 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/DeviceIdentifier.scala b/src/DeviceIdentifier.scala index 39f3fe1f..721dda13 100644 --- a/src/DeviceIdentifier.scala +++ b/src/DeviceIdentifier.scala @@ -24,15 +24,10 @@ object DeviceIdentifier { new File(context.getFilesDir, "tocalls.yaml") def getDeviceInfo(context: Context, tocall: String): Option[DeviceInfo] = { - if (tocall == null || tocall.isEmpty) { - Log.d(TAG, "getDeviceInfo: empty/null tocall") - return None - } + if (tocall == null || tocall.isEmpty) return None reloadIfStale(context) - val result = patterns.find { case (regex, _) => regex.pattern.matcher(tocall).matches() } - .map { case (_, info) => info } - Log.d(TAG, "getDeviceInfo: tocall='" + tocall + "' patterns=" + patterns.size + " result=" + result.flatMap(_.get("model")).getOrElse("")) - result + patterns.find { case (regex, _) => regex.pattern.matcher(tocall).matches() } + .map { case (_, info) => info } } // Backward-compatible helper. diff --git a/src/StationListAdapter.scala b/src/StationListAdapter.scala index aaead5ae..5b273e16 100644 --- a/src/StationListAdapter.scala +++ b/src/StationListAdapter.scala @@ -151,7 +151,6 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper, val parts = Seq(clazz, os).filter(_.nonEmpty) if (parts.nonEmpty) head + " (" + parts.mkString(", ") + ")" else head }) - Log.d("APRSdroid.StationListAdapter", "bindView call=" + call + " origin=" + cursor.getString(COLUMN_ORIGIN) + " tocall=" + tocall + " yaml=" + yamlDeviceOpt.flatMap(_.get("model")).getOrElse("") + " comment=" + commentDeviceOpt.flatMap(_.get("model")).getOrElse("") + " shown=" + deviceTextOpt.getOrElse("")) if (deviceTextOpt.isDefined) { deviceTextView.setText(deviceTextOpt.get) deviceTextView.setVisibility(View.VISIBLE) From 789e82cb966df3c3453714c6c4315da9cf6e4c5d Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Wed, 8 Apr 2026 23:06:43 -0500 Subject: [PATCH 11/13] Restore APRS-IS log ordering --- src/IgateService.scala | 126 ++++++++++++++++++++--------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/src/IgateService.scala b/src/IgateService.scala index 983d996a..d9a1d5a5 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}") @@ -552,30 +552,30 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic 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) + 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 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 @@ -586,12 +586,12 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic 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 @@ -602,7 +602,7 @@ class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsServic 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.") From ae4c8bb05e87989267a53532eb437fe9b423f0b0 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Wed, 8 Apr 2026 23:13:53 -0500 Subject: [PATCH 12/13] Limit APRS-IS gating to messages --- src/IgateService.scala | 45 ++++++++---------------------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/src/IgateService.scala b/src/IgateService.scala index d9a1d5a5..aeea4dfe 100644 --- a/src/IgateService.scala +++ b/src/IgateService.scala @@ -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.") 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}") + val aprsInfo = fap.getAprsInformation() + if (aprsInfo == null) return - // Check the type of the parsed packet - fap.getAprsInformation() match { - case msg: MessagePacket => - // Process MessagePacket + 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 => From a9007d96de3720f22994f4b34a5ab73370ff34c3 Mon Sep 17 00:00:00 2001 From: fromalexx <> Date: Thu, 9 Apr 2026 00:32:25 -0500 Subject: [PATCH 13/13] Store derived device labels at parse time --- src/StationListAdapter.scala | 27 ++++++++++++++------------- src/StorageDatabase.scala | 26 ++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/StationListAdapter.scala b/src/StationListAdapter.scala index 5b273e16..95d74093 100644 --- a/src/StationListAdapter.scala +++ b/src/StationListAdapter.scala @@ -138,19 +138,20 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper, deviceTextView.setVisibility(View.GONE) try { val yamlDeviceOpt = DeviceIdentifier.getDeviceInfo(context, tocall) - val commentDeviceOpt = AprsPacket.micEDeviceInfo(comment).orElse(AprsPacket.kenwoodDeviceInfo(comment)) - val chosenDeviceOpt = if (yamlDeviceOpt.isDefined) yamlDeviceOpt else commentDeviceOpt - val deviceTextOpt = chosenDeviceOpt.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 - }) + 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) diff --git a/src/StorageDatabase.scala b/src/StorageDatabase.scala index bdbf2c34..220d9a4b 100644 --- a/src/StorageDatabase.scala +++ b/src/StorageDatabase.scala @@ -64,16 +64,17 @@ object StorageDatabase { 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 TEXT, %s INTEGER, %s TEXT, %s TEXT)""" .format(TABLE, _ID, TS, CALL, LAT, LON, SPEED, COURSE, ALT, - SYMBOL, COMMENT, ORIGIN, QRG, FLAGS, TOCALL) + 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, TOCALL) + 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 @@ -89,6 +90,7 @@ object StorageDatabase { 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 @@ -221,6 +223,9 @@ class StorageDatabase(context : Context) extends 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") { @@ -245,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) @@ -260,6 +277,7 @@ class StorageDatabase(context : Context) extends 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])