From 92406067619fbffad8655759a6a36ed800204eb5 Mon Sep 17 00:00:00 2001
From: nbschultz97 <126931519+nbschultz97@users.noreply.github.com>
Date: Thu, 11 Sep 2025 23:32:18 -0600
Subject: [PATCH] feat: scaffold multi-module LLM-enabled project
---
.github/workflows/build.yml | 17 ++++++
CHANGELOG.md | 4 ++
CONTRIBUTING.md | 11 ++++
README.md | 28 +++++-----
app/build.gradle | 41 --------------
app/build.gradle.kts | 56 +++++++++++++++++++
.../com/example/tacticalapp/MainActivity.kt | 20 ++++---
app/src/main/res/layout/activity_main.xml | 21 +++++++
build.gradle | 17 ------
build.gradle.kts | 6 ++
docs/README.md | 3 +
llm/build.gradle.kts | 34 +++++++++++
llm/src/main/java/com/example/llm/EdgeLlm.kt | 48 ++++++++++++++++
llm/src/main/java/com/example/llm/Llm.kt | 9 +++
.../main/java/com/example/llm/LlmRouter.kt | 21 +++++++
llm/src/main/java/com/example/llm/LocalLlm.kt | 13 +++++
.../java/com/example/llm/LlmRouterTest.kt | 40 +++++++++++++
prompts/medevac_9line.md | 9 +++
prompts/system.txt | 1 +
scripts/dev/run_edge_stub.sh | 18 ++++++
settings.gradle | 2 -
settings.gradle.kts | 17 ++++++
tak-plugin/build.gradle.kts | 32 +++++++++++
tak-plugin/src/main/AndroidManifest.xml | 8 +++
.../com/example/takplugin/TakPluginService.kt | 12 ++++
25 files changed, 406 insertions(+), 82 deletions(-)
create mode 100644 .github/workflows/build.yml
create mode 100644 CHANGELOG.md
create mode 100644 CONTRIBUTING.md
delete mode 100644 app/build.gradle
create mode 100644 app/build.gradle.kts
create mode 100644 app/src/main/res/layout/activity_main.xml
delete mode 100644 build.gradle
create mode 100644 build.gradle.kts
create mode 100644 docs/README.md
create mode 100644 llm/build.gradle.kts
create mode 100644 llm/src/main/java/com/example/llm/EdgeLlm.kt
create mode 100644 llm/src/main/java/com/example/llm/Llm.kt
create mode 100644 llm/src/main/java/com/example/llm/LlmRouter.kt
create mode 100644 llm/src/main/java/com/example/llm/LocalLlm.kt
create mode 100644 llm/src/test/java/com/example/llm/LlmRouterTest.kt
create mode 100644 prompts/medevac_9line.md
create mode 100644 prompts/system.txt
create mode 100755 scripts/dev/run_edge_stub.sh
delete mode 100644 settings.gradle
create mode 100644 settings.gradle.kts
create mode 100644 tak-plugin/build.gradle.kts
create mode 100644 tak-plugin/src/main/AndroidManifest.xml
create mode 100644 tak-plugin/src/main/java/com/example/takplugin/TakPluginService.kt
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..439dabb
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,17 @@
+name: Build
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+ - name: Build
+ run: gradle test assembleDebug
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..37be459
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,4 @@
+# Changelog
+
+## [Unreleased]
+- Initial multi-module setup with LLM interface and TAK plugin stub.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..86389b5
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,11 @@
+# Contributing
+
+## Prerequisites
+- Android Studio Giraffe or newer
+- JDK 17
+- Android SDK with API 34
+
+## Building
+```
+./gradlew assembleDebug
+```
diff --git a/README.md b/README.md
index 404235c..dff16bb 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,22 @@
# Tactical App
-This repository contains a minimal Android application skeleton intended for tactical scenarios.
+Multi-module Android project for an offline-first tactical AI assistant.
-## Features
-- Placeholder `MainActivity` ready for integration with the **Android Team Awareness Kit (ATAK)** SDK.
-- Kotlin-based implementation with Material3 theme.
-- Structured to support future offline LLM integration (e.g., GPT-OSS models) for edge deployments.
+## Modules
+- `app`: main Android application with basic home screen.
+- `llm`: common LLM interface, router, and stub implementations.
+- `tak-plugin`: CivTAK plugin stub for future CoT integration.
## Building
-The project uses Gradle. Ensure a compatible Gradle version is installed on your system and run:
-
-```bash
-gradle assembleDebug
+Ensure JDK 17 and Android SDK are installed, then run:
+```
+./gradlew assembleDebug
```
-> **Note:** Building requires the Android SDK and network access to resolve dependencies. The Gradle wrapper is omitted to keep binary files out of version control.
+## Development
+A simple edge LLM stub server is available:
+```
+./scripts/dev/run_edge_stub.sh
+```
-## Future Work
-- Integrate ATAK APIs for mission planning and situational awareness.
-- Embed a local LLM runtime (e.g., `llama.cpp`) for disconnected operations.
-- Add WiFi CSI processing and pose estimation modules as described in Vantage Scanner roadmap.
+See `CONTRIBUTING.md` for details.
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 76a567f..0000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,41 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'org.jetbrains.kotlin.android'
-}
-
-android {
- namespace 'com.example.tacticalapp'
- compileSdk 34
-
- defaultConfig {
- applicationId 'com.example.tacticalapp'
- minSdk 26
- targetSdk 34
- versionCode 1
- versionName "1.0"
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = '1.8'
- }
-}
-
-dependencies {
- implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
- implementation 'androidx.appcompat:appcompat:1.7.0'
- implementation 'com.google.android.material:material:1.12.0'
- // Placeholder for ATAK SDK integration
- // implementation 'com.atakmap.android:atakapi:4.10.0'
-}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..9c4b80c
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,56 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.kapt")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "com.example.tacticalapp"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.example.tacticalapp"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ buildFeatures {
+ viewBinding = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(project(":llm"))
+ implementation(project(":tak-plugin"))
+
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.24")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.retrofit2:retrofit:2.11.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ implementation("androidx.room:room-runtime:2.6.1")
+ implementation("androidx.room:room-ktx:2.6.1")
+ kapt("androidx.room:room-compiler:2.6.1")
+ implementation("androidx.work:work-runtime-ktx:2.9.0")
+}
diff --git a/app/src/main/java/com/example/tacticalapp/MainActivity.kt b/app/src/main/java/com/example/tacticalapp/MainActivity.kt
index 1d939eb..15c79e7 100644
--- a/app/src/main/java/com/example/tacticalapp/MainActivity.kt
+++ b/app/src/main/java/com/example/tacticalapp/MainActivity.kt
@@ -2,17 +2,21 @@ package com.example.tacticalapp
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
-import android.widget.TextView
+import com.example.tacticalapp.databinding.ActivityMainBinding
-/**
- * Main entry point for TacticalApp.
- * Future enhancements: integrate ATAK API and local LLM for offline analysis.
- */
class MainActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityMainBinding
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val tv = TextView(this)
- tv.text = "Tactical App Placeholder"
- setContentView(tv)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ binding.btnLocalChat.setOnClickListener {
+ // TODO: open local chat UI
+ }
+ binding.btnTakStatus.setOnClickListener {
+ // TODO: show TAK plugin status
+ }
}
}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..57604eb
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/build.gradle b/build.gradle
deleted file mode 100644
index a828fb7..0000000
--- a/build.gradle
+++ /dev/null
@@ -1,17 +0,0 @@
-buildscript {
- repositories {
- google()
- mavenCentral()
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:8.5.1'
- classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24'
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..c1040f3
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,6 @@
+plugins {
+ id("com.android.application") version "8.5.1" apply false
+ id("com.android.library") version "8.5.1" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
+}
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..6cf6604
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,3 @@
+# Tactical App Docs
+
+Placeholder for screenshots and walkthroughs.
diff --git a/llm/build.gradle.kts b/llm/build.gradle.kts
new file mode 100644
index 0000000..eece144
--- /dev/null
+++ b/llm/build.gradle.kts
@@ -0,0 +1,34 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "com.example.llm"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.retrofit2:retrofit:2.11.0")
+ implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
+}
diff --git a/llm/src/main/java/com/example/llm/EdgeLlm.kt b/llm/src/main/java/com/example/llm/EdgeLlm.kt
new file mode 100644
index 0000000..05ce974
--- /dev/null
+++ b/llm/src/main/java/com/example/llm/EdgeLlm.kt
@@ -0,0 +1,48 @@
+package com.example.llm
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.http.Body
+import retrofit2.http.POST
+import retrofit2.http.Streaming
+import retrofit2.converter.kotlinx.serialization.asConverterFactory
+
+class EdgeLlm(
+ baseUrl: String,
+ client: OkHttpClient = OkHttpClient()
+) : Llm {
+ private val api: EdgeApi
+
+ init {
+ val contentType = "application/json".toMediaType()
+ api = Retrofit.Builder()
+ .baseUrl(baseUrl)
+ .client(client)
+ .addConverterFactory(Json.asConverterFactory(contentType))
+ .build()
+ .create(EdgeApi::class.java)
+ }
+
+ @Serializable
+ data class ChatRequest(val messages: List)
+ @Serializable
+ data class ChatResponse(val text: String, val done: Boolean = false)
+
+ interface EdgeApi {
+ @POST("chat")
+ @Streaming
+ suspend fun chat(@Body body: ChatRequest): List
+ }
+
+ override suspend fun chat(messages: List): Flow = flow {
+ val responses = api.chat(ChatRequest(messages))
+ for (resp in responses) {
+ emit(Llm.TokenChunk(resp.text, resp.done))
+ }
+ }
+}
diff --git a/llm/src/main/java/com/example/llm/Llm.kt b/llm/src/main/java/com/example/llm/Llm.kt
new file mode 100644
index 0000000..4d829a4
--- /dev/null
+++ b/llm/src/main/java/com/example/llm/Llm.kt
@@ -0,0 +1,9 @@
+package com.example.llm
+
+import kotlinx.coroutines.flow.Flow
+
+interface Llm {
+ data class Message(val role: String, val content: String)
+ data class TokenChunk(val text: String, val done: Boolean = false)
+ suspend fun chat(messages: List): Flow
+}
diff --git a/llm/src/main/java/com/example/llm/LlmRouter.kt b/llm/src/main/java/com/example/llm/LlmRouter.kt
new file mode 100644
index 0000000..b639ba0
--- /dev/null
+++ b/llm/src/main/java/com/example/llm/LlmRouter.kt
@@ -0,0 +1,21 @@
+package com.example.llm
+
+import kotlinx.coroutines.flow.Flow
+
+class LlmRouter(
+ private val local: Llm,
+ private val edge: Llm?,
+ private val prefs: Preferences,
+ private val systemState: SystemState
+) : Llm {
+
+ data class Preferences(val preferEdge: Boolean)
+ data class SystemState(val edgeReachable: Boolean, val batteryPercent: Int)
+
+ override suspend fun chat(messages: List): Flow {
+ val useEdge = prefs.preferEdge && edge != null &&
+ systemState.edgeReachable && systemState.batteryPercent > 20
+ val target = if (useEdge) edge!! else local
+ return target.chat(messages)
+ }
+}
diff --git a/llm/src/main/java/com/example/llm/LocalLlm.kt b/llm/src/main/java/com/example/llm/LocalLlm.kt
new file mode 100644
index 0000000..c7ff8c0
--- /dev/null
+++ b/llm/src/main/java/com/example/llm/LocalLlm.kt
@@ -0,0 +1,13 @@
+package com.example.llm
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+
+/**
+ * Placeholder local LLM implementation.
+ */
+class LocalLlm : Llm {
+ override suspend fun chat(messages: List): Flow = flow {
+ emit(Llm.TokenChunk(text = "local-response", done = true))
+ }
+}
diff --git a/llm/src/test/java/com/example/llm/LlmRouterTest.kt b/llm/src/test/java/com/example/llm/LlmRouterTest.kt
new file mode 100644
index 0000000..8af7c4e
--- /dev/null
+++ b/llm/src/test/java/com/example/llm/LlmRouterTest.kt
@@ -0,0 +1,40 @@
+package com.example.llm
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+private class DummyLlm(private val name: String) : Llm {
+ override suspend fun chat(messages: List): Flow = flow {
+ emit(Llm.TokenChunk(name, true))
+ }
+}
+
+class LlmRouterTest {
+ @Test
+ fun usesEdgeWhenPreferred() = runBlocking {
+ val router = LlmRouter(
+ local = DummyLlm("local"),
+ edge = DummyLlm("edge"),
+ prefs = LlmRouter.Preferences(preferEdge = true),
+ systemState = LlmRouter.SystemState(edgeReachable = true, batteryPercent = 90)
+ )
+ val result = router.chat(emptyList()).first()
+ assertEquals("edge", result.text)
+ }
+
+ @Test
+ fun fallsBackToLocal() = runBlocking {
+ val router = LlmRouter(
+ local = DummyLlm("local"),
+ edge = DummyLlm("edge"),
+ prefs = LlmRouter.Preferences(preferEdge = true),
+ systemState = LlmRouter.SystemState(edgeReachable = false, batteryPercent = 90)
+ )
+ val result = router.chat(emptyList()).first()
+ assertEquals("local", result.text)
+ }
+}
diff --git a/prompts/medevac_9line.md b/prompts/medevac_9line.md
new file mode 100644
index 0000000..f08a7be
--- /dev/null
+++ b/prompts/medevac_9line.md
@@ -0,0 +1,9 @@
+1. Location: {location}
+2. Radio Freq/Callsign: {freq}
+3. Priority: {priority}
+4. Equipment: {equipment}
+5. Type: {type}
+6. Security: {security}
+7. Method of Marking: {marking}
+8. Patient Nationality: {nationality}
+9. Terrain/Notes: {terrain}
diff --git a/prompts/system.txt b/prompts/system.txt
new file mode 100644
index 0000000..5515f34
--- /dev/null
+++ b/prompts/system.txt
@@ -0,0 +1 @@
+You are a SOF teammate; output strict, concise, no fluff.
diff --git a/scripts/dev/run_edge_stub.sh b/scripts/dev/run_edge_stub.sh
new file mode 100755
index 0000000..2370060
--- /dev/null
+++ b/scripts/dev/run_edge_stub.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Simple Flask server that echoes chat messages for EdgeLlm testing
+python3 - <<'PY'
+from flask import Flask, request, Response
+import json
+app = Flask(__name__)
+
+@app.post('/chat')
+def chat():
+ data = request.get_json(force=True)
+ messages = data.get('messages', [])
+ text = ' '.join(m.get('content', '') for m in messages)
+ resp = [{'text': text, 'done': True}]
+ return Response(json.dumps(resp), mimetype='application/json')
+
+if __name__ == '__main__':
+ app.run(host='0.0.0.0', port=11434)
+PY
diff --git a/settings.gradle b/settings.gradle
deleted file mode 100644
index bcc0365..0000000
--- a/settings.gradle
+++ /dev/null
@@ -1,2 +0,0 @@
-rootProject.name = "TacticalApp"
-include(":app")
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..2b303c2
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "TacticalApp"
+include(":app", ":llm", ":tak-plugin")
diff --git a/tak-plugin/build.gradle.kts b/tak-plugin/build.gradle.kts
new file mode 100644
index 0000000..ff78f48
--- /dev/null
+++ b/tak-plugin/build.gradle.kts
@@ -0,0 +1,32 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.example.takplugin"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ buildFeatures {
+ viewBinding = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(project(":llm"))
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ // CivTAK SDK placeholder
+}
diff --git a/tak-plugin/src/main/AndroidManifest.xml b/tak-plugin/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..051df39
--- /dev/null
+++ b/tak-plugin/src/main/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/tak-plugin/src/main/java/com/example/takplugin/TakPluginService.kt b/tak-plugin/src/main/java/com/example/takplugin/TakPluginService.kt
new file mode 100644
index 0000000..5f1f9f1
--- /dev/null
+++ b/tak-plugin/src/main/java/com/example/takplugin/TakPluginService.kt
@@ -0,0 +1,12 @@
+package com.example.takplugin
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+
+/**
+ * Placeholder TAK plugin service.
+ */
+class TakPluginService : Service() {
+ override fun onBind(intent: Intent?): IBinder? = null
+}