Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ android {
dependencies {
implementation(project(":llm"))
implementation(project(":tak-plugin"))
implementation(project(":core"))

implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.24")
implementation("androidx.appcompat:appcompat:1.7.0")
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.tacticalapp">

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<application
android:allowBackup="true"
android:label="@string/app_name"
Expand All @@ -15,6 +17,13 @@
</intent-filter>
</activity>
<activity android:name=".LocalChatActivity" />
<activity android:name=".InteropSettingsActivity" />

<receiver android:name=".CotMarkerReceiver" android:exported="true">
<intent-filter>
<action android:name="com.tacticalapp.SEND_MARKER" />
</intent-filter>
</receiver>
</application>

</manifest>
14 changes: 14 additions & 0 deletions app/src/main/java/com/example/tacticalapp/CotMarkerReceiver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.example.tacticalapp

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.example.core.interop.Interop

/** Receives external marker intents and publishes them as CoT. */
class CotMarkerReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val json = intent.getStringExtra("marker") ?: return
Interop.publishMarkerJson(json)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example.tacticalapp

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.core.interop.Interop
import com.example.tacticalapp.databinding.ActivityInteropSettingsBinding

class InteropSettingsActivity : AppCompatActivity() {
private lateinit var binding: ActivityInteropSettingsBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityInteropSettingsBinding.inflate(layoutInflater)
setContentView(binding.root)

// Load current settings
binding.switchEnabled.isChecked = Interop.enabled
binding.editHost.setText(Interop.host)
binding.editPort.setText(Interop.port.toString())

binding.btnSave.setOnClickListener {
Interop.enabled = binding.switchEnabled.isChecked
Interop.host = binding.editHost.text.toString()
Interop.port = binding.editPort.text.toString().toIntOrNull() ?: Interop.port
if (Interop.enabled) {
Interop.startSelfBeacon(this)
}
finish()
}
}
}
14 changes: 14 additions & 0 deletions app/src/main/java/com/example/tacticalapp/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,19 @@ class MainActivity : AppCompatActivity() {
binding.btnTakStatus.setOnClickListener {
Toast.makeText(this, "TAK plugin status: OK", Toast.LENGTH_SHORT).show()
}

binding.btnShareCot.setOnClickListener {
val marker = com.example.core.interop.MarkerEntity(
uid = "share-${System.currentTimeMillis()}",
lat = 0.0,
lon = 0.0,
)
com.example.core.interop.Interop.publishMarker(marker)
Toast.makeText(this, "CoT sent", Toast.LENGTH_SHORT).show()
}

binding.btnInteropSettings.setOnClickListener {
startActivity(Intent(this, InteropSettingsActivity::class.java))
}
}
}
32 changes: 32 additions & 0 deletions app/src/main/res/layout/activity_interop_settings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">

<Switch
android:id="@+id/switchEnabled"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enable CoT" />

<EditText
android:id="@+id/editHost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Host" />

<EditText
android:id="@+id/editPort"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:hint="Port" />

<Button
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save" />
</LinearLayout>
14 changes: 14 additions & 0 deletions app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,18 @@
android:layout_marginTop="16dp"
android:text="TAK Plugin Status" />

<Button
android:id="@+id/btnShareCot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Share as CoT" />

<Button
android:id="@+id/btnInteropSettings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="CoT Settings" />

</LinearLayout>
26 changes: 26 additions & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}

