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")