diff --git a/atak/plugin/app/src/main/java/TacticalEdgeRouteAgent/plugin/TERAPlugin.java b/atak/plugin/app/src/main/java/TacticalEdgeRouteAgent/plugin/TERAPlugin.java index a0f5a6d..c699c32 100644 --- a/atak/plugin/app/src/main/java/TacticalEdgeRouteAgent/plugin/TERAPlugin.java +++ b/atak/plugin/app/src/main/java/TacticalEdgeRouteAgent/plugin/TERAPlugin.java @@ -39,6 +39,8 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import gov.tak.api.plugin.IPlugin; import gov.tak.api.plugin.IServiceController; @@ -201,7 +203,7 @@ public void onComplete(boolean ok, String msg, JSONObject planJson) { appendChatMessage(transcript, chatScroll, hasMessages, "TERA", msg, false); if (ok && planJson != null) { - drawRoute(planJson); + drawMapResponse(planJson); } }); } @@ -478,19 +480,27 @@ private void clearActiveRoute() { activeWaypointMarkers.clear(); } - private void drawRoute(JSONObject planJson) { + private void drawMapResponse(JSONObject planJson) { MapView mapView = MapView.getMapView(); if (mapView == null) return; clearActiveRoute(); + boolean routeDrawn = drawRoute(mapView, planJson); + int pointCount = drawPointMarkers(mapView, planJson, !routeDrawn); + + if (!routeDrawn && pointCount == 0) { + Toast.makeText(pluginContext, "No route or points in response", Toast.LENGTH_SHORT).show(); + } + } + + private boolean drawRoute(MapView mapView, JSONObject planJson) { try { // Extract LineString coordinates from route.geometry.coordinates // GeoJSON format: [[lon, lat], [lon, lat], ...] JSONObject route = planJson.optJSONObject("route"); if (route == null) { - Toast.makeText(pluginContext, "No route in response", Toast.LENGTH_SHORT).show(); - return; + return false; } JSONObject geometry = route.optJSONObject("geometry"); if (geometry == null) { @@ -498,8 +508,7 @@ private void drawRoute(JSONObject planJson) { } JSONArray coords = geometry.optJSONArray("coordinates"); if (coords == null || coords.length() < 2) { - Toast.makeText(pluginContext, "Route has no coordinates", Toast.LENGTH_SHORT).show(); - return; + return false; } GeoPoint[] points = new GeoPoint[coords.length()]; @@ -541,11 +550,206 @@ private void drawRoute(JSONObject planJson) { double centerLat = sumLat / coords.length(); double centerLon = sumLon / coords.length(); mapView.getMapController().panTo(new GeoPoint(centerLat, centerLon), true); + return true; } catch (JSONException e) { Toast.makeText(pluginContext, "Route parse error: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + return false; + } + } + + private int drawPointMarkers(MapView mapView, JSONObject response, boolean includeWaypoints) { + List specs = new ArrayList<>(); + if (includeWaypoints) { + collectPointSpecs(specs, response.optJSONArray("waypoints"), "WP"); + } + collectPointSpecs(specs, response.optJSONArray("points"), "Point"); + collectPointSpecs(specs, response.optJSONArray("markers"), "Marker"); + collectPointSpecs(specs, response.optJSONArray("cot_events"), "CoT"); + collectPointSpecs(specs, response.optJSONArray("events"), "CoT"); + collectPointSpec(specs, response.optJSONObject("point"), "Point"); + collectPointSpec(specs, response.optJSONObject("marker"), "Marker"); + collectPointSpec(specs, response.optJSONObject("cot_event"), "CoT"); + collectFeatureSpecs(specs, response.optJSONObject("feature")); + collectFeatureArraySpecs(specs, response.optJSONArray("features")); + + GeoPoint first = null; + int rendered = 0; + for (PointMarkerSpec spec : specs) { + GeoPoint point = new GeoPoint(spec.lat, spec.lon); + if (!point.isValid()) { + continue; + } + Marker marker = new Marker(point, spec.uid); + marker.setTitle(spec.label); + marker.setType(spec.type); + mapView.getRootGroup().addItem(marker); + activeWaypointMarkers.add(marker); + rendered += 1; + if (first == null) { + first = point; + } + } + if (first != null) { + mapView.getMapController().panTo(first, true); + } + return rendered; + } + + private void collectPointSpecs(List specs, JSONArray points, + String defaultLabel) { + if (points == null) { + return; + } + for (int i = 0; i < points.length(); i++) { + collectPointSpec(specs, points.optJSONObject(i), defaultLabel + "-" + (i + 1)); + } + } + + private void collectFeatureArraySpecs(List specs, JSONArray features) { + if (features == null) { + return; + } + for (int i = 0; i < features.length(); i++) { + collectFeatureSpecs(specs, features.optJSONObject(i)); + } + } + + private void collectFeatureSpecs(List specs, JSONObject feature) { + if (feature == null) { + return; + } + JSONObject geometry = feature.optJSONObject("geometry"); + if (geometry == null || !"Point".equalsIgnoreCase(geometry.optString("type"))) { + return; + } + JSONArray coords = geometry.optJSONArray("coordinates"); + if (coords == null || coords.length() < 2) { + return; + } + JSONObject properties = feature.optJSONObject("properties"); + String label = labelFrom(properties, "Point"); + specs.add(new PointMarkerSpec(coords.optDouble(1, Double.NaN), + coords.optDouble(0, Double.NaN), label, cotTypeFrom(properties), + uidFrom(properties))); + } + + private void collectPointSpec(List specs, JSONObject point, + String defaultLabel) { + if (point == null) { + return; + } + + JSONObject geometry = point.optJSONObject("geometry"); + if (geometry != null && "Point".equalsIgnoreCase(geometry.optString("type"))) { + JSONArray coords = geometry.optJSONArray("coordinates"); + if (coords != null && coords.length() >= 2) { + specs.add(new PointMarkerSpec(coords.optDouble(1, Double.NaN), + coords.optDouble(0, Double.NaN), labelFrom(point, defaultLabel), + cotTypeFrom(point), uidFrom(point))); + return; + } + } + + JSONObject nestedPoint = point.optJSONObject("point"); + if (nestedPoint != null) { + collectPointSpec(specs, nestedPoint, labelFrom(point, defaultLabel)); + return; + } + + String cotXml = point.optString("cot_xml", point.optString("xml", "")); + PointMarkerSpec cotSpec = pointFromCotXml(cotXml, labelFrom(point, defaultLabel), + cotTypeFrom(point), uidFrom(point)); + if (cotSpec != null) { + specs.add(cotSpec); + return; + } + + double lat = firstFinite( + point.optDouble("lat", Double.NaN), + point.optDouble("latitude", Double.NaN), + point.optDouble("center_lat", Double.NaN)); + double lon = firstFinite( + point.optDouble("lon", Double.NaN), + point.optDouble("longitude", Double.NaN), + point.optDouble("center_lon", Double.NaN)); + if (Double.isNaN(lat) || Double.isNaN(lon)) { + return; + } + specs.add(new PointMarkerSpec(lat, lon, labelFrom(point, defaultLabel), + cotTypeFrom(point), uidFrom(point))); + } + + private PointMarkerSpec pointFromCotXml(String xml, String defaultLabel, + String defaultType, String defaultUid) { + if (xml == null || xml.trim().isEmpty()) { + return null; + } + double lat = xmlDoubleAttribute(xml, "lat"); + double lon = xmlDoubleAttribute(xml, "lon"); + if (Double.isNaN(lat) || Double.isNaN(lon)) { + return null; + } + String callsign = xmlAttribute(xml, "callsign"); + String uid = xmlAttribute(xml, "uid"); + String type = xmlAttribute(xml, "type"); + return new PointMarkerSpec(lat, lon, + callsign.isEmpty() ? defaultLabel : callsign, + type.isEmpty() ? defaultType : type, + uid.isEmpty() ? defaultUid : uid); + } + + private double xmlDoubleAttribute(String xml, String name) { + String value = xmlAttribute(xml, name); + if (value.isEmpty()) { + return Double.NaN; + } + try { + return Double.parseDouble(value); + } catch (NumberFormatException ignored) { + return Double.NaN; + } + } + + private String xmlAttribute(String xml, String name) { + Matcher matcher = Pattern.compile(name + "=\"([^\"]+)\"").matcher(xml); + return matcher.find() ? matcher.group(1) : ""; + } + + private double firstFinite(double first, double second, double third) { + if (!Double.isNaN(first)) return first; + if (!Double.isNaN(second)) return second; + return third; + } + + private String labelFrom(JSONObject json, String fallback) { + if (json == null) { + return fallback; } + String label = json.optString("label", ""); + if (label.trim().isEmpty()) label = json.optString("name", ""); + if (label.trim().isEmpty()) label = json.optString("title", ""); + if (label.trim().isEmpty()) label = json.optString("callsign", ""); + return label.trim().isEmpty() ? fallback : label; + } + + private String cotTypeFrom(JSONObject json) { + if (json == null) { + return "a-f-G-U-C"; + } + String type = json.optString("cot_type", ""); + if (type.trim().isEmpty()) type = json.optString("type", ""); + return type.trim().isEmpty() ? "a-f-G-U-C" : type; + } + + private String uidFrom(JSONObject json) { + if (json == null) { + return UUID.randomUUID().toString(); + } + String uid = json.optString("uid", ""); + if (uid.trim().isEmpty()) uid = json.optString("id", ""); + return uid.trim().isEmpty() ? UUID.randomUUID().toString() : uid; } private String statusForPlanResult(boolean ok, String message) { @@ -729,4 +933,20 @@ private void appendChatMessage(LinearLayout transcript, ScrollView chatScroll, LinearLayout.LayoutParams.WRAP_CONTENT)); scrollChatToBottom(chatScroll); } + + private static final class PointMarkerSpec { + final double lat; + final double lon; + final String label; + final String type; + final String uid; + + PointMarkerSpec(double lat, double lon, String label, String type, String uid) { + this.lat = lat; + this.lon = lon; + this.label = label; + this.type = type; + this.uid = uid; + } + } } diff --git a/atak/plugin/app/src/main/java/TacticalEdgeRouteAgent/plugin/TeraPlanClient.java b/atak/plugin/app/src/main/java/TacticalEdgeRouteAgent/plugin/TeraPlanClient.java index a35301c..c771ecd 100644 --- a/atak/plugin/app/src/main/java/TacticalEdgeRouteAgent/plugin/TeraPlanClient.java +++ b/atak/plugin/app/src/main/java/TacticalEdgeRouteAgent/plugin/TeraPlanClient.java @@ -83,7 +83,8 @@ static void requestPlan(String endpoint, String prompt, JSONObject mapContext, int code = connection.getResponseCode(); String responseBody = readResponse(connection, code); - if (code >= 200 && code < 300 && shouldVerifyPlanResponse(responseBody)) { + if (code >= 200 && code < 300 && endpoint.trim().contains("/plan") + && shouldVerifyPlanResponse(responseBody)) { VerifyResult verify = verifyPlanResponse(endpoint, responseBody); if (!verify.ok) { callback.onComplete(false, REJECTED_SIGNATURE + "\n" + verify.message, null); @@ -93,7 +94,10 @@ static void requestPlan(String endpoint, String prompt, JSONObject mapContext, JSONObject planJson = null; try { - planJson = new JSONObject(responseBody); + JSONObject json = new JSONObject(responseBody); + if (hasRenderableMapContent(json)) { + planJson = json; + } } catch (JSONException ignored) { } @@ -163,7 +167,7 @@ private static VerifyResult verifyPlanResponse(String planEndpoint, String planR private static boolean shouldVerifyPlanResponse(String body) { try { JSONObject json = new JSONObject(body); - return json.has("route") || json.has("signature"); + return json.has("route"); } catch (JSONException ignored) { return false; } @@ -230,10 +234,15 @@ private static String buildPlanPayload(String prompt, JSONObject mapContext) throws JSONException { JSONObject payload = new JSONObject(); payload.put("prompt", prompt); + payload.put("model", "gemma3:4b"); + payload.put("llm_provider", "ollama"); + payload.put("agent_profile", "tera-atak-live"); // Extract operator GPS position for the /plan PlanRequest `current` field. // Prefer the self-marker (operator's real GPS) over the map centre. if (mapContext != null) { + payload.put("map_context", mapContext); + JSONObject current = null; JSONObject selectedArea = mapContext.optJSONObject("selected_area"); if (selectedArea != null @@ -253,13 +262,39 @@ private static String buildPlanPayload(String prompt, JSONObject mapContext) if (current != null) { payload.put("current", current); } + + JSONObject viewBounds = mapContext.optJSONObject("view_bounds"); + if (viewBounds != null) { + payload.put("bbox", buildBoundingBox(viewBounds)); + payload.put("display_bounds", buildDisplayBounds(viewBounds)); + } } return payload.toString(); } + private static JSONObject buildBoundingBox(JSONObject viewBounds) throws JSONException { + JSONObject bbox = new JSONObject(); + bbox.put("west_lon", viewBounds.getDouble("west")); + bbox.put("south_lat", viewBounds.getDouble("south")); + bbox.put("east_lon", viewBounds.getDouble("east")); + bbox.put("north_lat", viewBounds.getDouble("north")); + return bbox; + } + + private static JSONObject buildDisplayBounds(JSONObject viewBounds) throws JSONException { + JSONObject displayBounds = new JSONObject(); + displayBounds.put("west", viewBounds.getDouble("west")); + displayBounds.put("south", viewBounds.getDouble("south")); + displayBounds.put("east", viewBounds.getDouble("east")); + displayBounds.put("north", viewBounds.getDouble("north")); + return displayBounds; + } + private static PromptResult parsePromptResult(int code, String body) { // Parse PlanResponse: {route, waypoints, rationale, signature, request_id} + // Also accept point-only map responses so ATAK can drop CoT-style markers + // without requiring a route geometry. try { JSONObject json = new JSONObject(body); if (code >= 200 && code < 300 && json.has("route")) { @@ -278,6 +313,19 @@ private static PromptResult parsePromptResult(int code, String body) { } return new PromptResult(true, summary.toString()); } + if (code >= 200 && code < 300 && hasRenderablePoints(json)) { + StringBuilder summary = new StringBuilder(); + summary.append("[POINTS ACCEPTED]"); + String rationale = json.optString("rationale", ""); + if (!rationale.trim().isEmpty()) { + summary.append("\n").append(rationale.trim()); + } + int count = renderablePointCount(json); + if (count > 0) { + summary.append("\nPoints: ").append(count); + } + return new PromptResult(true, summary.toString()); + } if (code >= 200 && code < 300 && json.has("response")) { String response = json.optString("response", "").trim(); if (!response.isEmpty()) { @@ -298,6 +346,46 @@ private static PromptResult parsePromptResult(int code, String body) { return new PromptResult(false, "HTTP " + code + "\n" + truncate(body)); } + private static boolean hasRenderableMapContent(JSONObject json) { + return json.has("route") || hasRenderablePoints(json); + } + + private static boolean hasRenderablePoints(JSONObject json) { + return renderablePointCount(json) > 0; + } + + private static int renderablePointCount(JSONObject json) { + int count = 0; + count += json.optJSONArray("waypoints") == null ? 0 : json.optJSONArray("waypoints").length(); + count += json.optJSONArray("points") == null ? 0 : json.optJSONArray("points").length(); + count += json.optJSONArray("markers") == null ? 0 : json.optJSONArray("markers").length(); + count += json.optJSONArray("cot_events") == null ? 0 : json.optJSONArray("cot_events").length(); + count += json.optJSONArray("events") == null ? 0 : json.optJSONArray("events").length(); + JSONObject point = json.optJSONObject("point"); + if (point != null) { + count += 1; + } + JSONObject feature = json.optJSONObject("feature"); + if (feature != null && isPointFeature(feature)) { + count += 1; + } + org.json.JSONArray features = json.optJSONArray("features"); + if (features != null) { + for (int i = 0; i < features.length(); i++) { + JSONObject item = features.optJSONObject(i); + if (item != null && isPointFeature(item)) { + count += 1; + } + } + } + return count; + } + + private static boolean isPointFeature(JSONObject feature) { + JSONObject geometry = feature.optJSONObject("geometry"); + return geometry != null && "Point".equalsIgnoreCase(geometry.optString("type")); + } + private static String friendlyException(Exception e) { String message = e.getMessage(); String suffix = message == null || message.trim().isEmpty() diff --git a/atak/plugin/app/src/main/res/values/strings.xml b/atak/plugin/app/src/main/res/values/strings.xml index ba9651d..61d8e03 100644 --- a/atak/plugin/app/src/main/res/values/strings.xml +++ b/atak/plugin/app/src/main/res/values/strings.xml @@ -4,8 +4,8 @@ TERA Tactical Edge Route Agent - http://127.0.0.1:8000/plan - /plan + http://127.0.0.1:8080/api/prompt + /api/prompt 192.168.4.1 local Jetson IP