android {
namespace = "com.example.core"
compileSdk = 34

defaultConfig {
minSdk = 26
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}

dependencies {
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
16 changes: 16 additions & 0 deletions core/src/main/java/com/example/core/interop/CotEvent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.example.core.interop

import java.time.Instant

/**
* Minimal representation of a Cursor-on-Target event.
*/
data class CotEvent(
val uid: String,
val type: String,
val how: String,
val time: Instant,
val stale: Instant,
val lat: Double,
val lon: Double,
)
27 changes: 27 additions & 0 deletions core/src/main/java/com/example/core/interop/CotSender.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.core.interop

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress

/** Sends CoT XML payloads over UDP. */
class CotSender(
private var host: String = DEFAULT_HOST,
private var port: Int = DEFAULT_PORT,
) {
suspend fun send(xml: String) = withContext(Dispatchers.IO) {
val data = xml.toByteArray()
val packet = DatagramPacket(data, data.size, InetAddress.getByName(host), port)
DatagramSocket().use { socket ->
socket.broadcast = true
socket.send(packet)
}
}

companion object {
const val DEFAULT_HOST = "239.2.3.1"
const val DEFAULT_PORT = 6969
}
}
57 changes: 57 additions & 0 deletions core/src/main/java/com/example/core/interop/CotXml.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.example.core.interop

import org.w3c.dom.Element
import java.io.StringWriter
import java.time.format.DateTimeFormatter
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult

/** Utilities for serializing and deserializing CoT events to XML. */
object CotXml {
private val df: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT

fun toXml(event: CotEvent): String {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
val root = doc.createElement("event")
root.setAttribute("version", "2.0")
root.setAttribute("uid", event.uid)
root.setAttribute("type", event.type)
root.setAttribute("how", event.how)
root.setAttribute("time", df.format(event.time))
root.setAttribute("start", df.format(event.time))
root.setAttribute("stale", df.format(event.stale))

val point: Element = doc.createElement("point")
point.setAttribute("lat", event.lat.toString())
point.setAttribute("lon", event.lon.toString())
point.setAttribute("hae", "0")
point.setAttribute("ce", "9999999")
point.setAttribute("le", "9999999")
root.appendChild(point)
doc.appendChild(root)

val tf = TransformerFactory.newInstance().newTransformer()
tf.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes")
val writer = StringWriter()
tf.transform(DOMSource(doc), StreamResult(writer))
return writer.toString()
}

fun fromXml(xml: String): CotEvent {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(xml.byteInputStream())
val root = doc.documentElement
val uid = root.getAttribute("uid")
val type = root.getAttribute("type")
val how = root.getAttribute("how")
val time = df.parse(root.getAttribute("time"), java.time.Instant::from)
val stale = df.parse(root.getAttribute("stale"), java.time.Instant::from)
val point = root.getElementsByTagName("point").item(0) as Element
val lat = point.getAttribute("lat").toDouble()
val lon = point.getAttribute("lon").toDouble()
return CotEvent(uid, type, how, time, stale, lat, lon)
}
}
52 changes: 52 additions & 0 deletions core/src/main/java/com/example/core/interop/Interop.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.example.core.interop

import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.concurrent.TimeUnit

/** Public API for CoT interoperability. */
object Interop {
var enabled: Boolean = false
var host: String = CotSender.DEFAULT_HOST
var port: Int = CotSender.DEFAULT_PORT

fun sendCoT(event: CotEvent) {
if (!enabled) return
val xml = CotXml.toXml(event)
CoroutineScope(Dispatchers.IO).launch {
CotSender(host, port).send(xml)
}
}

fun publishMarker(marker: MarkerEntity) {
val now = Instant.now()
val evt = CotEvent(
uid = marker.uid,
type = marker.type,
how = "m-g",
time = now,
stale = now.plusSeconds(60),
lat = marker.lat,
lon = marker.lon,
)
sendCoT(evt)
}

fun publishMarkerJson(json: String) {
val marker = Json.decodeFromString(MarkerEntity.serializer(), json)
publishMarker(marker)
}

fun startSelfBeacon(context: Context) {
val request = PeriodicWorkRequestBuilder<SelfBeaconWorker>(5, TimeUnit.SECONDS).build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("cot-beacon", ExistingPeriodicWorkPolicy.UPDATE, request)
}
}
11 changes: 11 additions & 0 deletions core/src/main/java/com/example/core/interop/MarkerEntity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.core.interop

import kotlinx.serialization.Serializable

@Serializable
data class MarkerEntity(
val uid: String,
val lat: Double,
val lon: Double,
val type: String = "b-m-p",
)
30 changes: 30 additions & 0 deletions core/src/main/java/com/example/core/interop/SelfBeaconWorker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.example.core.interop

import android.content.Context
import android.location.Location
import android.location.LocationManager
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import java.time.Instant

/** Periodically broadcasts device position as a FRIENDLY CoT marker. */
class SelfBeaconWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
if (!Interop.enabled) return Result.success()
val lm = applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val loc: Location? = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER)
loc ?: return Result.success()
val now = Instant.now()
val evt = CotEvent(
uid = "SELF",
type = "a-f-G-U-C-I",
how = "m-g",
time = now,
stale = now.plusSeconds(15),
lat = loc.latitude,
lon = loc.longitude,
)
Interop.sendCoT(evt)
return Result.success()
}
}
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ dependencyResolutionManagement {
}

rootProject.name = "TacticalApp"
include(":app", ":llm", ":tak-plugin")
include(":app", ":llm", ":tak-plugin", ":core")
Loading