aprsdroid/src/AprsService.scala

688 wiersze
23 KiB
Scala

package org.aprsdroid.app
import _root_.android.app.Service
import _root_.android.content.{Context, Intent, IntentFilter}
import _root_.android.location._
import _root_.android.os.{Bundle, IBinder, Handler}
import _root_.android.preference.PreferenceManager
import _root_.android.util.Log
import _root_.android.widget.Toast
import _root_.net.ab0oo.aprs.parser._
import scala.collection.mutable
import java.time.Instant
object AprsService {
val PACKAGE = "org.aprsdroid.app"
// action intents
val SERVICE = PACKAGE + ".SERVICE"
val SERVICE_ONCE = PACKAGE + ".ONCE"
val SERVICE_SEND_PACKET = PACKAGE + ".SEND_PACKET"
val SERVICE_FREQUENCY = PACKAGE + ".FREQUENCY"
val SERVICE_STOP = PACKAGE + ".SERVICE_STOP"
// event intents
val SERVICE_STARTED = PACKAGE + ".SERVICE_STARTED"
val SERVICE_STOPPED = PACKAGE + ".SERVICE_STOPPED"
val POSITION = PACKAGE + ".POSITION"
val MICLEVEL = PACKAGE + ".MICLEVEL" // internal volume event intent
val LINK_ON = PACKAGE + ".LINK_ON"
val LINK_OFF = PACKAGE + ".LINK_OFF"
val LINK_INFO = PACKAGE + ".LINK_INFO"
// broadcast actions
val UPDATE = PACKAGE + ".UPDATE" // something added to the log
val MESSAGE = PACKAGE + ".MESSAGE" // we received a message/ack
val MESSAGETX = PACKAGE + ".MESSAGETX" // we created a message for TX
// broadcast intent extras
// SERVICE_STARTED
val API_VERSION = "api_version" // API version
val CALLSIGN = "callsign" // callsign + ssid of the user
// UPDATE
val TYPE = "type" // type
val STATUS = "status" // content
// POSITION
val LOCATION = "location" // Location object
val SOURCE = "source" // sender callsign
val PACKET = "packet" // raw packet content
// MESSAGE
// +- SOURCE
val DEST = "dest" // destination callsign
val BODY = "body" // body of the message
// APRSdroid API version
val API_VERSION_CODE = 1
// private intents for message handling
lazy val MSG_PRIV_INTENT = new Intent(MESSAGE).setPackage("org.aprsdroid.app")
lazy val MSG_TX_PRIV_INTENT = new Intent(MESSAGETX).setPackage("org.aprsdroid.app")
def intent(ctx : Context, action : String) : Intent = {
new Intent(action, null, ctx, classOf[AprsService])
}
var running = false
var link_error = 0
implicit def block2runnable[F](f: => F) = new Runnable() { def run() { f } }
}
class AprsService extends Service {
import AprsService._
val TAG = "APRSdroid.Service"
lazy val APP_VERSION = "APDR%s".format(
getPackageManager().getPackageInfo(getPackageName(), 0).versionName
filter (_.isDigit) take 2)
lazy val prefs = new PrefsWrapper(this)
lazy val dedupeTime = prefs.getStringInt("p.dedupe", 30) // Fetch NUM_OF_RETRIES from prefs, defaulting to 7 if not found
lazy val digipeaterpath = prefs.getString("digipeater_path", "WIDE1,WIDE2")
val handler = new Handler()
lazy val db = StorageDatabase.open(this)
lazy val msgService = new MessageService(this)
lazy val locSource = LocationSource.instanciateLocation(this, prefs)
lazy val msgNotifier = msgService.createMessageNotifier()
var poster : AprsBackend = null
var singleShot = false
override def onStart(i : Intent, startId : Int) {
Log.d(TAG, "onStart: " + i + ", " + startId);
super.onStart(i, startId)
handleStart(i)
}
override def onStartCommand(i : Intent, flags : Int, startId : Int) : Int = {
Log.d(TAG, "onStartCommand: " + i + ", " + flags + ", " + startId);
handleStart(i)
Service.START_REDELIVER_INTENT
}
def handleStart(i : Intent) {
if (i.getAction() == SERVICE_STOP) {
// explicitly disabled, remember this
prefs.setBoolean("service_running", false)
if (running)
stopSelf()
return
} else
if (i.getAction() == SERVICE_SEND_PACKET) {
if (!running) {
Log.d(TAG, "SEND_PACKET ignored, service not running.")
return
}
val data_field = i.getStringExtra("data")
if (data_field == null) {
Log.d(TAG, "SEND_PACKET ignored, data extra is empty.")
return
}
val p = Parser.parseBody(prefs.getCallSsid(), APP_VERSION, null,
data_field)
sendPacket(p)
return
} else
if (i.getAction() == SERVICE_FREQUENCY) {
val data_field = i.getStringExtra("frequency")
if (data_field == null) {
Log.d(TAG, "FREQUENCY ignored, 'frequency' extra is empty.")
return
}
val freq_cleaned = data_field.replace("MHz", "").trim
val freq = try { freq_cleaned.toFloat; freq_cleaned } catch { case _ : Throwable => "" }
if (prefs.getString("frequency", null) != freq) {
prefs.set("frequency", freq)
if (!running) return
// XXX: fall through into SERVICE_ONCE
} else return
}
// display notification (even though we are not actually started yet,
// but we need this to prevent error message reordering)
val toastString = if (i.getAction() == SERVICE_ONCE) {
// if already running, we want to send immediately and continue;
// otherwise, we finish after a single position report
// set to true if not yet running or already running singleShot
singleShot = !running || singleShot
if (singleShot)
getString(R.string.service_once)
else null
} else {
getString(R.string.service_start)
}
// only show toast on newly started service
if (toastString != null)
showToast(toastString.format(
prefs.getLocationSourceName(),
prefs.getBackendName()))
val callssid = prefs.getCallSsid()
ServiceNotifier.instance.start(this, callssid)
// the poster needs to be running before location updates come in
if (!running) {
running = true
startPoster()
// register for outgoing message notifications
registerReceiver(msgNotifier, new IntentFilter(AprsService.MESSAGETX))
} else
onPosterStarted()
}
def startPoster() {
if (poster != null)
poster.stop()
poster = AprsBackend.instanciateUploader(this, prefs)
if (poster.start())
onPosterStarted()
}
def onPosterStarted() {
Log.d(TAG, "onPosterStarted")
// (re)start location source, get location source name
val loc_info = locSource.start(singleShot)
val callssid = prefs.getCallSsid()
val message = "%s: %s".format(callssid, loc_info)
ServiceNotifier.instance.start(this, message)
msgService.sendPendingMessages()
sendBroadcast(new Intent(SERVICE_STARTED)
.putExtra(API_VERSION, API_VERSION_CODE)
.putExtra(CALLSIGN, callssid))
// startup completed, remember state
if (!singleShot)
prefs.setBoolean("service_running", true)
}
override def onBind(i : Intent) : IBinder = null
override def onUnbind(i : Intent) : Boolean = false
def showToast(msg : String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
addPost(StorageDatabase.Post.TYPE_INFO, null, msg)
}
override def onDestroy() {
running = false
link_error = 0
// catch FC when service is killed from outside
if (poster != null) {
poster.stop()
showToast(getString(R.string.service_stop))
sendBroadcast(new Intent(SERVICE_STOPPED))
}
msgService.stop()
locSource.stop()
scala.util.control.Exception.ignoring(classOf[IllegalArgumentException]) {
unregisterReceiver(msgNotifier)
}
ServiceNotifier.instance.stop(this)
}
def newPacket(payload : InformationField) = {
val digipath = prefs.getString("digi_path", "WIDE1-1")
new APRSPacket(prefs.getCallSsid(), APP_VERSION,
Digipeater.parseList(digipath, true), payload)
}
def formatLoc(symbol : String, status : String, location : Location) = {
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)) {
if(prefs.getBoolean("compressed_location", false)) {
// Compressed format
AprsPacket.formatCourseSpeedCompressed(location)
} else {
AprsPacket.formatCourseSpeed(location)
}
} else ""
val status_freq = AprsPacket.formatFreq(status_spd, prefs.getStringFloat("frequency", 0.0f))
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 ""
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))
}
def sendPacket(packet : APRSPacket, status_postfix : String) {
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) }
}
}
def sendPacket(packet : APRSPacket) { sendPacket(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)
Log.d(TAG, "packet: " + packet)
sendPacket(packet, " (±%dm)".format(location.getAccuracy.asInstanceOf[Int]))
}
def sendPacketFinished(result : String) {
if (singleShot) {
singleShot = false
stopSelf()
} else {
val message = "%s: %s".format(prefs.getCallSsid(), result)
ServiceNotifier.instance.notifyPosition(this, prefs, message)
}
}
def sendTestPacket(packetString: String): Unit = {
// Parse the incoming string to an APRSPacket object
try {
val testPacket = Parser.parse(packetString)
// Define additional information to be passed as status postfix
val digistatus = " - Digipeated"
// Send the packet with the additional status postfix
sendPacket(testPacket, 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 parsePacket(ts : Long, message : String, source : Int) {
try {
var fap = Parser.parse(message)
if (fap.getType() == APRSTypes.T_THIRDPARTY) {
Log.d(TAG, "parsePacket: third-party packet from " + fap.getSourceCall())
val inner = fap.getAprsInformation().toString()
// strip away leading "}"
fap = Parser.parse(inner.substring(1, inner.length()))
}
val callssid = prefs.getCallSsid()
if (source == StorageDatabase.Post.TYPE_INCMG &&
fap.getSourceCall().equalsIgnoreCase(callssid) &&
fap.getLastUsedDigi() != null) {
Log.i(TAG, "got digipeated own packet")
val message = getString(R.string.got_digipeated, fap.getLastUsedDigi(),
fap.getAprsInformation().toString())
ServiceNotifier.instance.notifyPosition(this, prefs, message, "dgp_")
return
}
if (fap.getAprsInformation() == null) {
Log.d(TAG, "parsePacket() misses payload: " + message)
return
}
if (fap.hasFault())
throw new Exception("FAP fault")
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)
}
} catch {
case e : Exception =>
Log.d(TAG, "parsePacket() unsupported packet: " + message)
e.printStackTrace()
}
}
def getCSE(field : InformationField) : CourseAndSpeedExtension = {
field.getExtension() match {
case cse : CourseAndSpeedExtension => cse
case _ => null
}
}
def addPosition(ts : Long, ap : APRSPacket, field : InformationField, pos : Position, objectname : String) {
val cse = getCSE(field)
db.addPosition(ts, ap, pos, cse, objectname)
sendBroadcast(new Intent(POSITION)
.putExtra(SOURCE, ap.getSourceCall())
.putExtra(LOCATION, AprsPacket.position2location(ts, pos, cse))
.putExtra(CALLSIGN, if (objectname != null) objectname else ap.getSourceCall())
.putExtra(PACKET, ap.toString())
)
}
def addPost(t : Int, status : String, message : String) {
val ts = System.currentTimeMillis()
db.addPost(ts, t, status, message)
if (t == StorageDatabase.Post.TYPE_POST || t == StorageDatabase.Post.TYPE_INCMG) {
parsePacket(ts, message, t)
} else {
// only log status messages
Log.d(TAG, "addPost: " + status + " - " + message)
}
sendBroadcast(new Intent(UPDATE)
.putExtra(TYPE, t)
.putExtra(STATUS, message))
}
// support for translated IDs
def addPost(t : Int, status_id : Int, message : String) {
addPost(t, getString(status_id), message)
}
def postAddPost(t : Int, status_id : Int, message : String) {
// only log "info" if enabled in prefs
if (t == StorageDatabase.Post.TYPE_INFO && prefs.getBoolean("conn_log", false) == false)
return
handler.post {
addPost(t, status_id, message)
if (t == StorageDatabase.Post.TYPE_INCMG)
msgService.sendPendingMessages()
else if (t == StorageDatabase.Post.TYPE_ERROR)
stopSelf()
}
}
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
processIncomingPost(post)
}
// Map to store recent digipeats with their timestamps
val recentDigipeats: mutable.Map[String, Instant] = mutable.Map()
// 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) = Instant.now() // 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 30 seconds
Instant.now().isBefore(timestamp.plusSeconds(dedupeTime))
case None =>
false // Not found in recent digipeats
}
}
// Function to clean up old entries
def cleanupOldDigipeats(): Unit = {
val now = Instant.now()
// Retain only those digipeats that are within the last 30 seconds
recentDigipeats.retain { case (_, timestamp) =>
now.isBefore(timestamp.plusSeconds(dedupeTime))
}
}
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
}
// Check if both digipeating and regeneration are enabled. Temp fix until re-implementation. Remove later on.
if (prefs.isDigipeaterEnabled() && prefs.isRegenerateEnabled()) {
Log.d("APRSdroid.Service", "Both Digipeating and Regeneration are enabled; Set Regen to false.")
prefs.setBoolean("p.regenerate", false) // Disable regeneration
}
// New regen
if (!prefs.isDigipeaterEnabled() && prefs.isRegenerateEnabled()) {
Log.d("APRSdroid.Service", "Regen enabled")
sendTestPacket(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 testPacket = s"$sourceCall>$destinationCall,$modifiedDigiPath:$payload"
// Optionally, send a test packet with the formatted string only if a digipeat occurred
if (digipeatOccurred) {
sendTestPacket(testPacket)
// 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) =>
// 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 == callssid =>
// If `callssid` is found (without *), replace with `callssid*`
if (!hasModified) {
(acc :+ s"$callssid*", true)
} else {
(acc :+ w, hasModified) // If already modified, keep `callssid` as-is
}
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) {
// 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)
}
def postAbort(post : String) {
postAddPost(StorageDatabase.Post.TYPE_ERROR, R.string.post_error, post)
}
def postPosterStarted() {
handler.post {
onPosterStarted()
}
}
def postLinkOn(link : Int) {
link_error = 0
sendBroadcast(new Intent(LINK_ON).putExtra(LINK_INFO, link))
val message = getString(R.string.status_linkon, getString(link))
ServiceNotifier.instance.start(this, message)
}
def postLinkOff(link : Int) {
link_error = link
sendBroadcast(new Intent(LINK_OFF).putExtra(LINK_INFO, link))
val message = getString(R.string.status_linkoff, getString(link))
ServiceNotifier.instance.start(this, message)
}
}