diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9c4b80c..8b6a946 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0c18b8a..c6d9bb4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,8 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/example/tacticalapp/CotMarkerReceiver.kt b/app/src/main/java/com/example/tacticalapp/CotMarkerReceiver.kt
new file mode 100644
index 0000000..acd3722
--- /dev/null
+++ b/app/src/main/java/com/example/tacticalapp/CotMarkerReceiver.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/example/tacticalapp/InteropSettingsActivity.kt b/app/src/main/java/com/example/tacticalapp/InteropSettingsActivity.kt
new file mode 100644
index 0000000..3c94abf
--- /dev/null
+++ b/app/src/main/java/com/example/tacticalapp/InteropSettingsActivity.kt
@@ -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()
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/tacticalapp/MainActivity.kt b/app/src/main/java/com/example/tacticalapp/MainActivity.kt
index d5dd6c8..0742377 100644
--- a/app/src/main/java/com/example/tacticalapp/MainActivity.kt
+++ b/app/src/main/java/com/example/tacticalapp/MainActivity.kt
@@ -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))
+ }
}
}
diff --git a/app/src/main/res/layout/activity_interop_settings.xml b/app/src/main/res/layout/activity_interop_settings.xml
new file mode 100644
index 0000000..51b045a
--- /dev/null
+++ b/app/src/main/res/layout/activity_interop_settings.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 57604eb..1a1df9b 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -18,4 +18,18 @@
android:layout_marginTop="16dp"
android:text="TAK Plugin Status" />
+
+
+
+
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
new file mode 100644
index 0000000..8a164f6
--- /dev/null
+++ b/core/build.gradle.kts
@@ -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")
+}
diff --git a/core/src/main/java/com/example/core/interop/CotEvent.kt b/core/src/main/java/com/example/core/interop/CotEvent.kt
new file mode 100644
index 0000000..660aa18
--- /dev/null
+++ b/core/src/main/java/com/example/core/interop/CotEvent.kt
@@ -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,
+)
diff --git a/core/src/main/java/com/example/core/interop/CotSender.kt b/core/src/main/java/com/example/core/interop/CotSender.kt
new file mode 100644
index 0000000..0157f02
--- /dev/null
+++ b/core/src/main/java/com/example/core/interop/CotSender.kt
@@ -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
+ }
+}
diff --git a/core/src/main/java/com/example/core/interop/CotXml.kt b/core/src/main/java/com/example/core/interop/CotXml.kt
new file mode 100644
index 0000000..6d874f2
--- /dev/null
+++ b/core/src/main/java/com/example/core/interop/CotXml.kt
@@ -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)
+ }
+}
diff --git a/core/src/main/java/com/example/core/interop/Interop.kt b/core/src/main/java/com/example/core/interop/Interop.kt
new file mode 100644
index 0000000..75fdb16
--- /dev/null
+++ b/core/src/main/java/com/example/core/interop/Interop.kt
@@ -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(5, TimeUnit.SECONDS).build()
+ WorkManager.getInstance(context)
+ .enqueueUniquePeriodicWork("cot-beacon", ExistingPeriodicWorkPolicy.UPDATE, request)
+ }
+}
diff --git a/core/src/main/java/com/example/core/interop/MarkerEntity.kt b/core/src/main/java/com/example/core/interop/MarkerEntity.kt
new file mode 100644
index 0000000..9ec96d1
--- /dev/null
+++ b/core/src/main/java/com/example/core/interop/MarkerEntity.kt
@@ -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",
+)
diff --git a/core/src/main/java/com/example/core/interop/SelfBeaconWorker.kt b/core/src/main/java/com/example/core/interop/SelfBeaconWorker.kt
new file mode 100644
index 0000000..edc37d1
--- /dev/null
+++ b/core/src/main/java/com/example/core/interop/SelfBeaconWorker.kt
@@ -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()
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 2b303c2..282def7 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -14,4 +14,4 @@ dependencyResolutionManagement {
}
rootProject.name = "TacticalApp"
-include(":app", ":llm", ":tak-plugin")
+include(":app", ":llm", ":tak-plugin", ":core")