diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..628957a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+# Gradle
+.gradle/
+build/
+**/build/
+gradle-wrapper.jar
+!gradle-wrapper.properties
+local.properties
+
+# Android
+*.apk
+*.ap_
+*.aab
+*.dex
+*.class
+bin/
+gen/
+out/
+release/
+
+# IDE
+.idea/
+*.iml
+*.ipr
+*.iws
+.DS_Store
+
+# Keys / secrets
+*.jks
+*.keystore
+google-services.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b6de865
--- /dev/null
+++ b/README.md
@@ -0,0 +1,66 @@
+# Cortex Android Agent
+
+An Android foreground-service agent that connects to [Cortex Hub](https://ai.jerxie.com) over gRPC and exposes device capabilities as remotely-dispatchable tasks.
+
+## Features
+
+- **WiFi-only connectivity** — connects to Hub only on WiFi; stays disconnected on cellular
+- **Online / Offline toggle** — switch in the UI to fully disable the Hub connection
+- **Continuous Monitoring** — periodic background capture with per-feature controls:
+ - Camera photos (back + front, every 20 min)
+ - Screenshots (every 20 min, requires screen projection grant)
+ - GPS location (every 5 min)
+ - SMS messages (every 5 min)
+ - Call logs (every 5 min)
+ - Mic / Audio (records when mic is in use by another app)
+ - Notifications (captures incoming notifications from all apps)
+- **On-demand tasks** — Hub can dispatch tasks to the agent:
+ - `android_capture_photo` — take a photo
+ - `android_capture_audio` — record audio
+ - `android_capture_screenshot` — capture screen
+ - `android_read_sms` / `android_read_calls` / `android_read_location`
+ - `shell` — run shell commands / interactive TTY sessions
+- **Intelligence Vault** — on-device history viewer with Timeline, Location, Screenshots, Camera, Audio, Intelligence, and Explorer tabs
+- **File sync** — captured files auto-mirror to Hub via gRPC file sync
+
+## Setup
+
+1. Install the APK on your Android device
+2. Open the app and enter:
+ - **Hub Host** — e.g. `ai.jerxie.com`
+ - **Hub Port** — `443`
+ - **Auth Token** — from Cortex Hub node registration
+ - **Node ID** — unique identifier for this device
+3. Enable **Secure Connection (TLS)** if the Hub uses HTTPS
+4. Tap **Save & Connect**
+5. Grant all requested permissions (camera, mic, location, SMS, notifications, screen capture)
+
+## Architecture
+
+```
+AgentService (foreground)
+├── MeshClient gRPC bidirectional stream to Hub
+├── NetworkMonitor WiFi-only connection gating
+├── MonitoringModule Periodic capture scheduler
+├── ShellSessionManager TTY + built-in command handler
+├── FileSyncModule Hub ↔ device file sync
+└── NotificationWatcherService (NotificationListenerService)
+```
+
+Captured files are stored under:
+```
+/sdcard/Android/media/com.cortex.agentnode/cortex_sync/
+├── history/YYYY-MM-DD/HHmm/ periodic monitoring snapshots
+├── location/ GPS captures
+├── notifications/ per-app notification logs
+├── audio/ mic recordings
+├── photo/ on-demand photos
+└── screenshot/ on-demand screenshots
+```
+
+## Build
+
+```bash
+./gradlew assembleDebug
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+```
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..a3ef2c4
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,90 @@
+import com.google.protobuf.gradle.*
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.protobuf)
+}
+
+android {
+ namespace = "com.cortex.agentnode"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.cortex.agentnode"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ // Suppress lint errors for proto-generated code
+ lint {
+ disable += "InvalidPackage"
+ }
+}
+
+protobuf {
+ protoc {
+ artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}"
+ }
+ plugins {
+ create("grpc") {
+ artifact = "io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.get()}"
+ }
+ create("grpckt") {
+ artifact = "io.grpc:protoc-gen-grpc-kotlin:${libs.versions.grpcKotlin.get()}:jdk8@jar"
+ }
+ }
+ generateProtoTasks {
+ all().forEach { task ->
+ task.plugins {
+ create("grpc") { option("lite") }
+ create("grpckt") { option("lite") }
+ }
+ task.builtins {
+ create("java") { option("lite") }
+ create("kotlin") { option("lite") }
+ }
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ implementation(libs.androidx.lifecycle.service)
+
+ // gRPC + Protobuf
+ implementation(libs.grpc.okhttp)
+ implementation(libs.grpc.android)
+ implementation(libs.grpc.protobuf.lite)
+ implementation(libs.grpc.stub)
+ implementation(libs.grpc.kotlin.stub)
+ implementation(libs.protobuf.kotlin.lite)
+
+ // Coroutines
+ implementation(libs.kotlinx.coroutines.android)
+
+ // CameraX
+ implementation(libs.camerax.core)
+ implementation(libs.camerax.camera2)
+ implementation(libs.camerax.lifecycle)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..6b89b05
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,5 @@
+-keep class io.grpc.** { *; }
+-keep class com.google.protobuf.** { *; }
+-keep class agent.** { *; }
+-dontwarn io.grpc.**
+-dontwarn com.google.protobuf.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0caf551
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:networkSecurityConfig="@xml/network_security_config">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/cortex/agentnode/AgentApplication.kt b/app/src/main/java/com/cortex/agentnode/AgentApplication.kt
new file mode 100644
index 0000000..0e2cd7a
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/AgentApplication.kt
@@ -0,0 +1,34 @@
+package com.cortex.agentnode
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.os.Build
+
+class AgentApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Agent Service",
+ NotificationManager.IMPORTANCE_MIN // No sound, no banner
+ ).apply {
+ description = "Cortex edge agent"
+ setShowBadge(false)
+ }
+ getSystemService(NotificationManager::class.java)
+ .createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ const val CHANNEL_ID = "cortex_agent_channel"
+ const val NOTIFICATION_ID = 1001
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/AuthActivity.kt b/app/src/main/java/com/cortex/agentnode/AuthActivity.kt
new file mode 100644
index 0000000..abf396b
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/AuthActivity.kt
@@ -0,0 +1,113 @@
+package com.cortex.agentnode
+
+import android.app.AlertDialog
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.text.InputType
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.*
+import androidx.appcompat.app.AppCompatActivity
+
+/**
+ * Global Authentication Lock for the Intelligence Node.
+ */
+class AuthActivity : AppCompatActivity() {
+
+ private val MASTER_PASSWORD = "Y@ngy@ngX1e2019"
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Premium Dark Theme
+ window.apply {
+ addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+ statusBarColor = Color.parseColor("#0F172A")
+ navigationBarColor = Color.parseColor("#0F172A")
+ }
+
+ setContentView(buildAuthLayout())
+ }
+
+ private fun buildAuthLayout(): View {
+ val root = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setBackgroundColor(Color.parseColor("#0F172A"))
+ gravity = Gravity.CENTER_HORIZONTAL
+ setPadding(80, 0, 80, 0)
+ }
+
+ root.addView(View(this).apply { layoutParams = LinearLayout.LayoutParams(1, 0, 1f) })
+
+ // Logo / Icon
+ root.addView(ImageView(this).apply {
+ setImageResource(android.R.drawable.ic_lock_idle_lock)
+ setColorFilter(Color.parseColor("#38BDF8"))
+ layoutParams = LinearLayout.LayoutParams(180, 180)
+ })
+
+ root.addView(TextView(this).apply {
+ text = "Intelligence Node Lock"
+ textSize = 24f
+ setTypeface(null, Typeface.BOLD)
+ setTextColor(Color.WHITE)
+ setPadding(0, 48, 0, 8)
+ })
+
+ root.addView(TextView(this).apply {
+ text = "Restricted access only"
+ textSize = 14f
+ setTextColor(Color.parseColor("#94A3B8"))
+ setPadding(0, 0, 0, 80)
+ })
+
+ val etPass = EditText(this).apply {
+ hint = "Master Password"
+ setHintTextColor(Color.parseColor("#475569"))
+ setTextColor(Color.WHITE)
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ gravity = Gravity.CENTER
+ background = GradientDrawable().apply {
+ cornerRadius = 24f
+ setColor(Color.parseColor("#1E293B"))
+ }
+ setPadding(48, 36, 48, 36)
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+ }
+ root.addView(etPass)
+
+ root.addView(Button(this).apply {
+ text = "Unlock Terminal"
+ setTextColor(Color.WHITE)
+ setTypeface(null, Typeface.BOLD)
+ background = GradientDrawable().apply {
+ cornerRadius = 24f
+ setColor(Color.parseColor("#38BDF8"))
+ }
+ setOnClickListener {
+ if (etPass.text.toString() == MASTER_PASSWORD) {
+ setResult(RESULT_OK)
+ finish()
+ } else {
+ Toast.makeText(this@AuthActivity, "Access Denied", Toast.LENGTH_SHORT).show()
+ }
+ }
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 140).apply {
+ setMargins(0, 48, 0, 0)
+ }
+ })
+
+ root.addView(View(this).apply { layoutParams = LinearLayout.LayoutParams(1, 0, 1.5f) })
+
+ return root
+ }
+
+ override fun onBackPressed() {
+ // Prevent bypassing lock via back button
+ moveTaskToBack(true)
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/CameraActivity.kt b/app/src/main/java/com/cortex/agentnode/CameraActivity.kt
new file mode 100644
index 0000000..ee2b527
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/CameraActivity.kt
@@ -0,0 +1,47 @@
+package com.cortex.agentnode
+
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import com.cortex.agentnode.service.AgentService
+import kotlinx.coroutines.*
+
+/**
+ * A transparent Activity that brings the app to the foreground to trigger
+ * the mechanical pop-up camera on devices like the OnePlus 7 Pro.
+ */
+class CameraActivity : AppCompatActivity() {
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Keep it transparent and minimal
+ window.setBackgroundDrawableResource(android.R.color.transparent)
+
+ val cameraId = intent.getStringExtra("camera_id") ?: "front"
+ val taskId = intent.getStringExtra("task_id") ?: ""
+
+ Log.i("CameraActivity", "Starting foreground capture for task=$taskId, cam=$cameraId")
+
+ scope.launch {
+ try {
+ val service = AgentServiceHolder.instance
+ if (service != null) {
+ // Give the UI a tiny moment to settle
+ delay(500)
+ // We let the service handle the actual capture logic
+ // But since THIS activity is in foreground, the motor will lift.
+ }
+ // Wait for the service's 5s delay + capture time
+ delay(8000)
+ } finally {
+ finish()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ scope.cancel()
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/Config.kt b/app/src/main/java/com/cortex/agentnode/Config.kt
new file mode 100644
index 0000000..6fbb39b
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/Config.kt
@@ -0,0 +1,73 @@
+package com.cortex.agentnode
+
+import android.content.Context
+import android.content.SharedPreferences
+import java.util.UUID
+
+object Config {
+ private const val PREFS = "cortex_agent"
+
+ private fun prefs(ctx: Context): SharedPreferences =
+ ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ fun hubHost(ctx: Context): String = prefs(ctx).getString("hub_host", "ai.jerxie.com")!!
+ fun hubPort(ctx: Context): Int = prefs(ctx).getInt("hub_port", 443)
+ fun authToken(ctx: Context): String = prefs(ctx).getString("auth_token", "")!!
+ fun useTls(ctx: Context): Boolean = prefs(ctx).getBoolean("use_tls", true)
+
+ fun nodeId(ctx: Context): String = prefs(ctx).getString("node_id", "")!!
+
+ fun save(ctx: Context, host: String, port: Int, token: String, tls: Boolean, nodeId: String) {
+ val id = nodeId.ifBlank { "android-" + UUID.randomUUID().toString().take(8) }
+ prefs(ctx).edit()
+ .putString("hub_host", host)
+ .putInt("hub_port", port)
+ .putString("auth_token", token)
+ .putBoolean("use_tls", tls)
+ .putString("node_id", id)
+ .apply()
+ }
+
+ // MediaProjection token survives until reboot (stored as flat bytes in prefs)
+ fun saveProjectionToken(ctx: Context, data: android.content.Intent) {
+ val bytes = android.util.Base64.encodeToString(
+ data.toUri(0).toByteArray(), android.util.Base64.DEFAULT
+ )
+ prefs(ctx).edit().putString("projection_token", bytes).apply()
+ }
+
+ fun hasProjectionToken(ctx: Context): Boolean =
+ prefs(ctx).contains("projection_token")
+
+ fun isMonitoringEnabled(ctx: Context): Boolean = prefs(ctx).getBoolean("monitoring_enabled", true)
+
+ fun setMonitoringEnabled(ctx: Context, enabled: Boolean) {
+ prefs(ctx).edit().putBoolean("monitoring_enabled", enabled).apply()
+ }
+
+ fun isMonitorCamera(ctx: Context): Boolean = prefs(ctx).getBoolean("monitor_camera", true)
+ fun isMonitorScreenshot(ctx: Context): Boolean = prefs(ctx).getBoolean("monitor_screenshot", true)
+ fun isMonitorLocation(ctx: Context): Boolean = prefs(ctx).getBoolean("monitor_location", true)
+ fun isMonitorSms(ctx: Context): Boolean = prefs(ctx).getBoolean("monitor_sms", true)
+ fun isMonitorCalls(ctx: Context): Boolean = prefs(ctx).getBoolean("monitor_calls", true)
+ fun isMonitorAudio(ctx: Context): Boolean = prefs(ctx).getBoolean("monitor_audio", true)
+
+ fun setMonitorCamera(ctx: Context, v: Boolean) { prefs(ctx).edit().putBoolean("monitor_camera", v).apply() }
+ fun setMonitorScreenshot(ctx: Context, v: Boolean) { prefs(ctx).edit().putBoolean("monitor_screenshot", v).apply() }
+ fun setMonitorLocation(ctx: Context, v: Boolean) { prefs(ctx).edit().putBoolean("monitor_location", v).apply() }
+ fun setMonitorSms(ctx: Context, v: Boolean) { prefs(ctx).edit().putBoolean("monitor_sms", v).apply() }
+ fun setMonitorCalls(ctx: Context, v: Boolean) { prefs(ctx).edit().putBoolean("monitor_calls", v).apply() }
+ fun setMonitorAudio(ctx: Context, v: Boolean) { prefs(ctx).edit().putBoolean("monitor_audio", v).apply() }
+ fun isMonitorNotifications(ctx: Context): Boolean = prefs(ctx).getBoolean("monitor_notifications", true)
+ fun setMonitorNotifications(ctx: Context, v: Boolean) { prefs(ctx).edit().putBoolean("monitor_notifications", v).apply() }
+
+ fun isOnlineEnabled(ctx: Context): Boolean = prefs(ctx).getBoolean("online_enabled", true)
+
+ fun setOnlineEnabled(ctx: Context, enabled: Boolean) {
+ prefs(ctx).edit().putBoolean("online_enabled", enabled).apply()
+ }
+
+ fun clearProjectionToken(ctx: Context) {
+ prefs(ctx).edit().remove("projection_token").apply()
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/HistoryActivity.kt b/app/src/main/java/com/cortex/agentnode/HistoryActivity.kt
new file mode 100644
index 0000000..c38a75c
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/HistoryActivity.kt
@@ -0,0 +1,515 @@
+package com.cortex.agentnode
+
+import android.app.AlertDialog
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.media.MediaPlayer
+import android.media.ThumbnailUtils
+import android.os.Bundle
+import android.util.LruCache
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.*
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewpager2.widget.ViewPager2
+import com.google.android.material.tabs.TabLayout
+import kotlinx.coroutines.*
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+/**
+ * Advanced Intelligence Vault.
+ * Now featuring a dedicated "Intelligence" tab for Text/JSON logs with slide-to-browse.
+ */
+class HistoryActivity : AppCompatActivity() {
+
+ private lateinit var syncDir: File
+ private lateinit var contentArea: FrameLayout
+ private var mediaPlayer: MediaPlayer? = null
+ private val dateFormat = SimpleDateFormat("MMM dd, yyyy • HH:mm:ss", Locale.US)
+ private var currentTab = 0
+
+ private val bgPrimary = Color.parseColor("#0F172A")
+ private val textPrimary = Color.parseColor("#F8FAFC")
+ private val accent = Color.parseColor("#38BDF8")
+ private val cardBg = Color.parseColor("#1E293B")
+ private val cardHighlight = Color.parseColor("#334155")
+
+ private val expandedDirs = mutableSetOf()
+ private val uiScope = CoroutineScope(Dispatchers.Main + Job())
+
+ private val thumbnailCache: LruCache by lazy {
+ val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
+ val cacheSize = maxMemory / 8
+ object : LruCache(cacheSize) {
+ override fun sizeOf(key: String, bitmap: Bitmap): Int = bitmap.byteCount / 1024
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ window.apply {
+ addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+ statusBarColor = bgPrimary
+ navigationBarColor = bgPrimary
+ }
+ val mediaDir = externalMediaDirs.firstOrNull() ?: getExternalFilesDir(null) ?: filesDir
+ syncDir = File(mediaDir, "cortex_sync")
+ setContentView(buildLayout())
+ switchTab(0)
+ }
+
+ private fun buildLayout(): View {
+ return LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setBackgroundColor(bgPrimary)
+
+ val headerRow = LinearLayout(this@HistoryActivity).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(64, 80, 64, 8)
+
+ addView(TextView(this@HistoryActivity).apply {
+ text = "Intelligence Vault"
+ textSize = 28f
+ setTypeface(null, Typeface.BOLD)
+ setTextColor(textPrimary)
+ layoutParams = LinearLayout.LayoutParams(0, -2, 1f)
+ })
+
+ addView(Button(this@HistoryActivity).apply {
+ text = "🧹 Clean"
+ background = GradientDrawable().apply { cornerRadius = 24f; setColor(cardHighlight) }
+ setTextColor(accent)
+ setPadding(32, 0, 32, 0)
+ setOnClickListener { cleanupPlaceholders() }
+ })
+ addView(Button(this@HistoryActivity).apply {
+ text = "🗑 Clear All"
+ background = GradientDrawable().apply { cornerRadius = 24f; setColor(Color.parseColor("#7F1D1D")) }
+ setTextColor(Color.WHITE)
+ setPadding(32, 0, 32, 0)
+ layoutParams = LinearLayout.LayoutParams(-2, -2).apply { marginStart = 16 }
+ setOnClickListener { confirmClearAll() }
+ })
+ }
+ addView(headerRow)
+
+ val tabLayout = TabLayout(this@HistoryActivity).apply {
+ setBackgroundColor(bgPrimary); setSelectedTabIndicatorColor(accent); setTabTextColors(Color.parseColor("#94A3B8"), accent)
+ tabMode = TabLayout.MODE_SCROLLABLE; tabRippleColor = null
+ listOf("Timeline", "Location", "Screenshots", "Camera", "Audio", "Intelligence", "Explorer").forEach { addTab(newTab().setText(it)) }
+ addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
+ override fun onTabSelected(tab: TabLayout.Tab?) { tab?.let { switchTab(it.position) } }
+ override fun onTabUnselected(tab: TabLayout.Tab?) {}
+ override fun onTabReselected(tab: TabLayout.Tab?) {}
+ })
+ }
+ addView(tabLayout)
+
+ contentArea = FrameLayout(this@HistoryActivity).apply { layoutParams = LinearLayout.LayoutParams(-1, 0, 1f) }
+ addView(contentArea)
+ }
+ }
+
+ private fun switchTab(index: Int) {
+ currentTab = index
+ uiScope.launch {
+ contentArea.removeAllViews()
+ val loading = ProgressBar(this@HistoryActivity).apply { layoutParams = FrameLayout.LayoutParams(100, 100, Gravity.CENTER) }
+ contentArea.addView(loading)
+
+ if (currentTab == 0) {
+ // Timeline tab
+ val entries = withContext(Dispatchers.IO) { collectTimeline() }
+ contentArea.removeView(loading)
+ val list = RecyclerView(this@HistoryActivity).apply {
+ layoutParams = ViewGroup.LayoutParams(-1, -1)
+ layoutManager = LinearLayoutManager(this@HistoryActivity)
+ setPadding(40, 24, 40, 80); clipToPadding = false
+ }
+ list.adapter = TimelineAdapter(entries)
+ contentArea.addView(list)
+ return@launch
+ }
+
+ val files = withContext(Dispatchers.IO) {
+ when (currentTab) {
+ 1 -> collectFiles(listOf("txt"), "location")
+ 2 -> collectFiles(listOf("jpg", "png"), "screenshot")
+ 3 -> collectFiles(listOf("jpg", "png"), "camera")
+ 4 -> collectFiles(listOf("aac", "mp3", "wav"), "")
+ 5 -> collectFiles(listOf("json", "jsonl", "txt"), "intelligence")
+ else -> null
+ }
+ }
+
+ contentArea.removeView(loading)
+ val list = RecyclerView(this@HistoryActivity).apply {
+ layoutParams = ViewGroup.LayoutParams(-1, -1)
+ layoutManager = LinearLayoutManager(this@HistoryActivity)
+ setPadding(40, 24, 40, 80); clipToPadding = false
+ }
+ contentArea.addView(list)
+
+ if (currentTab == 6) {
+ val explorerItems = withContext(Dispatchers.IO) {
+ val items = mutableListOf()
+ collectExplorerItems(syncDir, 0, items)
+ items
+ }
+ list.adapter = ExplorerAdapter(explorerItems)
+ } else {
+ list.adapter = VaultAdapter(files ?: emptyList(), currentTab in 2..3, currentTab == 5)
+ }
+ }
+ }
+
+ private fun collectFiles(exts: List, filter: String): List {
+ val out = mutableListOf()
+ fun walk(d: File) {
+ d.listFiles()?.forEach {
+ if (it.isDirectory) walk(it)
+ else if (exts.contains(it.extension.lowercase())) {
+ val n = it.name.lowercase()
+ val m = when(filter) {
+ "location" -> n.contains("loc_") || it.parentFile?.name == "location" || n == "metadata.txt"
+ "screenshot" -> n.contains("screen") || it.parentFile?.name == "screenshot"
+ "camera" -> n.contains("camera") || n.contains("cam_") || n.contains("photo") || it.parentFile?.name == "photo"
+ "intelligence" -> !n.contains("placeholder") && n != "metadata.txt"
+ else -> true
+ }
+ if (m) out.add(it)
+ }
+ }
+ }
+ walk(syncDir)
+ return out.sortedByDescending { it.lastModified() }
+ }
+
+ inner class VaultAdapter(val files: List, val isImg: Boolean, val isText: Boolean) : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = VaultViewHolder(LinearLayout(this@HistoryActivity).apply {
+ orientation = LinearLayout.HORIZONTAL; layoutParams = LinearLayout.LayoutParams(-1, -2).apply { setMargins(0, 12, 0, 12) }
+ background = GradientDrawable().apply { cornerRadius = 32f; setColor(cardBg) }; setPadding(40, 48, 40, 48); gravity = Gravity.CENTER_VERTICAL
+ isClickable = true; isFocusable = true
+ })
+ override fun onBindViewHolder(h: VaultViewHolder, p: Int) = h.bind(files[p], files, p, isImg, isText)
+ override fun getItemCount() = files.size
+ }
+
+ inner class VaultViewHolder(val card: LinearLayout) : RecyclerView.ViewHolder(card) {
+ private var loadJob: Job? = null
+ fun bind(file: File, all: List, pos: Int, isImg: Boolean, isText: Boolean) {
+ card.removeAllViews()
+ val iconFrame = FrameLayout(this@HistoryActivity).apply {
+ layoutParams = LinearLayout.LayoutParams(130, 130).apply { setMargins(0, 0, 32, 0) }
+ background = GradientDrawable().apply { cornerRadius = 24f; setColor(cardHighlight) }
+ }
+ val imageView = ImageView(this@HistoryActivity).apply { scaleType = ImageView.ScaleType.CENTER_CROP; clipToOutline = true; background = GradientDrawable().apply { cornerRadius = 24f } }
+ iconFrame.addView(imageView)
+ card.addView(iconFrame)
+
+ if (isImg) {
+ loadJob?.cancel()
+ val cached = thumbnailCache.get(file.absolutePath)
+ if (cached != null) imageView.setImageBitmap(cached)
+ else {
+ imageView.setImageResource(android.R.drawable.ic_menu_gallery)
+ loadJob = uiScope.launch {
+ val bitmap = withContext(Dispatchers.IO) {
+ runCatching {
+ val b = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = 8 })
+ b?.let { ThumbnailUtils.extractThumbnail(it, 200, 200) }
+ }.getOrNull()
+ }
+ if (bitmap != null) { thumbnailCache.put(file.absolutePath, bitmap); imageView.setImageBitmap(bitmap) }
+ }
+ }
+ } else {
+ imageView.setImageResource(if (isText) android.R.drawable.ic_menu_edit else android.R.drawable.ic_btn_speak_now)
+ imageView.setColorFilter(accent); imageView.setPadding(24, 24, 24, 24)
+ }
+
+ card.addView(LinearLayout(this@HistoryActivity).apply {
+ orientation = LinearLayout.VERTICAL; layoutParams = LinearLayout.LayoutParams(0, -2, 1f)
+ addView(TextView(this@HistoryActivity).apply { text = file.name; textSize = 15f; setTextColor(textPrimary); maxLines = 1; ellipsize = android.text.TextUtils.TruncateAt.END; setTypeface(null, Typeface.BOLD) })
+ addView(TextView(this@HistoryActivity).apply { text = dateFormat.format(Date(file.lastModified())); textSize = 12f; setTextColor(accent) })
+ })
+ card.setOnClickListener { if (isImg || isText) showSlider(all, pos, isText) else previewFile(file) }
+ }
+ }
+
+ private fun showSlider(files: List, start: Int, isText: Boolean) {
+ val root = FrameLayout(this).apply { setBackgroundColor(bgPrimary) }
+ val vp = ViewPager2(this).apply {
+ adapter = object : RecyclerView.Adapter() {
+ override fun getItemViewType(position: Int) = if (isText) 1 else 0
+ override fun onCreateViewHolder(p: ViewGroup, t: Int): RecyclerView.ViewHolder {
+ return if (t == 1) {
+ val sv = ScrollView(this@HistoryActivity).apply {
+ layoutParams = ViewGroup.LayoutParams(-1, -1)
+ isFillViewport = true // Ensure the content stretches to the full height
+ setPadding(64, 250, 64, 300)
+ }
+ val tv = TextView(this@HistoryActivity).apply {
+ layoutParams = FrameLayout.LayoutParams(-1, -1)
+ setTextColor(textPrimary)
+ textSize = 14f
+ setTypeface(Typeface.MONOSPACE)
+ }
+ sv.addView(tv)
+ object : RecyclerView.ViewHolder(sv) {}
+ } else object : RecyclerView.ViewHolder(ImageView(this@HistoryActivity).apply { layoutParams = ViewGroup.LayoutParams(-1, -1); scaleType = ImageView.ScaleType.FIT_CENTER }) {}
+ }
+ override fun onBindViewHolder(h: RecyclerView.ViewHolder, p: Int) {
+ if (isText) {
+ val tv = (h.itemView as ViewGroup).getChildAt(0) as TextView
+ uiScope.launch { tv.text = withContext(Dispatchers.IO) { runCatching { files[p].readText() }.getOrDefault("Error reading file") } }
+ } else (h.itemView as ImageView).setImageURI(android.net.Uri.fromFile(files[p]))
+ }
+ override fun getItemCount() = files.size
+ }
+ }
+ root.addView(vp)
+ val title = TextView(this).apply { text = files[start].name; setTextColor(textPrimary); textSize = 14f; gravity = 1; layoutParams = FrameLayout.LayoutParams(-1, -2).apply { topMargin = 120 } }
+ root.addView(title)
+ val date = TextView(this).apply { text = dateFormat.format(Date(files[start].lastModified())); setTextColor(accent); textSize = 12f; gravity = 1; layoutParams = FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM).apply { bottomMargin = 150 } }
+ root.addView(date)
+ vp.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
+ override fun onPageSelected(p: Int) { title.text = files[p].name; date.text = dateFormat.format(Date(files[p].lastModified())) }
+ })
+ vp.setCurrentItem(start, false)
+ AlertDialog.Builder(this, android.R.style.Theme_Black_NoTitleBar_Fullscreen).setView(root).show()
+ }
+
+ private fun confirmClearAll() {
+ AlertDialog.Builder(this)
+ .setTitle("Clear All Captures?")
+ .setMessage("This will permanently delete all captured files (photos, screenshots, audio, location, SMS, calls, notifications). This cannot be undone.")
+ .setPositiveButton("Delete Everything") { _, _ ->
+ AlertDialog.Builder(this)
+ .setTitle("Are you sure?")
+ .setMessage("All capture history will be wiped.")
+ .setPositiveButton("Yes, wipe it") { _, _ ->
+ uiScope.launch {
+ val count = withContext(Dispatchers.IO) {
+ var n = 0
+ fun del(f: File) { f.listFiles()?.forEach { if (it.isDirectory) del(it) else { it.delete(); n++ } }; f.delete() }
+ del(syncDir)
+ syncDir.mkdirs()
+ n
+ }
+ Toast.makeText(this@HistoryActivity, "Deleted $count files", Toast.LENGTH_SHORT).show()
+ refreshTab()
+ }
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ private fun cleanupPlaceholders() {
+ uiScope.launch {
+ var count = 0
+ withContext(Dispatchers.IO) {
+ fun walk(d: File) {
+ d.listFiles()?.forEach {
+ if (it.isDirectory) walk(it)
+ else if (it.extension == "txt" && it.name.contains("placeholder")) {
+ val content = runCatching { it.readText() }.getOrNull()
+ if (content?.startsWith("REMOVED:") == true) {
+ if (it.delete()) count++
+ }
+ }
+ }
+ }
+ walk(syncDir)
+ }
+ Toast.makeText(this@HistoryActivity, "Purged $count placeholders", Toast.LENGTH_SHORT).show()
+ refreshTab()
+ }
+ }
+
+ private fun previewFile(f: File) {
+ val e = f.extension.lowercase()
+ if (listOf("jpg","png").contains(e)) showSlider(listOf(f), 0, false)
+ else if (listOf("json","jsonl","txt").contains(e)) showSlider(listOf(f), 0, true)
+ else if (listOf("aac","mp3","wav").contains(e)) try { mediaPlayer?.release(); mediaPlayer = MediaPlayer().apply { setDataSource(f.absolutePath); prepare(); start() } } catch (ex: Exception) {}
+ }
+
+ data class TimelineEntry(
+ val dir: File,
+ val dateTime: Date,
+ val hasCamera: Boolean,
+ val hasScreenshot: Boolean,
+ val hasLocation: Boolean,
+ val hasSms: Boolean,
+ val hasCalls: Boolean
+ )
+
+ private fun collectTimeline(): List {
+ val historyDir = File(syncDir, "history")
+ if (!historyDir.exists()) return emptyList()
+ val dateFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ val timeFmt = SimpleDateFormat("HHmm", Locale.US)
+ val combinedFmt = SimpleDateFormat("yyyy-MM-dd HHmm", Locale.US)
+ val entries = mutableListOf()
+ historyDir.listFiles()
+ ?.filter { it.isDirectory }
+ ?.sortedByDescending { it.name }
+ ?.forEach { dateDir ->
+ runCatching { dateFmt.parse(dateDir.name) } .getOrNull() ?: return@forEach
+ dateDir.listFiles()
+ ?.filter { it.isDirectory }
+ ?.sortedByDescending { it.name }
+ ?.forEach { timeDir ->
+ runCatching { timeFmt.parse(timeDir.name) }.getOrNull() ?: return@forEach
+ val dateTime = runCatching { combinedFmt.parse("${dateDir.name} ${timeDir.name}") }.getOrNull() ?: Date(timeDir.lastModified())
+ val files = timeDir.listFiles() ?: emptyArray()
+ val hasCamera = files.any { it.name.startsWith("photo_back") || it.name.startsWith("photo_front") }
+ val hasScreenshot = files.any { it.name.startsWith("screenshot") }
+ val hasLocation = files.any { it.name == "metadata.txt" }
+ val hasSms = files.any { it.extension.lowercase() == "json" && it.name.lowercase().contains("sms") }
+ val hasCalls = files.any { it.extension.lowercase() == "json" && it.name.lowercase().contains("call") }
+ entries.add(TimelineEntry(timeDir, dateTime, hasCamera, hasScreenshot, hasLocation, hasSms, hasCalls))
+ }
+ }
+ return entries
+ }
+
+ inner class TimelineAdapter(val entries: List) : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TimelineViewHolder(
+ LinearLayout(this@HistoryActivity).apply {
+ orientation = LinearLayout.HORIZONTAL
+ layoutParams = LinearLayout.LayoutParams(-1, -2).apply { setMargins(0, 12, 0, 12) }
+ background = GradientDrawable().apply { cornerRadius = 32f; setColor(cardBg) }
+ setPadding(40, 40, 40, 40)
+ gravity = Gravity.CENTER_VERTICAL
+ isClickable = true; isFocusable = true
+ }
+ )
+ override fun onBindViewHolder(h: TimelineViewHolder, p: Int) = h.bind(entries[p])
+ override fun getItemCount() = entries.size
+ }
+
+ inner class TimelineViewHolder(val card: LinearLayout) : RecyclerView.ViewHolder(card) {
+ private val timeFmtDisplay = SimpleDateFormat("HH:mm", Locale.US)
+ private val dateFmtDisplay = SimpleDateFormat("MMM dd, yyyy", Locale.US)
+
+ fun bind(entry: TimelineEntry) {
+ card.removeAllViews()
+
+ // Left side: time + date
+ card.addView(LinearLayout(this@HistoryActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ layoutParams = LinearLayout.LayoutParams(0, -2, 1f)
+ addView(TextView(this@HistoryActivity).apply {
+ text = timeFmtDisplay.format(entry.dateTime)
+ textSize = 20f
+ setTypeface(null, Typeface.BOLD)
+ setTextColor(textPrimary)
+ })
+ addView(TextView(this@HistoryActivity).apply {
+ text = dateFmtDisplay.format(entry.dateTime)
+ textSize = 12f
+ setTextColor(accent)
+ })
+ })
+
+ // Right side: badge row
+ val badgeRow = LinearLayout(this@HistoryActivity).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ }
+ data class Badge(val icon: String, val label: String, val present: Boolean)
+ val badges = listOf(
+ Badge("◉", "CAM", entry.hasCamera),
+ Badge("⬛", "SCR", entry.hasScreenshot),
+ Badge("◎", "GPS", entry.hasLocation),
+ Badge("◈", "SMS", entry.hasSms),
+ Badge("◆", "CALL", entry.hasCalls)
+ )
+ badges.filter { it.present }.forEach { badge ->
+ badgeRow.addView(LinearLayout(this@HistoryActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ setPadding(10, 6, 10, 6)
+ background = GradientDrawable().apply {
+ cornerRadius = 20f
+ setColor(Color.parseColor("#334155"))
+ }
+ layoutParams = LinearLayout.LayoutParams(-2, -2).apply { setMargins(4, 0, 4, 0) }
+ addView(TextView(this@HistoryActivity).apply {
+ text = badge.icon
+ textSize = 10f
+ setTextColor(accent)
+ gravity = Gravity.CENTER
+ })
+ addView(TextView(this@HistoryActivity).apply {
+ text = badge.label
+ textSize = 8f
+ setTextColor(Color.parseColor("#94A3B8"))
+ gravity = Gravity.CENTER
+ })
+ })
+ }
+ card.addView(badgeRow)
+
+ card.setOnClickListener {
+ val files = entry.dir.listFiles()?.sortedBy { it.name } ?: emptyList()
+ if (files.isEmpty()) {
+ Toast.makeText(this@HistoryActivity, "No files in this session", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ val fileNames = files.map { it.name }.toTypedArray()
+ val listView = ListView(this@HistoryActivity).apply {
+ adapter = ArrayAdapter(this@HistoryActivity, android.R.layout.simple_list_item_1, fileNames)
+ }
+ val dialog = AlertDialog.Builder(this@HistoryActivity)
+ .setTitle("${timeFmtDisplay.format(entry.dateTime)} ${dateFmtDisplay.format(entry.dateTime)}")
+ .setView(listView)
+ .setNegativeButton("Close", null)
+ .create()
+ listView.setOnItemClickListener { _, _, position, _ ->
+ dialog.dismiss()
+ previewFile(files[position])
+ }
+ dialog.show()
+ }
+ }
+ }
+
+ data class ExplorerItem(val file: File, val depth: Int)
+ private fun collectExplorerItems(dir: File, depth: Int, items: MutableList) {
+ dir.listFiles()?.sortedBy { !it.isDirectory }?.forEach { file ->
+ items.add(ExplorerItem(file, depth))
+ if (file.isDirectory && expandedDirs.contains(file.absolutePath)) collectExplorerItems(file, depth + 1, items)
+ }
+ }
+ inner class ExplorerAdapter(val items: List) : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ExplorerViewHolder(LinearLayout(this@HistoryActivity).apply { orientation = LinearLayout.HORIZONTAL; layoutParams = LinearLayout.LayoutParams(-1, -2); gravity = Gravity.CENTER_VERTICAL; isClickable = true; isFocusable = true })
+ override fun onBindViewHolder(h: ExplorerViewHolder, p: Int) = h.bind(items[p])
+ override fun getItemCount() = items.size
+ }
+ inner class ExplorerViewHolder(val row: LinearLayout) : RecyclerView.ViewHolder(row) {
+ fun bind(item: ExplorerItem) {
+ row.removeAllViews(); row.setPadding(64 + (item.depth * 50), 36, 64, 36)
+ val iconText = if (item.file.isDirectory) (if (expandedDirs.contains(item.file.absolutePath)) "▼ " else "▶ ") else "• "
+ row.addView(TextView(this@HistoryActivity).apply { text = iconText; setTextColor(accent); textSize = 16f })
+ row.addView(TextView(this@HistoryActivity).apply { text = item.file.name; setTextColor(textPrimary); textSize = 15f; setTypeface(null, if (item.file.isDirectory) Typeface.BOLD else Typeface.NORMAL); layoutParams = LinearLayout.LayoutParams(0, -2, 1f) })
+ row.setOnClickListener { if (item.file.isDirectory) { if (expandedDirs.contains(item.file.absolutePath)) expandedDirs.remove(item.file.absolutePath) else expandedDirs.add(item.file.absolutePath); refreshTab() } else previewFile(item.file) }
+ }
+ }
+ private fun refreshTab() { switchTab(currentTab) }
+ override fun onDestroy() { super.onDestroy(); mediaPlayer?.release(); uiScope.cancel() }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/MainActivity.kt b/app/src/main/java/com/cortex/agentnode/MainActivity.kt
new file mode 100644
index 0000000..3552ea5
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/MainActivity.kt
@@ -0,0 +1,436 @@
+package com.cortex.agentnode
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.media.projection.MediaProjectionManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.Settings
+import android.util.Log
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.Window
+import android.view.WindowManager
+import android.app.AlertDialog
+import android.widget.*
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import com.cortex.agentnode.service.AgentService
+import kotlinx.coroutines.launch
+
+/**
+ * Premium Setup & Dashboard for Cortex Android Agent.
+ * Features auto-start, glassmorphism design, and real-time status feedback.
+ * Includes Global Master Password protection.
+ */
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var projMgr: MediaProjectionManager
+ private lateinit var etHost: EditText
+ private lateinit var etPort: EditText
+ private lateinit var etToken: EditText
+ private lateinit var etNodeId: EditText
+ private lateinit var cbTls: CheckBox
+ private lateinit var swOnline: Switch
+ private lateinit var swMonitor: Switch
+ private lateinit var tvStatus: TextView
+ private lateinit var tvNetwork: TextView
+ private lateinit var statusCircle: View
+
+ private var isUnlocked = false
+
+ private val authLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ isUnlocked = true
+ } else {
+ finish() // Close app if auth failed or cancelled
+ }
+ }
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { granted ->
+ val denied = granted.filterValues { !it }.keys
+ if (denied.isEmpty()) {
+ updateStatus("Permissions ready", isError = false)
+ } else {
+ updateStatus("Missing permissions: ${denied.joinToString(", ")}", isError = true)
+ }
+ }
+
+ private val screenCaptureLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK && result.data != null) {
+ AgentServiceHolder.instance?.screenModule?.onProjectionGranted(result.data!!)
+ updateStatus("Screen capture granted (valid until reboot)", isError = false)
+ } else {
+ updateStatus("Screen capture denied", isError = true)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ AgentServiceHolder.mainActivity = this
+ projMgr = getSystemService(MediaProjectionManager::class.java)
+
+ // Make status bar transparent for premium look
+ window.apply {
+ clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+ addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+ statusBarColor = Color.TRANSPARENT
+ decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ }
+
+ setContentView(buildLayout())
+ loadConfig()
+ refreshNetworkLabel()
+
+ // GLOBAL LOCK: Launch AuthActivity immediately
+ if (!isUnlocked) {
+ authLauncher.launch(Intent(this, AuthActivity::class.java))
+ }
+
+ requestRequiredPermissions()
+
+ // Auto-start logic: Only start if everything is configured
+ val savedHost = Config.hubHost(this)
+ val savedToken = Config.authToken(this)
+ val savedId = Config.nodeId(this)
+
+ if (savedHost.isNotBlank() && savedToken.isNotBlank() && savedId.isNotBlank()) {
+ AgentService.start(this)
+ updateStatus("Agent auto-started • Connected to $savedHost", isError = false)
+ if (!Config.isOnlineEnabled(this))
+ updateNetworkMode("Offline Mode — Hub disconnected", true)
+ } else {
+ updateStatus("Setup Required: Provide Token & ID", isError = true)
+ updateNetworkMode("Not configured", true)
+ }
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ if (intent?.getBooleanExtra("RETRIGGER_PERMISSIONS", false) == true) {
+ requestRequiredPermissions()
+ }
+ }
+
+ private fun loadConfig() {
+ etHost.setText(Config.hubHost(this))
+ etPort.setText(Config.hubPort(this).toString())
+ etToken.setText(Config.authToken(this))
+ etNodeId.setText(Config.nodeId(this))
+ cbTls.isChecked = Config.useTls(this)
+ }
+
+ private fun updateStatus(text: String, isError: Boolean) {
+ tvStatus.text = text
+ tvStatus.setTextColor(if (isError) Color.parseColor("#EF4444") else Color.parseColor("#22C55E"))
+ statusCircle.background = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(if (isError) Color.parseColor("#EF4444") else Color.parseColor("#22C55E"))
+ }
+ }
+
+ private fun refreshNetworkLabel() {
+ if (!Config.isOnlineEnabled(this)) {
+ updateNetworkMode("Offline Mode — Hub disconnected", true)
+ return
+ }
+ val cm = getSystemService(android.net.ConnectivityManager::class.java)
+ val caps = cm.getNetworkCapabilities(cm.activeNetwork)
+ when {
+ caps?.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) == true ->
+ updateNetworkMode("Wi-Fi • Connected", false)
+ caps?.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) == true ->
+ updateNetworkMode("Cellular • WiFi-only mode (paused)", true)
+ else -> updateNetworkMode("No network • Waiting for WiFi", true)
+ }
+ }
+
+ fun updateNetworkMode(mode: String, isWarning: Boolean) {
+ runOnUiThread {
+ tvNetwork.text = mode
+ tvNetwork.setTextColor(if (isWarning) Color.parseColor("#F59E0B") else Color.parseColor("#94A3B8"))
+ }
+ }
+
+ private fun saveAndStart() {
+ val host = etHost.text.toString().trim()
+ val port = etPort.text.toString().toIntOrNull() ?: 50051
+ val token = etToken.text.toString().trim()
+ val nodeId = etNodeId.text.toString().trim()
+ val tls = cbTls.isChecked
+
+ if (host.isEmpty() || token.isEmpty()) {
+ updateStatus("Error: Hub host and token are required", isError = true)
+ return
+ }
+ Config.save(this, host, port, token, tls, nodeId)
+ AgentService.start(this)
+ updateStatus("Agent started • $host:$port", isError = false)
+
+ val it = Intent(this, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }
+ startActivity(it); startActivity(it)
+ }
+
+ private fun requestScreenCapture() {
+ screenCaptureLauncher.launch(projMgr.createScreenCaptureIntent())
+ }
+
+ private fun requestRequiredPermissions() {
+ val needed = mutableListOf(
+ Manifest.permission.RECORD_AUDIO,
+ Manifest.permission.CAMERA,
+ Manifest.permission.READ_SMS,
+ Manifest.permission.READ_CALL_LOG,
+ Manifest.permission.READ_PHONE_STATE,
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ ).also {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) it.add(Manifest.permission.POST_NOTIFICATIONS)
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) it.add(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }.filter {
+ ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
+ }
+
+ if (needed.isNotEmpty()) {
+ permissionLauncher.launch(needed.toTypedArray())
+ return
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val pm = getSystemService(android.os.PowerManager::class.java)
+ if (!pm.isIgnoringBatteryOptimizations(packageName)) {
+ runCatching {
+ val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
+ data = Uri.parse("package:$packageName")
+ }
+ startActivity(intent)
+ }
+ return
+ }
+ }
+
+ val appOps = getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager
+ val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ appOps.unsafeCheckOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), packageName)
+ } else {
+ appOps.checkOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), packageName)
+ }
+
+ if (mode != android.app.AppOpsManager.MODE_ALLOWED) {
+ startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
+ return
+ }
+
+ // Check for Notification Listener Access
+ val enabledListeners = Settings.Secure.getString(contentResolver, "enabled_notification_listeners")
+ if (enabledListeners == null || !enabledListeners.contains(packageName)) {
+ startActivity(Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"))
+ return
+ }
+
+ requestScreenCapture()
+ }
+
+ private fun buildLayout(): View {
+ val mainBg = Color.parseColor("#0F172A")
+ val cardBg = Color.parseColor("#1E293B")
+ val accent = Color.parseColor("#6A1B9A")
+ val textPrimary = Color.parseColor("#F8FAFC")
+ val textSecondary = Color.parseColor("#94A3B8")
+
+ return ScrollView(this).apply {
+ setBackgroundColor(mainBg)
+ isFillViewport = true
+
+ addView(LinearLayout(this@MainActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(64, 120, 64, 64)
+ gravity = Gravity.CENTER_HORIZONTAL
+
+ addView(ImageView(this@MainActivity).apply {
+ setImageResource(R.drawable.ic_agent)
+ layoutParams = LinearLayout.LayoutParams(200, 200)
+ setColorFilter(accent)
+ })
+
+ addView(TextView(this@MainActivity).apply {
+ text = "Cortex Agent"
+ textSize = 28f
+ setTypeface(null, Typeface.BOLD)
+ setTextColor(textPrimary)
+ setPadding(0, 24, 0, 8)
+ })
+
+ tvNetwork = TextView(this@MainActivity).apply {
+ text = "Detecting network..."
+ textSize = 14f; setTextColor(textSecondary); setPadding(0, 0, 0, 64)
+ }
+ addView(tvNetwork)
+
+ addView(LinearLayout(this@MainActivity).apply {
+ orientation = LinearLayout.HORIZONTAL; setPadding(32, 32, 32, 32); gravity = Gravity.CENTER_VERTICAL
+ background = GradientDrawable().apply { cornerRadius = 24f; setColor(cardBg) }
+ isClickable = true; isFocusable = true
+ setOnClickListener { requestRequiredPermissions() }
+ statusCircle = View(this@MainActivity).apply { layoutParams = LinearLayout.LayoutParams(24, 24).apply { marginEnd = 24 } }
+ addView(statusCircle)
+ tvStatus = TextView(this@MainActivity).apply { text = "Initializing..."; textSize = 14f; setTextColor(textPrimary); layoutParams = LinearLayout.LayoutParams(0, -2, 1f) }
+ addView(tvStatus)
+ })
+
+ addView(View(this@MainActivity).apply { layoutParams = LinearLayout.LayoutParams(1, 48) })
+
+ val configCard = LinearLayout(this@MainActivity).apply {
+ orientation = LinearLayout.VERTICAL; setPadding(48, 48, 48, 48)
+ background = GradientDrawable().apply { cornerRadius = 32f; setColor(cardBg) }
+ }
+
+ fun addField(l: String, h: String, t: Int = android.text.InputType.TYPE_CLASS_TEXT): EditText {
+ configCard.addView(TextView(this@MainActivity).apply { text = l; textSize = 12f; setTextColor(textSecondary); setPadding(0, 16, 0, 8) })
+ return EditText(this@MainActivity).apply { hint = h; setHintTextColor(Color.parseColor("#475569")); setTextColor(textPrimary); inputType = t; background = null; setPadding(0, 8, 0, 16) }.also { configCard.addView(it) }
+ }
+
+ etHost = addField("HUB HOST", "hub.cortex.ai")
+ etPort = addField("HUB PORT", "50051", android.text.InputType.TYPE_CLASS_NUMBER)
+ etToken = addField("AUTH TOKEN", "node_token_...", android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD)
+ etNodeId = addField("NODE IDENTIFIER", "android-edge-01")
+
+ cbTls = CheckBox(this@MainActivity).apply { text = "Secure Connection (TLS)"; setTextColor(textSecondary); buttonTintList = android.content.res.ColorStateList.valueOf(accent) }.also { configCard.addView(it) }
+
+ swOnline = Switch(this@MainActivity).apply {
+ text = "Online Mode (WiFi Only)"; setTextColor(textSecondary)
+ thumbTintList = android.content.res.ColorStateList.valueOf(accent)
+ isChecked = Config.isOnlineEnabled(this@MainActivity)
+ setOnCheckedChangeListener { _, checked ->
+ Config.setOnlineEnabled(this@MainActivity, checked)
+ if (checked) {
+ updateNetworkMode("Online Mode — WiFi required", false)
+ val svc = AgentServiceHolder.instance
+ if (svc != null) svc.onOnlineModeEnabled()
+ else AgentService.start(this@MainActivity)
+ } else {
+ updateNetworkMode("Offline Mode — Hub disconnected", true)
+ AgentServiceHolder.instance?.onOfflineModeEnabled()
+ }
+ }
+ }.also { configCard.addView(it) }
+
+ // Sub-card for per-feature monitoring controls (shown only when monitoring is ON)
+ val monitorSubCard = LinearLayout(this@MainActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(16, 8, 0, 8)
+ visibility = if (Config.isMonitoringEnabled(this@MainActivity)) View.VISIBLE else View.GONE
+ }
+
+ data class MonitorFeature(val label: String, val icon: String, val getter: (Context) -> Boolean, val setter: (Context, Boolean) -> Unit)
+ val features = listOf(
+ MonitorFeature("Camera Photos", "◉", Config::isMonitorCamera, Config::setMonitorCamera),
+ MonitorFeature("Screenshots", "⬛", Config::isMonitorScreenshot, Config::setMonitorScreenshot),
+ MonitorFeature("GPS Location", "◎", Config::isMonitorLocation, Config::setMonitorLocation),
+ MonitorFeature("SMS Messages", "◈", Config::isMonitorSms, Config::setMonitorSms),
+ MonitorFeature("Call Logs", "◆", Config::isMonitorCalls, Config::setMonitorCalls),
+ MonitorFeature("Mic / Audio", "◉", Config::isMonitorAudio, Config::setMonitorAudio),
+ MonitorFeature("Notifications", "◎", Config::isMonitorNotifications, Config::setMonitorNotifications),
+ )
+ features.forEach { feat ->
+ monitorSubCard.addView(LinearLayout(this@MainActivity).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(8, 4, 8, 4)
+ addView(TextView(this@MainActivity).apply {
+ text = "${feat.icon} ${feat.label}"
+ setTextColor(textSecondary)
+ textSize = 12f
+ layoutParams = LinearLayout.LayoutParams(0, -2, 1f)
+ })
+ addView(Switch(this@MainActivity).apply {
+ isChecked = feat.getter(this@MainActivity)
+ thumbTintList = android.content.res.ColorStateList.valueOf(accent)
+ scaleX = 0.8f; scaleY = 0.8f
+ setOnCheckedChangeListener { _, checked -> feat.setter(this@MainActivity, checked) }
+ })
+ })
+ }
+
+ swMonitor = Switch(this@MainActivity).apply {
+ text = "Continuous Monitoring"; setTextColor(textSecondary)
+ thumbTintList = android.content.res.ColorStateList.valueOf(accent)
+ isChecked = Config.isMonitoringEnabled(this@MainActivity)
+ setOnCheckedChangeListener { _, checked ->
+ Config.setMonitoringEnabled(this@MainActivity, checked)
+ monitorSubCard.visibility = if (checked) View.VISIBLE else View.GONE
+ if (checked) AgentServiceHolder.instance?.startMonitoring() else AgentServiceHolder.instance?.stopMonitoring()
+ updateStatus("Monitoring ${if (checked) "enabled" else "disabled"}", isError = false)
+ }
+ }.also { configCard.addView(it) }
+ configCard.addView(monitorSubCard)
+
+ addView(configCard)
+ addView(View(this@MainActivity).apply { layoutParams = LinearLayout.LayoutParams(1, 48) })
+
+ // Primary CTA
+ addView(Button(this@MainActivity).apply {
+ text = "Save & Connect"
+ setTextColor(Color.WHITE)
+ background = GradientDrawable().apply { cornerRadius = 24f; setColor(accent) }
+ layoutParams = LinearLayout.LayoutParams(-1, 130).apply { setMargins(0, 0, 0, 24) }
+ setOnClickListener { saveAndStart() }
+ })
+
+ // Utility icon row
+ addView(LinearLayout(this@MainActivity).apply {
+ orientation = LinearLayout.HORIZONTAL
+ layoutParams = LinearLayout.LayoutParams(-1, -2)
+
+ fun iconBtn(label: String, icon: String, tint: Int, action: () -> Unit) =
+ LinearLayout(this@MainActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ setPadding(16, 28, 16, 28)
+ background = GradientDrawable().apply { cornerRadius = 20f; setColor(cardBg) }
+ layoutParams = LinearLayout.LayoutParams(0, -2, 1f).apply { setMargins(0, 0, 16, 0) }
+ isClickable = true; isFocusable = true
+ setOnClickListener { action() }
+ addView(TextView(this@MainActivity).apply {
+ text = icon; textSize = 20f; gravity = Gravity.CENTER; setTextColor(tint)
+ })
+ addView(TextView(this@MainActivity).apply {
+ text = label; textSize = 10f; gravity = Gravity.CENTER
+ setTextColor(textSecondary); setPadding(0, 6, 0, 0)
+ })
+ }
+
+ addView(iconBtn("History", "◷", textSecondary) { startActivity(Intent(this@MainActivity, HistoryActivity::class.java)) })
+ addView(iconBtn("Screen", "⬛", textSecondary) { requestScreenCapture() })
+ addView(iconBtn("Stop", "⏹", Color.parseColor("#EF4444")) { AgentService.stop(this@MainActivity) }.apply {
+ (layoutParams as LinearLayout.LayoutParams).marginEnd = 0
+ })
+ })
+ })
+ }
+ }
+
+ companion object { private const val TAG = "MainActivity" }
+}
+
+object AgentServiceHolder {
+ var instance: com.cortex.agentnode.service.AgentService? = null
+ var mainActivity: com.cortex.agentnode.MainActivity? = null
+}
diff --git a/app/src/main/java/com/cortex/agentnode/boot/BootReceiver.kt b/app/src/main/java/com/cortex/agentnode/boot/BootReceiver.kt
new file mode 100644
index 0000000..4801126
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/boot/BootReceiver.kt
@@ -0,0 +1,26 @@
+package com.cortex.agentnode.boot
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import com.cortex.agentnode.Config
+import com.cortex.agentnode.service.AgentService
+
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action ?: return
+ if (action != Intent.ACTION_BOOT_COMPLETED &&
+ action != Intent.ACTION_MY_PACKAGE_REPLACED) return
+
+ // Only auto-start if previously configured
+ if (Config.authToken(context).isBlank()) return
+
+ val svcIntent = Intent(context, AgentService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(svcIntent)
+ } else {
+ context.startService(svcIntent)
+ }
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/grpc/MeshClient.kt b/app/src/main/java/com/cortex/agentnode/grpc/MeshClient.kt
new file mode 100644
index 0000000..b1f925f
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/grpc/MeshClient.kt
@@ -0,0 +1,266 @@
+package com.cortex.agentnode.grpc
+
+import agent.*
+import android.content.Context
+import android.util.Log
+import com.cortex.agentnode.Config
+import io.grpc.ManagedChannel
+import io.grpc.android.AndroidChannelBuilder
+import io.grpc.okhttp.OkHttpChannelBuilder
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.*
+import java.security.SecureRandom
+import java.security.cert.X509Certificate
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManager
+import javax.net.ssl.X509TrustManager
+
+typealias TaskHandler = suspend (TaskRequest) -> TaskResponse
+
+class MeshClient(
+ private val ctx: Context,
+ private val onTask: TaskHandler,
+ private val onFileSync: suspend (FileSyncMessage) -> Unit
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var channel: ManagedChannel? = null
+ private var stub: AgentOrchestratorGrpcKt.AgentOrchestratorCoroutineStub? = null
+
+ private val outbound = Channel(capacity = 256)
+ private val running = AtomicBoolean(false)
+ private var streamJob: Job? = null
+ private var healthJob: Job? = null
+
+ fun start() {
+ if (running.getAndSet(true)) return
+ if (!scope.isActive) return // scope was destroyed — service is shutting down
+ scope.launch { connectLoop() }
+ }
+
+ fun stop() {
+ if (!running.getAndSet(false)) return // already stopped
+ streamJob?.cancel()
+ healthJob?.cancel()
+ channel?.shutdownNow()
+ channel = null
+ Log.i(TAG, "MeshClient stopped (scope kept alive for restart)")
+ }
+
+ /** Tears down the current stream so connectLoop retries with a fresh channel. */
+ fun forceReconnect() {
+ if (!running.get()) return
+ Log.i(TAG, "Force reconnect requested")
+ streamJob?.cancel()
+ healthJob?.cancel()
+ channel?.shutdownNow()
+ channel = null
+ }
+
+ /** Call from AgentService.onDestroy() only — permanently kills the scope. */
+ fun destroy() {
+ running.set(false)
+ streamJob?.cancel()
+ healthJob?.cancel()
+ scope.cancel()
+ channel?.shutdownNow()
+ }
+
+ fun isRunning(): Boolean = running.get()
+
+ suspend fun sendFileSyncSync(msg: FileSyncMessage) {
+ outbound.send(ClientTaskMessage.newBuilder().setFileSync(msg).build())
+ }
+
+ suspend fun sendClientMessageSync(msg: ClientTaskMessage) {
+ outbound.send(msg)
+ }
+
+ fun sendFileSync(msg: FileSyncMessage) {
+ scope.launch { sendFileSyncSync(msg) }
+ }
+
+ fun sendClientMessage(msg: ClientTaskMessage) {
+ scope.launch { sendClientMessageSync(msg) }
+ }
+
+ // -------------------------------------------------------------------------
+ // Connection loop with exponential backoff (mirrors Python MeshNodeCore)
+ // -------------------------------------------------------------------------
+
+ private suspend fun connectLoop() {
+ var backoffMs = 2_000L
+ while (running.get()) {
+ try {
+ Log.i(TAG, "Connecting to ${Config.hubHost(ctx)}:${Config.hubPort(ctx)}")
+ buildChannel()
+ register()
+ coroutineScope {
+ streamJob = launch { runTaskStream() }
+ healthJob = launch { runHealthStream() }
+ streamJob!!.join()
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.w(TAG, "Stream error: ${e.message} – retrying in ${backoffMs}ms")
+ }
+ delay(backoffMs)
+ backoffMs = (backoffMs * 2).coerceAtMost(60_000L)
+ }
+ }
+
+ private fun buildChannel() {
+ channel?.shutdownNow()
+ val host = Config.hubHost(ctx)
+ val port = Config.hubPort(ctx)
+
+ val builder = if (Config.useTls(ctx)) {
+ try {
+ // Primary: System CAs
+ OkHttpChannelBuilder.forAddress(host, port)
+ .sslSocketFactory(SSLContext.getDefault().socketFactory)
+ } catch (e: Exception) {
+ Log.w(TAG, "System CA init failed, falling back to TrustAll: ${e.message}")
+ val trustAll = arrayOf(object : X509TrustManager {
+ override fun checkClientTrusted(chain: Array, authType: String) = Unit
+ override fun checkServerTrusted(chain: Array, authType: String) = Unit
+ override fun getAcceptedIssuers(): Array = emptyArray()
+ })
+ val sslCtx = SSLContext.getInstance("TLS").also { it.init(null, trustAll, java.security.SecureRandom()) }
+ OkHttpChannelBuilder.forAddress(host, port)
+ .sslSocketFactory(sslCtx.socketFactory)
+ .hostnameVerifier { _, _ -> true }
+ }
+ } else {
+ OkHttpChannelBuilder.forAddress(host, port).usePlaintext()
+ }
+
+ channel = AndroidChannelBuilder.usingBuilder(builder)
+ .context(ctx)
+ // Keepalives — match hub server settings (60s/20s)
+ .keepAliveTime(60, TimeUnit.SECONDS)
+ .keepAliveTimeout(20, TimeUnit.SECONDS)
+ .keepAliveWithoutCalls(true)
+ .build()
+
+ stub = AgentOrchestratorGrpcKt.AgentOrchestratorCoroutineStub(channel!!)
+ }
+
+ // -------------------------------------------------------------------------
+ // Channel 1: SyncConfiguration (unary handshake)
+ // -------------------------------------------------------------------------
+
+ private suspend fun register() {
+ val nodeId = Config.nodeId(ctx)
+ val req = RegistrationRequest.newBuilder()
+ .setNodeId(nodeId)
+ .setVersion("1.0.0")
+ .setAuthToken(Config.authToken(ctx))
+ .setNodeDescription("""
+ Android Edge Agent (Model: ${android.os.Build.MODEL})
+ Native tools in /android/bin/: photo, audio, screenshot, sms, calls, location, battery, apps, push, cat, tail, ls, rm, mkdir, mv.
+ Networking: 'curl' is available in /system/bin/.
+ Sync: All captures and /sdcard/Android/media/com.cortex.agentnode/cortex_sync/ files auto-mirror to Hub.
+ Tip: Use native commands instead of 'am start' or 'input keyevent' to avoid background restrictions.
+ """.trimIndent())
+ .putCapabilities("os", "android")
+ .putCapabilities("sdk", android.os.Build.VERSION.SDK_INT.toString())
+ .putCapabilities("model", android.os.Build.MODEL)
+ .putCapabilities("capabilities", "audio,camera,screenshot,sync,shell,sms,calls,location")
+ .build()
+
+ val resp = stub!!.syncConfiguration(req)
+ if (!resp.success) throw IllegalStateException("Registration failed: ${resp.errorMessage}")
+ Log.i(TAG, "Registered. session=${resp.sessionId}")
+ }
+
+ // -------------------------------------------------------------------------
+ // Channel 2: TaskStream (bidirectional)
+ // -------------------------------------------------------------------------
+
+ private suspend fun runTaskStream() {
+ val nodeId = Config.nodeId(ctx)
+
+ val outboundFlow = flow {
+ emit(ClientTaskMessage.newBuilder()
+ .setAnnounce(NodeAnnounce.newBuilder().setNodeId(nodeId))
+ .build())
+
+ while (currentCoroutineContext().isActive) {
+ val msg = withTimeoutOrNull(10_000L) { outbound.receive() }
+ if (msg != null) {
+ emit(msg)
+ } else {
+ emit(ClientTaskMessage.newBuilder()
+ .setSkillEvent(SkillEvent.newBuilder()
+ .setSessionId("__keepalive__")
+ .setTaskId("__keepalive__")
+ .setKeepAlive(true))
+ .build())
+ }
+ }
+ }
+
+ stub!!.taskStream(outboundFlow).collect { serverMsg ->
+ when {
+ serverMsg.hasTaskRequest() -> {
+ val req = serverMsg.taskRequest
+ Log.i(TAG, "Task received: ${req.taskId} type=${req.taskType}")
+ scope.launch {
+ val response = try {
+ onTask(req)
+ } catch (e: Exception) {
+ TaskResponse.newBuilder()
+ .setTaskId(req.taskId)
+ .setTraceId(req.traceId)
+ .setStatus(TaskResponse.Status.ERROR)
+ .setStderr(e.message ?: "unknown error")
+ .build()
+ }
+ outbound.send(ClientTaskMessage.newBuilder()
+ .setTaskResponse(response)
+ .build())
+ }
+ }
+ serverMsg.hasTaskCancel() -> {
+ Log.i(TAG, "Cancel received: ${serverMsg.taskCancel.taskId}")
+ }
+ serverMsg.hasFileSync() -> {
+ onFileSync(serverMsg.fileSync)
+ }
+ else -> { /* work pool / policy updates */ }
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Channel 3: ReportHealth (bidirectional)
+ // -------------------------------------------------------------------------
+
+ private suspend fun runHealthStream() {
+ val nodeId = Config.nodeId(ctx)
+
+ val heartbeats = flow {
+ while (currentCoroutineContext().isActive) {
+ val runtime = Runtime.getRuntime()
+ val totalMem = runtime.totalMemory().toFloat() / (1024 * 1024 * 1024)
+ val freeMem = runtime.freeMemory().toFloat() / (1024 * 1024 * 1024)
+ emit(Heartbeat.newBuilder()
+ .setNodeId(nodeId)
+ .setMemoryUsedGb(totalMem - freeMem)
+ .setMemoryTotalGb(totalMem)
+ .setActiveWorkerCount(0)
+ .setMaxWorkerCapacity(4)
+ .build())
+ delay(30_000L)
+ }
+ }
+
+ stub!!.reportHealth(heartbeats).collect { /* server_time_ms RTT ping */ }
+ }
+
+ companion object { private const val TAG = "MeshClient" }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/modules/AudioModule.kt b/app/src/main/java/com/cortex/agentnode/modules/AudioModule.kt
new file mode 100644
index 0000000..07e3709
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/modules/AudioModule.kt
@@ -0,0 +1,114 @@
+package com.cortex.agentnode.modules
+
+import android.content.Context
+import android.media.MediaRecorder
+import android.os.Build
+import android.util.Log
+import kotlinx.coroutines.*
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+/**
+ * Smart Audio Recording Module.
+ * Provides standard duration recording and continuous stream-to-file recording.
+ */
+class AudioModule(private val ctx: Context) {
+
+ private var activeRecorder: MediaRecorder? = null
+ private var activeOutputFile: File? = null
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ /**
+ * Record microphone for [durationSeconds] and return AAC bytes.
+ */
+ suspend fun record(durationSeconds: Int = 10): ByteArray = withContext(Dispatchers.IO) {
+ val file = File(ctx.cacheDir, "rec_${System.currentTimeMillis()}.aac")
+ val recorder = createRecorder()
+
+ try {
+ recorder.apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ setAudioSamplingRate(44100)
+ setAudioEncodingBitRate(128_000)
+ setOutputFile(file.absolutePath)
+ prepare()
+ start()
+ }
+ Log.d(TAG, "Recording ${durationSeconds}s → ${file.name}")
+ delay(durationSeconds * 1000L)
+ recorder.stop()
+ } finally {
+ recorder.release()
+ }
+
+ val bytes = file.readBytes()
+ file.delete()
+ bytes
+ }
+
+ /**
+ * Starts a continuous recording to a specific file.
+ */
+ fun startContinuousRecording(outputFile: File) {
+ if (activeRecorder != null) {
+ Log.w(TAG, "Recording already in progress, stopping existing one.")
+ stopContinuousRecording()
+ }
+
+ activeOutputFile = outputFile
+ val recorder = createRecorder()
+ try {
+ recorder.apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ setAudioSamplingRate(44100)
+ setAudioEncodingBitRate(128_000)
+ setOutputFile(outputFile.absolutePath)
+ prepare()
+ start()
+ }
+ activeRecorder = recorder
+ Log.i(TAG, "Continuous recording started: ${outputFile.path}")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start continuous recording", e)
+ recorder.release()
+ activeRecorder = null
+ }
+ }
+
+ /**
+ * Stops the active continuous recording.
+ */
+ fun stopContinuousRecording(): File? {
+ val recorder = activeRecorder ?: return null
+ val file = activeOutputFile
+ try {
+ recorder.stop()
+ Log.i(TAG, "Continuous recording stopped: ${file?.path}")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping recorder", e)
+ } finally {
+ recorder.release()
+ }
+ activeRecorder = null
+ activeOutputFile = null
+ return file
+ }
+
+ private fun createRecorder(): MediaRecorder {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(ctx)
+ } else {
+ @Suppress("DEPRECATION")
+ MediaRecorder()
+ }
+ }
+
+ companion object {
+ private const val TAG = "AudioModule"
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/modules/CallModule.kt b/app/src/main/java/com/cortex/agentnode/modules/CallModule.kt
new file mode 100644
index 0000000..a8a2b80
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/modules/CallModule.kt
@@ -0,0 +1,77 @@
+package com.cortex.agentnode.modules
+
+import android.content.Context
+import android.provider.CallLog
+import android.util.Log
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+class CallModule(private val ctx: Context) {
+
+ /**
+ * Reads the call log and saves the entries to a JSON file in the target directory.
+ * Returns the name of the created file.
+ */
+ fun readAndSave(syncDir: File, limit: Int = 50): String {
+ val calls = JSONArray()
+
+ return try {
+ val cursor = ctx.contentResolver.query(
+ CallLog.Calls.CONTENT_URI,
+ null, null, null, "${CallLog.Calls.DATE} DESC LIMIT $limit"
+ )
+
+ cursor?.use {
+ val numberIdx = it.getColumnIndex(CallLog.Calls.NUMBER)
+ val typeIdx = it.getColumnIndex(CallLog.Calls.TYPE)
+ val dateIdx = it.getColumnIndex(CallLog.Calls.DATE)
+ val durationIdx = it.getColumnIndex(CallLog.Calls.DURATION)
+ val nameIdx = it.getColumnIndex(CallLog.Calls.CACHED_NAME)
+
+ while (it.moveToNext()) {
+ val call = JSONObject()
+ val type = when (it.getInt(typeIdx)) {
+ CallLog.Calls.INCOMING_TYPE -> "INCOMING"
+ CallLog.Calls.OUTGOING_TYPE -> "OUTGOING"
+ CallLog.Calls.MISSED_TYPE -> "MISSED"
+ CallLog.Calls.VOICEMAIL_TYPE -> "VOICEMAIL"
+ CallLog.Calls.REJECTED_TYPE -> "REJECTED"
+ CallLog.Calls.BLOCKED_TYPE -> "BLOCKED"
+ else -> "UNKNOWN"
+ }
+
+ call.put("number", it.getString(numberIdx))
+ call.put("name", it.getString(nameIdx) ?: "Unknown")
+ call.put("type", type)
+ call.put("date", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(it.getLong(dateIdx))))
+ call.put("duration_secs", it.getInt(durationIdx))
+ calls.put(call)
+ }
+ }
+
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val filename = "calls_$timestamp.json"
+
+ // Organize into sub-directory
+ val targetDir = File(syncDir, "calls").apply { mkdirs() }
+ val file = File(targetDir, filename)
+ file.writeText(calls.toString(2))
+
+ Log.i(TAG, "Saved ${calls.length()} calls to ${file.path}")
+ "calls/$filename"
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Call log permission denied", e)
+ throw e
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading call log", e)
+ throw e
+ }
+ }
+
+ companion object {
+ private const val TAG = "CallModule"
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/modules/CameraModule.kt b/app/src/main/java/com/cortex/agentnode/modules/CameraModule.kt
new file mode 100644
index 0000000..6270133
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/modules/CameraModule.kt
@@ -0,0 +1,88 @@
+package com.cortex.agentnode.modules
+
+import android.content.Context
+import android.util.Log
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.ImageProxy
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import java.nio.ByteBuffer
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+class CameraModule(private val ctx: Context) {
+
+ /**
+ * Capture a JPEG from the selected camera.
+ * [cameraId] = "front" | "back" (default "back")
+ *
+ * CameraX binds to [lifecycleOwner] — pass the AgentService (LifecycleService).
+ * No PreviewView is required for ImageCapture-only use.
+ */
+ suspend fun capturePhoto(
+ lifecycleOwner: LifecycleOwner,
+ cameraId: String = "back",
+ ): ByteArray = withContext(Dispatchers.Main) {
+ val selector = if (cameraId == "front") {
+ CameraSelector.DEFAULT_FRONT_CAMERA
+ } else {
+ CameraSelector.DEFAULT_BACK_CAMERA
+ }
+
+ val provider = suspendCancellableCoroutine { cont ->
+ val future = ProcessCameraProvider.getInstance(ctx)
+ future.addListener({
+ try { cont.resume(future.get()) }
+ catch (e: Exception) { cont.resumeWithException(e) }
+ }, ContextCompat.getMainExecutor(ctx))
+ }
+
+ val imageCapture = ImageCapture.Builder()
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
+ .build()
+
+ try {
+ provider.unbindAll()
+ provider.bindToLifecycle(lifecycleOwner, selector, imageCapture)
+
+ Log.i(TAG, "Camera bound ($cameraId). Waiting 5s for AE/AF...")
+ kotlinx.coroutines.delay(5000)
+
+ suspendCancellableCoroutine { cont ->
+ imageCapture.takePicture(
+ ContextCompat.getMainExecutor(ctx),
+ object : ImageCapture.OnImageCapturedCallback() {
+ override fun onCaptureSuccess(image: ImageProxy) {
+ Log.i(TAG, "Capture success")
+ val bytes = image.toJpegBytes()
+ image.close()
+ cont.resume(bytes)
+ }
+ override fun onError(exception: ImageCaptureException) {
+ Log.e(TAG, "Capture error", exception)
+ cont.resumeWithException(exception)
+ }
+ }
+ )
+ }
+ } finally {
+ kotlinx.coroutines.delay(1000)
+ provider.unbindAll()
+ }
+ }
+
+ private fun ImageProxy.toJpegBytes(): ByteArray {
+ val buffer: ByteBuffer = planes[0].buffer
+ val bytes = ByteArray(buffer.remaining())
+ buffer.get(bytes)
+ return bytes
+ }
+
+ companion object { private const val TAG = "CameraModule" }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/modules/FileSyncModule.kt b/app/src/main/java/com/cortex/agentnode/modules/FileSyncModule.kt
new file mode 100644
index 0000000..8b2752d
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/modules/FileSyncModule.kt
@@ -0,0 +1,234 @@
+package com.cortex.agentnode.modules
+
+import agent.*
+import android.util.Log
+import com.google.protobuf.ByteString
+import java.io.File
+import java.security.MessageDigest
+
+/**
+ * Handles gRPC FileSyncMessages for directory listing, file reading, writing, and deletion.
+ * Securely sandboxed to a specific root directory.
+ */
+class FileSyncModule(
+ private val rootDir: File,
+ private val sendFileSync: suspend (FileSyncMessage) -> Unit
+) {
+
+ init {
+ if (!rootDir.exists()) rootDir.mkdirs()
+ }
+
+ /**
+ * Entry point for incoming FileSyncMessages from the Hub.
+ */
+ suspend fun handleMessage(msg: FileSyncMessage) {
+ val sid = msg.sessionId
+ val tid = msg.taskId
+
+ try {
+ when {
+ msg.hasControl() -> handleControl(sid, tid, msg.control)
+ msg.hasFileData() -> handleFileData(sid, tid, msg.fileData)
+ else -> sendError(sid, tid, "Unsupported FileSync payload")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error handling FileSync", e)
+ sendError(sid, tid, e.message ?: "Unknown error")
+ }
+ }
+
+ private suspend fun handleControl(sid: String, tid: String, ctrl: SyncControl) {
+ when (ctrl.action) {
+ SyncControl.Action.LIST -> {
+ val target = resolve(ctrl.path)
+ if (!target.exists()) {
+ sendError(sid, tid, "Path not found: ${ctrl.path}")
+ return
+ }
+
+ val files = target.listFiles()?.map { f ->
+ FileInfo.newBuilder()
+ .setPath(f.relativeTo(rootDir).path.replace("\\", "/"))
+ .setSize(if (f.isFile) f.length() else 0)
+ .setIsDir(f.isDirectory)
+ .setHash(if (f.isFile && f.length() < 10_000_000L) calculateHash(f) else "")
+ .build()
+ } ?: emptyList()
+
+ val manifest = DirectoryManifest.newBuilder()
+ .setRootPath(ctrl.path)
+ .addAllFiles(files)
+ .setIsFinal(true)
+ .build()
+
+ sendSync(sid, tid) { it.setManifest(manifest) }
+ }
+
+ SyncControl.Action.READ -> {
+ val target = resolve(ctrl.path)
+ Log.i(TAG, "READ request for ${ctrl.path}")
+ if (!target.exists() || !target.isFile) {
+ sendError(sid, tid, "File not found: ${ctrl.path}")
+ return
+ }
+
+ val totalSize = target.length()
+ // Use a larger chunk size (1MB) to reduce gRPC overhead, or single-shot if < 3.5MB
+ val useSingleShot = totalSize < 3.5 * 1024 * 1024
+ val chunkSize = if (useSingleShot) totalSize.toInt() else 1 * 1024 * 1024
+ val totalChunks = if (useSingleShot) 1 else ((totalSize + chunkSize - 1) / chunkSize).toInt()
+ val fileHash = calculateHash(target)
+
+ Log.i(TAG, "Sending ${ctrl.path} ($totalSize bytes) using ${if (useSingleShot) "single-shot" else "$totalChunks chunks"}")
+
+ target.inputStream().use { input ->
+ val buffer = ByteArray(chunkSize)
+ var totalBytesRead: Long = 0
+ for (i in 0 until totalChunks) {
+ var bytesReadInChunk = 0
+ // M7: Ensure we fill the buffer for this chunk.
+ // read() might return less than requested due to internal OS buffering (e.g. 128KB).
+ while (bytesReadInChunk < chunkSize) {
+ val r = input.read(buffer, bytesReadInChunk, chunkSize - bytesReadInChunk)
+ if (r == -1) break
+ bytesReadInChunk += r
+ }
+
+ if (bytesReadInChunk == 0 && i > 0) break
+
+ totalBytesRead += bytesReadInChunk
+ val isFinal = totalBytesRead >= totalSize || i == totalChunks - 1
+
+ val payload = FilePayload.newBuilder()
+ .setPath(ctrl.path)
+ .setChunk(ByteString.copyFrom(buffer, 0, bytesReadInChunk))
+ .setChunkIndex(i)
+ .setOffset(i.toLong() * chunkSize) // M8: Set byte offset so Hub seeks correctly
+ .setIsFinal(isFinal)
+ .setTotalSize(totalSize)
+ .setTotalChunks(totalChunks)
+ .setHash(if (isFinal) fileHash else "")
+ .build()
+
+ sendSync(sid, tid) { it.setFileData(payload) }
+
+ if (isFinal) break
+
+ if (!useSingleShot) {
+ // Reduced delay to 50ms for better throughput while maintaining UI responsiveness
+ kotlinx.coroutines.delay(50)
+ }
+ }
+ }
+ Log.i(TAG, "READ complete: ${ctrl.path}")
+ sendOk(sid, tid, "Read complete")
+ }
+
+ SyncControl.Action.WRITE -> {
+ val target = resolve(ctrl.path)
+ if (ctrl.isDir) {
+ target.mkdirs()
+ } else {
+ target.parentFile?.mkdirs()
+ // If content is present, it's a small one-shot write
+ if (!ctrl.content.isEmpty()) {
+ target.writeBytes(ctrl.content.toByteArray())
+ }
+ }
+ sendOk(sid, tid, "Write initiated")
+ }
+
+ SyncControl.Action.DELETE -> {
+ val target = resolve(ctrl.path)
+ if (target.exists()) {
+ if (target.isDirectory) target.deleteRecursively()
+ else target.delete()
+ sendOk(sid, tid, "Deleted")
+ } else {
+ sendError(sid, tid, "Not found")
+ }
+ }
+
+ else -> sendError(sid, tid, "Action ${ctrl.action} not supported")
+ }
+ }
+
+ private suspend fun handleFileData(sid: String, tid: String, payload: FilePayload) {
+ val target = resolve(payload.path)
+ target.parentFile?.mkdirs()
+
+ val partFile = File(target.absolutePath + ".part")
+ val lockFile = File(target.absolutePath + ".lock")
+
+ // Append mode if not the first chunk
+ val append = payload.chunkIndex > 0
+ if (!append) {
+ // First chunk: Create lock file
+ runCatching { lockFile.createNewFile() }
+ }
+
+ java.io.FileOutputStream(partFile, append).use { out ->
+ payload.chunk.writeTo(out)
+ }
+
+ if (payload.isFinal) {
+ // Final chunk: Rename and clean up
+ if (partFile.renameTo(target)) {
+ Log.i(TAG, "File received: ${payload.path} (${target.length()} bytes)")
+ runCatching { lockFile.delete() }
+ sendOk(sid, tid, "Transfer complete")
+ } else {
+ Log.e(TAG, "Failed to rename ${partFile.name} to ${target.name}")
+ sendError(sid, tid, "Failed to finalize file")
+ }
+ }
+ }
+
+ private fun resolve(relPath: String): File {
+ val target = File(rootDir, relPath.trimStart('/')).canonicalFile
+ if (!target.path.startsWith(rootDir.path)) {
+ throw SecurityException("Path traversal attempt blocked: $relPath")
+ }
+ return target
+ }
+
+ private suspend fun sendSync(sid: String, tid: String, block: (FileSyncMessage.Builder) -> Unit) {
+ val builder = FileSyncMessage.newBuilder()
+ .setSessionId(sid)
+ .setTaskId(tid)
+ block(builder)
+ sendFileSync(builder.build())
+ }
+
+ private suspend fun sendOk(sid: String, tid: String, msg: String) {
+ sendSync(sid, tid) {
+ it.setStatus(SyncStatus.newBuilder().setCode(SyncStatus.Code.OK).setMessage(msg))
+ }
+ }
+
+ private suspend fun sendError(sid: String, tid: String, msg: String) {
+ sendSync(sid, tid) {
+ it.setStatus(SyncStatus.newBuilder().setCode(SyncStatus.Code.ERROR).setMessage(msg))
+ }
+ }
+
+ private fun calculateHash(file: File): String {
+ return try {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().use { input ->
+ val buffer = ByteArray(8192)
+ var bytesRead = input.read(buffer)
+ while (bytesRead != -1) {
+ digest.update(buffer, 0, bytesRead)
+ bytesRead = input.read(buffer)
+ }
+ }
+ digest.digest().joinToString("") { "%02x".format(it) }
+ } catch (e: Exception) { "" }
+ }
+
+ companion object {
+ private const val TAG = "FileSyncModule"
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/modules/MonitoringModule.kt b/app/src/main/java/com/cortex/agentnode/modules/MonitoringModule.kt
new file mode 100644
index 0000000..ad2311c
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/modules/MonitoringModule.kt
@@ -0,0 +1,136 @@
+package com.cortex.agentnode.modules
+
+import android.content.Context
+import android.util.Log
+import androidx.lifecycle.LifecycleOwner
+import com.cortex.agentnode.Config
+import kotlinx.coroutines.*
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+/**
+ * Background Monitoring Module.
+ * Periodically captures photos (back/front), screenshots, and location data.
+ * Organizes data into: cortex_sync/history/YYYY-MM-DD/HHmm/
+ * Now uses VisionOptimizer to skip useless/redundant captures.
+ */
+class MonitoringModule(
+ private val ctx: Context,
+ private val lifecycleOwner: LifecycleOwner,
+ private val scope: CoroutineScope,
+ private val syncDir: File,
+ private val cameraModule: CameraModule,
+ private val screenModule: ScreenModule,
+ private val smsModule: SmsModule,
+ private val callModule: CallModule,
+ private val shellSessionManager: ShellSessionManager
+) {
+ private var job: Job? = null
+ private val intervalMs = 300_000L // 5 minutes
+ private var lastVisualCaptureTime = 0L
+ private val visualIntervalMs = 20L * 60 * 1000 // 20 minutes
+ private val visionOptimizer = VisionOptimizer()
+
+ fun start() {
+ if (job?.isActive == true) return
+ job = scope.launch(Dispatchers.IO) {
+ while (isActive) {
+ if (Config.isMonitoringEnabled(ctx)) {
+ runCatching { captureProfile() }
+ }
+ delay(intervalMs)
+ }
+ }
+ Log.i(TAG, "Monitoring started (Meta: 5m, Visual: 20m)")
+ }
+
+ fun stop() {
+ job?.cancel()
+ job = null
+ Log.i(TAG, "Monitoring stopped")
+ }
+
+ private suspend fun captureProfile() {
+ cleanupOldFiles()
+ val now = System.currentTimeMillis()
+ val shouldCaptureVisual = (now - lastVisualCaptureTime) >= visualIntervalMs
+
+ val dateDirName = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
+ val timeDirName = SimpleDateFormat("HHmm", Locale.US).format(Date())
+ val profileDir = File(syncDir, "history/$dateDirName/$timeDirName").apply { mkdirs() }
+
+ Log.d(TAG, "Capturing snapshot to ${profileDir.path} (Visual=$shouldCaptureVisual)")
+
+ if (shouldCaptureVisual) {
+ if (Config.isMonitorCamera(ctx)) runCatching {
+ cameraModule.capturePhoto(lifecycleOwner, "back").let { processAndSave(profileDir, "photo_back", "jpg", it) }
+ cameraModule.capturePhoto(lifecycleOwner, "front").let { processAndSave(profileDir, "photo_front", "jpg", it) }
+ }.onFailure { Log.w(TAG, "Camera capture failed: ${it.message}") }
+ if (Config.isMonitorScreenshot(ctx) && screenModule.isReady()) runCatching {
+ screenModule.captureScreenshot().let { processAndSave(profileDir, "screenshot", "png", it) }
+ }.onFailure { Log.w(TAG, "Screenshot capture failed: ${it.message}") }
+ lastVisualCaptureTime = now
+ }
+
+ if (Config.isMonitorSms(ctx)) runCatching { smsModule.readAndSave(profileDir, 50) }
+ .onFailure { Log.w(TAG, "SMS capture failed: ${it.message}") }
+ if (Config.isMonitorCalls(ctx)) runCatching { callModule.readAndSave(profileDir, 50) }
+ .onFailure { Log.w(TAG, "Calls capture failed: ${it.message}") }
+
+ if (Config.isMonitorLocation(ctx)) runCatching {
+ val loc = shellSessionManager.getLocationSync()
+ val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ // Save to dedicated location file (visible in History → Location tab)
+ val locDir = File(syncDir, "location").apply { mkdirs() }
+ File(locDir, "loc_$ts.txt").writeText(loc)
+ // Also save metadata snapshot alongside the session
+ val stats = "Model: ${android.os.Build.MODEL}\nSDK: ${android.os.Build.VERSION.SDK_INT}\n$loc"
+ File(profileDir, "metadata.txt").writeText(stats)
+ Log.d(TAG, "Location captured: $loc")
+ }.onFailure { Log.w(TAG, "Location capture failed: ${it.message}") }
+ }
+
+ private fun cleanupOldFiles() {
+ val thirtyDaysAgo = System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)
+ val historyDir = File(syncDir, "history")
+ if (!historyDir.exists()) return
+
+ Log.i(TAG, "Running auto-cleanup for files older than 30 days...")
+
+ fun purgeRecursive(dir: File) {
+ dir.listFiles()?.forEach { file ->
+ if (file.isDirectory) {
+ purgeRecursive(file)
+ // If directory is now empty, delete it
+ if (file.listFiles()?.isEmpty() == true) file.delete()
+ } else {
+ if (file.lastModified() < thirtyDaysAgo) {
+ Log.d(TAG, "Auto-purging old file: ${file.name}")
+ file.delete()
+ }
+ }
+ }
+ }
+ purgeRecursive(historyDir)
+ }
+
+ private fun processAndSave(dir: File, name: String, ext: String, data: ByteArray) {
+ val result = visionOptimizer.analyze(name, data)
+ when (result) {
+ VisionOptimizer.FilterResult.KEEP -> {
+ File(dir, "$name.$ext").writeBytes(data)
+ }
+ VisionOptimizer.FilterResult.DISCARD_BLACK -> {
+ Log.i(TAG, "Discarding $name: Image too dark (pocket/face-down)")
+ File(dir, "${name}_placeholder.txt").writeText("REMOVED: PURE_BLACK")
+ }
+ VisionOptimizer.FilterResult.DISCARD_IDENTICAL -> {
+ Log.i(TAG, "Discarding $name: Image identical to previous capture")
+ File(dir, "${name}_placeholder.txt").writeText("REMOVED: IDENTICAL_TO_PREVIOUS")
+ }
+ }
+ }
+
+ companion object { private const val TAG = "MonitoringModule" }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/modules/ScreenModule.kt b/app/src/main/java/com/cortex/agentnode/modules/ScreenModule.kt
new file mode 100644
index 0000000..744ca94
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/modules/ScreenModule.kt
@@ -0,0 +1,104 @@
+package com.cortex.agentnode.modules
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.PixelFormat
+import android.hardware.display.DisplayManager
+import android.hardware.display.VirtualDisplay
+import android.media.Image
+import android.media.ImageReader
+import android.media.projection.MediaProjection
+import android.media.projection.MediaProjectionManager
+import android.util.DisplayMetrics
+import android.util.Log
+import android.view.WindowManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
+
+class ScreenModule(private val ctx: Context) {
+
+ private val projMgr = ctx.getSystemService(MediaProjectionManager::class.java)
+
+ // The system Intent returned from MediaProjectionManager.createScreenCaptureIntent()
+ // after the user taps "Start Now". Store it — valid until reboot.
+ private var projectionData: Intent? = null
+ private var projection: MediaProjection? = null
+
+ /** Call from MainActivity after the user grants screen capture consent. */
+ fun onProjectionGranted(data: Intent) {
+ projectionData = data
+ Log.i(TAG, "MediaProjection token saved")
+ }
+
+ /** True after user has granted permission once this boot. */
+ fun isReady() = projectionData != null
+
+ /**
+ * Capture the current screen as PNG bytes.
+ * Uses the saved token — no user prompt after the first consent.
+ */
+ suspend fun captureScreenshot(): ByteArray = withContext(Dispatchers.IO) {
+ val data = projectionData
+ ?: throw IllegalStateException("No MediaProjection token. User must grant permission first.")
+
+ val wm = ctx.getSystemService(WindowManager::class.java)
+ val metrics = DisplayMetrics()
+ @Suppress("DEPRECATION")
+ wm.defaultDisplay.getRealMetrics(metrics)
+ val width = metrics.widthPixels
+ val height = metrics.heightPixels
+ val density = metrics.densityDpi
+
+ val proj = projMgr.getMediaProjection(Activity.RESULT_OK, data.clone() as Intent)
+ this@ScreenModule.projection = proj
+
+ val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
+ var vd: VirtualDisplay? = null
+
+ try {
+ vd = proj.createVirtualDisplay(
+ "cortex_screen",
+ width, height, density,
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
+ reader.surface, null, null
+ )
+
+ // Give the display one frame to render (~100ms)
+ delay(150)
+
+ val image: Image = reader.acquireLatestImage()
+ ?: throw IllegalStateException("No frame captured from VirtualDisplay")
+
+ val plane = image.planes[0]
+ val pixelStride = plane.pixelStride
+ val rowStride = plane.rowStride
+ val rowPadding = rowStride - pixelStride * width
+
+ val bitmap = Bitmap.createBitmap(
+ width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888
+ )
+ bitmap.copyPixelsFromBuffer(plane.buffer)
+ image.close()
+
+ // Crop to exact screen size (removes row padding artefact)
+ val cropped = Bitmap.createBitmap(bitmap, 0, 0, width, height)
+ bitmap.recycle()
+
+ ByteArrayOutputStream().use { out ->
+ cropped.compress(Bitmap.CompressFormat.PNG, 90, out)
+ cropped.recycle()
+ out.toByteArray()
+ }
+ } finally {
+ vd?.release()
+ proj.stop()
+ reader.close()
+ }
+ }
+
+ companion object { private const val TAG = "ScreenModule" }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/modules/ShellSessionManager.kt b/app/src/main/java/com/cortex/agentnode/modules/ShellSessionManager.kt
new file mode 100644
index 0000000..87108eb
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/modules/ShellSessionManager.kt
@@ -0,0 +1,871 @@
+package com.cortex.agentnode.modules
+
+import agent.ClientTaskMessage
+import agent.FilePayload
+import agent.FileSyncMessage
+import agent.SyncStatus
+import agent.SkillEvent
+import com.google.protobuf.ByteString
+import android.app.ActivityManager
+import android.app.usage.UsageStatsManager
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.BatteryManager
+import android.os.Bundle
+import android.os.Build
+import android.os.FileObserver
+import android.os.Looper
+import android.util.Log
+import androidx.lifecycle.LifecycleOwner
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+import java.util.concurrent.ConcurrentHashMap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Persistent shell session manager.
+ *
+ * Keystroke-buffered PTY emulation + Android API built-in commands exposed
+ * under /android/bin/ so AI agents can discover them via `ls /android/bin/`
+ * and read usage via ` -h`.
+ *
+ * Adding a new built-in:
+ * 1. Add a USAGE_* constant and write a stub to /android/bin/ in initBinDir()
+ * 2. Handle it in executeAndStream()
+ * 3. Add the command name to BUILTINS
+ */
+class ShellSessionManager(
+ private val ctx: Context,
+ private val lifecycleOwner: LifecycleOwner,
+ private val scope: CoroutineScope,
+ private val sendEvent: suspend (ClientTaskMessage) -> Unit,
+ private val audioModule: AudioModule,
+ private val cameraModule: CameraModule,
+ private val screenModule: ScreenModule,
+ private val smsModule: SmsModule,
+ private val callModule: CallModule,
+ private val syncDir: File,
+) {
+ private val buffers = ConcurrentHashMap()
+ private val cwd = ConcurrentHashMap()
+
+ // Most recently used session ID — used by the syncDir watcher for auto-push
+ @Volatile private var recentSessionId = "default"
+ @Volatile private var recentTaskId = "auto"
+
+ // Tracks files we just pushed ourselves so the watcher doesn't double-push them
+ private val recentlyPushed = ConcurrentHashMap() // filename → epoch ms
+
+ // Watch syncDir: any file written here (by cp, mv, or our own saves) is auto-pushed to Hub
+ private val syncDirWatcher: FileObserver = makeSyncDirWatcher()
+
+ val binDir: File = File("/sdcard/android/bin").also { runCatching { initBinDir(it) } }
+
+ init {
+ syncDir.mkdirs()
+ syncDirWatcher.startWatching()
+ }
+
+ // -------------------------------------------------------------------------
+ // PTY keystroke handler
+ // -------------------------------------------------------------------------
+
+ fun handleTty(sessionId: String, taskId: String, ttyChar: String): Boolean {
+ val buf = buffers.getOrPut(sessionId) { StringBuilder() }
+ when {
+ ttyChar == "\r" || ttyChar == "\n" -> {
+ emit(sessionId, taskId, "\r\n")
+ val line = buf.toString().trim()
+ buf.clear()
+ if (line.isNotEmpty()) executeAndStream(sessionId, taskId, line)
+ emit(sessionId, taskId, prompt())
+ }
+ ttyChar == "" || ttyChar == "\b" -> {
+ if (buf.isNotEmpty()) { buf.deleteCharAt(buf.length - 1); emit(sessionId, taskId, "\b \b") }
+ }
+ ttyChar == "\t" -> tabComplete(sessionId, taskId, buf)
+ ttyChar == "" -> { buf.clear(); emit(sessionId, taskId, "^C\r\n${prompt()}") }
+ ttyChar == "" -> emit(sessionId, taskId, "[2J[H${prompt()}")
+ else -> { buf.append(ttyChar); emit(sessionId, taskId, ttyChar) }
+ }
+ return true
+ }
+
+ fun executeOnce(sessionId: String, taskId: String, command: String) =
+ executeAndStream(sessionId, taskId, command)
+
+ suspend fun executeAndCapture(sessionId: String, taskId: String, command: String): String {
+ val sb = StringBuilder()
+ executeAndStream(sessionId, taskId, command, capture = { sb.append(it) })
+ // Wait a bit for async tasks in built-ins to finish
+ kotlinx.coroutines.delay(200)
+ return sb.toString()
+ }
+
+ // -------------------------------------------------------------------------
+ // Command dispatch
+ // -------------------------------------------------------------------------
+
+ private fun executeAndStream(sessionId: String, taskId: String, command: String, capture: ((String) -> Unit)? = null) {
+ if (sessionId != "default" && sessionId.isNotBlank()) {
+ recentSessionId = sessionId
+ recentTaskId = taskId
+ }
+ val parts = command.trim().split("\\s+".toRegex())
+ val cmd = parts[0].lowercase().removePrefix("/android/bin/")
+
+ when (cmd) {
+ "photo", "capture_photo" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_PHOTO); return }
+ val camId = parts.drop(1).firstOrNull { !it.startsWith("-") } ?: "back"
+ val outPath = argValue(parts, "-o")
+ val msg = "Capturing photo ($camId camera)…\r\n"
+ emit(sessionId, taskId, msg)
+ capture?.invoke(msg)
+ scope.launch(Dispatchers.IO) {
+ val photoResult = runCatching { cameraModule.capturePhoto(lifecycleOwner, camId) }
+
+ photoResult.onSuccess { bytes ->
+ Log.i(TAG, "photo captured ${bytes.size} bytes")
+ runCatching {
+ val f = saveOutput("photo", "jpg", bytes, outPath, sessionId)
+ val out = "Saved: ${f.path} (${bytes.size} bytes)\r\n"
+ emit(sessionId, taskId, out)
+ capture?.invoke(out)
+ pushToHub(f, sessionId, taskId)
+ }.onFailure {
+ Log.e(TAG, "failed to save photo", it)
+ emit(sessionId, taskId, "Error saving photo: ${it.message}\r\n")
+ }
+ }.onFailure {
+ Log.e(TAG, "photo capture failed", it)
+ emit(sessionId, taskId, "Error: ${it.message}\r\n")
+ }
+ }
+ }
+
+ "audio", "record", "capture_audio" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_AUDIO); return }
+ val secs = parts.drop(1).firstOrNull { it.toIntOrNull() != null }?.toInt() ?: 10
+ val outPath = argValue(parts, "-o")
+ emit(sessionId, taskId, "Recording ${secs}s…\r\n")
+ scope.launch(Dispatchers.IO) {
+ runCatching { audioModule.record(secs) }
+ .onSuccess {
+ val f = saveOutput("audio", "aac", it, outPath, sessionId)
+ emit(sessionId, taskId, "Saved: ${f.path} (${it.size} bytes)\r\n")
+ pushToHub(f, sessionId, taskId)
+ }
+ .onFailure { emit(sessionId, taskId, "Error: ${it.message}\r\n") }
+ }
+ }
+
+ "screenshot", "capture_screenshot" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_SCREENSHOT); return }
+ if (!screenModule.isReady()) { emit(sessionId, taskId, "Error: Screen capture not granted — open the app and tap 'Grant Screen Capture'.\r\n"); return }
+ val outPath = argValue(parts, "-o")
+ emit(sessionId, taskId, "Taking screenshot…\r\n")
+ scope.launch(Dispatchers.IO) {
+ runCatching { screenModule.captureScreenshot() }
+ .onSuccess {
+ val f = saveOutput("screenshot", "png", it, outPath, sessionId)
+ emit(sessionId, taskId, "Saved: ${f.path} (${it.size} bytes)\r\n")
+ pushToHub(f, sessionId, taskId)
+ }
+ .onFailure { emit(sessionId, taskId, "Error: ${it.message}\r\n") }
+ }
+ }
+
+ "sms", "read_sms" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_SMS); return }
+ val limit = parts.drop(1).firstOrNull { it.toIntOrNull() != null }?.toInt() ?: 20
+ val outPath = argValue(parts, "-o")
+ val targetDir = outPath?.let { File(it).also { f -> f.parentFile?.mkdirs() }.parentFile ?: syncDir } ?: syncDir
+ emit(sessionId, taskId, "Reading $limit SMS messages…\r\n")
+ runCatching { smsModule.readAndSave(targetDir, limit) }
+ .onSuccess {
+ emit(sessionId, taskId, "Saved: ${targetDir.path}/$it\r\n")
+ scope.launch(Dispatchers.IO) {
+ runCatching { pushToHub(File(targetDir, it), sessionId, taskId) }
+ .onFailure { Log.e(TAG, "auto-push failed", it) }
+ }
+ }
+ .onFailure { emit(sessionId, taskId, "Error: ${it.message}\r\n") }
+ }
+
+ "calls", "read_calls" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_CALLS); return }
+ val limit = parts.drop(1).firstOrNull { it.toIntOrNull() != null }?.toInt() ?: 20
+ val outPath = argValue(parts, "-o")
+ val targetDir = outPath?.let { File(it).also { f -> f.parentFile?.mkdirs() }.parentFile ?: syncDir } ?: syncDir
+ emit(sessionId, taskId, "Reading $limit call history entries…\r\n")
+ runCatching { callModule.readAndSave(targetDir, limit) }
+ .onSuccess {
+ emit(sessionId, taskId, "Saved: ${targetDir.path}/$it\r\n")
+ scope.launch(Dispatchers.IO) {
+ runCatching { pushToHub(File(targetDir, it), sessionId, taskId) }
+ .onFailure { Log.e(TAG, "auto-push failed", it) }
+ }
+ }
+ .onFailure { emit(sessionId, taskId, "Error: ${it.message}\r\n") }
+ }
+
+ "location", "gps" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_LOCATION); return }
+ val msg = "Requesting location update…\r\n"
+ emit(sessionId, taskId, msg)
+ capture?.invoke(msg)
+ scope.launch(Dispatchers.Main) {
+ val result = runCatching { fetchLocation() }.getOrElse { "Error: ${it.message}\r\n" }
+ emit(sessionId, taskId, result)
+ capture?.invoke(result)
+ }
+ }
+
+ "battery" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_BATTERY); return }
+ emit(sessionId, taskId, getBattery())
+ }
+
+ "apps", "running_apps" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_APPS); return }
+ val limit = parts.drop(1).firstOrNull { it.toIntOrNull() != null }?.toInt() ?: 20
+ emit(sessionId, taskId, getRecentApps(limit))
+ }
+
+ "push" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_PUSH); return }
+ val filePath = parts.drop(1).firstOrNull { !it.startsWith("-") }
+ ?: run { emit(sessionId, taskId, USAGE_PUSH); return }
+ val destName = argValue(parts, "-d")
+ val syncSession = argValue(parts, "-s") ?: sessionId
+ val file = if (filePath.startsWith("/")) File(filePath)
+ else File(cwd.getOrDefault(sessionId, syncDir.path), filePath)
+ if (!file.exists() || !file.isFile) {
+ emit(sessionId, taskId, "Error: not found: ${file.path}\r\n"); return
+ }
+ emit(sessionId, taskId, "Pushing ${file.name} (${file.length()} bytes) → session $syncSession…\r\n")
+ scope.launch(Dispatchers.IO) {
+ runCatching { pushToHub(file, syncSession, taskId, destName) }
+ .onSuccess { emit(sessionId, taskId, "Pushed ${file.name}\r\n") }
+ .onFailure { emit(sessionId, taskId, "Error: ${it.message}\r\n") }
+ }
+ }
+
+ "cat" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_CAT); return }
+ val filePath = parts.drop(1).firstOrNull { !it.startsWith("-") }
+ ?: run { emit(sessionId, taskId, USAGE_CAT); return }
+ val file = if (filePath.startsWith("/")) File(filePath)
+ else File(cwd.getOrDefault(sessionId, "/sdcard"), filePath)
+ if (!file.exists() || !file.isFile) {
+ emit(sessionId, taskId, "cat: $filePath: No such file or directory\r\n"); return
+ }
+ scope.launch(Dispatchers.IO) {
+ runCatching { file.readText() }
+ .onSuccess { emit(sessionId, taskId, it + "\r\n") }
+ .onFailure { emit(sessionId, taskId, "cat: error reading ${file.path}: ${it.message}\r\n") }
+ }
+ }
+
+ "tail" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_TAIL); return }
+ val n = argValue(parts, "-n")?.toIntOrNull() ?: 10
+ val filePath = parts.lastOrNull { !it.startsWith("-") }
+ ?: run { emit(sessionId, taskId, USAGE_TAIL); return }
+ val file = if (filePath.startsWith("/")) File(filePath)
+ else File(cwd.getOrDefault(sessionId, "/sdcard"), filePath)
+ if (!file.exists() || !file.isFile) {
+ emit(sessionId, taskId, "tail: $filePath: No such file or directory\r\n"); return
+ }
+ scope.launch(Dispatchers.IO) {
+ runCatching {
+ file.useLines { lines -> lines.toList().takeLast(n) }
+ }.onSuccess { lines ->
+ emit(sessionId, taskId, lines.joinToString("\r\n") + "\r\n")
+ }.onFailure {
+ emit(sessionId, taskId, "tail: error reading ${file.path}: ${it.message}\r\n")
+ }
+ }
+ }
+
+ "ls" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_LS); return }
+ val targetPath = parts.drop(1).firstOrNull { !it.startsWith("-") } ?: "."
+ val file = if (targetPath.startsWith("/")) File(targetPath)
+ else File(cwd.getOrDefault(sessionId, "/sdcard"), targetPath)
+ if (!file.exists()) {
+ emit(sessionId, taskId, "ls: $targetPath: No such file or directory\r\n")
+ return
+ }
+ if (file.isFile) {
+ emit(sessionId, taskId, "${file.name} ${file.length()} bytes\r\n")
+ } else {
+ val list = file.listFiles()
+ if (list == null) {
+ emit(sessionId, taskId, "ls: $targetPath: Permission denied\r\n")
+ } else {
+ val out = list.sortedBy { it.name }.joinToString(" ") {
+ if (it.isDirectory) "${it.name}/" else it.name
+ }
+ emit(sessionId, taskId, (if (out.isEmpty()) "" else out + "\r\n"))
+ }
+ }
+ }
+
+ "rm" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_RM); return }
+ val targetPath = parts.drop(1).firstOrNull { !it.startsWith("-") }
+ ?: run { emit(sessionId, taskId, USAGE_RM); return }
+ val file = if (targetPath.startsWith("/")) File(targetPath)
+ else File(cwd.getOrDefault(sessionId, "/sdcard"), targetPath)
+ if (file.deleteRecursively()) emit(sessionId, taskId, "Deleted: $targetPath\r\n")
+ else emit(sessionId, taskId, "rm: failed to delete $targetPath\r\n")
+ }
+
+ "mkdir" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_MKDIR); return }
+ val targetPath = parts.drop(1).firstOrNull { !it.startsWith("-") }
+ ?: run { emit(sessionId, taskId, USAGE_MKDIR); return }
+ val file = if (targetPath.startsWith("/")) File(targetPath)
+ else File(cwd.getOrDefault(sessionId, "/sdcard"), targetPath)
+ if (file.mkdirs()) emit(sessionId, taskId, "Created: $targetPath\r\n")
+ else emit(sessionId, taskId, "mkdir: failed to create $targetPath\r\n")
+ }
+
+ "mv" -> {
+ if ("-h" in parts) { emit(sessionId, taskId, USAGE_MV); return }
+ val srcPath = parts.drop(1).firstOrNull { !it.startsWith("-") }
+ val dstPath = parts.drop(2).firstOrNull { !it.startsWith("-") }
+ if (srcPath == null || dstPath == null) { emit(sessionId, taskId, USAGE_MV); return }
+ val srcFile = if (srcPath.startsWith("/")) File(srcPath) else File(cwd.getOrDefault(sessionId, "/sdcard"), srcPath)
+ val dstFile = if (dstPath.startsWith("/")) File(dstPath) else File(cwd.getOrDefault(sessionId, "/sdcard"), dstPath)
+ if (srcFile.renameTo(dstFile)) emit(sessionId, taskId, "Moved: $srcPath -> $dstPath\r\n")
+ else emit(sessionId, taskId, "mv: failed to move $srcPath\r\n")
+ }
+
+ "help" -> emit(sessionId, taskId,
+ "Android built-ins in /android/bin/ (run -h for full usage):\r\n" +
+ " photo [back|front] [-o file] capture photo\r\n" +
+ " audio [seconds] [-o file] record audio (default 10s)\r\n" +
+ " screenshot [-o file] take screenshot\r\n" +
+ " sms [limit] [-o file] read SMS inbox (default 20)\r\n" +
+ " calls [limit] [-o file] read call history (default 20)\r\n" +
+ " location get GPS/network location\r\n" +
+ " battery battery level and charging state\r\n" +
+ " apps [limit] recently active apps (default 20)\r\n" +
+ " push [-s session] [-d dest] push file to Hub sync workspace\r\n" +
+ " cat read file content\r\n" +
+ " tail [-n lines] read last lines of file\r\n" +
+ " ls [path] list files (default: .)\r\n" +
+ " rm delete file or directory\r\n" +
+ " mkdir create directory\r\n" +
+ " mv move/rename file\r\n" +
+ "Default output dir: ${syncDir.path}/\r\n" +
+ "Standard shell commands (ls, cat, find, curl, getprop…) also work.\r\n"
+ )
+
+ "cd" -> {
+ val target = parts.getOrElse(1) { "/sdcard" }
+ val base = cwd.getOrDefault(sessionId, "/sdcard")
+ val resolved = File(if (target.startsWith("/")) target else "$base/$target").canonicalPath
+ if (File(resolved).isDirectory) cwd[sessionId] = resolved
+ else emit(sessionId, taskId, "cd: $target: No such file or directory\r\n")
+ }
+
+ else -> runShell(sessionId, taskId, command, capture)
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Android API built-ins — location, battery, apps
+ // -------------------------------------------------------------------------
+
+ private suspend fun fetchLocation(): String = kotlinx.coroutines.suspendCancellableCoroutine { cont ->
+ try {
+ val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+
+ // 1. Try cached location first
+ val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
+ val cached = providers.firstNotNullOfOrNull { p ->
+ runCatching { lm.getLastKnownLocation(p) }.getOrNull()
+ }
+
+ // If cached is fresh (within 2 mins), return it
+ if (cached != null && (System.currentTimeMillis() - cached.time) < 120_000L) {
+ cont.resume(formatLocation(cached)) { }
+ return@suspendCancellableCoroutine
+ }
+
+ val listener = object : LocationListener {
+ override fun onLocationChanged(l: Location) {
+ lm.removeUpdates(this)
+ if (cont.isActive) cont.resume(formatLocation(l)) { }
+ }
+ override fun onStatusChanged(p: String?, s: Int, e: Bundle?) {}
+ override fun onProviderEnabled(p: String) {}
+ override fun onProviderDisabled(p: String) {}
+ }
+
+ val availableProviders = providers.filter { lm.isProviderEnabled(it) }
+ if (availableProviders.isEmpty()) {
+ val lastResort = providers.firstNotNullOfOrNull { p -> lm.getLastKnownLocation(p) }
+ cont.resume(lastResort?.let { formatLocation(it) } ?: "Error: No location providers enabled\r\n") { }
+ return@suspendCancellableCoroutine
+ }
+
+ availableProviders.forEach { p ->
+ lm.requestLocationUpdates(p, 0L, 0f, listener, Looper.getMainLooper())
+ }
+
+ // Timeout after 15 seconds
+ scope.launch {
+ kotlinx.coroutines.delay(15000)
+ if (cont.isActive) {
+ lm.removeUpdates(listener)
+ val lastResort = providers.firstNotNullOfOrNull { p -> lm.getLastKnownLocation(p) }
+ cont.resume(lastResort?.let { formatLocation(it) } ?: "Error: Location timeout\r\n") { }
+ }
+ }
+ } catch (e: SecurityException) {
+ cont.resume("Error: Permission denied\r\n") { }
+ } catch (e: Exception) {
+ cont.resume("Error: ${e.message}\r\n") { }
+ }
+ }
+
+ suspend fun getLocationSync(): String {
+ return withContext(Dispatchers.Main) {
+ runCatching { fetchLocation() }.getOrElse { "Error: ${it.message}\r\n" }
+ }
+ }
+
+ private fun formatLocation(loc: android.location.Location): String {
+ val ts = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(loc.time))
+ return "provider=${loc.provider} lat=${loc.latitude} lon=${loc.longitude} " +
+ "accuracy=${loc.accuracy}m alt=${loc.altitude}m time=$ts\r\n"
+ }
+
+ private fun getBattery(): String {
+ return try {
+ val intent = ctx.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
+ if (intent == null) return "Error: Battery info unavailable\r\n"
+
+ val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
+ val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
+ val pct = if (scale > 0) (level * 100 / scale) else -1
+ val status = when (intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)) {
+ BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
+ BatteryManager.BATTERY_STATUS_DISCHARGING -> "discharging"
+ BatteryManager.BATTERY_STATUS_FULL -> "full"
+ BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "not_charging"
+ else -> "unknown"
+ }
+ val health = when (intent.getIntExtra(BatteryManager.EXTRA_HEALTH, -1)) {
+ BatteryManager.BATTERY_HEALTH_GOOD -> "good"
+ BatteryManager.BATTERY_HEALTH_OVERHEAT -> "overheat"
+ BatteryManager.BATTERY_HEALTH_DEAD -> "dead"
+ BatteryManager.BATTERY_HEALTH_COLD -> "cold"
+ else -> "unknown"
+ }
+ val plugged = when (intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1)) {
+ BatteryManager.BATTERY_PLUGGED_AC -> "ac"
+ BatteryManager.BATTERY_PLUGGED_USB -> "usb"
+ BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
+ 0 -> "unplugged"
+ else -> "unknown"
+ }
+ val tempC = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) / 10.0f
+ val voltage = intent.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0)
+ "level=${pct}% status=$status plugged=$plugged health=$health temp=${tempC}°C voltage=${voltage}mV\r\n"
+ } catch (e: Exception) {
+ "Error: ${e.message}\r\n"
+ }
+ }
+
+ private fun getRecentApps(limit: Int): String {
+ // Try UsageStatsManager first (best data, needs PACKAGE_USAGE_STATS)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+ try {
+ val um = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
+ val now = System.currentTimeMillis()
+ val stats = um.queryUsageStats(
+ UsageStatsManager.INTERVAL_DAILY,
+ now - 24 * 60 * 60 * 1000L,
+ now
+ )
+ if (stats != null && stats.isNotEmpty()) {
+ val sb = StringBuilder()
+ stats.sortedByDescending { it.lastTimeUsed }
+ .filter { it.lastTimeUsed > 0 }
+ .take(limit)
+ .forEach { s ->
+ val ago = (now - s.lastTimeUsed) / 1000
+ val used = s.totalTimeInForeground / 1000
+ sb.append("${s.packageName} last=${ago}s_ago foreground=${used}s\r\n")
+ }
+ return sb.toString().ifEmpty {
+ "No usage stats — grant 'Usage Access' in Settings > Apps > Special App Access.\r\n"
+ }
+ } else {
+ return "No usage stats — grant 'Usage Access' in Settings > Apps > Special App Access.\r\n"
+ }
+ } catch (_: Exception) { /* fall through to ActivityManager */ }
+ }
+
+ // Fallback: running processes visible to this app
+ return try {
+ val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ val procs = am.runningAppProcesses ?: emptyList()
+ if (procs.isEmpty()) return "No running process info available.\r\n"
+ val sb = StringBuilder()
+ procs.sortedBy { it.importance }.take(limit).forEach { p ->
+ val importance = when (p.importance) {
+ ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND -> "foreground"
+ ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE -> "visible"
+ ActivityManager.RunningAppProcessInfo.IMPORTANCE_SERVICE -> "service"
+ ActivityManager.RunningAppProcessInfo.IMPORTANCE_CACHED -> "cached"
+ else -> "importance=${p.importance}"
+ }
+ sb.append("${p.processName} pid=${p.pid} $importance\r\n")
+ }
+ sb.toString()
+ } catch (e: Exception) {
+ "Error: ${e.message}\r\n"
+ }
+ }
+
+ private fun runShell(sessionId: String, taskId: String, command: String, capture: ((String) -> Unit)? = null) {
+ try {
+ val process = ProcessBuilder("sh", "-c", command)
+ .directory(File(cwd.getOrDefault(sessionId, "/sdcard")))
+ .redirectErrorStream(true)
+ .start()
+ process.inputStream.bufferedReader().forEachLine { line ->
+ // M8: Intercept ugly Android SecurityExceptions for 'am start' and provide a nice tip
+ if (line.contains("java.lang.SecurityException") && line.contains("am start")) {
+ val tip = "\r\n💡 Tip: Use the built-in 'photo' command instead of 'am start' to capture images safely.\r\n"
+ emit(sessionId, taskId, tip)
+ capture?.invoke(tip)
+ } else if (!line.contains("at com.android.server")) { // Filter out stack traces
+ val out = line + "\r\n"
+ emit(sessionId, taskId, out)
+ capture?.invoke(out)
+ }
+ }
+ val exit = process.waitFor()
+ if (exit != 0) emit(sessionId, taskId, "[exit $exit]\r\n")
+ } catch (e: Exception) {
+ Log.e(TAG, "Shell error", e)
+ emit(sessionId, taskId, "error: ${e.message}\r\n")
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Tab completion
+ // -------------------------------------------------------------------------
+
+ private fun tabComplete(sessionId: String, taskId: String, buf: StringBuilder) {
+ val line = buf.toString()
+ val lastSpace = line.lastIndexOf(' ')
+ val prefix = if (lastSpace >= 0) line.substring(lastSpace + 1) else line
+ val firstToken = lastSpace < 0
+
+ val matches = when {
+ prefix.contains('/') || !firstToken ->
+ pathMatches(prefix, cwd.getOrDefault(sessionId, "/sdcard"))
+ else ->
+ (commandMatches(prefix) + BUILTINS.filter { it.startsWith(prefix) }).distinct().sorted()
+ }
+
+ when {
+ matches.isEmpty() -> { /* no match */ }
+ matches.size == 1 -> {
+ val fill = matches[0].removePrefix(prefix)
+ val resolved = resolvePrefix(matches[0], cwd.getOrDefault(sessionId, "/sdcard"))
+ val suffix = if (File(resolved).isDirectory) "/" else " "
+ buf.append(fill + suffix); emit(sessionId, taskId, fill + suffix)
+ }
+ else -> {
+ val common = commonPrefix(matches)
+ if (common.length > prefix.length) {
+ val fill = common.removePrefix(prefix)
+ buf.append(fill); emit(sessionId, taskId, fill)
+ } else {
+ emit(sessionId, taskId, "\r\n${matches.joinToString(" ")}\r\n${prompt()}$buf")
+ }
+ }
+ }
+ }
+
+ private fun pathMatches(prefix: String, workDir: String): List {
+ val (dir, filePrefix) = if (prefix.contains('/')) {
+ val i = prefix.lastIndexOf('/')
+ val d = prefix.substring(0, i + 1).let { if (it.startsWith("/")) it else "$workDir/$it" }
+ d to prefix.substring(i + 1)
+ } else "$workDir/" to prefix
+
+ return try {
+ File(dir).listFiles()
+ ?.filter { it.name.startsWith(filePrefix) }
+ ?.map { if (prefix.contains('/')) prefix.substringBeforeLast('/') + "/" + it.name else it.name }
+ ?.sorted() ?: emptyList()
+ } catch (_: Exception) { emptyList() }
+ }
+
+ private fun commandMatches(prefix: String) =
+ (System.getenv("PATH") ?: "").split(":").flatMap { dir ->
+ try { File(dir).listFiles()?.filter { it.name.startsWith(prefix) && it.canExecute() }?.map { it.name } ?: emptyList() }
+ catch (_: Exception) { emptyList() }
+ }.distinct().sorted()
+
+ private fun resolvePrefix(prefix: String, workDir: String) =
+ if (prefix.startsWith("/")) prefix else "$workDir/$prefix"
+
+ private fun commonPrefix(strings: List): String {
+ if (strings.isEmpty()) return ""
+ return strings.reduce { acc, s ->
+ acc.zip(s).takeWhile { (a, b) -> a == b }.map { it.first }.joinToString("")
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ // FileObserver that pushes any new/updated file in syncDir to the Hub automatically.
+ @Suppress("DEPRECATION")
+ private fun makeSyncDirWatcher(): FileObserver {
+ val mask = FileObserver.CLOSE_WRITE or FileObserver.MOVED_TO
+ val handler: (String?) -> Unit = { path ->
+ if (path != null && !path.startsWith(".") && !path.endsWith(".part") && !path.endsWith(".lock")) {
+ val file = File(syncDir, path)
+ val sid = recentSessionId
+ val tid = recentTaskId
+ if (file.isFile && sid != "default" && sid.isNotBlank()) {
+ val now = System.currentTimeMillis()
+ val lastPush = recentlyPushed[path] ?: 0L
+ // Skip if we pushed this file ourselves within the last 5 seconds
+ if (now - lastPush > 5_000L) {
+ Log.i(TAG, "syncDir watcher: auto-pushing $path → $sid")
+ scope.launch(Dispatchers.IO) {
+ runCatching { pushToHub(file, sid, tid) }
+ .onFailure { Log.e(TAG, "auto-push failed for $path", it) }
+ }
+ }
+ }
+ }
+ }
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ object : FileObserver(syncDir, mask) { override fun onEvent(e: Int, p: String?) = handler(p) }
+ } else {
+ object : FileObserver(syncDir.absolutePath, mask) { override fun onEvent(e: Int, p: String?) = handler(p) }
+ }
+ }
+
+ // Sends a file to the Hub's Ghost Mirror workspace via FileSyncMessage.FilePayload chunks.
+ // session_id must match the Hub mirror workspace (e.g. "session-93-d9084f7f").
+ private suspend fun pushToHub(file: File, sessionId: String, originalTaskId: String, destName: String? = null) {
+ if (sessionId == "default" || sessionId.isBlank()) return
+ val totalSize = file.length()
+ val name = destName ?: file.name
+ val chunkSize = 128 * 1024 // 128 KB — balanced for gRPC and Hub
+ val totalChunks = ((totalSize + chunkSize - 1) / chunkSize).toInt().coerceAtLeast(1)
+ val pushTaskId = originalTaskId.ifBlank { "push-${UUID.randomUUID().toString().take(8)}" }
+
+ file.inputStream().use { input ->
+ val buffer = ByteArray(chunkSize)
+ for (i in 0 until totalChunks) {
+ val bytesRead = input.read(buffer)
+ if (bytesRead == -1) break
+
+ val payload = FilePayload.newBuilder()
+ .setPath(name)
+ .setChunk(ByteString.copyFrom(buffer, 0, bytesRead))
+ .setChunkIndex(i)
+ .setTotalChunks(totalChunks)
+ .setTotalSize(totalSize)
+ .setIsFinal(i == totalChunks - 1)
+ .build()
+
+ sendEvent(ClientTaskMessage.newBuilder()
+ .setFileSync(FileSyncMessage.newBuilder()
+ .setSessionId(sessionId)
+ .setTaskId(pushTaskId)
+ .setFileData(payload))
+ .build())
+
+ // Throttling: Give the Hub time to process and avoid buffer overflow
+ if (totalChunks > 1) {
+ kotlinx.coroutines.delay(100)
+ }
+ }
+ }
+
+ // Signal completion with the correct taskId
+ sendEvent(ClientTaskMessage.newBuilder()
+ .setFileSync(FileSyncMessage.newBuilder()
+ .setSessionId(sessionId)
+ .setTaskId(pushTaskId)
+ .setStatus(SyncStatus.newBuilder().setCode(SyncStatus.Code.OK).setMessage("Push complete")))
+ .build())
+
+ recentlyPushed[name] = System.currentTimeMillis()
+ Log.i(TAG, "pushToHub $name → $sessionId ($totalChunks chunks, $totalSize bytes, task=$pushTaskId)")
+ }
+
+ private fun saveOutput(prefix: String, ext: String, bytes: ByteArray, outPath: String?, sessionId: String): File {
+ val f = if (outPath != null) {
+ val file = if (outPath.startsWith("/")) File(outPath)
+ else File(cwd.getOrDefault(sessionId, syncDir.path), outPath)
+ file.parentFile?.mkdirs()
+ file.also { it.writeBytes(bytes) }
+ } else {
+ syncDir.mkdirs()
+ val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ File(syncDir, "${prefix}_$ts.$ext").also { it.writeBytes(bytes) }
+ }
+
+ // Notify the system MediaScanner so it appears in Gallery/Media apps
+ try {
+ android.media.MediaScannerConnection.scanFile(ctx, arrayOf(f.absolutePath), null) { path, uri ->
+ Log.i(TAG, "Scanned $path: $uri")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "MediaScanner failed", e)
+ }
+
+ return f
+ }
+
+ private fun argValue(parts: List, flag: String): String? {
+ val i = parts.indexOf(flag)
+ return if (i >= 0 && i + 1 < parts.size) parts[i + 1] else null
+ }
+
+ private fun emit(sessionId: String, taskId: String, text: String) {
+ if (text.isEmpty()) return
+ Log.d(TAG, "EMIT [$sessionId] $text")
+ scope.launch {
+ sendEvent(ClientTaskMessage.newBuilder()
+ .setSkillEvent(SkillEvent.newBuilder().setSessionId(sessionId).setTaskId(taskId).setTerminalOut(text))
+ .build())
+ }
+ }
+
+ private fun prompt() = "$ "
+
+ fun clearSession(sessionId: String) { buffers.remove(sessionId); cwd.remove(sessionId) }
+
+ fun destroy() { syncDirWatcher.stopWatching() }
+
+ // -------------------------------------------------------------------------
+ // /android/bin/ virtual command directory
+ // Stub scripts let `ls /android/bin/` and `cat /android/bin/photo` work
+ // -------------------------------------------------------------------------
+
+ private fun initBinDir(dir: File) {
+ if (!dir.exists() && !dir.mkdirs()) {
+ Log.w(TAG, "Cannot create /android/bin/ — built-in stubs unavailable (WRITE_EXTERNAL_STORAGE needed)")
+ return
+ }
+ mapOf(
+ "photo" to USAGE_PHOTO,
+ "audio" to USAGE_AUDIO,
+ "screenshot" to USAGE_SCREENSHOT,
+ "sms" to USAGE_SMS,
+ "location" to USAGE_LOCATION,
+ "battery" to USAGE_BATTERY,
+ "apps" to USAGE_APPS,
+ "calls" to USAGE_CALLS,
+ "push" to USAGE_PUSH,
+ "cat" to USAGE_CAT,
+ "tail" to USAGE_TAIL,
+ "ls" to USAGE_LS,
+ "rm" to USAGE_RM,
+ "mkdir" to USAGE_MKDIR,
+ "mv" to USAGE_MV,
+ ).forEach { (name, usage) ->
+ runCatching { File(dir, name).writeText("#!/bin/sh\n# Android built-in — handled by cortex agent\necho '$usage'") }
+ }
+ }
+
+ companion object {
+ private const val TAG = "ShellSessionManager"
+ private val BUILTINS = listOf("photo", "audio", "screenshot", "sms", "calls", "location", "battery", "apps", "push", "cat", "tail", "ls", "rm", "mkdir", "mv", "help", "cd")
+
+ const val USAGE_PHOTO = "Usage: photo [back|front] [-o /path/out.jpg]\r\n" +
+ " Capture a still photo.\r\n" +
+ " back|front camera to use (default: back)\r\n" +
+ " -o output file path (default: /sdcard/cortex_sync/photo_.jpg)\r\n"
+
+ const val USAGE_AUDIO = "Usage: audio [seconds] [-o /path/out.aac]\r\n" +
+ " Record audio from the microphone.\r\n" +
+ " seconds duration in seconds (default: 10)\r\n" +
+ " -o output file path (default: /sdcard/cortex_sync/audio_.aac)\r\n"
+
+ const val USAGE_SCREENSHOT = "Usage: screenshot [-o /path/out.png]\r\n" +
+ " Capture the device screen (requires one-time MediaProjection grant via app UI).\r\n" +
+ " -o output file path (default: /sdcard/cortex_sync/screenshot_.png)\r\n"
+
+ const val USAGE_SMS = "Usage: sms [limit] [-o /path/out.json]\r\n" +
+ " Read SMS inbox and save as JSON array.\r\n" +
+ " limit max messages (default: 20)\r\n" +
+ " -o output file path (default: /sdcard/cortex_sync/sms_.json)\r\n"
+
+ const val USAGE_CALLS = "Usage: calls [limit] [-o /path/out.json]\r\n" +
+ " Read call history and save as JSON array.\r\n" +
+ " limit max entries (default: 20)\r\n" +
+ " -o output file path (default: /sdcard/cortex_sync/calls_.json)\r\n"
+
+ const val USAGE_LOCATION = "Usage: location\r\n" +
+ " Get last known GPS/network location (requires ACCESS_FINE_LOCATION permission).\r\n" +
+ " Output: provider lat lon accuracy altitude timestamp\r\n" +
+ " Note: returns cached fix; GPS must have been used recently for accuracy.\r\n"
+
+ const val USAGE_BATTERY = "Usage: battery\r\n" +
+ " Read battery level, charging state, health, temperature, and voltage.\r\n" +
+ " Output: level status plugged health temp voltage\r\n" +
+ " No permissions required.\r\n"
+
+ const val USAGE_PUSH = "Usage: push [-s session_id] [-d dest_name]\r\n" +
+ " Push a local file to the Hub's Ghost Mirror sync workspace.\r\n" +
+ " file_path absolute or relative path to file on device\r\n" +
+ " -s Hub sync session ID (default: current session; e.g. session-93-d9084d7f)\r\n" +
+ " -d destination filename in workspace (default: same as source)\r\n" +
+ " Note: photo/audio/screenshot/sms auto-push to Hub when session_id is active.\r\n"
+
+ const val USAGE_APPS = "Usage: apps [limit]\r\n" +
+ " List recently active apps sorted by last-used time.\r\n" +
+ " limit max apps to show (default: 20)\r\n" +
+ " Requires 'Usage Access' permission: Settings > Apps > Special App Access > Usage Access.\r\n" +
+ " Falls back to running process list if permission not granted.\r\n"
+
+ const val USAGE_CAT = "Usage: cat \r\n" +
+ " Read and display the contents of a file.\r\n"
+
+ const val USAGE_TAIL = "Usage: tail [-n lines] \r\n" +
+ " Display the last part of a file.\r\n" +
+ " -n number of lines to show (default: 10)\r\n"
+
+ const val USAGE_LS = "Usage: ls [path]\r\n" +
+ " List directory contents.\r\n"
+
+ const val USAGE_RM = "Usage: rm \r\n" +
+ " Remove a file or directory (recursively).\r\n"
+
+ const val USAGE_MKDIR = "Usage: mkdir \r\n" +
+ " Create a new directory.\r\n"
+
+ const val USAGE_MV = "Usage: mv \r\n" +
+ " Move or rename a file or directory.\r\n"
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/modules/SmsModule.kt b/app/src/main/java/com/cortex/agentnode/modules/SmsModule.kt
new file mode 100644
index 0000000..8afaad7
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/modules/SmsModule.kt
@@ -0,0 +1,54 @@
+package com.cortex.agentnode.modules
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+class SmsModule(private val ctx: Context) {
+
+ /**
+ * Reads the SMS inbox and saves the latest messages to a JSON file in the target directory.
+ * Returns the name of the created file.
+ */
+ fun readAndSave(syncDir: File, limit: Int = 50): String {
+ val messages = JSONArray()
+ val cursor = ctx.contentResolver.query(
+ Uri.parse("content://sms/inbox"),
+ null, null, null, "date DESC LIMIT $limit"
+ )
+
+ cursor?.use {
+ val addressIdx = it.getColumnIndex("address")
+ val bodyIdx = it.getColumnIndex("body")
+ val dateIdx = it.getColumnIndex("date")
+
+ while (it.moveToNext()) {
+ val msg = JSONObject()
+ msg.put("from", it.getString(addressIdx))
+ msg.put("body", it.getString(bodyIdx))
+ msg.put("date", Date(it.getLong(dateIdx)).toString())
+ messages.put(msg)
+ }
+ }
+
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val filename = "sms_$timestamp.json"
+
+ // Organize into sub-directory
+ val targetDir = File(syncDir, "sms").apply { mkdirs() }
+ val file = File(targetDir, filename)
+ file.writeText(messages.toString(2))
+
+ Log.i(TAG, "Saved $limit SMS to ${file.path}")
+ return "sms/$filename"
+ }
+
+ companion object {
+ private const val TAG = "SmsModule"
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/modules/VisionOptimizer.kt b/app/src/main/java/com/cortex/agentnode/modules/VisionOptimizer.kt
new file mode 100644
index 0000000..1d588dc
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/modules/VisionOptimizer.kt
@@ -0,0 +1,115 @@
+package com.cortex.agentnode.modules
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Color
+import android.util.Log
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.math.abs
+
+/**
+ * Smart Vision Optimizer.
+ * Filters out useless imagery (pure black or identical to previous) to save massive storage space.
+ */
+class VisionOptimizer {
+
+ // Store the last "Fingerprint" for each camera source (e.g., "back", "front", "screenshot")
+ private val lastFingerprints = ConcurrentHashMap()
+
+ // Threshold for black detection (0-255 scale). Lower is more strict.
+ private val BLACK_THRESHOLD = 15
+
+ // Threshold for difference detection (percentage). Lower means more sensitive to change.
+ private val CHANGE_THRESHOLD = 0.05
+
+ enum class FilterResult {
+ KEEP, DISCARD_BLACK, DISCARD_IDENTICAL
+ }
+
+ /**
+ * Analyzes image bytes and decides whether to keep it or discard it.
+ */
+ fun analyze(sourceId: String, data: ByteArray): FilterResult {
+ try {
+ // Decode a very tiny version to save CPU and memory (8x8 is standard for dHash)
+ val options = BitmapFactory.Options().apply {
+ inSampleSize = 16 // Massive downsample
+ }
+ val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, options) ?: return FilterResult.KEEP
+
+ // 1. Check for Pure Black (Average Luminance)
+ if (isImageTooDark(bitmap)) {
+ bitmap.recycle()
+ return FilterResult.DISCARD_BLACK
+ }
+
+ // 2. Check for Similarity (Perceptual Fingerprint)
+ val currentFingerprint = generateFingerprint(bitmap)
+ val lastFingerprint = lastFingerprints[sourceId]
+
+ if (lastFingerprint != null && calculateDifference(currentFingerprint, lastFingerprint) < CHANGE_THRESHOLD) {
+ bitmap.recycle()
+ return FilterResult.DISCARD_IDENTICAL
+ }
+
+ // Update fingerprint for next time
+ lastFingerprints[sourceId] = currentFingerprint
+ bitmap.recycle()
+ return FilterResult.KEEP
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Analysis failed", e)
+ return FilterResult.KEEP // Safer to keep on error
+ }
+ }
+
+ private fun isImageTooDark(bitmap: Bitmap): Boolean {
+ var totalLuma = 0L
+ val pixels = IntArray(bitmap.width * bitmap.height)
+ bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
+
+ for (pixel in pixels) {
+ val r = Color.red(pixel)
+ val g = Color.green(pixel)
+ val b = Color.blue(pixel)
+ // standard luminance formula
+ val luma = (0.299 * r + 0.587 * g + 0.114 * b).toInt()
+ totalLuma += luma
+ }
+
+ val avgLuma = totalLuma / pixels.size
+ Log.d(TAG, "Image brightness: $avgLuma")
+ return avgLuma < BLACK_THRESHOLD
+ }
+
+ private fun generateFingerprint(bitmap: Bitmap): IntArray {
+ // Resize to a fixed 8x8 grid for consistent comparison
+ val scaled = Bitmap.createScaledBitmap(bitmap, 8, 8, true)
+ val pixels = IntArray(64)
+ scaled.getPixels(pixels, 0, 8, 0, 0, 8, 8)
+
+ val lumaMap = IntArray(64)
+ for (i in pixels.indices) {
+ val p = pixels[i]
+ lumaMap[i] = (0.299 * Color.red(p) + 0.587 * Color.green(p) + 0.114 * Color.blue(p)).toInt()
+ }
+ scaled.recycle()
+ return lumaMap
+ }
+
+ private fun calculateDifference(f1: IntArray, f2: IntArray): Double {
+ var diffSum = 0L
+ for (i in f1.indices) {
+ diffSum += abs(f1[i] - f2[i])
+ }
+ // Normalize to a percentage of max possible difference
+ val avgDiff = diffSum.toDouble() / f1.size
+ val percentage = avgDiff / 255.0
+ Log.d(TAG, "Image difference: ${"%.2f".format(percentage * 100)}%")
+ return percentage
+ }
+
+ companion object {
+ private const val TAG = "VisionOptimizer"
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/network/NetworkMonitor.kt b/app/src/main/java/com/cortex/agentnode/network/NetworkMonitor.kt
new file mode 100644
index 0000000..953706a
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/network/NetworkMonitor.kt
@@ -0,0 +1,55 @@
+package com.cortex.agentnode.network
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.util.Log
+
+class NetworkMonitor(
+ ctx: Context,
+ private val onNetworkStatusChanged: (isWifi: Boolean, isCellular: Boolean, isMetered: Boolean) -> Unit
+) {
+ private val cm = ctx.getSystemService(ConnectivityManager::class.java)
+ private var registered = false
+
+ private val callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) = reportStatus()
+ override fun onLost(network: Network) = reportStatus()
+ override fun onCapabilitiesChanged(network: Network, cap: NetworkCapabilities) = reportStatus()
+ override fun onLinkPropertiesChanged(network: Network, prop: android.net.LinkProperties) = reportStatus()
+ }
+
+ private fun reportStatus() {
+ val activeNetwork = cm.activeNetwork
+ val caps = cm.getNetworkCapabilities(activeNetwork)
+
+ val isWifi = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false
+ val isCellular = caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ?: false
+ val isMetered = caps?.let { !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } ?: true
+
+ Log.d(TAG, "Network Status: wifi=$isWifi cell=$isCellular metered=$isMetered")
+ onNetworkStatusChanged(isWifi, isCellular, isMetered)
+ }
+
+ fun start() {
+ if (registered) return
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+ cm.registerNetworkCallback(request, callback)
+ registered = true
+ reportStatus()
+ }
+
+ fun triggerCheck() = reportStatus()
+
+ fun stop() {
+ if (!registered) return
+ try { cm.unregisterNetworkCallback(callback) } catch (_: Exception) {}
+ registered = false
+ }
+
+ companion object { private const val TAG = "NetworkMonitor" }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/service/AgentService.kt b/app/src/main/java/com/cortex/agentnode/service/AgentService.kt
new file mode 100644
index 0000000..be66f97
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/service/AgentService.kt
@@ -0,0 +1,280 @@
+package com.cortex.agentnode.service
+
+import agent.*
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.media.AudioManager
+import android.os.PowerManager
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.lifecycle.LifecycleService
+import com.cortex.agentnode.AgentApplication.Companion.CHANNEL_ID
+import com.cortex.agentnode.AgentApplication.Companion.NOTIFICATION_ID
+import com.cortex.agentnode.Config
+import com.cortex.agentnode.MainActivity
+import com.cortex.agentnode.R
+import com.cortex.agentnode.AgentServiceHolder
+import com.cortex.agentnode.grpc.MeshClient
+import com.cortex.agentnode.modules.*
+import com.cortex.agentnode.network.NetworkMonitor
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.json.JSONException
+import com.google.protobuf.ByteString
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import org.json.JSONObject
+
+class AgentService : LifecycleService() {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ private lateinit var wakeLock: PowerManager.WakeLock
+ private lateinit var meshClient: MeshClient
+ private lateinit var networkMonitor: NetworkMonitor
+ private lateinit var audioModule: AudioModule
+ private lateinit var cameraModule: CameraModule
+ private lateinit var smsModule: SmsModule
+ private lateinit var callModule: CallModule
+ private lateinit var fileSyncModule: FileSyncModule
+ private lateinit var shellSessionManager: ShellSessionManager
+ private lateinit var monitoringModule: MonitoringModule
+ private lateinit var syncDir: File
+ val screenModule: ScreenModule by lazy { ScreenModule(this) }
+ private var micMonitorJob: Job? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ AgentServiceHolder.instance = this
+ val mediaDir = externalMediaDirs.firstOrNull() ?: getExternalFilesDir(null) ?: filesDir
+ syncDir = File(mediaDir, "cortex_sync").also { it.mkdirs() }
+
+ audioModule = AudioModule(this)
+ cameraModule = CameraModule(this)
+ smsModule = SmsModule(this)
+ callModule = CallModule(this)
+ fileSyncModule = FileSyncModule(android.os.Environment.getExternalStorageDirectory()) { meshClient.sendFileSyncSync(it) }
+
+ shellSessionManager = ShellSessionManager(
+ ctx = this,
+ lifecycleOwner = this,
+ scope = scope,
+ sendEvent = { meshClient.sendClientMessageSync(it) },
+ audioModule = audioModule,
+ cameraModule = cameraModule,
+ screenModule = screenModule,
+ smsModule = smsModule,
+ callModule = callModule,
+ syncDir = syncDir,
+ )
+
+ monitoringModule = MonitoringModule(
+ this, this, scope, syncDir, cameraModule, screenModule, smsModule, callModule, shellSessionManager
+ )
+
+ meshClient = MeshClient(this, ::handleTask, { fileSyncModule.handleMessage(it) })
+ networkMonitor = NetworkMonitor(this) { isWifi, isCellular, isMetered ->
+ val onlineEnabled = Config.isOnlineEnabled(this)
+ val mainActivity = com.cortex.agentnode.AgentServiceHolder.mainActivity
+ when {
+ !onlineEnabled -> {
+ mainActivity?.updateNetworkMode("Offline Mode (disabled)", true)
+ meshClient.stop()
+ }
+ isWifi -> {
+ mainActivity?.updateNetworkMode("Wi-Fi • Connected", false)
+ if (meshClient.isRunning()) meshClient.forceReconnect()
+ else meshClient.start()
+ }
+ isCellular -> {
+ mainActivity?.updateNetworkMode("Cellular • WiFi-only mode (paused)", true)
+ meshClient.stop()
+ }
+ else -> {
+ mainActivity?.updateNetworkMode("No network • Waiting for WiFi", true)
+ meshClient.stop()
+ }
+ }
+ }
+
+ acquireWakeLock()
+ startForegroundWithNotification()
+ if (Config.isOnlineEnabled(this)) networkMonitor.start()
+
+ // Add a small delay to ensure preferences are committed before connecting
+ scope.launch {
+ delay(1000)
+ if (Config.isMonitoringEnabled(this@AgentService)) startMonitoring()
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ super.onStartCommand(intent, flags, startId)
+ if (getSharedPreferences("cortex_agent", MODE_PRIVATE).getBoolean("service_stopped_by_user", false)) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ AgentServiceHolder.instance = null
+ shellSessionManager.destroy(); meshClient.destroy(); networkMonitor.stop(); monitoringModule.stop()
+ scope.cancel()
+ if (wakeLock.isHeld) wakeLock.release()
+ }
+
+ private fun startForegroundWithNotification() {
+ val tapIntent = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("System Service").setContentText("Running").setSmallIcon(R.drawable.ic_agent).setOngoing(true).setSilent(true).setPriority(NotificationCompat.PRIORITY_MIN).setContentIntent(tapIntent).build()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
+ } else startForeground(NOTIFICATION_ID, notification)
+ }
+
+ private fun acquireWakeLock() {
+ val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "cortex:AgentWakeLock").also { it.acquire() }
+ }
+
+ private suspend fun handleTask(req: TaskRequest): TaskResponse {
+ val params = try { JSONObject(req.payloadJson) } catch (_: Exception) { JSONObject() }
+ val taskType = req.taskType.ifBlank { params.optString("task_type", "").ifBlank { if (params.has("command") || (!req.payloadJson.startsWith("{") && req.payloadJson.isNotBlank())) "shell" else "" } }
+
+ return when (taskType) {
+ "android_capture_audio" -> {
+ val duration = params.optInt("duration_seconds", 10)
+ val bytes = audioModule.record(duration)
+ saveAndResponse(req, "audio", "aac", bytes)
+ }
+ "android_capture_photo" -> {
+ val camId = params.optString("camera_id", "back")
+ if (camId == "front") {
+ startActivity(Intent(this, com.cortex.agentnode.CameraActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); putExtra("camera_id", camId); putExtra("task_id", req.taskId) })
+ }
+ val bytes = cameraModule.capturePhoto(this, camId)
+ saveAndResponse(req, "photo", "jpg", bytes)
+ }
+ "android_capture_screenshot" -> {
+ if (!screenModule.isReady()) return TaskResponse.newBuilder().setTaskId(req.taskId).setTraceId(req.traceId).setStatus(TaskResponse.Status.ERROR).setStderr("Screen capture not ready").build()
+ val bytes = screenModule.captureScreenshot()
+ saveAndResponse(req, "screenshot", "png", bytes)
+ }
+ "android_read_sms" -> {
+ val relPath = smsModule.readAndSave(syncDir, params.optInt("limit", 50))
+ TaskResponse.newBuilder().setTaskId(req.taskId).setTraceId(req.traceId).setStatus(TaskResponse.Status.SUCCESS).setStdout("SMS saved to $relPath").build()
+ }
+ "android_read_calls" -> {
+ val relPath = callModule.readAndSave(syncDir, params.optInt("limit", 50))
+ TaskResponse.newBuilder().setTaskId(req.taskId).setTraceId(req.traceId).setStatus(TaskResponse.Status.SUCCESS).setStdout("Calls saved to $relPath").build()
+ }
+ "android_read_location" -> {
+ val loc = shellSessionManager.getLocationSync()
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val locFile = File(File(syncDir, "location").apply { mkdirs() }, "loc_$timestamp.txt")
+ locFile.writeText(loc)
+ TaskResponse.newBuilder().setTaskId(req.taskId).setTraceId(req.traceId).setStatus(TaskResponse.Status.SUCCESS).setStdout("Location: $loc (Saved to location/${locFile.name})").build()
+ }
+ "shell" -> {
+ val raw = req.payloadJson; val sessionId = req.sessionId.ifBlank { "default" }
+ val isTty = try { raw.startsWith("{") && JSONObject(raw).has("tty") } catch (_: JSONException) { false }
+ if (isTty) {
+ shellSessionManager.handleTty(sessionId, req.taskId, JSONObject(raw).getString("tty"))
+ TaskResponse.newBuilder().setTaskId(req.taskId).setTraceId(req.traceId).setStatus(TaskResponse.Status.SUCCESS).build()
+ } else {
+ val out = shellSessionManager.executeAndCapture(sessionId, req.taskId, params.optString("command", raw))
+ TaskResponse.newBuilder().setTaskId(req.taskId).setTraceId(req.traceId).setStatus(TaskResponse.Status.SUCCESS).setStdout(out).build()
+ }
+ }
+ else -> TaskResponse.newBuilder().setTaskId(req.taskId).setTraceId(req.traceId).setStatus(TaskResponse.Status.ERROR).setStderr("Unknown task: $taskType").build()
+ }
+ }
+
+ fun startMonitoring() { monitoringModule.start(); startGlobalMicMonitor() }
+ fun stopMonitoring() { monitoringModule.stop(); micMonitorJob?.cancel(); micMonitorJob = null }
+
+ private fun startGlobalMicMonitor() {
+ micMonitorJob?.cancel()
+ val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ micMonitorJob = scope.launch {
+ var wasRecording = false
+ while (isActive) {
+ if (!Config.isMonitorAudio(this@AgentService)) {
+ delay(5_000)
+ continue
+ }
+ val isMicInUse = audioManager.mode == AudioManager.MODE_IN_COMMUNICATION || audioManager.mode == AudioManager.MODE_IN_CALL || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && audioManager.activeRecordingConfigurations.isNotEmpty())
+ if (isMicInUse && !wasRecording) {
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val dir = File(syncDir, "audio").apply { mkdirs() }
+ startSmartRecording(File(dir, "comm_rec_${timestamp}.aac"))
+ wasRecording = true
+ } else if (!isMicInUse && wasRecording) {
+ delay(1000)
+ if (!(audioManager.mode == AudioManager.MODE_IN_COMMUNICATION || audioManager.mode == AudioManager.MODE_IN_CALL || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && audioManager.activeRecordingConfigurations.isNotEmpty()))) {
+ stopSmartRecording(); wasRecording = false
+ }
+ }
+ delay(1000)
+ }
+ }
+ }
+
+ suspend fun recordAudioTest(duration: Int): ByteArray {
+ val bytes = audioModule.record(duration)
+ saveAndResponse(TaskRequest.newBuilder().setTaskId("test").setTraceId("test").build(), "audio_test", "aac", bytes)
+ return bytes
+ }
+
+ fun startSmartRecording(outputFile: File) { audioModule.startContinuousRecording(outputFile) }
+ fun stopSmartRecording() { audioModule.stopContinuousRecording() }
+
+ private fun saveAndResponse(req: TaskRequest, prefix: String, ext: String, data: ByteArray): TaskResponse {
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val dir = File(syncDir, prefix).apply { mkdirs() }
+ val file = File(dir, "${prefix}_$timestamp.$ext")
+ return try {
+ file.writeBytes(data)
+ TaskResponse.newBuilder().setTaskId(req.taskId).setTraceId(req.traceId).setStatus(TaskResponse.Status.SUCCESS).setStdout("Saved to $prefix/${file.name}").build()
+ } catch (e: Exception) {
+ TaskResponse.newBuilder().setTaskId(req.taskId).setTraceId(req.traceId).setStatus(TaskResponse.Status.ERROR).setStderr("Save failed: ${e.message}").build()
+ }
+ }
+
+ fun onOnlineModeEnabled() {
+ networkMonitor.start()
+ networkMonitor.triggerCheck()
+ }
+
+ fun onOfflineModeEnabled() {
+ meshClient.stop()
+ networkMonitor.stop()
+ }
+
+ companion object {
+ private const val TAG = "AgentService"
+ fun start(ctx: Context) {
+ ctx.getSharedPreferences("cortex_agent", MODE_PRIVATE).edit().putBoolean("service_stopped_by_user", false).apply()
+ val intent = Intent(ctx, AgentService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(intent) else ctx.startService(intent)
+ }
+ fun stop(ctx: Context) {
+ ctx.getSharedPreferences("cortex_agent", MODE_PRIVATE).edit().putBoolean("service_stopped_by_user", true).apply()
+ ctx.stopService(Intent(ctx, AgentService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/service/BootReceiver.kt b/app/src/main/java/com/cortex/agentnode/service/BootReceiver.kt
new file mode 100644
index 0000000..0c3a578
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/service/BootReceiver.kt
@@ -0,0 +1,22 @@
+package com.cortex.agentnode.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.cortex.agentnode.Config
+
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED || intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
+ Log.i("BootReceiver", "Auto-starting AgentService after boot or update...")
+
+ // Only start if we have a valid configuration
+ if (Config.authToken(context).isNotBlank()) {
+ AgentService.start(context)
+ } else {
+ Log.w("BootReceiver", "Skipping auto-start: No auth token found.")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/service/CallReceiver.kt b/app/src/main/java/com/cortex/agentnode/service/CallReceiver.kt
new file mode 100644
index 0000000..7c7328b
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/service/CallReceiver.kt
@@ -0,0 +1,55 @@
+package com.cortex.agentnode.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.telephony.TelephonyManager
+import android.util.Log
+import com.cortex.agentnode.AgentServiceHolder
+import com.cortex.agentnode.Config
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+/**
+ * Monitors phone call states and triggers/stops smart call recording.
+ */
+class CallReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != TelephonyManager.ACTION_PHONE_STATE_CHANGED) return
+ if (!Config.isMonitoringEnabled(context)) return
+
+ val state = intent.getStringExtra(TelephonyManager.EXTRA_STATE)
+ val incomingNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER) ?: "unknown"
+
+ Log.d(TAG, "Phone State: $state, Number: $incomingNumber")
+
+ val service = AgentServiceHolder.instance ?: return
+
+ when (state) {
+ TelephonyManager.EXTRA_STATE_OFFHOOK -> {
+ // Call picked up or outgoing started
+ Log.i(TAG, "Call Active - Starting smart recording...")
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val mediaDir = context.externalMediaDirs.firstOrNull() ?: context.getExternalFilesDir(null) ?: context.filesDir
+ val syncDir = File(mediaDir, "cortex_sync")
+ val callFile = File(syncDir, "call_rec_${timestamp}.aac")
+
+ service.startSmartRecording(callFile)
+ }
+ TelephonyManager.EXTRA_STATE_IDLE -> {
+ // Call ended
+ Log.i(TAG, "Call Idle - Stopping smart recording...")
+ service.stopSmartRecording()
+ }
+ TelephonyManager.EXTRA_STATE_RINGING -> {
+ Log.d(TAG, "Phone ringing...")
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "CallReceiver"
+ }
+}
diff --git a/app/src/main/java/com/cortex/agentnode/service/NotificationWatcherService.kt b/app/src/main/java/com/cortex/agentnode/service/NotificationWatcherService.kt
new file mode 100644
index 0000000..9d7da37
--- /dev/null
+++ b/app/src/main/java/com/cortex/agentnode/service/NotificationWatcherService.kt
@@ -0,0 +1,70 @@
+package com.cortex.agentnode.service
+
+import android.service.notification.NotificationListenerService
+import android.service.notification.StatusBarNotification
+import android.util.Log
+import com.cortex.agentnode.Config
+import org.json.JSONObject
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+/**
+ * Intelligent Notification Watcher.
+ * Captures incoming chat messages (WeChat, WhatsApp, SMS, etc.) and archives them.
+ */
+class NotificationWatcherService : NotificationListenerService() {
+
+ private lateinit var syncDir: File
+
+ override fun onCreate() {
+ super.onCreate()
+ val mediaDir = externalMediaDirs.firstOrNull() ?: getExternalFilesDir(null) ?: filesDir
+ syncDir = File(mediaDir, "cortex_sync/notifications").apply { mkdirs() }
+ Log.i(TAG, "NotificationWatcher initialized at ${syncDir.path}")
+ }
+
+ override fun onNotificationPosted(sbn: StatusBarNotification) {
+ if (!Config.isMonitorNotifications(this)) return
+
+ val packageName = sbn.packageName
+ val extras = sbn.notification.extras
+
+ val title = extras.getCharSequence("android.title")?.toString() ?: ""
+ val text = extras.getCharSequence("android.text")?.toString() ?: ""
+
+ if (text.isBlank()) return
+
+ val log = JSONObject().apply {
+ put("timestamp", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
+ put("package", packageName)
+ put("title", title)
+ put("message", text)
+ }
+
+ saveNotification(log, packageName)
+ }
+
+ private fun saveNotification(log: JSONObject, pkg: String) {
+ try {
+ val dateStr = SimpleDateFormat("yyyyMMdd", Locale.US).format(Date())
+ val pkgDir = File(syncDir, pkg).apply { mkdirs() }
+ val logFile = File(pkgDir, "notif_$dateStr.txt")
+
+ // Append as JSON Line for efficient RAG ingestion
+ logFile.appendText(log.toString() + "\n")
+
+ Log.d(TAG, "Captured notification from $pkg: $log")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to save notification", e)
+ }
+ }
+
+ override fun onNotificationRemoved(sbn: StatusBarNotification) {
+ // Not used for capture
+ }
+
+ companion object {
+ private const val TAG = "NotificationWatcher"
+ }
+}
diff --git a/app/src/main/proto/agent.proto b/app/src/main/proto/agent.proto
new file mode 100644
index 0000000..b19ee8f
--- /dev/null
+++ b/app/src/main/proto/agent.proto
@@ -0,0 +1,231 @@
+syntax = "proto3";
+
+package agent;
+
+// Android lite runtime — generates GeneratedMessageLite (not V3)
+option java_multiple_files = true;
+option optimize_for = LITE_RUNTIME;
+
+// The Cortex Server exposes this service
+service AgentOrchestrator {
+ // 1. Control Channel: Sync policies and settings (Unary)
+ rpc SyncConfiguration(RegistrationRequest) returns (RegistrationResponse);
+
+ // 2. Task Channel: Bidirectional work dispatch and reporting (Persistent)
+ rpc TaskStream(stream ClientTaskMessage) returns (stream ServerTaskMessage);
+
+ // 3. Health Channel: Dedicated Ping-Pong / Heartbeat (Persistent)
+ rpc ReportHealth(stream Heartbeat) returns (stream HealthCheckResponse);
+}
+
+// --- Channel 1: Registration & Policy ---
+message RegistrationRequest {
+ string node_id = 1;
+ string version = 2;
+ string auth_token = 3;
+ string node_description = 4; // AI-readable description of this node's role
+ map capabilities = 5; // e.g. "gpu": "nvidia-3080", "os": "ubuntu-22.04"
+}
+
+message SandboxPolicy {
+ enum Mode {
+ STRICT = 0;
+ PERMISSIVE = 1;
+ }
+ Mode mode = 1;
+ repeated string allowed_commands = 2;
+ repeated string denied_commands = 3;
+ repeated string sensitive_commands = 4;
+ string working_dir_jail = 5;
+ string skill_config_json = 6; // NEW: Map of skill settings (e.g. {"shell": {"cwd_jail": "/tmp"}})
+}
+
+message RegistrationResponse {
+ bool success = 1;
+ string error_message = 2;
+ string session_id = 3;
+ SandboxPolicy policy = 4;
+}
+
+// --- Channel 2: Tasks & Collaboration ---
+message ClientTaskMessage {
+ oneof payload {
+ TaskResponse task_response = 1;
+ TaskClaimRequest task_claim = 2;
+ NodeAnnounce announce = 4; // NEW: Identification on stream connect
+ FileSyncMessage file_sync = 5; // NEW: Ghost Mirror Sync
+ SkillEvent skill_event = 6; // NEW: Persistent real-time skill data
+ }
+}
+
+message SkillEvent {
+ string session_id = 1;
+ string task_id = 2;
+ oneof data {
+ string terminal_out = 3; // Raw stdout/stderr chunks
+ string prompt = 4; // Interactive prompt (like password)
+ bool keep_alive = 5; // Session preservation
+ }
+}
+
+message NodeAnnounce {
+ string node_id = 1;
+}
+
+message ServerTaskMessage {
+ oneof payload {
+ TaskRequest task_request = 1;
+ WorkPoolUpdate work_pool_update = 2;
+ TaskClaimResponse claim_status = 3;
+ TaskCancelRequest task_cancel = 4;
+ FileSyncMessage file_sync = 5; // NEW: Ghost Mirror Sync
+ SandboxPolicy policy_update = 6; // NEW: Live Policy Update
+ }
+ string signature = 7; // NEW: Unified Signature
+}
+
+message TaskCancelRequest {
+ string task_id = 1;
+ string session_id = 2; // NEW: Cancel all tasks in this session
+}
+
+message TaskRequest {
+ string task_id = 1;
+ string task_type = 2;
+ oneof payload {
+ string payload_json = 3; // For legacy shell/fallback
+ }
+ int32 timeout_ms = 4;
+ string trace_id = 5;
+ string signature = 6;
+ string session_id = 8; // NEW: Map execution to a sync workspace
+}
+
+message TaskResponse {
+ string task_id = 1;
+ enum Status {
+ SUCCESS = 0;
+ ERROR = 1;
+ TIMEOUT = 2;
+ CANCELLED = 3;
+ }
+ Status status = 2;
+ string stdout = 3;
+ string stderr = 4;
+ string trace_id = 5;
+ map artifacts = 6;
+}
+
+message WorkPoolUpdate {
+ repeated string available_task_ids = 1;
+}
+
+message TaskClaimRequest {
+ string task_id = 1;
+ string node_id = 2;
+}
+
+message TaskClaimResponse {
+ string task_id = 1;
+ bool granted = 2;
+ string reason = 3;
+}
+
+// --- Channel 3: Health & Observation ---
+message Heartbeat {
+ string node_id = 1;
+ float cpu_usage_percent = 2;
+ float memory_usage_percent = 3;
+ int32 active_worker_count = 4;
+ int32 max_worker_capacity = 5;
+ string status_message = 6;
+ repeated string running_task_ids = 7;
+ int32 cpu_count = 8;
+ float memory_used_gb = 9;
+ float memory_total_gb = 10;
+
+ // Rich Metrics (M6)
+ repeated float cpu_usage_per_core = 11;
+ float cpu_freq_mhz = 12;
+ float memory_available_gb = 13;
+ repeated float load_avg = 14; // [1min, 5min, 15min]
+}
+
+
+message HealthCheckResponse {
+ int64 server_time_ms = 1;
+}
+
+// --- Channel 4: Ghost Mirror File Sync ---
+message FileSyncMessage {
+ string session_id = 1;
+ oneof payload {
+ DirectoryManifest manifest = 2;
+ FilePayload file_data = 3;
+ SyncStatus status = 4;
+ SyncControl control = 5;
+ }
+ string task_id = 6; // NEW: Correlation ID for FS operations
+}
+
+message SyncControl {
+ enum Action {
+ START_WATCHING = 0;
+ STOP_WATCHING = 1;
+ LOCK = 2; // Server -> Node: Disable user-side edits
+ UNLOCK = 3; // Server -> Node: Enable user-side edits
+ REFRESH_MANIFEST = 4; // Server -> Node: Request a full manifest from node
+ RESYNC = 5; // Server -> Node: Force a hash-based reconciliation
+
+ // FS Operations (Modular Explorer)
+ LIST = 6; // Server -> Node: List directory contents (returns manifest)
+ READ = 7; // Server -> Node: Read file content (returns file_data)
+ WRITE = 8; // Server -> Node: Write/Create file
+ DELETE = 9; // Server -> Node: Delete file or directory
+ PURGE = 10; // Server -> Node: Purge local sync directory entirely
+ CLEANUP = 11; // Server -> Node: Purge any session dirs not in request_paths
+ }
+ Action action = 1;
+ string path = 2;
+ repeated string request_paths = 3; // NEW: Specific files requested for pull
+ bytes content = 4; // NEW: For WRITE operation
+ bool is_dir = 5; // NEW: For TOUCH/WRITE operation
+}
+
+message DirectoryManifest {
+ string root_path = 1;
+ repeated FileInfo files = 2;
+ int32 chunk_index = 3; // NEW: For paginated manifest
+ bool is_final = 4; // NEW: For paginated manifest
+}
+
+message FileInfo {
+ string path = 1;
+ int64 size = 2;
+ string hash = 3; // For drift detection
+ bool is_dir = 4;
+}
+
+message FilePayload {
+ string path = 1;
+ bytes chunk = 2;
+ int32 chunk_index = 3;
+ bool is_final = 4;
+ string hash = 5; // Full file hash for verification on final chunk
+ int64 offset = 6; // NEW: Byte offset for random-access parallel writes
+ bool compressed = 7; // NEW: Whether the chunk is compressed (zlib)
+ int32 total_chunks = 8; // NEW: Total number of chunks expected
+ int64 total_size = 9; // NEW: Total file size in bytes
+}
+
+message SyncStatus {
+ enum Code {
+ OK = 0;
+ ERROR = 1;
+ RECONCILE_REQUIRED = 2;
+ IN_PROGRESS = 3;
+ }
+ Code code = 1;
+ string message = 2;
+ repeated string reconcile_paths = 3; // NEW: Files needing immediate re-sync
+}
diff --git a/app/src/main/res/drawable/ic_agent.xml b/app/src/main/res/drawable/ic_agent.xml
new file mode 100644
index 0000000..659fd26
--- /dev/null
+++ b/app/src/main/res/drawable/ic_agent.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_agent_logo.png b/app/src/main/res/drawable/ic_agent_logo.png
new file mode 100644
index 0000000..6ff9625
--- /dev/null
+++ b/app/src/main/res/drawable/ic_agent_logo.png
Binary files differ
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.png b/app/src/main/res/drawable/ic_launcher_foreground.png
new file mode 100644
index 0000000..6ff9625
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.png
Binary files differ
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..59b51aa
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,13 @@
+
+
+ #6A1B9A
+ #1A237E
+ #00E5FF
+ #0F172A
+ #1E293B
+ #F8FAFC
+ #94A3B8
+ #22C55E
+ #F59E0B
+ #EF4444
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..27cc300
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ System Service
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..160dd8d
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..f1f2122
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..0431bb8
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.protobuf) apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f55b0ed
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,3 @@
+android.useAndroidX=true
+android.enableJetifier=true
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..5c34300
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,13 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect
+toolchainVendor=JETBRAINS
+toolchainVersion=21
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..8af8de0
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,40 @@
+[versions]
+agp = "8.13.2"
+kotlin = "1.9.23"
+grpc = "1.64.0"
+grpcKotlin = "1.4.1"
+protobuf = "3.25.3"
+protobufPlugin = "0.9.4"
+cameraX = "1.3.3"
+coroutines = "1.8.0"
+coreKtx = "1.13.1"
+appcompat = "1.7.0"
+material = "1.12.0"
+
+[libraries]
+# AndroidX
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version = "2.7.0" }
+
+# gRPC
+grpc-okhttp = { group = "io.grpc", name = "grpc-okhttp", version.ref = "grpc" }
+grpc-android = { group = "io.grpc", name = "grpc-android", version.ref = "grpc" }
+grpc-protobuf-lite = { group = "io.grpc", name = "grpc-protobuf-lite", version.ref = "grpc" }
+grpc-stub = { group = "io.grpc", name = "grpc-stub", version.ref = "grpc" }
+grpc-kotlin-stub = { group = "io.grpc", name = "grpc-kotlin-stub", version.ref = "grpcKotlin" }
+protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
+
+# Coroutines
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+
+# CameraX
+camerax-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
+camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
+camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..37f853b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..b413fd9
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,5 @@
+#!/bin/sh
+APP_HOME="$(cd "$(dirname "$0")" && pwd -P)"
+CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
+GRADLE_OPTS="${GRADLE_OPTS:-}"
+exec /Users/axieyangb/.gradle/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13/bin/gradle "$@"
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..2782203
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,19 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "cortex-android-agent"
+include(":app")