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() {}
}