diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 11398d0b..916542f3 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -89,6 +89,29 @@ android:parentActivityName=".PrefsAct" android:launchMode="singleTop" /> + + + + + + + "${ANDROID_SDK_ROOT}/licenses/android-sdk-license" +echo 84831b9409646a918e30573bab4c9c91346d8abd > "${ANDROID_SDK_ROOT}/licenses/android-sdk-preview-license" +sdkmanager --install emulator 'system-images;android-24;default;armeabi-v7a' + +git clone https://github.com/na7q/aprsdroid/ cd aprsdroid git submodule update --init --recursive # replace AI... with your API key: echo "mapsApiKey=AI..." > local.properties # for a debug build: -./gradlew installDebug +./gradlew assembleDebug # for a release build: -./gradlew installRelease +./gradlew assembleRelease ``` diff --git a/build.gradle b/build.gradle index 0c002ec4..88177c38 100644 --- a/build.gradle +++ b/build.gradle @@ -26,7 +26,6 @@ plugins { // obtain revision from git id 'org.ajoberstar.grgit' version '1.6.0' // gradle-amazon-app-store-publisher - id "app.brant.amazonappstorepublisher" version "0.1.0" } allprojects { @@ -69,13 +68,6 @@ def mapsApiKey() { properties.getProperty('mapsApiKey', "AIzaSyA12R_iI_upYQ33FWnPU_8GlMKrEmjDxiQ") } -amazon { - securityProfile = file("amazon-publish-credentials.json") - applicationId = "amzn1.devportal.mobileapp.90ffde1571a347f8a100e1083c64812e" - pathToApks = [ file("build/outputs/apk/release/aprsdroid-release.apk") ] - replaceEdit = true -} - android { compileSdkVersion 33 buildToolsVersion "33.0.2" diff --git a/libs/mapsforge-map-0.3.0-jar-with-dependencies.jar b/libs/mapsforge-map-0.3.0-jar-with-dependencies.jar index 1d57b9e1..af40abc5 100644 Binary files a/libs/mapsforge-map-0.3.0-jar-with-dependencies.jar and b/libs/mapsforge-map-0.3.0-jar-with-dependencies.jar differ diff --git a/res/menu/options.xml b/res/menu/options.xml index b24eb557..47788156 100644 --- a/res/menu/options.xml +++ b/res/menu/options.xml @@ -16,6 +16,10 @@ android:title="@string/clear_log" android:alphabeticShortcut="c" android:icon="@android:drawable/ic_menu_delete" /> + @string/p_link_bt + @string/p_link_ble @string/p_link_usb @string/p_link_tcpip bluetooth + ble usb tcpip + + @string/p_afsk_vox + @string/p_afsk_digirig + + + vox + digirig + 0 2 @@ -85,6 +95,10 @@ 12 13 14 15 + + 1 2 + + @string/age_30 @string/age_2h @@ -110,4 +124,14 @@ 460800 921600 + + Off Duty + En Route + In Service + Returning + Committed + Special + Priority + EMERGENCY! + diff --git a/res/values/strings.xml b/res/values/strings.xml index 4e97d0dd..8c819e1a 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -61,7 +61,7 @@ SmartBeaconing™ requires GPS! Error -received +Received Connecting to %1$s:%2$d... Connection lost. Reconnect in %d seconds... @@ -108,6 +108,43 @@ \n\nTranslation: Home Page +Regenerate Packets +Dangerously regenerates heard packets with NO filter!! + +Digipeating Preferences +Digipeater +Enable Digipeater +Digi & Regen Functions +Direct Only +Only digipeats stations directly heard + +IGating Preferences +IGate +Enable IGate +IGate Settings + +Suppress APRS-IS Traffic Log +Suppresses all APRS-IS traffic in the log + +Enable Bidirectional IGate +Allows messages to pass to RF + +OSM Maps +Enable offline mapping +Uses locally hosted tile server + +Dedupe Timeout +Timeout in seconds +Digipeat timeout for duplicate packets + +Digipeater Path +Set Digi Path +Paths to digipeat + +Digi Path +Set RF Digi Path +APRS-IS to RF Digi Path + Overlays Google: Map @@ -134,6 +171,8 @@ Export Log Nothing to export Clear Log +Clear Messages + Quit Preferences @@ -153,6 +192,9 @@ SSID Station type (1..15; 9=Mobile, 10=APRS-IS) Enter the SSID for your station +Distance Units +Unit Preference +Select Distance Unit (none) Primary Station @@ -172,6 +214,12 @@ 14: Freight vehicle 15: Generic additional station + + + Metric + Imperial + + APRS symbol Your symbol for map display Overlay: @@ -182,6 +230,13 @@ The text after your coordinates Enter your beacon comment APRS Connection +Digipeating Preferences +IGating Preferences +Messaging Preferences +Bidirectional Settings +Server Settings +Transmit Rate Limiting + Connection Protocol Choose the connection protocol Connection Type @@ -206,8 +261,12 @@ UDP (send only) Bluetooth SPP +Bluetooth Low Energy TCP/IP USB Serial + +VOX +Digirig Manual Position Periodic GPS/Network Position @@ -241,14 +300,30 @@ Server +Server APRS-IS server (port 8080) to send beacons Enter the APRS-IS server hostname +Enter the APRS-IS server hostname host:port +host:port Position Reports Location Source Manual, Periodic or SmartBeaconing™ Location Settings +Normal Compressed Beacons +Send Compressed Beacons +Send Uncompressed Beacons + +Mic-E Compressed Beacons +Send Mic-E Beacons +Send Uncompressed Beacons + +Compressed Beacon Settings +Normal & Mic-E Compression + +Mic-E Status + SmartBeaconing™ SmartBeaconing™ help Fast Speed [km/h] @@ -312,6 +387,30 @@ Error: %s Connected: %s +Messaging Preferences +Advanced Messaging Options +Message Retries +Number of messages to retry +Message retry limit + +Retry Interval +Retry interval start rate +Rate doubles each retry + +Ack Dupe Timeout or Disable +Ack dupe timeout (0 = ack disabled) +Dupe acks allowed after timeout + +Ack Dupe Timeout +Sends dupe acks after the timeout period + +Duplicate Messages +Allows dupe messages after timeout period + +Duplicate messages allowed after timeout +Dupe message timeout (0 = disabled) +Dupe Message Timeout + APRS digi path hop 1, hop 2, ... @@ -325,6 +424,8 @@ Bluetooth Headset Use Bluetooth (SCO) headset for AFSK Audio Output +Use Push-to-Talk +Push-to-Talk Port Voice Call Ringtone @@ -334,14 +435,37 @@ APRS-IS TCP server (port 14580) to contact +APRS-IS TCP server (port 14580) to contact Neighbor radius Receive packets from stations in this radius Radius around you to monitor for packets [km] Packet filter +Packet filter b/BUDDY o/OBJECT ... Filter for incoming packets Enter a filter for incoming packets +b/BUDDY o/OBJECT ... +Filter for incoming packets +Enter a filter for incoming packets + +Connection Retry Interval +Reconnect to server in seconds +Retry interval in seconds + +TX Rate Limit 1 Min +Limit Number of TX Packets in 1 Min Interval +Packet Limit at 1 Min Interval + +TX Rate Limit 5 Min +Limit Number of TX Packets in 5 Min Interval +Packet Limit at 5 Min Interval + +Recently heard station timeout +Timeout to pass messages to recently heard stations to RF +Timeout in minutes + + Message filter help Online reference for APRS-IS filters @@ -349,6 +473,14 @@ Time before resetting the connection Timeout value in seconds (0 = disable) +TCP socket timeout +Time before resetting the connection +Timeout value in seconds (0 = disable) + +TCP reconnect timeout +Time before reconnecting +Timeout value in seconds + Map file name MapsForge map file for APRSdroid Choose map file @@ -434,4 +566,13 @@ No USB device found! The following permissions are required: + + +Radio Freq Control +Freq +Sets Freq at Start +Enable Radio Frequency Control +Set Frequency +144.390 + diff --git a/res/xml/backend_ble.xml b/res/xml/backend_ble.xml new file mode 100644 index 00000000..b4d9bc2b --- /dev/null +++ b/res/xml/backend_ble.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/res/xml/backend_digirig.xml b/res/xml/backend_digirig.xml new file mode 100644 index 00000000..b01ea1b7 --- /dev/null +++ b/res/xml/backend_digirig.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/res/xml/backend_tcp.xml b/res/xml/backend_tcp.xml index 1fa2069b..83c801b2 100644 --- a/res/xml/backend_tcp.xml +++ b/res/xml/backend_tcp.xml @@ -45,6 +45,14 @@ android:defaultValue="120" android:dialogTitle="@string/p_sotimeout_entry" /> + + diff --git a/res/xml/backend_tcptnc.xml b/res/xml/backend_tcptnc.xml index ebc2d828..01ca37de 100644 --- a/res/xml/backend_tcptnc.xml +++ b/res/xml/backend_tcptnc.xml @@ -22,6 +22,15 @@ android:defaultValue="120" android:dialogTitle="@string/p_sotimeout_entry" /> + + + diff --git a/res/xml/compressed.xml b/res/xml/compressed.xml new file mode 100644 index 00000000..76e63861 --- /dev/null +++ b/res/xml/compressed.xml @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/res/xml/digi.xml b/res/xml/digi.xml new file mode 100644 index 00000000..7d1f317e --- /dev/null +++ b/res/xml/digi.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + diff --git a/res/xml/igate.xml b/res/xml/igate.xml new file mode 100644 index 00000000..d21e12bb --- /dev/null +++ b/res/xml/igate.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/xml/location.xml b/res/xml/location.xml index 43686272..a4dd599c 100644 --- a/res/xml/location.xml +++ b/res/xml/location.xml @@ -1,6 +1,7 @@ + + + + + + + + + + + + + + + + + diff --git a/res/xml/preferences.xml b/res/xml/preferences.xml index 2f2fff0f..a379c00a 100644 --- a/res/xml/preferences.xml +++ b/res/xml/preferences.xml @@ -49,9 +49,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + diff --git a/res/xml/proto_afsk.xml b/res/xml/proto_afsk.xml index 80a8a324..c1683203 100644 --- a/res/xml/proto_afsk.xml +++ b/res/xml/proto_afsk.xml @@ -38,7 +38,14 @@ android:summary="@string/p_afsk_prefix_summary" android:dialogTitle="@string/p_afsk_prefix_entry" /> - - + + + diff --git a/src/AprsPacket.scala b/src/AprsPacket.scala index 12d72eeb..fc57b71a 100644 --- a/src/AprsPacket.scala +++ b/src/AprsPacket.scala @@ -7,6 +7,154 @@ import scala.math.abs object AprsPacket { val QRG_RE = ".*?(\\d{2,3}[.,]\\d{3,4}).*?".r + val characters = Array( + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", + "E", "F", "G", "H", "I", "J", "K", "L", "P", "Q", "R", "S", "T", "U", "V", + "W", "X", "Y", "Z" + ) + + def statusToBits(status: String): (Int, Int, Int) = status match { + case "Off Duty" => (1, 1, 1) + case "En Route" => (1, 1, 0) + case "In Service" => (1, 0, 1) + case "Returning" => (1, 0, 0) + case "Committed" => (0, 1, 1) + case "Special" => (0, 1, 0) + case "Priority" => (0, 0, 1) + case "EMERGENCY!" => (0, 0, 0) + case _ => (1, 1, 1) // Default if status is not found + } + + def degreesToDDM(dd: Double): (Int, Double) = { + val degrees = Math.floor(dd).toInt + val minutes = (dd - degrees) * 60 + (degrees, minutes) + } + + def miceLong(dd: Double): (Int, Int, Int) = { + val (degrees, minutes) = degreesToDDM(Math.abs(dd)) + val minutesInt = Math.floor(minutes).toInt + val minutesHundreths = Math.floor(100 * (minutes - minutesInt)).toInt + (degrees, minutesInt, minutesHundreths) + } + + def encodeDest(dd: Double, longOffset: Int, west: Int, messageA: Int, messageB: Int, messageC: Int, ambiguity: Int): String = { + val north = if (dd < 0) 0 else 1 + val (degrees, minutes, minutesHundreths) = miceLong(dd) + + val degrees10 = Math.floor(degrees / 10.0).toInt + val degrees1 = degrees - (degrees10 * 10) + + val minutes10 = Math.floor(minutes / 10.0).toInt + val minutes1 = minutes - (minutes10 * 10) + + val minutesHundreths10 = Math.floor(minutesHundreths / 10.0).toInt + val minutesHundreths1 = minutesHundreths - (minutesHundreths10 * 10) + + val sb = new StringBuilder + + if (messageA == 1) sb.append(characters(degrees10 + 22)) else sb.append(characters(degrees10)) + if (messageB == 1) sb.append(characters(degrees1 + 22)) else sb.append(characters(degrees1)) + if (messageC == 1) sb.append(characters(minutes10 + 22)) else sb.append(characters(minutes10)) + + if (north == 1) sb.append(characters(minutes1 + 22)) else sb.append(characters(minutes1)) + if (longOffset == 1) sb.append(characters(minutesHundreths10 + 22)) else sb.append(characters(minutesHundreths10)) + if (west == 1) sb.append(characters(minutesHundreths1 + 22)) else sb.append(characters(minutesHundreths1)) + + val encoded = sb.toString() + + // Replace the last characters with 'Z', ensuring ambiguity is set + val validAmbiguity = ambiguity.max(0).min(4) + encoded.take(6 - validAmbiguity) + "Z" * validAmbiguity + } + + def encodeInfo(dd: Double, speed: Double, heading: Double, symbol: String): (String, Int, Int) = { + + val (degrees, minutes, minutesHundreths) = miceLong(dd) + + val west = if (dd < 0) 1 else 0 + + val sb = new StringBuilder + sb.append("`") + + val speedHT = Math.floor(speed / 10.0).toInt + val speedUnits = speed - (speedHT * 10) + + val headingHundreds = Math.floor(heading / 100.0).toInt + val headingTensUnits = heading - (headingHundreds * 100) + + var longOffset = 0 + + if (degrees <= 9) { + sb.append((degrees + 118).toChar) + longOffset = 1 + } else if (degrees >= 10 && degrees <= 99) { + sb.append((degrees + 28).toChar) + longOffset = 0 + } else if (degrees >= 100 && degrees <= 109) { + sb.append((degrees + 8).toChar) + longOffset = 1 + } else if (degrees >= 110) { + sb.append((degrees - 72).toChar) + longOffset = 1 + } + + if (minutes <= 9) sb.append((minutes + 88).toChar) else sb.append((minutes + 28).toChar) + sb.append((minutesHundreths + 28).toChar) + + if (speed <= 199) sb.append((speedHT + 108).toChar) else sb.append((speedHT + 28).toChar) + sb.append((Math.floor(speedUnits * 10).toInt + headingHundreds + 32).toChar) + sb.append((headingTensUnits + 28).toChar) + + sb.append(symbol(1)) + sb.append(symbol(0)) + sb.append("`") + + (sb.toString(), west, longOffset) + } + + def altitude(alt: Double): String = { + val altM = Math.round(alt * 0.3048).toInt + val relAlt = altM + 10000 + + val val1 = Math.floor(relAlt / 8281.0).toInt + val rem = relAlt % 8281 + val val2 = Math.floor(rem / 91.0).toInt + val val3 = rem % 91 + + // Ensure that the characters are treated as strings and concatenate properly + charFromInt(val1).toString + charFromInt(val2).toString + charFromInt(val3).toString + "}" + } + + private def charFromInt(value: Int): Char = (value + 33).toChar + + def formatCourseSpeedMice(location: Location): (Int, Int) = { + // Default values + val status_spd = if (location.hasSpeed && location.getSpeed > 2) { + // Convert speed from m/s to knots, and return as an integer + mps2kt(location.getSpeed).toInt + } else { + 0 // If no valid speed or below threshold, set speed to 0 + } + + val course = if (location.hasBearing) { + // Get bearing as an integer (course) + location.getBearing.asInstanceOf[Int] + } else { + 0 // If no bearing, set course to 0 + } + + (status_spd, course) + } + + def formatAltitudeMice(location: Location): Option[Int] = { + if (location.hasAltitude) { + // Convert altitude to feet, round to nearest integer, and wrap in Some + Some(math.round(m2ft(location.getAltitude)).toInt) + } else { + None // If no altitude, return None + } + } def passcode(callssid : String) : Int = { // remove ssid, uppercase, add \0 for odd-length calls @@ -44,6 +192,20 @@ object AprsPacket { else "" } + + def formatAltitudeCompressed(location : Location) : String = { + if (location.hasAltitude) { + var altitude = m2ft(location.getAltitude) + var compressedAltitude = ((math.log(altitude) / math.log(1.002)) + 0.5).asInstanceOf[Int] + var c = (compressedAltitude / 91).asInstanceOf[Byte] + 33 + var s = (compressedAltitude % 91).asInstanceOf[Byte] + 33 + // Negative altitudes cannot be expressed in base-91 and results in corrupt packets + if(c < 33) c = 33 + if(s < 33) s = 33 + "%c%c".format(c.asInstanceOf[Char], s.asInstanceOf[Char]) + } else + "" + } def formatCourseSpeed(location : Location) : String = { // only report speeds above 2m/s (7.2km/h) @@ -55,12 +217,35 @@ object AprsPacket { "" } + def formatCourseSpeedCompressed(location : Location) : String = { + // only report speeds above 2m/s (7.2km/h) + if (location.hasSpeed && location.hasBearing) { + // && location.getSpeed > 2) + var compressedBearing = (location.getBearing.asInstanceOf[Int] / 4).asInstanceOf[Int] + var compressedSpeed = ((math.log(mps2kt(location.getSpeed)) / math.log(1.08)) - 1).asInstanceOf[Int] + var c = compressedBearing.asInstanceOf[Byte] + 33; + var s = compressedSpeed.asInstanceOf[Byte] + 33; + // Negative speeds a courses cannot be expressed in base-91 and results in corrupt packets + if(c < 33) c = 33 + if(s < 33) s = 33 + "%c%c".format(c.asInstanceOf[Char], s.asInstanceOf[Char]) + } else { + "" + } + } + def formatFreq(csespd : String, freq : Float) : String = { if (freq == 0) "" else { val prefix = if (csespd.length() > 0) "/" else "" prefix + "%07.3fMHz".formatLocal(null, freq) } } + + def formatFreqMice(freq : Float) : String = { + if (freq == 0) "" else { + "%07.3fMHz".formatLocal(null, freq) + } + } def formatLogin(callsign : String, ssid : String, passcode : String, version : String) : String = { "user %s pass %s vers %s".format(formatCallSsid(callsign, ssid), passcode, version) diff --git a/src/AprsService.scala b/src/AprsService.scala index 1fb87427..bb064b5d 100644 --- a/src/AprsService.scala +++ b/src/AprsService.scala @@ -9,6 +9,7 @@ import _root_.android.util.Log import _root_.android.widget.Toast import _root_.net.ab0oo.aprs.parser._ +import scala.collection.mutable object AprsService { val PACKAGE = "org.aprsdroid.app" @@ -80,6 +81,8 @@ class AprsService extends Service { lazy val msgService = new MessageService(this) lazy val locSource = LocationSource.instanciateLocation(this, prefs) lazy val msgNotifier = msgService.createMessageNotifier() + lazy val digipeaterService = new DigipeaterService(prefs, TAG, sendDigipeatedPacket) + lazy val igateService = new IgateService(this, prefs) var poster : AprsBackend = null @@ -175,6 +178,9 @@ class AprsService extends Service { poster = AprsBackend.instanciateUploader(this, prefs) if (poster.start()) onPosterStarted() + if (prefs.isIgateEnabled() && (prefs.getBackendName().contains("KISS") || prefs.getBackendName().contains("AFSK"))) { + igateService.start() + } } def onPosterStarted() { @@ -212,6 +218,9 @@ class AprsService extends Service { // catch FC when service is killed from outside if (poster != null) { poster.stop() + if (prefs.isIgateEnabled() && (prefs.getBackendName().contains("KISS") || prefs.getBackendName().contains("AFSK"))) { + igateService.stop() + } showToast(getString(R.string.service_stop)) sendBroadcast(new Intent(SERVICE_STOPPED)) @@ -230,46 +239,180 @@ class AprsService extends Service { Digipeater.parseList(digipath, true), payload) } - def formatLoc(symbol : String, status : String, location : Location) = { + def newPacketMice(payload: String, destString: String) = { + val digipath = prefs.getString("digi_path", "WIDE1-1") + val parsedDigipath = Digipeater.parseList(digipath, true) + val callsign_ssid = prefs.getCallSsid() + Log.d("newPacketMice", s"digipath retrieved: $digipath") + + // Construct the micePacket string + val micePacketString = s"$callsign_ssid>$destString${if (digipath.nonEmpty) s",$digipath" else ""}:$payload" + + // Log or return the constructed packet + Log.d("newPacketMice", s"Constructed MICE Packet: $micePacketString") + + val micePacketParsed = Parser.parse(micePacketString) + + micePacketParsed + } + + def formatLoc(symbol: String, status: String, location: Location) = { + // Log the inputs + Log.d("formatLoc", s"Symbol: $symbol") + Log.d("formatLoc", s"Status: $status") + Log.d("formatLoc", s"Location: Latitude=${location.getLatitude}, Longitude=${location.getLongitude}") + val pos = new Position(location.getLatitude, location.getLongitude, 0, symbol(0), symbol(1)) pos.setPositionAmbiguity(prefs.getStringInt("priv_ambiguity", 0)) - val status_spd = if (prefs.getBoolean("priv_spdbear", true)) - AprsPacket.formatCourseSpeed(location) else "" + + // Calculate status_spd + val status_spd = if (prefs.getBoolean("priv_spdbear", true)) { + if(prefs.getBoolean("compressed_location", false)) { + // Compressed format + AprsPacket.formatCourseSpeedCompressed(location) + } else { + AprsPacket.formatCourseSpeed(location) + } + } else "" + // Log status_spd + Log.d("formatLoc", s"Status Speed: $status_spd") + + // Calculate status_freq val status_freq = AprsPacket.formatFreq(status_spd, prefs.getStringFloat("frequency", 0.0f)) - val status_alt = if (prefs.getBoolean("priv_altitude", true)) - AprsPacket.formatAltitude(location) else "" - val comment = status_spd + status_freq + status_alt + " " + status; + + // Calculate status_alt + val status_alt = if (prefs.getBoolean("priv_altitude", true)) { + // if speed is empty then use compressed altitude, otherwise use full length altitude + if(prefs.getBoolean("compressed_location", false) && status_spd == "") { + // Compressed format + AprsPacket.formatAltitudeCompressed(location) + } else { + AprsPacket.formatAltitude(location) + } + } else "" + // Log status_alt + Log.d("formatLoc", s"Status Altitude: $status_alt") + + if(prefs.getBoolean("compressed_location", false)) { + if(status_spd == "") { + // Speed is empty, so we can use a compressed altitude + if(status_alt == "") { + // Altitude is empty, so don't send any altitude data + pos.setCsTField(" sT") + } else { + // 3 signifies current GPS fix, GGA altitude, software compressed. + pos.setCsTField(status_alt + "3") + } + val packet = new PositionPacket( + pos, status_freq + " " + status, /* messaging = */ true) + packet.setCompressedFormat(true) + newPacket(packet) + } else { + // Speed is present, so we need to append the altitude to the end of the packet using the + // uncompressed method + // Apply the csT field with speed and course + // [ signifies current GPS fix, RMC speed, software compressed. + pos.setCsTField(status_spd + "[") + val packet = new PositionPacket( + pos, status_freq + status_alt + " " + status, /* messaging = */ true) + packet.setCompressedFormat(true) + newPacket(packet) + } + } else { + val packet = new PositionPacket( + pos, status_spd + status_freq + status_alt + " " + status, /* messaging = */ true) + newPacket(packet) + } + + //val comment = status_spd + status_freq + status_alt + " " + status; // TODO: slice after 43 bytes, not after 43 UTF-8 codepoints - newPacket(new PositionPacket(pos, comment.slice(0, 43), /* messaging = */ true)) + //newPacket(new PositionPacket(pos, comment.slice(0, 43), /* messaging = */ true)) } + def sendPacket(packet : APRSPacket, status_postfix : String) { - implicit val ec = scala.concurrent.ExecutionContext.global + implicit val ec = scala.concurrent.ExecutionContext.global scala.concurrent.Future { - val status = try { - val status = poster.update(packet) - val full_status = status + status_postfix - addPost(StorageDatabase.Post.TYPE_POST, full_status, packet.toString) - full_status - } catch { - case e : Exception => - addPost(StorageDatabase.Post.TYPE_ERROR, "Error", e.toString()) - e.printStackTrace() - e.toString() - } - handler.post { sendPacketFinished(status) } + val status = try { + val status = poster.update(packet) + + // Check if the status_postfix is "Digipeated" + val full_status = if (status_postfix == "Digipeated") { + addPost(StorageDatabase.Post.TYPE_DIGI, status_postfix, packet.toString) + status_postfix + } else if (status_postfix == "APRS-IS > RF") { + addPost(StorageDatabase.Post.TYPE_TX, "APRS-IS > RF", packet.toString) + status_postfix + } else { + val fullStatus = status + status_postfix + addPost(StorageDatabase.Post.TYPE_POST, fullStatus, packet.toString) + fullStatus + } + + full_status + } catch { + case e: Exception => + addPost(StorageDatabase.Post.TYPE_ERROR, "Error", e.toString()) + e.printStackTrace() + e.toString() + } + + handler.post { sendPacketFinished(status) } } } def sendPacket(packet : APRSPacket) { sendPacket(packet, "") } + def formatLocMice(symbol : String, status : String, location : Location) = { + val privambiguity = 5 - prefs.getStringInt("priv_ambiguity", 0) + val ambiguity = if (privambiguity == 5) 0 else privambiguity + + Log.d("MICE", s"Set Ambiguity $ambiguity") + + val miceStatus = prefs.getString("p__location_mice_status", "Off Duty") + val (a, b, c) = AprsPacket.statusToBits(miceStatus) + + val status_freq = AprsPacket.formatFreqMice(prefs.getStringFloat("frequency", 0.0f)) + val (status_spd, course) = AprsPacket.formatCourseSpeedMice(location) + + // Encoding process + val (infoString, west, longOffset) = AprsPacket.encodeInfo(location.getLongitude, status_spd, course, symbol) + val destString = AprsPacket.encodeDest(location.getLatitude, longOffset, west, a, b, c, ambiguity) + + val altitudeValue = if (prefs.getBoolean("priv_altitude", true)) { + AprsPacket.formatAltitudeMice(location) + } else { + None + } + val altString = altitudeValue.map(alt => AprsPacket.altitude(alt.toInt)).getOrElse("") + + val formatPayload = infoString + + (if (altString.isEmpty) "" else altString) + + (if (status.isEmpty) "" else status) + + (if (status.nonEmpty && status_freq.nonEmpty) " " else "") + + (if (status_freq.isEmpty) "" else status_freq) + "[1" + + Log.d("formatLoc", s"MICE: $infoString $destString $altString") + + val packet = newPacketMice(formatPayload, destString) + + packet + + } + def postLocation(location : Location) { var symbol = prefs.getString("symbol", "") if (symbol.length != 2) symbol = getString(R.string.default_symbol) val status = prefs.getString("status", getString(R.string.default_status)) - val packet = formatLoc(symbol, status, location) - + + // Use inline prefs.getBoolean to decide the packet format + val packet = if (prefs.getBoolean("compressed_mice", false)) { + formatLocMice(symbol, status, location) + } else { + formatLoc(symbol, status, location) + } + Log.d(TAG, "packet: " + packet) sendPacket(packet, " (±%dm)".format(location.getAccuracy.asInstanceOf[Int])) } @@ -284,9 +427,48 @@ class AprsService extends Service { } } + def sendDigipeatedPacket(packetString: String): Unit = { + // Parse the incoming string to an APRSPacket object + try { + val digipeatedPacket = Parser.parse(packetString) + + // Define additional information to be passed as status postfix + val digistatus = "Digipeated" + + // Send the packet with the additional status postfix + sendPacket(digipeatedPacket, digistatus) + + Log.d("APRSdroid.Service", s"Successfully sent packet: $packetString") + } catch { + case e: Exception => + Log.e("APRSdroid.Service", s"Failed to send packet: $packetString", e) + } + } + + def sendThirdPartyPacket(packetString: String): Unit = { + // Parse the incoming string to an APRSPacket object + try { + val igatedPacket = Parser.parse(packetString) + + // Define additional information to be passed as status postfix + val igstatus = "APRS-IS > RF" + + // Send the packet with the additional status postfix + sendPacket(igatedPacket, igstatus) + + Log.d("APRSdroid.Service", s"Successfully sent packet: $packetString") + } catch { + case e: Exception => + Log.e("APRSdroid.Service", s"Failed to send packet: $packetString", e) + } + } + + def parsePacket(ts : Long, message : String, source : Int) { try { var fap = Parser.parse(message) + var digiPathCheck = fap.getDigiString() + if (fap.getType() == APRSTypes.T_THIRDPARTY) { Log.d(TAG, "parsePacket: third-party packet from " + fap.getSourceCall()) val inner = fap.getAprsInformation().toString() @@ -314,7 +496,8 @@ class AprsService extends Service { fap.getAprsInformation() match { case pp : PositionPacket => addPosition(ts, fap, pp, pp.getPosition(), null) case op : ObjectPacket => addPosition(ts, fap, op, op.getPosition(), op.getObjectName()) - case msg : MessagePacket => msgService.handleMessage(ts, fap, msg) + case msg : MessagePacket => msgService.handleMessage(ts, fap, msg, digiPathCheck) + } } catch { case e : Exception => @@ -372,7 +555,17 @@ class AprsService extends Service { } } def postSubmit(post : String) { + // Log the incoming post message for debugging + Log.d("APRSdroid.Service", s"Incoming post: $post") postAddPost(StorageDatabase.Post.TYPE_INCMG, R.string.post_incmg, post) + + // Process the incoming post + digipeaterService.processIncomingPost(post) + + if (prefs.isIgateEnabled() && (prefs.getBackendName().contains("KISS") || prefs.getBackendName().contains("AFSK"))) { + igateService.handlePostSubmitData(post) + } + } def postAbort(post : String) { diff --git a/src/BackendPrefs.scala b/src/BackendPrefs.scala index 319f6a3c..b59d0662 100644 --- a/src/BackendPrefs.scala +++ b/src/BackendPrefs.scala @@ -67,7 +67,7 @@ class BackendPrefs extends PreferenceActivity } override def onSharedPreferenceChanged(sp: SharedPreferences, key : String) { - if (key == "proto" || key == "link" || key == "aprsis") { + if (key == "proto" || key == "link" || key == "aprsis" || key == "afsk") { setPreferenceScreen(null) loadXml() } diff --git a/src/CompressedPrefs.scala b/src/CompressedPrefs.scala new file mode 100644 index 00000000..38d87db8 --- /dev/null +++ b/src/CompressedPrefs.scala @@ -0,0 +1,77 @@ +package org.aprsdroid.app + +import _root_.android.content.SharedPreferences +import _root_.android.os.Bundle +import _root_.android.preference.{PreferenceActivity, PreferenceManager, CheckBoxPreference, ListPreference} +import _root_.android.util.Log + +class CompressedPrefs extends PreferenceActivity with SharedPreferences.OnSharedPreferenceChangeListener { + + lazy val prefs = new PrefsWrapper(this) + + def loadXml() { + // Load compressed.xml preferences + addPreferencesFromResource(R.xml.compressed) + } + + override def onCreate(savedInstanceState: Bundle) { + super.onCreate(savedInstanceState) + loadXml() + getPreferenceScreen().getSharedPreferences().registerOnSharedPreferenceChangeListener(this) + + // Update preferences state on activity creation + updateCheckBoxState() + } + + override def onDestroy() { + super.onDestroy() + getPreferenceScreen().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this) + } + + override def onSharedPreferenceChanged(sp: SharedPreferences, key: String) { + // Handle preference changes for specific keys + key match { + case "compressed_location" | "compressed_mice" => + // Update checkbox states when either preference changes + updateCheckBoxState() + case "p__location_mice_status" => + // Handle changes for the p__location_mice_status preference (if needed) + updateStatus() + case _ => // No action for other preferences + } + } + + // This method will enable/disable the checkboxes based on their current state + private def updateCheckBoxState(): Unit = { + val compressedLocationPref = findPreference("compressed_location").asInstanceOf[CheckBoxPreference] + val compressedMicePref = findPreference("compressed_mice").asInstanceOf[CheckBoxPreference] + val locationMiceStatusPref = findPreference("p__location_mice_status").asInstanceOf[ListPreference] + + // If "compressed_location" is checked, disable "p__location_mice_status" + if (compressedLocationPref.isChecked) { + locationMiceStatusPref.setEnabled(false) + compressedMicePref.setEnabled(false) // Also disable "compressed_mice" when "compressed_location" is checked + } else { + locationMiceStatusPref.setEnabled(true) // Enable "p__location_mice_status" when "compressed_location" is not checked + compressedMicePref.setEnabled(true) // Re-enable "compressed_mice" if "compressed_location" is unchecked + } + + // If "compressed_mice" is checked, disable "compressed_location" + if (compressedMicePref.isChecked) { + compressedLocationPref.setEnabled(false) + } else { + compressedLocationPref.setEnabled(true) + } + } + + + // Method to handle updates related to p__location_mice_status + private def updateStatus(): Unit = { + val statusPref = findPreference("p__location_mice_status").asInstanceOf[ListPreference] + val statusValue = statusPref.getValue + + // Here, you can handle actions based on the selected status. + // For example, logging the selected status: + Log.d("CompressedPrefs", s"Selected Location Mice Status: $statusValue") + } +} diff --git a/src/DigiPrefs.scala b/src/DigiPrefs.scala new file mode 100644 index 00000000..3bc17672 --- /dev/null +++ b/src/DigiPrefs.scala @@ -0,0 +1,58 @@ +package org.aprsdroid.app + +import _root_.android.content.SharedPreferences +import _root_.android.os.Bundle +import _root_.android.preference.{PreferenceActivity, CheckBoxPreference} + +class DigiPrefs extends PreferenceActivity with SharedPreferences.OnSharedPreferenceChangeListener { + + lazy val prefs = new PrefsWrapper(this) + + def loadXml() { + // Load digi.xml preferences + addPreferencesFromResource(R.xml.digi) + } + + override def onCreate(savedInstanceState: Bundle) { + super.onCreate(savedInstanceState) + loadXml() + getPreferenceScreen().getSharedPreferences().registerOnSharedPreferenceChangeListener(this) + + // Update preferences state on activity creation + updateCheckBoxState() + } + + override def onDestroy() { + super.onDestroy() + getPreferenceScreen().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this) + } + + override def onSharedPreferenceChanged(sp: SharedPreferences, key: String) { + key match { + case "p.digipeating" | "p.regenerate" => + // Update checkbox states when either preference changes + updateCheckBoxState() + case _ => // No action for other preferences + } + } + + // This method will enable/disable the checkboxes based on their current state + private def updateCheckBoxState(): Unit = { + val digipeatingPref = findPreference("p.digipeating").asInstanceOf[CheckBoxPreference] + val regeneratePref = findPreference("p.regenerate").asInstanceOf[CheckBoxPreference] + + // If "p.digipeating" is checked, disable "p.regenerate" + if (digipeatingPref.isChecked) { + regeneratePref.setEnabled(false) + } else { + regeneratePref.setEnabled(true) + } + + // If "p.regenerate" is checked, disable "p.digipeating" + if (regeneratePref.isChecked) { + digipeatingPref.setEnabled(false) + } else { + digipeatingPref.setEnabled(true) + } + } +} diff --git a/src/DigipeaterService.scala b/src/DigipeaterService.scala new file mode 100644 index 00000000..a90fff1e --- /dev/null +++ b/src/DigipeaterService.scala @@ -0,0 +1,219 @@ +package org.aprsdroid.app +import _root_.android.util.Log +import scala.collection.mutable +import _root_.net.ab0oo.aprs.parser._ +import java.util.Date + +class DigipeaterService(prefs: PrefsWrapper, TAG: String, sendDigipeatedPacket: String => Unit) { + private val recentDigipeats: mutable.Map[String, Date] = mutable.Map() + + def dedupeTime: Int = prefs.getStringInt("p.dedupe", 30) // Fetch the latest dedupe time from preferences + def digipeaterpath: String = prefs.getString("digipeater_path", "WIDE1,WIDE2") // Fetch digipeater path from preferences + + // Function to add or update the digipeat + def storeDigipeat(sourceCall: String, destinationCall: String, payload: String): Unit = { + // Unique identifier using source call, destination call, and payload + val key = s"$sourceCall>$destinationCall:$payload" + recentDigipeats(key) = new Date() // Store the current timestamp + } + + // Function to filter digipeats that are older than dedupeTime seconds + def isDigipeatRecent(sourceCall: String, destinationCall: String, payload: String): Boolean = { + // Unique identifier using source call, destination call, and payload + val key = s"$sourceCall>$destinationCall:$payload" + recentDigipeats.get(key) match { + case Some(timestamp) => + // Check if the packet was heard within the last dedupeTime seconds + val now = new Date() + now.getTime - timestamp.getTime < (dedupeTime * 1000) + case None => + false // Not found in recent digipeats + } + } + + // Function to clean up old entries + def cleanupOldDigipeats(): Unit = { + val now = new Date() + // Retain only those digipeats that are within the last dedupeTime seconds + recentDigipeats.retain { case (_, timestamp) => + now.getTime - timestamp.getTime < (dedupeTime * 1000) + } + } + + def processIncomingPost(post: String) { + Log.d(TAG, "POST STRING TEST: " + post) // Log the incoming post for debugging + + // Check if backendName contains "KISS" or "AFSK" + if (prefs.getBackendName().contains("KISS") || prefs.getBackendName().contains("AFSK")) { + android.util.Log.d("PrefsAct", "Backend contains KISS or AFSK") + } else { + android.util.Log.d("PrefsAct", "Backend does not contain KISS or AFSK") + return + } + //TODO, Add workaround for unsupported formats. + // Attempt to parse the incoming post to an APRSPacket. + val packet = try { + Parser.parse(post) // Attempt to parse + } catch { + case e: Exception => + Log.e("Parsing FAILED!", s"Failed to parse packet: $post", e) + return // Exit the function if parsing fails + } + + // New regen + if (!prefs.isDigipeaterEnabled() && prefs.isRegenerateEnabled()) { + Log.d("APRSdroid.Service", "Regen enabled") + sendDigipeatedPacket(packet.toString) + return // Exit if both digipeating and regeneration are enabled + } + + // Check if the digipeating setting is enabled + if (!prefs.isDigipeaterEnabled()) { + Log.d("APRSdroid.Service", "Digipeating is disabled; skipping processing.") + return // Exit if digipeating is not enabled + } + + cleanupOldDigipeats() // Clean up old digipeats before processing + + // Try to parse the incoming post to an APRSPacket + try { + // Now you can access the source call from the packet + val callssid = prefs.getCallSsid() + val sourceCall = packet.getSourceCall() + val destinationCall = packet.getDestinationCall(); + val lastUsedDigi = packet.getDigiString() + val payload = packet.getAprsInformation() + + val payloadString = packet.getAprsInformation().toString() // Ensure payload is a String + + + // Check if callssid matches sourceCall; if they match, do not digipeat + if (callssid == sourceCall) { + Log.d("APRSdroid.Service", s"No digipeat: callssid ($callssid) matches source call ($sourceCall).") + return // Exit if no digipeating is needed + } + + // Check if this packet has been digipeated recently + if (isDigipeatRecent(sourceCall, destinationCall, payloadString)) { + Log.d("APRSdroid.Service", s"Packet from $sourceCall to $destinationCall and $payload has been heard recently, skipping digipeating.") + return // Skip processing this packet + } + + + val (modifiedDigiPath, digipeatOccurred) = processDigiPath(lastUsedDigi, callssid) + + + Log.d("APRSdroid.Service", s"Source: $sourceCall") + Log.d("APRSdroid.Service", s"Destination: $destinationCall") + Log.d("APRSdroid.Service", s"Digi: $lastUsedDigi") + Log.d("APRSdroid.Service", s"Modified Digi Path: $modifiedDigiPath") + + Log.d("APRSdroid.Service", s"Payload: $payload") + + // Format the string for sending + val digipeatedPacket = s"$sourceCall>$destinationCall,$modifiedDigiPath:$payload" + + // Optionally, send a test packet with the formatted string only if a digipeat occurred + if (digipeatOccurred) { + sendDigipeatedPacket(digipeatedPacket) + + // Store the digipeat to the recent list + storeDigipeat(sourceCall, destinationCall, payloadString) + + } else { + Log.d("APRSdroid.Service", "No digipeat occurred, not sending a test packet.") + } + + } catch { + case e: Exception => + Log.e("APRSdroid.Service", s"Failed to parse packet: $post", e) + } + } + + def processDigiPath(lastUsedDigi: String, callssid: String): (String, Boolean) = { + // Log the input Digi path + Log.d("APRSdroid.Service", s"Original Digi Path: '$lastUsedDigi'") + + // If lastUsedDigi is empty, return it unchanged + if (lastUsedDigi.trim.isEmpty) { + Log.d("APRSdroid.Service", "LastUsedDigi is empty, returning unchanged.") + return (lastUsedDigi, false) + } + + // Remove leading comma for easier processing + val trimmedPath = lastUsedDigi.stripPrefix(",") + + // Split the path into components, avoiding empty strings + val pathComponents = trimmedPath.split(",").toList.filter(_.nonEmpty) + val digipeaterPaths = digipeaterpath.split(",").toList.filter(_.nonEmpty) + + // Create a new list of components with modifications + val (modifiedPath, modified) = pathComponents.foldLeft((List.empty[String], false)) { + case ((acc, hasModified), component) => + + // First check prefs, then check if component contains '*' + if (prefs.getBoolean("p.directonly", false) && component.contains("*")) { + // Skip digipeating if 'p.directlyonly' is true and '*' is found in the component + return (lastUsedDigi, false) // Return the original path, do not modify + } + + // Check if callssid* is in the path and skip if found + if (component == s"$callssid*") { + // Skip digipeating if callssid* is found + return (lastUsedDigi, false) // Return the original path, do not modify + + } else if (!hasModified && (digipeaterPaths.exists(path => component.split("-")(0) == path) || digipeaterPaths.contains(component) || component == callssid)) { + // We need to check if the first unused component matches digipeaterpath + if (acc.isEmpty || acc.last.endsWith("*")) { + // This is the first unused component + component match { + + case w if w.matches(".*-(\\d+)$") => + // Extract the number from the suffix + val number = w.split("-").last.toInt + // Decrement the number + val newNumber = number - 1 + + if (newNumber == 0 || w == callssid) { + // If the number is decremented to 0, remove the component and insert callssid* + (acc :+ s"$callssid*", true) + + } else { + // Otherwise, decrement the number and keep the component + val newComponent = w.stripSuffix(s"-$number") + s"-$newNumber" + (acc :+ s"$callssid*" :+ newComponent, true) + } + + case _ => + // Leave unchanged if there's no -N suffix + (acc :+ component, hasModified) + } + + } else { + // If the first unused component doesn't match digipeaterpath, keep unchanged + (acc :+ component, hasModified) + } + + } else { + // Keep the component as it is + (acc :+ component, hasModified) + } + } + + // Rebuild the modified path + val resultPath = modifiedPath.mkString(",") + + // Log the modified path before returning + Log.d("APRSdroid.Service", s"Modified Digi Path: '$resultPath'") + + // If no modification occurred, return the original lastUsedDigi + if (resultPath == trimmedPath) { + Log.d("APRSdroid.Service", "No modifications were made; returning the original path.") + return (lastUsedDigi, false) + } + + // Return the modified path with a leading comma + (s"$resultPath", true) + } + +} \ No newline at end of file diff --git a/src/IgatePrefs.scala b/src/IgatePrefs.scala new file mode 100644 index 00000000..7cc35d3c --- /dev/null +++ b/src/IgatePrefs.scala @@ -0,0 +1,56 @@ +package org.aprsdroid.app + +import _root_.android.content.SharedPreferences +import _root_.android.os.Bundle +import _root_.android.preference.{PreferenceActivity, CheckBoxPreference} +import android.util.Log + +class IgatePrefs extends PreferenceActivity with SharedPreferences.OnSharedPreferenceChangeListener { + + lazy val prefs = new PrefsWrapper(this) + + def loadXml(): Unit = { + // Load only the p.igating preference + addPreferencesFromResource(R.xml.igate) // Ensure this XML only contains p.igating + } + + override def onCreate(savedInstanceState: Bundle): Unit = { + super.onCreate(savedInstanceState) + loadXml() + getPreferenceScreen().getSharedPreferences.registerOnSharedPreferenceChangeListener(this) + + // Update preferences state on activity creation + updateCheckBoxState() + } + + override def onDestroy(): Unit = { + super.onDestroy() + getPreferenceScreen().getSharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + } + + override def onSharedPreferenceChanged(sp: SharedPreferences, key: String): Unit = { + key match { + case "p.igating" => + // Handle changes to "p.igating" preference (if necessary) + updateCheckBoxState() + case _ => // No action for other preferences + } + } + + // This method will enable/disable the checkboxes based on their current state + private def updateCheckBoxState(): Unit = { + val igatingPref = findPreference("p.igating").asInstanceOf[CheckBoxPreference] + + // Check if the service is running using your logic + val isServiceRunning = prefs.getBoolean("service_running", false) + + if (isServiceRunning) { + // Disable the checkbox and update the summary + igatingPref.setEnabled(false) + igatingPref.setSummary("Setting disabled while the service is running.") + } else { + // Enable the checkbox and restore the default summary + igatingPref.setEnabled(true) + } + } +} diff --git a/src/IgateService.scala b/src/IgateService.scala new file mode 100644 index 00000000..bd5895f3 --- /dev/null +++ b/src/IgateService.scala @@ -0,0 +1,587 @@ +package org.aprsdroid.app +import _root_.android.content.Context + +import _root_.java.io.{BufferedReader, InputStreamReader, OutputStream, PrintWriter, IOException} +import _root_.java.net.{Socket, SocketException} +import _root_.android.util.Log +import _root_.net.ab0oo.aprs.parser._ +import scala.collection.mutable +import java.util.Date + +// Define a callback interface for connection events +trait ConnectionListener { + def onConnectionLost(): Unit +} + +class IgateService(service: AprsService, prefs: PrefsWrapper) extends ConnectionListener { + + val TAG = "IgateService" + val hostport = prefs.getString("p.igserver", "rotate.aprs2.net") + val (host, port) = parseHostPort(hostport) + val so_timeout = prefs.getStringInt("p.igsotimeout", 120) + val connectretryinterval = prefs.getStringInt("p.igconnectretry", 30) + var conn: TcpSocketThread = _ + var reconnecting = false // Track if we're reconnecting + + // Parse host and port from the hostport string + def parseHostPort(hostport: String): (String, Int) = { + val parts = hostport.split(":") + if (parts.length == 2) { + (parts(0), parts(1).toInt) + } else { + (hostport, 14580) // default port + } + } + + // Start the connection + def start(): Unit = { + Log.d(TAG, s"start() - Starting connection to $host:$port") + if (conn == null) { + Log.d(TAG, "start() - No existing connection, creating new connection.") + createConnection() + } else { + Log.d(TAG, "start() - Connection already exists.") + } + } + + // Create the TCP connection and pass 'this' as listener + def createConnection(): Unit = { + Log.d(TAG, s"createConnection() - Connecting to $host:$port") + conn = new TcpSocketThread(host, port, so_timeout, service, prefs, this) // Pass host and port + Log.d(TAG, "createConnection() - TcpSocketThread created, starting thread.") + conn.start() + } + + // Stop the connection + def stop(): Unit = { + Log.d(TAG, "stop() - Stopping connection") + if (conn != null) { + conn.synchronized { + conn.running = false + } + Log.d(TAG, "stop() - Waiting for connection thread to join.") + conn.join(50) + conn.shutdown() // Make sure the socket is cleanly closed + Log.d(TAG, "stop() - Connection shutdown.") + } else { + Log.d(TAG, "stop() - No connection to stop.") + } + } + + // Handle and submit data to the server via TcpSocketThread + def handlePostSubmitData(data: String): Unit = { + Log.d(TAG, s"handlePostSubmitData() - Received data: $data") + if (conn != null) { + Log.d(TAG, "handlePostSubmitData() - Delegating data to TcpSocketThread.") + conn.handlePostSubmitData(data) + } else { + Log.d(TAG, "handlePostSubmitData() - No active connection to send data.") + } + } + + // 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) { + 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 + } + + // Callback implementation when the connection is lost + override def onConnectionLost(): Unit = { + Log.d(TAG, "onConnectionLost() - Connection lost, attempting to reconnect.") + reconnect() // Automatically reconnect + } +} + +class TcpSocketThread(host: String, port: Int, timeout: Int, service: AprsService, prefs: PrefsWrapper, listener: ConnectionListener) extends Thread { + @volatile var running = true + 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]() + + // 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", s"Connecting to $host:$port") + + while (running) { + try { + // Establish the connection + socket = new Socket(host, port) + socket.setSoTimeout(timeout * 1000) + Log.d("IgateService", s"run() - Connected to $host") + + reader = new BufferedReader(new InputStreamReader(socket.getInputStream)) + writer = new PrintWriter(socket.getOutputStream, true) + sendLogin() + service.addPost(StorageDatabase.Post.TYPE_INFO, "APRS-IS", "Connected to APRS-IS") + + + while (running) { + val message = reader.readLine() + if (message != null) { + Log.d("IgateService", s"run() - Received message: $message") + + handleMessage(message) + handleAprsTrafficPost(message) + + } else { + Log.d("IgateService", "run() - Server disconnected. Attempting to reconnect.") + running = false + listener.onConnectionLost() // Notify listener (IgateService) of failure + } + } + + } catch { + case e: SocketException => + Log.e("IgateService", s"run() - SocketException: ${e.getMessage}") + running = false + listener.onConnectionLost() // Notify listener (IgateService) of failure + case e: IOException => + Log.e("IgateService", s"run() - IOException: ${e.getMessage}") + running = false + listener.onConnectionLost() // Notify listener (IgateService) of failure + } finally { + shutdown() // Ensure resources are cleaned up + } + } + } + + // Send login information to the APRS-IS server + def sendLogin(): Unit = { + Log.d("IgateService", "sendLogin() - Sending login information to server.") + val callsign = prefs.getCallSsid() + val passcode = prefs.getPasscode() // Retrieve passcode from preferences + val version = s"APRSdroid ${service.APP_VERSION.filter(_.isDigit).takeRight(2).mkString.split("").mkString(".")}" + val filter = prefs.getString("p.igfilter", "") + + // Format the login message as per the Python example + val loginMessage = s"user $callsign pass $passcode vers $version\r\n" + val filterMessage = s"#filter $filter\r\n" // Retrieve filter from preferences + + Log.d("IgateService", s"sendLogin() - Sending login: $loginMessage") + Log.d("IgateService", s"sendLogin() - Sending filter: $filterMessage") + + // Send the login message to the server + writer.println(loginMessage) + writer.flush() + Log.d("IgateService", "sendLogin() - Login sent.") + + // Send the filter command + writer.println(filterMessage) + writer.flush() + Log.d("IgateService", "sendLogin() - Filter sent.") + } + + // Modify data string before sending it + def modifyData(data: String): String = { + Log.d("IgateService", s"modifyData() - Received data: $data") + // Check if data contains "RFONLY" or "TCPIP" + if (data.contains("RFONLY") || data.contains("TCPIP") || data.contains("NOGATE")) { + 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") + + // Set qconstruct based on whether bidirectional gate is enabled in preferences + val qconstruct = if (prefs.getBoolean("p.aprsistorf", false)) "qAR" else "qAO" + + if (colonIndex != -1) { + // Insert ",qAR, or ,qAS," before the first colon + val modifiedData = data.substring(0, colonIndex) + s",$qconstruct," + prefs.getCallSsid + data.substring(colonIndex) + Log.d("IgateService", s"modifyData() - Modified data: $modifiedData") + return modifiedData + } else { + // If there's no colon, return the data as is (or handle this case as needed) + Log.d("IgateService", "modifyData() - No colon found, returning data as is.") + return data + } + } + + // Handle modified data before submitting it to the server + def handlePostSubmitData(data: String): Unit = { + //Log.d("IgateService", s"handlePostSubmitData() - Received data: $data") + + // Modify the data before sending it to the server + val modifiedData = modifyData(data) + + // If the modified data is null, skip further processing + if (modifiedData == null) { + Log.d("IgateService", "handlePostSubmitData() - Skipping data processing due to RFONLY/TCPIP in packet") + return // Stop further processing if the packet contains RFONLY or TCPIP + } + + // Extract the callsign from the modified data (before the '>' symbol) + val callSign = modifiedData.split(">")(0).trim // Split the string and take the part before '>' + + // Update lastHeardCalls map with the current timestamp for that callsign + lastHeardCalls(callSign) = System.currentTimeMillis() // Use current time in milliseconds + Log.d("IgateService", s"handlePostSubmitData() - Extracted callsign: $callSign, updating last heard time to ${System.currentTimeMillis()} for that callsign.") + + // Log the modified data to confirm the change + Log.d("IgateService", s"handlePostSubmitData() - Modified data: $modifiedData") + + // Send the modified data to the APRS-IS server (or other logic as necessary) + if (socket != null && socket.isConnected) { + sendData(modifiedData) // Send it to the server + Log.d("IgateService", "handlePostSubmitData() - Data sent to server.") + service.addPost(StorageDatabase.Post.TYPE_IG, "APRS-IS Sent", modifiedData) + } else { + Log.e("IgateService", "handlePostSubmitData() - No active connection to send data.") + } + } + + // Send data to the server + def sendData(data: String): Unit = { + Log.d("IgateService", s"sendData() - Sending data: $data") + if (writer != null) { + writer.println(data) + writer.flush() + } else { + Log.e("IgateService", "sendData() - Writer is null, cannot send data.") + } + } + + // Clean up resources + def shutdown(): Unit = { + if (socket != null) { + try { + socket.close() + } catch { + case e: IOException => Log.e("IgateService", s"shutdown() - Error closing socket: ${e.getMessage}") + } + } + } + + def handleAprsTrafficPost(message: String): Unit = { + val aprsIstrafficEnabled = prefs.getBoolean("p.aprsistraffic", false) + + if (!aprsIstrafficEnabled) { + // If the checkbox is enabled, perform the action + if (message.startsWith("#")) { + service.addPost(StorageDatabase.Post.TYPE_INFO, "APRS-IS", message) + Log.d("IgateService", s"APRS-IS traffic enabled, post added: $message") + } else { + service.addPost(StorageDatabase.Post.TYPE_IG, "APRS-IS Received", message) + Log.d("IgateService", s"APRS-IS traffic enabled, post added: $message") + } + } else { + // If the checkbox is not enabled, skip the action + Log.d("IgateService", "APRS-IS traffic disabled, skipping the post.") + return + } + } + + 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 && + (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 && + (payloadString.substring(1, 4) == "BLN" || + payloadString.substring(1, 4) == "NWS" || + payloadString.substring(1, 4) == "SKY" || + payloadString.substring(1, 4) == "CWA" || + payloadString.substring(1, 4) == "BOM")) { + return null // Ignore unwanted prefixes + } + payloadString.stripPrefix(":").takeWhile(_ != ':').replaceAll("\\s", "") + } else { + null // No targeted callsign if it doesn't start with ":". Not a message. + } + } + + def processPacketPosition(fap: APRSPacket): String = { + //Process APRS-IS Packet for RF destination + try { + val callssid = prefs.getCallSsid() // Get local call sign from preferences + val sourceCall = fap.getSourceCall() // Source callsign from fap + val destinationCall = fap.getDestinationCall() // Destination callsign + 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 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) { + 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) + + // Process and create the packet + val igatedPacket = s"$callssid>$version$formattedDigipath:}$sourceCall>$destinationCall,TCPIP,$callssid*:$payload" + Log.d("IgateService", s"Processed packet: $igatedPacket") + + // Call checkRateLimit and return null if rate limit exceeded + if (checkRateLimit()) { + // Log when the rate limit is exceeded and the packet is skipped + 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 + val timelastheard = prefs.getStringInt("p.timelastheard", 30) + try { + val callssid = prefs.getCallSsid() // Get local call sign from preferences + val sourceCall = fap.getSourceCall() // Source callsign from fap + val destinationCall = fap.getDestinationCall() // Destination callsign + 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 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.") + return null + } + + // Check if we have seen this source call in the last 30 minutes + val currentTime = System.currentTimeMillis() + //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") + + // Call checkRateLimit and return null if rate limit exceeded + if (checkRateLimit()) { + // Log when the rate limit is exceeded and the packet is skipped + 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") + + // Add the current time to both 1-minute and 5-minute queues + sentPackets1Min.enqueue(currentTime) + sentPackets5Min.enqueue(currentTime) + + // Get the rate limit values from preferences for 1 minute and 5 minutes + val limit1Min = prefs.getStringInt("p.ratelimit1", 6) + val limit5Min = prefs.getStringInt("p.ratelimit5", 10) + + // If the queues exceed the limit, remove the oldest timestamp + if (sentPackets1Min.size > limit1Min) { + sentPackets1Min.dequeue() + } + if (sentPackets5Min.size > limit5Min) { + sentPackets5Min.dequeue() + } + + // Log the sizes of the queues after the new time is added + 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 + + // Log the current time for debugging purposes + Log.d("IgateService", s"Current time: $currentTime") + + // Remove packets older than 1 minute (60,000 ms) + sentPackets1Min.dequeueAll(packetTime => currentTime - packetTime > 60000) + // Log the size of the queue after dequeuing old packets + 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) + // Log the size of the queue after dequeuing old packets + Log.d("IgateService", s"sentPackets5Min size after cleanup: ${sentPackets5Min.size}") + + // Get the rate limit value from preferences for 1 minute (default is 6 if not set) + val limit1Min = prefs.getStringInt("p.ratelimit1", 6) + // Get the rate limit value from preferences for 5 minutes (default is 10 if not set) + val limit5Min = prefs.getStringInt("p.ratelimit5", 10) + + // Check if the packet limits for 1 minute and 5 minutes are exceeded + if (sentPackets1Min.size >= limit1Min) { + Log.w("IgateService", "Packet limit exceeded for 1 minute interval. Dropping packet.") + service.addPost(StorageDatabase.Post.TYPE_ERROR, "APRS-IS > RF", "Packet limit exceeded for 1 minute. Packet dropped.") + // Log the state when rate limit is exceeded + Log.d("IgateService", "Rate limit exceeded for 1 minute. Returning true.") + return true // Return true to indicate rate limit exceeded + } + + if (sentPackets5Min.size >= limit5Min) { + Log.w("IgateService", "Packet limit exceeded for 5 minute interval. Dropping packet.") + service.addPost(StorageDatabase.Post.TYPE_ERROR, "APRS-IS > RF", "Packet limit exceeded for 5 minutes. Packet dropped.") + // Log the state when rate limit is exceeded + Log.d("IgateService", "Rate limit exceeded for 5 minutes. Returning true.") + return true // Return true to indicate rate limit exceeded + } + + // Log when no rate limit is exceeded + Log.d("IgateService", "No rate limit exceeded. Returning false.") + return false // Return false to indicate no rate limit exceeded + } + + 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}") + + // 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 + } else { + Log.d("IgateService", "Packet not processed, skipping send.") + } + } 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.") + } + } catch { + case e: Exception => + Log.e("IgateService", s"handleMessage() - Failed to parse packet: $message", e) + } + } +} diff --git a/src/LogActivity.scala b/src/LogActivity.scala index 466444e4..7fcabc36 100644 --- a/src/LogActivity.scala +++ b/src/LogActivity.scala @@ -66,7 +66,7 @@ class LogActivity extends MainListActivity("log", R.id.log) { //super.onListItemClick(l, v, position, id) val c = getListView().getItemAtPosition(position).asInstanceOf[Cursor] val t = c.getInt(COLUMN_TYPE) - if (t != TYPE_POST && t != TYPE_INCMG) + if (t != TYPE_POST && t != TYPE_INCMG && t != TYPE_DIGI && t != TYPE_IG) return val call = c.getString(COLUMN_MESSAGE).split(">")(0) Log.d(TAG, "onListItemClick: %s".format(call)) diff --git a/src/MapAct.scala b/src/MapAct.scala index c68c96ba..2d8910fa 100644 --- a/src/MapAct.scala +++ b/src/MapAct.scala @@ -137,7 +137,7 @@ class MapAct extends MapActivity with MapMenuHelper { try { if (mapview.getMapFile == null) { val map_source = MapGeneratorInternal.MAPNIK - val map_gen = new OsmTileDownloader() + val map_gen = OsmTileDownloader.create(this) map_gen.setUserAgent(getString(R.string.build_version)) mapview.setMapGenerator(map_gen) } @@ -329,10 +329,28 @@ class StationOverlay(icons : Drawable, context : MapAct, db : StorageDatabase) e strokePaint.setShadowLayer(10, 0, 0, 0x80c8ffc8) + // Create a separate Paint for the zoom level text + val zoomTextPaint = new Paint(textPaint) + val zoomFontSize = fontSize * 0.7f // Adjust zoom text size here + zoomTextPaint.setTextSize(zoomFontSize) val p = new Point() val (width, height) = (c.getWidth(), c.getHeight()) val ss = drawSize/2 + + // Draw the zoom level text in the bottom-left corner + val zoomText = s"Zoom: $zoom" + + // Measure the width of the zoom text + val textWidth = zoomTextPaint.measureText(zoomText) + + // Get the canvas dimensions + val xPos = 20 + (textWidth / 2) + val yPos = height - 20 + + // Draw the zoom level text + c.drawText(zoomText, xPos, yPos, zoomTextPaint) + for (s <- stations) { proj.toPixels(s.pt, p) if (p.x >= 0 && p.y >= 0 && p.x < width && p.y < height) { @@ -373,8 +391,8 @@ class StationOverlay(icons : Drawable, context : MapAct, db : StorageDatabase) e val p = proj.toPixels(gp, null) // ... to pixel area ... to geo area //Log.d(TAG, "coords: " + p) - val botleft = proj.fromPixels(p.x - 16, p.y + 16) - val topright = proj.fromPixels(p.x + 16, p.y - 16) + val botleft = proj.fromPixels(p.x - 50, p.y + 50) + val topright = proj.fromPixels(p.x + 50, p.y - 50) Log.d(TAG, "from " + botleft + " to " + topright) // fetch stations in the tap val list = stations.filter(_.inArea(botleft, topright)).map(_.call) diff --git a/src/MessageListAdapter.scala b/src/MessageListAdapter.scala index 0a8257b1..9b60dcae 100644 --- a/src/MessageListAdapter.scala +++ b/src/MessageListAdapter.scala @@ -14,7 +14,6 @@ object MessageListAdapter { val LIST_FROM = Array("TSS", CALL, TEXT) val LIST_TO = Array(R.id.listts, R.id.liststatus, R.id.listmessage) - val NUM_OF_RETRIES = 7 // null, incoming, out-new, out-acked, out-rejected, out-aborted val COLORS = Array(0, 0xff8080b0, 0xff80a080, 0xff30b030, 0xffb03030, 0xffa08080) } @@ -25,6 +24,9 @@ class MessageListAdapter(context : Context, prefs : PrefsWrapper, lazy val storage = StorageDatabase.open(context) + lazy val NUM_OF_RETRIES = prefs.getStringInt("p.messaging", 7) // Fetch NUM_OF_RETRIES from prefs, defaulting to 7 if not found + + reload() lazy val locReceiver = new LocationReceiver2(load_cursor, @@ -45,7 +47,7 @@ class MessageListAdapter(context : Context, prefs : PrefsWrapper, case TYPE_INCOMING => targetcall case TYPE_OUT_NEW => - "%s %d/%d".format(mycall, retrycnt, MessageListAdapter.NUM_OF_RETRIES) + "%s %d/%d".format(mycall, retrycnt, NUM_OF_RETRIES) case TYPE_OUT_ACKED => mycall case TYPE_OUT_REJECTED => diff --git a/src/MessageService.scala b/src/MessageService.scala index 93a0b630..4f0e61fd 100644 --- a/src/MessageService.scala +++ b/src/MessageService.scala @@ -5,11 +5,19 @@ import _root_.android.util.Log import _root_.android.os.Handler import _root_.net.ab0oo.aprs.parser._ +import scala.collection.mutable class MessageService(s : AprsService) { val TAG = "APRSdroid.MsgService" - val NUM_OF_RETRIES = 7 + val NUM_OF_RETRIES = s.prefs.getStringInt("p.messaging", 7) + + val RETRY_INTERVAL = s.prefs.getStringInt("p.retry", 30) + + // Map to store the last ACK timestamps + private val lastAckTimestamps = mutable.Map[(String, String), Long]() + + val pendingSender = new Runnable() { override def run() { sendPendingMessages() } } def createMessageNotifier() = new BroadcastReceiver() { @@ -30,22 +38,50 @@ class MessageService(s : AprsService) { ) } - def handleMessage(ts : Long, ap : APRSPacket, msg : MessagePacket) { + def handleMessage(ts : Long, ap : APRSPacket, msg : MessagePacket, LastUsedDigi : String) { val callssid = s.prefs.getCallSsid() + + // Retrieve the ACK duplicate time setting from preferences (default is 0 seconds) + val lastAckDupeTime = s.prefs.getStringInt("p.ackdupe", 0) * 1000 // Convert seconds to milliseconds + + // Check if we have sent an ACK for the same source call and message number in the last `lastAckDupeTime` milliseconds + val currentTime = System.currentTimeMillis() + val messageNumber = msg.getMessageNumber() // Get the message number + val lastAckTime = lastAckTimestamps.get((ap.getSourceCall(), messageNumber)) + if (msg.getTargetCallsign().equalsIgnoreCase(callssid)) { if (msg.isAck() || msg.isRej()) { val new_type = if (msg.isAck()) StorageDatabase.Message.TYPE_OUT_ACKED else StorageDatabase.Message.TYPE_OUT_REJECTED + s.db.updateMessageAcked(ap.getSourceCall(), msg.getMessageNumber(), new_type) s.sendBroadcast(AprsService.MSG_PRIV_INTENT) } else { storeNotifyMessage(ts, ap.getSourceCall(), msg) - if (msg.getMessageNumber() != "") { - // we need to send an ack + + // Only check for duplicate ACKs if the feature is enabled + if (s.prefs.isAckDupeEnabled) { + + if (lastAckTime.exists(time => (currentTime - time) < lastAckDupeTime) || lastAckDupeTime == 0) { + Log.d(TAG, s"Duplicate msg or ack disabled, skipping ack for ${ap.getSourceCall()} messageNumber: $messageNumber") + // Recent ACK exists, skip sending a new ACK + return + } + } + + // Proceed to send ACK if messageNumber is not empty + if (msg.getMessageNumber() != "" && (!LastUsedDigi.split(",").contains(s.prefs.getCallSsid() + "*") && !ap.getDigiString().split(",").contains(s.prefs.getCallSsid() + "*"))) { + Log.d(TAG, s"DigiString: ${ap.getDigiString()}, LastUsedDigi: ${LastUsedDigi}, CallSSID: ${s.prefs.getCallSsid()}") + Log.d(TAG, s"Sending ACK: msgNumber = ${msg.getMessageNumber()}, digiString = ${ap.getDigiString()}, callsign = ${s.prefs.getCallSsid()}") + + // No recent ACK, we need to send an ACK val ack = s.newPacket(new MessagePacket(ap.getSourceCall(), "ack", msg.getMessageNumber())) s.sendPacket(ack) + + // Update the last ACK timestamp for this source call and message number + lastAckTimestamps((ap.getSourceCall(), msg.getMessageNumber())) = System.currentTimeMillis() } } } else if (msg.getTargetCallsign().split("-")(0).equalsIgnoreCase( @@ -59,7 +95,8 @@ class MessageService(s : AprsService) { } // return 2^n * 30s, at most 32min - def getRetryDelayMS(retrycnt : Int) = 30000 * (1 << math.min(retrycnt - 1, 6)) + def getRetryDelayMS(retrycnt : Int) = (RETRY_INTERVAL * 1000) * (1 << math.min(retrycnt - 1, NUM_OF_RETRIES)) + def scheduleNextSend(delay : Long) { // add some time to prevent fast looping diff --git a/src/MessagingPrefs.scala b/src/MessagingPrefs.scala new file mode 100644 index 00000000..ef6f09fb --- /dev/null +++ b/src/MessagingPrefs.scala @@ -0,0 +1,39 @@ +package org.aprsdroid.app + +import _root_.android.os.Bundle +import _root_.android.preference.{PreferenceActivity, PreferenceManager} +import _root_.android.content.SharedPreferences +import _root_.android.content.SharedPreferences.OnSharedPreferenceChangeListener + +class MessagingPrefs extends PreferenceActivity with OnSharedPreferenceChangeListener { + + lazy val prefs = new PrefsWrapper(this) + + // Load the preferences XML + def loadXml(): Unit = { + addPreferencesFromResource(R.xml.messaging) // Load the preferences from messaging.xml + } + + // Called when the activity is created + override def onCreate(savedInstanceState: Bundle): Unit = { + super.onCreate(savedInstanceState) + loadXml() // Load the XML file containing preferences + getPreferenceScreen.getSharedPreferences.registerOnSharedPreferenceChangeListener(this) // Register listener for preference changes + } + + // Called when the activity is destroyed + override def onDestroy(): Unit = { + super.onDestroy() + getPreferenceScreen.getSharedPreferences.unregisterOnSharedPreferenceChangeListener(this) // Unregister listener + } + + // Called when a shared preference is changed + override def onSharedPreferenceChanged(sp: SharedPreferences, key: String): Unit = { + key match { + case "p.messaging" | "p.retry" | "p.ackdupetoggle" | "p.ackdupe" | "p.msgdupetoggle" | "p.msgdupetime" => + setPreferenceScreen(null) // Clear the current preference screen + loadXml() // Reload the preferences to reflect any changes + case _ => // Ignore other keys + } + } +} diff --git a/src/OsmTileDownloader.java b/src/OsmTileDownloader.java index 40d78159..b3ed3c52 100644 --- a/src/OsmTileDownloader.java +++ b/src/OsmTileDownloader.java @@ -1,37 +1,69 @@ package org.aprsdroid.app; +import android.content.Context; // Import Context +import android.util.Log; // Import Log for logging import org.mapsforge.v3.android.maps.mapgenerator.tiledownloader.TileDownloader; import org.mapsforge.v3.core.Tile; public class OsmTileDownloader extends TileDownloader { - private static final String HOST_NAME = "tile.openstreetmap.org"; - private static final byte ZOOM_MAX = 18; - private final StringBuilder stringBuilder = new StringBuilder(); + private static final String HOST_NAME_ONLINE = "tile.openstreetmap.org"; + private static final String HOST_NAME_OFFLINE = "127.0.0.1"; // New hostname for offline maps + private static final byte ZOOM_MAX = 18; + private final StringBuilder stringBuilder = new StringBuilder(); + private static final String TAG = "OsmTileDownloader"; // Tag for logging + private final PrefsWrapper prefsWrapper; // Instance of PrefsWrapper - public OsmTileDownloader() { - } + // Constructor that accepts a PrefsWrapper instance + public OsmTileDownloader(PrefsWrapper prefsWrapper) { + this.prefsWrapper = prefsWrapper; + } - public String getHostName() { - return HOST_NAME; - } + // Factory method to create an instance + public static OsmTileDownloader create(Context context) { + PrefsWrapper prefsWrapper = new PrefsWrapper(context); + return new OsmTileDownloader(prefsWrapper); + } - public String getProtocol() { - return "https"; - } + @Override + public String getHostName() { + String hostName = prefsWrapper.isOfflineMap() ? HOST_NAME_OFFLINE : HOST_NAME_ONLINE; + Log.d(TAG, "Getting host name: " + hostName); // Log host name + return hostName; + } - public String getTilePath(Tile tile) { - this.stringBuilder.setLength(0); - this.stringBuilder.append('/'); - this.stringBuilder.append(tile.zoomLevel); - this.stringBuilder.append('/'); - this.stringBuilder.append(tile.tileX); + @Override + public String getProtocol() { + String protocol = prefsWrapper.isOfflineMap() ? "http" : "https"; // Use HTTP for offline maps + Log.d(TAG, "Getting protocol: " + protocol); // Log protocol + return protocol; + } + + @Override + public int getPort() { + int port = prefsWrapper.isOfflineMap() ? 8080 : 443; // Use port 8080 for offline maps + Log.d(TAG, "Getting port: " + port); // Log port + return port; + } + + @Override + public String getTilePath(Tile tile) { + this.stringBuilder.setLength(0); this.stringBuilder.append('/'); - this.stringBuilder.append(tile.tileY); - this.stringBuilder.append(".png"); - return this.stringBuilder.toString(); - } - - public byte getZoomLevelMax() { - return ZOOM_MAX; - } + this.stringBuilder.append(tile.zoomLevel); + this.stringBuilder.append('/'); + this.stringBuilder.append(tile.tileX); + this.stringBuilder.append('/'); + this.stringBuilder.append(tile.tileY); + this.stringBuilder.append(".png"); + + String tilePath = this.stringBuilder.toString(); + Log.d(TAG, "Generated tile path: " + tilePath); // Log the generated tile path + return tilePath; + } + + @Override + public byte getZoomLevelMax() { + Log.d(TAG, "Getting maximum zoom level: " + ZOOM_MAX); // Log max zoom level + return ZOOM_MAX; + } } diff --git a/src/PostListAdapter.scala b/src/PostListAdapter.scala index f73647cc..5a1776e6 100644 --- a/src/PostListAdapter.scala +++ b/src/PostListAdapter.scala @@ -25,7 +25,7 @@ class PostListAdapter(context : Context) class PostViewBinder extends ViewBinder { // post, info, error, incoming, tx - val COLORS = Array(0xff30b030, 0xffc0c080, 0xffffb0b0, 0xff8080b0, 0xff30b030) + val COLORS = Array(0xff30b030, 0xffc0c080, 0xffffb0b0, 0xff8080b0, 0xff30b030, 0xfff38c0c, 0xffe3d61c) override def setViewValue (view : View, cursor : Cursor, columnIndex : Int) : Boolean = { import StorageDatabase.Post._ @@ -36,7 +36,7 @@ class PostViewBinder extends ViewBinder { val v = view.asInstanceOf[TextView] v.setText(m) v.setTextColor(COLORS(t)) - if (t == TYPE_POST || t == TYPE_INCMG || t == TYPE_TX) + if (t == TYPE_POST || t == TYPE_INCMG || t == TYPE_TX || t == TYPE_DIGI || t == TYPE_IG) v.setTypeface(Typeface.MONOSPACE) else v.setTypeface(Typeface.DEFAULT) diff --git a/src/PrefsAct.scala b/src/PrefsAct.scala index c2db64fe..6c7948c7 100644 --- a/src/PrefsAct.scala +++ b/src/PrefsAct.scala @@ -26,7 +26,9 @@ class PrefsAct extends PreferenceActivity { try { directory.mkdirs() val prefs = PreferenceManager.getDefaultSharedPreferences(this) - val json = new JSONObject(prefs.getAll) + val allPrefs = prefs.getAll + allPrefs.remove("map_zoom") + val json = new JSONObject(allPrefs) val fo = new PrintWriter(file) fo.println(json.toString(2)) fo.close() @@ -50,8 +52,8 @@ class PrefsAct extends PreferenceActivity { override def onCreate(savedInstanceState: Bundle) { super.onCreate(savedInstanceState) addPreferencesFromResource(R.xml.preferences) - fileChooserPreference("mapfile", 123456, R.string.p_mapfile_choose) - fileChooserPreference("themefile", 123457, R.string.p_themefile_choose) + //fileChooserPreference("mapfile", 123456, R.string.p_mapfile_choose) + //fileChooserPreference("themefile", 123457, R.string.p_themefile_choose) } override def onResume() { super.onResume() @@ -114,7 +116,7 @@ class PrefsAct extends PreferenceActivity { val takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION) getContentResolver.takePersistableUriPermission(data.getData(), takeFlags) PreferenceManager.getDefaultSharedPreferences(this) - .edit().putString("mapfile", data.getDataString()).commit() + //.edit().putString("mapfile", data.getDataString()).commit() finish() startActivity(getIntent()) } else diff --git a/src/PrefsWrapper.scala b/src/PrefsWrapper.scala index 1be9d4c2..f1939e67 100644 --- a/src/PrefsWrapper.scala +++ b/src/PrefsWrapper.scala @@ -12,6 +12,27 @@ class PrefsWrapper(val context : Context) { def getString(key : String, defValue : String) = prefs.getString(key, defValue) def getBoolean(key : String, defValue : Boolean) = prefs.getBoolean(key, defValue) + def isIgateEnabled(): Boolean = { + prefs.getBoolean("p.igating", false) + } + def isDigipeaterEnabled(): Boolean = { + prefs.getBoolean("p.digipeating", false) + } + def isRegenerateEnabled(): Boolean = { + prefs.getBoolean("p.regenerate", false) + } + def isAckDupeEnabled(): Boolean = { + prefs.getBoolean("p.ackdupetoggle", false) + } + def isMsgDupeEnabled(): Boolean = { + prefs.getBoolean("p.msgdupetoggle", false) + } + def isMetric(): Boolean = { + prefs.getString("p.units", "1") == "1" // "1" for metric, "2" for imperial + } + def isOfflineMap(): Boolean = { + prefs.getBoolean("p.offlinemap", false) + } // safely read integers def getStringInt(key : String, defValue : Int) = { try { prefs.getString(key, null).trim.toInt } catch { case _ : Throwable => defValue } @@ -74,6 +95,7 @@ class PrefsWrapper(val context : Context) { R.array.p_conntype_ev, R.array.p_conntype_e) val link = AprsBackend.defaultProtoInfo(this).link link match { + case "afsk" => "%s, %s".format(proto, getListItemName(link, AprsBackend.DEFAULT_CONNTYPE, R.array.p_afsk_ev, R.array.p_afsk_e)) case "aprsis" => "%s, %s".format(proto, getListItemName(link, AprsBackend.DEFAULT_CONNTYPE, R.array.p_aprsis_ev, R.array.p_aprsis_e)) case "link" => "%s, %s".format(proto, getListItemName(link, AprsBackend.DEFAULT_CONNTYPE, R.array.p_link_ev, R.array.p_link_e)) case _ => proto @@ -102,6 +124,8 @@ class PrefsWrapper(val context : Context) { def getProto() = getString("proto", "aprsis") def getAfskHQ() = getBoolean("afsk.hqdemod", true) + def getAfskRTS() = getBoolean("afsk.ptt", false) + def getPTTPort() = getString("afsk.pttport", "") def getAfskBluetooth() = getBoolean("afsk.btsco", false) && getAfskHQ() def getAfskOutput() = if (getAfskBluetooth()) AudioManager.STREAM_VOICE_CALL else getStringInt("afsk.output", 0) } diff --git a/src/StationListAdapter.scala b/src/StationListAdapter.scala index 5ac85a0c..81ccb2ff 100644 --- a/src/StationListAdapter.scala +++ b/src/StationListAdapter.scala @@ -83,9 +83,20 @@ class StationListAdapter(context : Context, prefs : PrefsWrapper, val qrg_visible = if (qrg != null && qrg != "") View.VISIBLE else View.GONE view.findViewById(R.id.station_qrg).asInstanceOf[View].setVisibility(qrg_visible) val MCD = 1000000.0 - android.location.Location.distanceBetween(my_lat/MCD, my_lon/MCD, - lat/MCD, lon/MCD, dist) - distage.setText("%1.1f km %s\n%s".format(dist(0)/1000.0, getBearing(dist(1)), age)) + android.location.Location.distanceBetween(my_lat/MCD, my_lon/MCD, lat/MCD, lon/MCD, dist) + + // Determine whether to use metric or imperial based on user preference + val isMetric = prefs.isMetric() // Assuming isMetric() returns true for metric, false for imperial + val distanceText: String = if (isMetric) { + val distanceInKm = dist(0) / 1000.0 + "%1.1f km %s\n%s".format(distanceInKm, getBearing(dist(1)), age) + } else { + val distanceInMiles = dist(0) / 1000.0 * 0.621371 + "%1.1f mi %s\n%s".format(distanceInMiles, getBearing(dist(1)), age) + } + + distage.setText(distanceText) + view.findViewById(R.id.station_symbol).asInstanceOf[SymbolView].setSymbol(symbol) super.bindView(view, context, cursor) } diff --git a/src/StorageDatabase.scala b/src/StorageDatabase.scala index c9463856..c79ba5f5 100644 --- a/src/StorageDatabase.scala +++ b/src/StorageDatabase.scala @@ -7,6 +7,7 @@ import _root_.android.database.sqlite.SQLiteDatabase import _root_.android.database.Cursor import _root_.android.util.Log import _root_.android.widget.FilterQueryProvider +import _root_.android.preference.PreferenceManager import _root_.net.ab0oo.aprs.parser._ @@ -36,6 +37,8 @@ object StorageDatabase { val TYPE_ERROR = 2 val TYPE_INCMG = 3 val TYPE_TX = 4 + val TYPE_DIGI = 5 + val TYPE_IG = 6 val COLUMN_TS = 1 val COLUMN_TSS = 2 @@ -182,6 +185,8 @@ class StorageDatabase(context : Context) extends null, StorageDatabase.DB_VERSION) { import StorageDatabase._ + lazy val prefs = new PrefsWrapper(context) + override def onCreate(db: SQLiteDatabase) { Log.d(TAG, "onCreate(): creating new database " + DB_NAME); db.execSQL(Post.TABLE_CREATE); @@ -258,21 +263,47 @@ class StorageDatabase(context : Context) extends getWritableDatabase().replaceOrThrow(TABLE, CALL, cv) } - def isMessageDuplicate(call : String, msgid : String, text : String) : Boolean = { - val c = getReadableDatabase().query(Message.TABLE, Message.COLUMNS, - "type = 1 AND call = ? AND msgid = ? AND text = ?", - Array(call, msgid, text), - null, null, - null, null) - val result = (c.getCount() > 0) - c.close() - result - } - - // add an incoming message, returns false if duplicate - def addMessage(ts : Long, srccall : String, msg : MessagePacket) : Boolean = { + def isMessageDuplicate(call: String, msgid: String, text: String, currentTs: Long): Boolean = { + // Prepare the base query conditions + val selection = "type = 1 AND call = ? AND msgid = ? AND text = ?" + val selectionArgs = Array(call, msgid, text) + var query: String = selection + var args: Array[String] = selectionArgs + + // If message duplication is enabled, add the time threshold condition + if (prefs.isMsgDupeEnabled) { + val msgDupetime = prefs.getStringInt("p.msgdupetime", 30) + val timeThreshold = currentTs - (msgDupetime * 1000) + // If msgDupetime is 0, return false immediately (no duplication check) + if (msgDupetime == 0) { + return false + } + query += " AND ts >= ?" + args = args :+ timeThreshold.toString + } + + // Perform the database query with the constructed query and args + val cursor = getReadableDatabase().query( + Message.TABLE, + Message.COLUMNS, + query, + args, + null, null, null, null + ) + + // Check if there are any results (cursor.getCount() could be replaced with cursor.moveToFirst()) + val result = cursor.moveToFirst() // Checks if at least one record is found + cursor.close() + + result + } + + + // Add an incoming message, returns false if duplicate + def addMessage(ts: Long, srccall: String, msg: MessagePacket): Boolean = { import Message._ - if (isMessageDuplicate(srccall, msg.getMessageNumber(), msg.getMessageBody())) { + // Check if the message is a duplicate considering timestamp + if (isMessageDuplicate(srccall, msg.getMessageNumber(), msg.getMessageBody(), ts)) { Log.i(TAG, "received duplicate message from %s: %s".format(srccall, msg)) return false } @@ -375,12 +406,12 @@ class StorageDatabase(context : Context) extends def getExportPosts(call : String) : Cursor = { if (call != null) getWritableDatabase().query(Post.TABLE, Post.COLUMNS, - "type in (0, 3) and message LIKE ?", + "type in (0, 3, 5, 6) and message LIKE ?", Array("%s%%".format(call)), null, null, null, null) else getWritableDatabase().query(Post.TABLE, Post.COLUMNS, - "type in (0, 3)", null, + "type in (0, 3, 5, 6)", null, null, null, null, null) } @@ -445,6 +476,10 @@ class StorageDatabase(context : Context) extends Array(call)) } + def deleteAllMessages() { + getWritableDatabase().execSQL("DELETE FROM %s".format(Message.TABLE)) + } + def getConversations() = { getReadableDatabase().query("(SELECT * FROM messages ORDER BY _id DESC)", Message.COLUMNS, null, null, diff --git a/src/UIHelper.scala b/src/UIHelper.scala index 5e879f80..d5122d86 100644 --- a/src/UIHelper.scala +++ b/src/UIHelper.scala @@ -67,6 +67,10 @@ trait UIHelper extends Activity new MessageCleaner(StorageDatabase.open(this), call).execute() } + def clearAllMessages(call : String) { + new AllMessageCleaner(StorageDatabase.open(this)).execute() + } + def openMessageSend(call : String, message : String) { startActivity(new Intent(this, classOf[MessageActivity]).setData(Uri.parse(call)).putExtra("message", message)) } @@ -330,6 +334,10 @@ trait UIHelper extends Activity onStartLoading() new StorageCleaner(StorageDatabase.open(this)).execute() true + case R.id.clearallmessages => + onStartLoading() + new AllMessageCleaner(StorageDatabase.open(this)).execute() + true case R.id.about => aboutDialog() true @@ -476,6 +484,16 @@ trait UIHelper extends Activity sendBroadcast(AprsService.MSG_PRIV_INTENT) } } + class AllMessageCleaner(storage : StorageDatabase) extends MyAsyncTask[Unit, Unit] { + override def doInBackground1(params : Array[String]) { + Log.d("MessageCleaner", "deleting all messages...") + storage.deleteAllMessages() + } + override def onPostExecute(x : Unit) { + Log.d("MessageCleaner", "broadcasting...") + sendBroadcast(AprsService.MSG_PRIV_INTENT) + } + } class LogExporter(storage : StorageDatabase, call : String) extends MyAsyncTask[Unit, String] { val filename = "aprsdroid-%s.log".format(new SimpleDateFormat("yyyyMMdd-HHmm").format(new Date())) val directory = UIHelper.getExportDirectory(UIHelper.this) @@ -497,7 +515,7 @@ trait UIHelper extends Activity val packet = c.getString(COLUMN_MESSAGE) fo.print(ts) fo.print("\t") - fo.print(if (tpe == TYPE_INCMG) "RX" else "TX") + fo.print(if (tpe == TYPE_INCMG) "RX" else if (tpe == TYPE_DIGI) "DP" else if (tpe == TYPE_IG) "IG" else "TX" ) fo.print("\t") fo.println(packet) } diff --git a/src/backend/AprsBackend.scala b/src/backend/AprsBackend.scala index 64b33d71..53f9fd3a 100644 --- a/src/backend/AprsBackend.scala +++ b/src/backend/AprsBackend.scala @@ -1,11 +1,14 @@ + package org.aprsdroid.app import android.Manifest import android.os.Build +import _root_.android.util.Log import _root_.net.ab0oo.aprs.parser.APRSPacket import _root_.java.io.{InputStream, OutputStream} object AprsBackend { + val TAG = "AprsBackend" /** "Modular" system to connect to an APRS backend. * The backend config consists of three items backed by prefs values: * - *proto* inside the connection ("aprsis", "afsk", "kiss", "tnc2", "kenwood") - ProtoInfo class @@ -65,7 +68,7 @@ object AprsBackend { Set(), CAN_XMIT, PASSCODE_REQUIRED), - "afsk" -> new BackendInfo( + "vox" -> new BackendInfo( (s, p) => new AfskUploader(s, p), 0, Set(Manifest.permission.RECORD_AUDIO), @@ -83,6 +86,12 @@ object AprsBackend { Set(BLUETOOTH_PERMISSION), CAN_DUPLEX, PASSCODE_NONE), + "ble" -> new BackendInfo( + (s, p) => new BluetoothLETnc(s, p), + R.xml.backend_ble, + Set(BLUETOOTH_PERMISSION), + CAN_DUPLEX, + PASSCODE_NONE), "tcpip" -> new BackendInfo( (s, p) => new TcpUploader(s, p), R.xml.backend_tcptnc, @@ -94,7 +103,14 @@ object AprsBackend { R.xml.backend_usb, Set(), CAN_DUPLEX, - PASSCODE_NONE) + PASSCODE_NONE), + "digirig" -> new BackendInfo( + (s, p) => new DigiRig(s, p), + R.xml.backend_digirig, + Set(Manifest.permission.RECORD_AUDIO), + CAN_DUPLEX, + PASSCODE_NONE + ) ) class ProtoInfo( @@ -108,8 +124,8 @@ object AprsBackend { (s, is, os) => new AprsIsProto(s, is, os), R.xml.proto_aprsis, "aprsis"), "afsk" -> new ProtoInfo( - null, - R.xml.proto_afsk, null), + (s, is, os) => new AfskProto(s, is, os), + R.xml.proto_afsk, "afsk"), "kiss" -> new ProtoInfo( (s, is, os) => new KissProto(s, is, os), R.xml.proto_kiss, "link"), @@ -130,7 +146,15 @@ object AprsBackend { def defaultBackendInfo(prefs : PrefsWrapper) : BackendInfo = { val pi = defaultProtoInfo(prefs) - val link = if (pi.link != null) { prefs.getString(pi.link, DEFAULT_LINK) } else { prefs.getProto() } + var link = "" + if (pi.link != null) { + link = prefs.getString(pi.link, DEFAULT_LINK) + Log.d(TAG, "DEBUG: pi.link (" + pi.link + ") != null : " + link) + } else { + link = prefs.getProto() + Log.d(TAG, "DEBUG: pi.link == null : " + link) + } + backend_collection.get(link) match { case Some(bi) => bi case None => backend_collection(DEFAULT_CONNTYPE) diff --git a/src/backend/BluetoothLETnc.scala b/src/backend/BluetoothLETnc.scala new file mode 100644 index 00000000..f3e1a2eb --- /dev/null +++ b/src/backend/BluetoothLETnc.scala @@ -0,0 +1,436 @@ +package org.aprsdroid.app + +import _root_.android.bluetooth._ +import _root_.android.content.{BroadcastReceiver, Context, Intent, IntentFilter} +import _root_.android.util.Log + +import _root_.java.util.UUID +import _root_.android.bluetooth.BluetoothGattCharacteristic +import _root_.android.bluetooth.BluetoothGattCallback +import _root_.android.bluetooth.BluetoothGatt +import _root_.android.bluetooth.BluetoothDevice +import _root_.net.ab0oo.aprs.parser._ +import android.os.{Build, Handler, Looper} + +import java.io._ +import java.util.concurrent.Semaphore + +// This requires API level 21 at a minimum, API level 23 to work with dual-mode devices. +class BluetoothLETnc(service : AprsService, prefs : PrefsWrapper) extends AprsBackend(prefs) { + private val TAG = "APRSdroid.BluetoothLE" + + private val SERVICE_UUID = UUID.fromString("00000001-ba2a-46c9-ae49-01b0961f68bb") + private val CHARACTERISTIC_UUID_RX = UUID.fromString("00000003-ba2a-46c9-ae49-01b0961f68bb") + private val CHARACTERISTIC_UUID_TX = UUID.fromString("00000002-ba2a-46c9-ae49-01b0961f68bb") + + private val tncmac = prefs.getString("ble.mac", null) + private var gatt: BluetoothGatt = null + private var tncDevice: BluetoothDevice = null + private var txCharacteristic: BluetoothGattCharacteristic = null + private var rxCharacteristic: BluetoothGattCharacteristic = null + + private var proto: TncProto = _ + + private val bleInputStream = new BLEInputStream() + private val bleOutputStream = new BLEOutputStream() + + private var conn : BLEReceiveThread = null + + private var reconnect = true + + private var mtu = 20 // Default BLE MTU (-3) + + override def start(): Boolean = { + if (gatt == null) + createConnection() + false + } + + private def connect(): Unit = { + // Must use application context here, otherwise authorization dialogs always fail, and + // other GATT operations intermittently fail. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + gatt = tncDevice.connectGatt(service.getApplicationContext, false, callback, BluetoothDevice.TRANSPORT_LE) + } else { + // Dual-mode devices are not supported + gatt = tncDevice.connectGatt(service.getApplicationContext, false, callback) + } + } + + private val callback = new BluetoothGattCallback { + override def onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int): Unit = { + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "Connected to GATT server") + BluetoothLETnc.this.gatt = gatt + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + // For BLE devices, there is a 3-phase pairing process on Android, requiring that the user + // twice approve pairing to the device. (At least this is true for dual-mode devices with + // an encrypted characteristic.) + // + // Phase one is bonding, which must be done by the user using the device's Bluetooth + // Settings interface. + // + // During phase 2, the device is disconnected with an authorization error while attempting + // to access a secured resource. An authorization is initiated automatically, and the + // connection must be re-established. + // + // For dual mode devices, it may be necessary to "forget" the device in order for a BLE + // connection to be established. When that happens, the TNC will be in an unbonded state. + // The user must create the bond before attempting to connect. + // + // To recap: + // 1. Bond the device (by user, in Bluetooth Settings) + // 2. Grant access to characteristics requiring authorization + // 3. Establish connection + // + // Steps 1 and 2 typically occur once (or any time the device is "forgotten"). + if (status == BluetoothGatt.GATT_INSUFFICIENT_AUTHORIZATION) { + gatt.close() + BluetoothLETnc.this.gatt = null + if (tncDevice.getBondState == BluetoothDevice.BOND_NONE) { + service.postAbort(service.getString(R.string.bt_error_no_tnc)) + } else { + // The second phase of the pairing process will occur the first time an encrypted + // BLE characteristic is touched. This typically occurs when enabling notification + // on the RX characteristic, in onDescriptorWrite(). When that happens, we need to + // close the GATT connection and reconnect. A pairing dialog *may* appear. + Log.w(TAG, "Authorization error") + // Only try once. We don't want to spin endlessly when there is a problem. And there + // will be problems. When we do spin endlessly here, the only solution is for the + // user to disable and re-enable Bluetooth on the device. ¯\_(ツ)_/¯ + if (reconnect) { + reconnect = false + connect() + } else { + service.postAbort(service.getString(R.string.bt_error_connect, tncDevice.getName)) + } + } + } else { + Log.d(TAG, "Disconnected from GATT server") + } + } + } + + override def onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int): Unit = { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d(TAG, "Notification enabled") + if (!gatt.requestMtu(517)) { // This requires API Level 21 + Log.e(TAG, "Could not request MTU change") + service.postAbort(service.getString(R.string.bt_error_connect, tncDevice.getName)) + } + } else { + Log.e(TAG, f"Failed to write descriptor, status = $status") + } + } + + override def onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int): Unit = { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d(TAG, s"MTU changed to $mtu bytes") + BluetoothLETnc.this.mtu = mtu - 3 + } else { + Log.e(TAG, "Failed to change MTU") + } + // Once the MTU callback is complete, whether successful or not, we're ready to rock & roll. + // Start the receive thread and instantiate the protocol adapter. + conn.start() + proto = AprsBackend.instanciateProto(service, bleInputStream, bleOutputStream) + } + + override def onServicesDiscovered(gatt: BluetoothGatt, status: Int): Unit = { + if (status == BluetoothGatt.GATT_SUCCESS) { + val gservice = gatt.getService(SERVICE_UUID) + if (gservice != null) { + txCharacteristic = gservice.getCharacteristic(CHARACTERISTIC_UUID_TX) + rxCharacteristic = gservice.getCharacteristic(CHARACTERISTIC_UUID_RX) + + if (!gatt.setCharacteristicNotification(rxCharacteristic, true)) { + Log.e(TAG, "Could not enable notification on RX Characteristic") + service.postAbort(service.getString(R.string.bt_error_connect, tncDevice.getName)) + return + } + val descriptor = rxCharacteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) + if (descriptor != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) != BluetoothStatusCodes.SUCCESS) { + Log.e(TAG, "Could not write descriptor on RX Characteristic") + service.postAbort(service.getString(R.string.bt_error_connect, tncDevice.getName)) + return + } + } else { + descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + if (!gatt.writeDescriptor(descriptor)) { + Log.e(TAG, "Could not write descriptor on RX Characteristic") + service.postAbort(service.getString(R.string.bt_error_connect, tncDevice.getName)) + return + } + } + } + Log.d(TAG, "Services discovered and characteristics set") + } else { + Log.e(TAG, "KISS service not found") + service.postAbort(service.getString(R.string.bt_error_connect, tncDevice.getName)) + } + } else { + Log.d(TAG, "onServicesDiscovered received: " + status) + } + } + + override def onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int): Unit = { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d(TAG, "Characteristic write successful") + } else { + Log.d(TAG, "Characteristic write failed with status: " + status) + } + + bleOutputStream.sent() + } + + override def onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic): Unit = { + val data = characteristic.getValue + Log.d(TAG, "onCharacteristicChanged: " + data.length + " bytes from BLE") + bleInputStream.appendData(data) + } + } + + private def createConnection(): Unit = { + Log.d(TAG, "BluetoothTncBle.createConnection: " + tncmac) + val adapter = BluetoothAdapter.getDefaultAdapter + + // Lollipop may work for BLE-only devices. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + service.postAbort(service.getString(R.string.bt_error_unsupported)) + return + } + + if (adapter == null) { + service.postAbort(service.getString(R.string.bt_error_unsupported)) + return + } + + if (!adapter.isEnabled) { + service.postAbort(service.getString(R.string.bt_error_disabled)) + return + } + + if (tncmac == null) { + service.postAbort(service.getString(R.string.bt_error_no_tnc)) + return + } + + tncDevice = BluetoothAdapter.getDefaultAdapter.getRemoteDevice(tncmac) + if (tncDevice == null) { + service.postAbort(service.getString(R.string.bt_error_no_tnc)) + return + } + + reconnect = true + connect() + conn = new BLEReceiveThread() + } + + override def update(packet: APRSPacket): String = { + try { + proto.writePacket(packet) + "BLE OK" + } catch { + case e: Exception => + e.printStackTrace() + gatt.disconnect() + "BLE disconnected" + } + } + + private def sendToBle(data: Array[Byte]): Unit = { + if (txCharacteristic != null && gatt != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic(txCharacteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) + } else { + txCharacteristic.setValue(data) + gatt.writeCharacteristic(txCharacteristic) + } + } + } + + override def stop(): Unit = { + if (gatt == null) + return + + conn.returnFreq() + + gatt.disconnect() + gatt.close() + gatt = null + + conn.synchronized { + conn.running = false + } + conn.shutdown() + conn.interrupt() + conn.join(50) + } + + private class BLEReceiveThread extends Thread("APRSdroid Bluetooth connection") { + private val TAG = "APRSdroid.BLEReceiveThread" + var running = true + + def returnFreq() { + Log.d(TAG, "Return Frq") + + try { + val backendName = service.prefs.getBackendName() + // Check if the conditions for frequency control are met + if (service.prefs != null && backendName != null && + service.prefs.getBoolean("freq_control", false) && backendName.contains("Bluetooth")) { + proto.writeReturn() // Send return command + Log.d(TAG, "Return Issued") + } + } catch { + case e: Exception => + Log.e(TAG, "Error while sending return frequency command.", e) + } + } + + override def run(): Unit = { + running = true + Log.d(TAG, "BLEReceiveThread.run()") + + try { + // Attempt to start the poster (with exception handling) + service.postPosterStarted() + } catch { + case e: Exception => + Log.d("ProtoTNC", "Exception in postPosterStarted: " + e.getMessage) + } + + while (running) { + try { + // Log.d(TAG, "waiting for data...") + while (running) { + val line = proto.readPacket() + Log.d(TAG, "recv: " + line) + service.postSubmit(line) + } + } catch { + case e : Exception => + Log.d("ProtoTNC", "proto.readPacket exception") + } + + } + Log.d(TAG, "BLEReceiveThread.terminate()") + } + + def shutdown(): Unit = { + Log.d(TAG, "shutdown()") + } + } + + private class BLEInputStream extends InputStream { + private var buffer: Array[Byte] = Array() + private val bytesAvailable = new Semaphore(0, true) + + def appendData(data: Array[Byte]): Unit = { + buffer.synchronized { + buffer ++= data + bytesAvailable.release(data.length) + } + } + + override def read(): Int = { + try { + bytesAvailable.acquire(1) + buffer.synchronized { + val byte = buffer.head + buffer = buffer.tail + byte & 0xFF + } + } catch { + case e : InterruptedException => + Log.d(TAG, "read() interrupted") + -1 + } + } + + override def read(b: Array[Byte], off: Int, len: Int): Int = { + try { + bytesAvailable.acquire(1) + buffer.synchronized { + val size = math.min(len, buffer.length) + // Expect that we have at lease size - 1 permits available. + if (bytesAvailable.tryAcquire(size - 1)) { + System.arraycopy(buffer, 0, b, off, size) + buffer = buffer.drop(size) + size + } else { + // We have one... + Log.e(TAG, "invalid number of semaphore permits") + val head = buffer.head + buffer = buffer.tail + System.arraycopy(Array(head), 0, b, off, 1) + 1 + } + } + } catch { + case e : InterruptedException => + Log.d(TAG, "read() interrupted") + -1 + } + } + } + + private class BLEOutputStream extends OutputStream { + private var buffer: Array[Byte] = Array() + private var isWaitingForAck = false + + override def write(b: Int): Unit = { + Log.d(TAG, f"write 0x$b%02X") + buffer.synchronized { + buffer ++= Array(b.toByte) + } + } + + private def valueOf(bytes : Array[Byte]) = bytes.map{ + b => String.format("%02X", new java.lang.Integer(b & 0xff)) + }.mkString + + override def write(b: Array[Byte], off: Int, len: Int): Unit = { + val data = b.slice(off, off + len) + Log.d(TAG, "write 0x" + valueOf(data)) + buffer.synchronized { + buffer ++= data + } + } + + override def flush(): Unit = { + Log.d(TAG, "Flushed. Send to BLE") + + isWaitingForAck.synchronized { + if (!isWaitingForAck) { + send() + isWaitingForAck = true + } + } + } + + private def send(): Int = { + buffer.synchronized { + if (!buffer.isEmpty) { + val chunk = buffer.take(mtu) + buffer = buffer.drop(mtu) + sendToBle(chunk) + return chunk.size + } else { + return 0 + } + } + } + + def sent(): Unit = { + isWaitingForAck.synchronized { + if (send() == 0) { + isWaitingForAck = false + } + } + } + } +} diff --git a/src/backend/BluetoothTnc.scala b/src/backend/BluetoothTnc.scala index fbb20bdc..6e9889e5 100644 --- a/src/backend/BluetoothTnc.scala +++ b/src/backend/BluetoothTnc.scala @@ -70,6 +70,20 @@ class BluetoothTnc(service : AprsService, prefs : PrefsWrapper) extends AprsBack var socket : BluetoothSocket = null var proto : TncProto = null + def returnFreq() { + try { + val backendName = service.prefs.getBackendName() + // Check if the conditions for frequency control are met + if (service.prefs != null && backendName != null && + service.prefs.getBoolean("freq_control", false) && backendName.contains("Bluetooth SPP")) { + proto.writeReturn() // Send return command + } + } catch { + case e: Exception => + Log.e(TAG, "Error while sending return frequency command.", e) + } + } + def log(s : String) { service.postAddPost(StorageDatabase.Post.TYPE_INFO, R.string.post_info, s) } @@ -175,8 +189,10 @@ class BluetoothTnc(service : AprsService, prefs : PrefsWrapper) extends AprsBack def shutdown() { Log.d(TAG, "shutdown()") - if (proto != null) + if (proto != null) { + returnFreq() proto.stop() + } this.synchronized { catchLog("socket.close", socket.close) } diff --git a/src/backend/DigiRig.scala b/src/backend/DigiRig.scala new file mode 100644 index 00000000..7042ccb4 --- /dev/null +++ b/src/backend/DigiRig.scala @@ -0,0 +1,257 @@ +package org.aprsdroid.app + +import _root_.android.media.{AudioManager, AudioTrack} + +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.hardware.usb.UsbManager +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.media.AudioTrack.OnPlaybackPositionUpdateListener +import android.util.Log +import java.io.{InputStream, OutputStream} + +import net.ab0oo.aprs.parser._ +import com.nogy.afu.soundmodem.{Message, APRSFrame, Afsk} +import com.felhr.usbserial._ +import com.jazzido.PacketDroid.{AudioBufferProcessor, PacketCallback} +import sivantoledo.ax25.PacketHandler + +object DigiRig { + def deviceHandle(dev : UsbDevice) = { + "usb_%04x_%04x_%s".format(dev.getVendorId(), dev.getProductId(), dev.getDeviceName()) + } + + def checkDeviceHandle(prefs : SharedPreferences, dev_p : android.os.Parcelable) : Boolean = { + if (dev_p == null) + return false + val dev = dev_p.asInstanceOf[UsbDevice] + val last_use = prefs.getString(deviceHandle(dev), null) + if (last_use == null) + return false + prefs.edit().putString("proto", last_use) + .putString("link", "usb").commit() + true + } +} + +class DigiRig(service : AprsService, prefs : PrefsWrapper) extends AfskUploader(service, prefs) + with PacketHandler with PacketCallback { + override val TAG = "APRSdroid.Digirig" + + // USB stuff + val USB_PERM_ACTION = "org.aprsdroid.app.DigiRig.PERM" + val ACTION_USB_ATTACHED = "android.hardware.usb.action.USB_DEVICE_ATTACHED" + val ACTION_USB_DETACHED = "android.hardware.usb.action.USB_DEVICE_DETACHED" + + val usbManager = service.getSystemService(Context.USB_SERVICE).asInstanceOf[UsbManager]; + var thread : UsbThread = null + var dev : UsbDevice = null + var con : UsbDeviceConnection = null + var ser : UsbSerialInterface = null + var alreadyRunning = false + + val intent = new Intent(USB_PERM_ACTION) + val pendingIntent = PendingIntent.getBroadcast(service, 0, intent, PendingIntent.FLAG_MUTABLE) + + // Audio stuff + var audioPlaying = false + output.setVolume(AudioTrack.getMaxVolume()) + output.setPlaybackPositionUpdateListener(new OnPlaybackPositionUpdateListener { + override def onMarkerReached(audioTrack: AudioTrack): Unit = { + DigiRig.this.audioPlaying = false + } + + override def onPeriodicNotification(audioTrack: AudioTrack): Unit = {} + }) + + val receiver = new BroadcastReceiver() { + override def onReceive(ctx: Context, i: Intent) { + Log.d(TAG, "onReceive: " + i) + if (i.getAction() == ACTION_USB_DETACHED) { + log("USB device detached.") + ctx.stopService(AprsService.intent(ctx, AprsService.SERVICE)) + return + } + val granted = i.getExtras().getBoolean(UsbManager.EXTRA_PERMISSION_GRANTED) + if (!granted) { + service.postAbort(service.getString(R.string.p_serial_noperm)) + return + } + log("Obtained USB permissions.") + thread = new UsbThread() + thread.start() + } + } + + override val btScoReceiver = new BroadcastReceiver() { + override def onReceive(ctx : Context, i : Intent) { + Log.d(TAG, "onReceive: " + i) + if (i.getAction() == ACTION_USB_DETACHED) { + log("USB device detached.") + ctx.stopService(AprsService.intent(ctx, AprsService.SERVICE)) + return + } + val granted = i.getExtras().getBoolean(UsbManager.EXTRA_PERMISSION_GRANTED) + if (!granted) { + service.postAbort(service.getString(R.string.p_serial_noperm)) + return + } + log("Obtained USB permissions.") + thread = new UsbThread() + thread.start() + + val state = i.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1) + Log.d(TAG, "AudioManager SCO event: " + state) + if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) { + // we are connected, perform actual start + log(service.getString(R.string.afsk_info_sco_est)) + aw.start() + service.unregisterReceiver(this) + service.postPosterStarted() + } + } + } + + var proto : TncProto = null + var sis : SerialInputStream = null + + override def start() = { + val filter = new IntentFilter(USB_PERM_ACTION) + filter.addAction(ACTION_USB_DETACHED) + service.registerReceiver(receiver, filter) + alreadyRunning = true + if (ser == null) + requestPermissions() + + if (!isCallsignAX25Valid()) + false + + if (use_bt) { + log(service.getString(R.string.afsk_info_sco_req)) + service.getSystemService(Context.AUDIO_SERVICE) + .asInstanceOf[AudioManager].startBluetoothSco() + service.registerReceiver(btScoReceiver, new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_CHANGED)) + false + } else { + aw.start() + true + } + + false + } + + def requestPermissions() { + Log.d(TAG, "Digirig.requestPermissions"); + val dl = usbManager.getDeviceList(); + var requested = false + import scala.collection.JavaConversions._ + for ((name, dev) <- dl) { + val deviceVID = dev.getVendorId() + val devicePID = dev.getProductId() + if (UsbSerialDevice.isSupported(dev)) { + // this is not a USB Hub + log("Found USB device %04x:%04x, requesting permissions.".format(deviceVID, devicePID)) + this.dev = dev + usbManager.requestPermission(dev, pendingIntent) + return + } else + log("Unsupported USB device %04x:%04x.".format(deviceVID, devicePID)) + } + service.postAbort(service.getString(R.string.p_serial_notfound)) + } + + override def update(packet: APRSPacket): String = { + // Need to "parse" the packet in order to replace the Digipeaters + packet.setDigipeaters(Digipeater.parseList(Digis, true)) + val from = packet.getSourceCall() + val to = packet.getDestinationCall() + val data = packet.getAprsInformation().toString() + val msg = new APRSFrame(from, to, Digis, data, FrameLength).getMessage() + Log.d(TAG, "update(): From: " + from + " To: " + to + " Via: " + Digis + " telling " + data) + + ser.setRTS(true) + audioPlaying = true + val result = sendMessage(msg) + while (audioPlaying) { + Thread.sleep(10) + } + ser.setRTS(false) + + if (result) + "AFSK OK" + else + "AFSK busy" + } + + override def stop() { + // Stop USB thread + if (alreadyRunning) + service.unregisterReceiver(receiver) + alreadyRunning = false + if (ser != null) + ser.close() + if (sis != null) + sis.close() + if (con != null) + con.close() + if (thread == null) + return + thread.synchronized { + thread.running = false + } + thread.interrupt() + thread.join(50) + + // Stop AFSK Demodulator + aw.close() + if (use_bt) { + service.getSystemService(Context.AUDIO_SERVICE) + .asInstanceOf[AudioManager].stopBluetoothSco() + try { + service.unregisterReceiver(btScoReceiver) + } catch { + case e : RuntimeException => // ignore, receiver already unregistered + } + } + } + + class UsbThread() extends Thread("APRSdroid USB connection") { + val TAG = "UsbThread" + var running = true + + def log(s : String) { + service.postAddPost(StorageDatabase.Post.TYPE_INFO, R.string.post_info, s) + } + + override def run() { + val con = usbManager.openDevice(dev) + ser = UsbSerialDevice.createUsbSerialDevice(dev, con) + if (ser == null || !ser.syncOpen()) { + con.close() + service.postAbort(service.getString(R.string.p_serial_unsupported)) + return + } + val baudrate = prefs.getStringInt("baudrate", 115200) + ser.setBaudRate(baudrate) + ser.setDataBits(UsbSerialInterface.DATA_BITS_8) + ser.setStopBits(UsbSerialInterface.STOP_BITS_1) + ser.setParity(UsbSerialInterface.PARITY_NONE) + ser.setFlowControl(UsbSerialInterface.FLOW_CONTROL_OFF) + ser.setRTS(false) + + // success: remember this for usb-attach launch + prefs.prefs.edit().putString(UsbTnc.deviceHandle(dev), prefs.getString("proto", "afsk")).commit() + + log("Opened " + ser.getClass().getSimpleName() + " at " + baudrate + "bd") + service.postPosterStarted() + while (running) { /* do nothing */ } + Log.d(TAG, "terminate()") + } + } +} \ No newline at end of file diff --git a/src/tncproto/AfskProto.scala b/src/tncproto/AfskProto.scala new file mode 100644 index 00000000..45fe9b39 --- /dev/null +++ b/src/tncproto/AfskProto.scala @@ -0,0 +1,17 @@ +package org.aprsdroid.app + +import _root_.android.util.Log +import _root_.java.io.{InputStream, OutputStream} + +import _root_.net.ab0oo.aprs.parser._ + +class AfskProto(service : AprsService, is : InputStream, os : OutputStream) extends TncProto(is, os) { + val TAG = "APRSdroid.AfskProto" + + def readPacket() : String = { + "" + } + + def writePacket(p : APRSPacket) { + } +} diff --git a/src/tncproto/KissProto.scala b/src/tncproto/KissProto.scala index 7cc4df95..c5821af5 100644 --- a/src/tncproto/KissProto.scala +++ b/src/tncproto/KissProto.scala @@ -14,9 +14,11 @@ class KissProto(service : AprsService, is : InputStream, os : OutputStream) exte val FESC = 0xDB val TFEND = 0xDC val TFESC = 0xDD - // commands val CMD_DATA = 0x00 + val CONTROL_COMMAND = 0x06 + val FREQ = 0xEA + val RETURN = 0xEB } val initstring = java.net.URLDecoder.decode(service.prefs.getString("kiss.init", ""), "UTF-8") @@ -32,10 +34,50 @@ class KissProto(service : AprsService, is : InputStream, os : OutputStream) exte } } + val checkprefs = service.prefs.getBackendName() + Log.d(TAG, s"Backend Name1: $checkprefs") + + if (service.prefs.getBoolean("freq_control", false) && service.prefs.getBackendName().contains("Bluetooth")) { + Log.d(TAG, "Frequency control is enabled.") + + // Fetch the frequency control value as a float (default to 0.0f if not found) + val freqMHZ = service.prefs.getStringFloat("frequency_control_value", 144.390f) + Log.d(TAG, s"Frequency control value fetched: $freqMHZ MHz") + + // Use the freqConvert function to convert the frequency to a byte array + val freqBytes = freqConvert(freqMHZ) + writeFreq(freqBytes) // Send the entire array of bytes at once + + Log.d(TAG, s"Frequency in bytes (MSB first): ${freqBytes.map(b => f"0x$b%02X").mkString(" ")}") + + } + if (service.prefs.getCallsign().length() > 6) { throw new IllegalArgumentException(service.getString(R.string.e_toolong_callsign)) } + def freqConvert(freqMHz: Float): Array[Byte] = { + // Convert frequency from MHz to Hz (float to integer) + val freqHz = (freqMHz * 1000000).toLong + + // Convert the frequency to 32-bit (big-endian) + val bytes = Array[Byte]( + (freqHz >> 24).toByte, // MSB + (freqHz >> 16).toByte, + (freqHz >> 8).toByte, + freqHz.toByte // LSB + ) + + val escapedBytes = bytes.flatMap { byte => + if (byte == Kiss.FEND.toByte) { + Array[Byte](Kiss.FESC.toByte, Kiss.TFEND.toByte) + } else { + Array[Byte](byte) + } + } + escapedBytes + } + def readPacket() : String = { import Kiss._ val buf = scala.collection.mutable.ListBuffer[Byte]() @@ -82,10 +124,19 @@ class KissProto(service : AprsService, is : InputStream, os : OutputStream) exte def writePacket(p : APRSPacket) { Log.d(TAG, "writePacket: " + p) - os.write(Kiss.FEND) - os.write(Kiss.CMD_DATA) - os.write(p.toAX25Frame()) - os.write(Kiss.FEND) + val combinedData = Array[Byte](Kiss.FEND.toByte, Kiss.CMD_DATA.toByte) ++ p.toAX25Frame() ++ Array[Byte](Kiss.FEND.toByte) + os.write(combinedData) os.flush() } + + def writeFreq(freqBytes: Array[Byte]): Unit = { + val frame = Array[Byte]( + Kiss.FEND.toByte, + Kiss.CONTROL_COMMAND.toByte, + Kiss.FREQ.toByte // Frequency byte + ) ++ freqBytes ++ Array[Byte](Kiss.FEND.toByte) + + os.write(frame) + os.flush() + } } diff --git a/src/tncproto/TncProto.scala b/src/tncproto/TncProto.scala index 295b8886..e6547d53 100644 --- a/src/tncproto/TncProto.scala +++ b/src/tncproto/TncProto.scala @@ -3,8 +3,26 @@ import _root_.java.io.{InputStream, OutputStream} import _root_.net.ab0oo.aprs.parser._ +object KissConstants { + val FEND = 0xC0 + val CONTROL_COMMAND = 0x06 + val RETURN = 0xEB +} + + abstract class TncProto(is : InputStream, os : OutputStream) { + import KissConstants._ // Import the constants from the shared object + def readPacket() : String def writePacket(p : APRSPacket) + def writeReturn(): Unit = { + val frame = Array[Byte]( + FEND.toByte, // Start frame + CONTROL_COMMAND.toByte, // Command byte + RETURN.toByte, // Return command byte + FEND.toByte) // End frame + os.write(frame) + os.flush() + } def stop() {} }