diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 00000000000..a0e41613f87
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,53 @@
+plugins {
+ id "com.android.application"
+ id "org.jetbrains.kotlin.android"
+}
+
+android {
+ namespace "com.nerosovereign.vision"
+ compileSdk 34
+
+ defaultConfig {
+ applicationId "com.nerosovereign.vision"
+ minSdk 24
+ targetSdk 34
+ versionCode 1
+ versionName "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
+ }
+ debug {
+ minifyEnabled false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ packaging {
+ resources {
+ excludes += ["/META-INF/{AL2.0,LGPL2.1}"]
+ }
+ }
+}
+
+dependencies {
+ implementation "androidx.core:core-ktx:1.12.0"
+ implementation "androidx.appcompat:appcompat:1.6.1"
+ implementation "com.google.android.material:material:1.11.0"
+ implementation "org.msgpack:msgpack-core:0.9.8"
+ implementation "com.squareup.okhttp3:okhttp:4.12.0"
+ implementation "com.squareup.okhttp3:okhttp-ws:4.12.0"
+ implementation "androidx.security:security-crypto:1.1.0-alpha06"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000000..f0415fb6d45
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Intentionally minimal for initial production baseline.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..f1f613ddfd2
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/nerosovereign/vision/ApiClient.kt b/app/src/main/java/com/nerosovereign/vision/ApiClient.kt
new file mode 100644
index 00000000000..adb253a6c98
--- /dev/null
+++ b/app/src/main/java/com/nerosovereign/vision/ApiClient.kt
@@ -0,0 +1,302 @@
+package com.nerosovereign.vision
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+import java.lang.ref.WeakReference
+import java.util.concurrent.TimeUnit
+
+class ApiClient(context: Context) {
+ private val appContextRef = WeakReference(context.applicationContext)
+
+ private val httpClient: OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ data class RuntimeConfig(
+ val geminiApiKey: String,
+ val openRouterApiKey: String,
+ val useOllama: Boolean
+ )
+
+ data class ProviderResponse(
+ val provider: String,
+ val text: String
+ )
+
+ companion object {
+ private const val TAG = "ApiClient"
+ private const val PREFS_NAME = "nero_secure_prefs"
+ private const val KEY_GEMINI = "gemini_api_key"
+ private const val KEY_OPENROUTER = "openrouter_api_key"
+ private const val KEY_OLLAMA_ENABLED = "ollama_enabled"
+
+ fun getSecurePrefs(context: Context): SharedPreferences {
+ val appContext = context.applicationContext
+ val masterKey = MasterKey.Builder(appContext)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ return EncryptedSharedPreferences.create(
+ appContext,
+ PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ fun saveRuntimeConfig(
+ context: Context,
+ geminiApiKey: String,
+ openRouterApiKey: String,
+ useOllama: Boolean
+ ) {
+ getSecurePrefs(context).edit()
+ .putString(KEY_GEMINI, geminiApiKey.trim())
+ .putString(KEY_OPENROUTER, openRouterApiKey.trim())
+ .putBoolean(KEY_OLLAMA_ENABLED, useOllama)
+ .apply()
+ }
+
+ fun loadRuntimeConfig(context: Context): RuntimeConfig {
+ val prefs = getSecurePrefs(context)
+ return RuntimeConfig(
+ geminiApiKey = prefs.getString(KEY_GEMINI, "").orEmpty(),
+ openRouterApiKey = prefs.getString(KEY_OPENROUTER, "").orEmpty(),
+ useOllama = prefs.getBoolean(KEY_OLLAMA_ENABLED, false)
+ )
+ }
+ }
+
+ suspend fun sendText(prompt: String): Result = withContext(Dispatchers.IO) {
+ val context = appContextRef.get() ?: return@withContext Result.failure(
+ IllegalStateException("Context is not available")
+ )
+ val config = loadRuntimeConfig(context)
+
+ runCatching { geminiText(prompt, config.geminiApiKey) }
+ .recoverCatching {
+ if (config.useOllama) {
+ ollamaText(prompt)
+ } else {
+ throw it
+ }
+ }
+ .recoverCatching {
+ openRouterText(prompt, config.openRouterApiKey)
+ }
+ }
+
+ suspend fun analyzeScreen(
+ base64Jpeg: String,
+ prompt: String = "Analyze this screen and summarize the most important actionable insights."
+ ): Result = withContext(Dispatchers.IO) {
+ val context = appContextRef.get() ?: return@withContext Result.failure(
+ IllegalStateException("Context is not available")
+ )
+ val config = loadRuntimeConfig(context)
+
+ runCatching { geminiVision(prompt, base64Jpeg, config.geminiApiKey) }
+ .recoverCatching {
+ if (config.useOllama) {
+ ollamaVision(prompt, base64Jpeg)
+ } else {
+ throw it
+ }
+ }
+ .recoverCatching {
+ openRouterVision(prompt, base64Jpeg, config.openRouterApiKey)
+ }
+ }
+
+ private fun geminiText(prompt: String, apiKey: String): ProviderResponse {
+ require(apiKey.isNotBlank()) { "Gemini API key is missing" }
+ val body = JSONObject()
+ .put("contents", JSONArray().put(JSONObject().put("parts", JSONArray().put(JSONObject().put("text", prompt)))))
+ .put("generationConfig", JSONObject().put("temperature", 0.2))
+
+ val request = Request.Builder()
+ .url("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=$apiKey")
+ .post(body.toString().toRequestBody("application/json".toMediaType()))
+ .build()
+
+ val response = executeJson(request)
+ val text = response.optJSONArray("candidates")
+ ?.optJSONObject(0)
+ ?.optJSONObject("content")
+ ?.optJSONArray("parts")
+ ?.optJSONObject(0)
+ ?.optString("text")
+ .orEmpty()
+ require(text.isNotBlank()) { "Gemini returned an empty response" }
+ return ProviderResponse(provider = "gemini", text = text)
+ }
+
+ private fun geminiVision(prompt: String, base64Jpeg: String, apiKey: String): ProviderResponse {
+ require(apiKey.isNotBlank()) { "Gemini API key is missing" }
+ val parts = JSONArray()
+ .put(JSONObject().put("text", prompt))
+ .put(
+ JSONObject().put(
+ "inline_data",
+ JSONObject()
+ .put("mime_type", "image/jpeg")
+ .put("data", base64Jpeg)
+ )
+ )
+ val body = JSONObject()
+ .put("contents", JSONArray().put(JSONObject().put("parts", parts)))
+ .put("generationConfig", JSONObject().put("temperature", 0.2))
+
+ val request = Request.Builder()
+ .url("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=$apiKey")
+ .post(body.toString().toRequestBody("application/json".toMediaType()))
+ .build()
+
+ val response = executeJson(request)
+ val text = response.optJSONArray("candidates")
+ ?.optJSONObject(0)
+ ?.optJSONObject("content")
+ ?.optJSONArray("parts")
+ ?.optJSONObject(0)
+ ?.optString("text")
+ .orEmpty()
+ require(text.isNotBlank()) { "Gemini vision returned an empty response" }
+ return ProviderResponse(provider = "gemini", text = text)
+ }
+
+ private fun ollamaText(prompt: String): ProviderResponse {
+ val body = JSONObject()
+ .put("model", "llama3.2:3b")
+ .put("prompt", prompt)
+ .put("stream", false)
+
+ val request = Request.Builder()
+ .url("http://127.0.0.1:11434/api/generate")
+ .post(body.toString().toRequestBody("application/json".toMediaType()))
+ .build()
+
+ val response = executeJson(request)
+ val text = response.optString("response")
+ require(text.isNotBlank()) { "Ollama returned an empty response" }
+ return ProviderResponse(provider = "ollama", text = text)
+ }
+
+ private fun ollamaVision(prompt: String, base64Jpeg: String): ProviderResponse {
+ val body = JSONObject()
+ .put("model", "llava")
+ .put("prompt", prompt)
+ .put("stream", false)
+ .put("images", JSONArray().put(base64Jpeg))
+
+ val request = Request.Builder()
+ .url("http://127.0.0.1:11434/api/generate")
+ .post(body.toString().toRequestBody("application/json".toMediaType()))
+ .build()
+
+ val response = executeJson(request)
+ val text = response.optString("response")
+ require(text.isNotBlank()) { "Ollama vision returned an empty response" }
+ return ProviderResponse(provider = "ollama", text = text)
+ }
+
+ private fun openRouterText(prompt: String, apiKey: String): ProviderResponse {
+ require(apiKey.isNotBlank()) { "OpenRouter API key is missing" }
+ val body = JSONObject()
+ .put("model", "meta-llama/llama-3.2-3b-instruct:free")
+ .put(
+ "messages",
+ JSONArray().put(
+ JSONObject()
+ .put("role", "user")
+ .put("content", prompt)
+ )
+ )
+
+ val request = Request.Builder()
+ .url("https://openrouter.ai/api/v1/chat/completions")
+ .addHeader("Authorization", "Bearer $apiKey")
+ .addHeader("Content-Type", "application/json")
+ .post(body.toString().toRequestBody("application/json".toMediaType()))
+ .build()
+
+ val response = executeJson(request)
+ val text = response.optJSONArray("choices")
+ ?.optJSONObject(0)
+ ?.optJSONObject("message")
+ ?.optString("content")
+ .orEmpty()
+ require(text.isNotBlank()) { "OpenRouter returned an empty response" }
+ return ProviderResponse(provider = "openrouter", text = text)
+ }
+
+ private fun openRouterVision(prompt: String, base64Jpeg: String, apiKey: String): ProviderResponse {
+ require(apiKey.isNotBlank()) { "OpenRouter API key is missing" }
+ val content = JSONArray()
+ .put(JSONObject().put("type", "text").put("text", prompt))
+ .put(
+ JSONObject()
+ .put("type", "image_url")
+ .put(
+ "image_url",
+ JSONObject().put("url", "data:image/jpeg;base64,$base64Jpeg")
+ )
+ )
+
+ val body = JSONObject()
+ .put("model", "qwen/qwen2.5-vl-72b-instruct:free")
+ .put(
+ "messages",
+ JSONArray().put(
+ JSONObject()
+ .put("role", "user")
+ .put("content", content)
+ )
+ )
+
+ val request = Request.Builder()
+ .url("https://openrouter.ai/api/v1/chat/completions")
+ .addHeader("Authorization", "Bearer $apiKey")
+ .addHeader("Content-Type", "application/json")
+ .post(body.toString().toRequestBody("application/json".toMediaType()))
+ .build()
+
+ val response = executeJson(request)
+ val text = response.optJSONArray("choices")
+ ?.optJSONObject(0)
+ ?.optJSONObject("message")
+ ?.optString("content")
+ .orEmpty()
+ require(text.isNotBlank()) { "OpenRouter vision returned an empty response" }
+ return ProviderResponse(provider = "openrouter", text = text)
+ }
+
+ private fun executeJson(request: Request): JSONObject {
+ httpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw IOException("HTTP ${response.code}: ${response.body?.string().orEmpty()}")
+ }
+ val body = response.body?.string().orEmpty()
+ if (body.isBlank()) throw IOException("Empty response body")
+ return try {
+ JSONObject(body)
+ } catch (jsonException: Exception) {
+ Log.e(TAG, "Failed to parse JSON body: $body", jsonException)
+ throw IOException("Invalid JSON response", jsonException)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nerosovereign/vision/HermesBridge.kt b/app/src/main/java/com/nerosovereign/vision/HermesBridge.kt
new file mode 100644
index 00000000000..d5b9f19bcec
--- /dev/null
+++ b/app/src/main/java/com/nerosovereign/vision/HermesBridge.kt
@@ -0,0 +1,339 @@
+package com.nerosovereign.vision
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import android.util.Log
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeout
+import okio.ByteString
+import okio.ByteString.Companion.toByteString
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import org.msgpack.core.MessagePack
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.io.File
+import java.lang.ref.WeakReference
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+import kotlin.math.min
+import kotlin.math.pow
+
+class HermesBridge(context: Context) {
+ private val contextRef = WeakReference(context.applicationContext)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val reconnectMutex = Mutex()
+ private val pendingResponses = ConcurrentHashMap>()
+
+ private var keepAliveJob: Job? = null
+ private var webSocket: WebSocket? = null
+ private var wsOpenSignal: CompletableDeferred? = null
+ private var reconnectAttempts = 0
+
+ private val wsClient: OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ companion object {
+ private const val TAG = "HermesBridge"
+ private const val UNIX_SOCKET_PATH = "/data/data/com.termux/files/usr/tmp/hermes.sock"
+ private const val FALLBACK_WS_URL = "ws://localhost:8765"
+ private const val MAX_RECONNECT_RETRIES = 5
+ }
+
+ data class HermesEnvelope(
+ val dest: String,
+ val payload: ByteArray,
+ val timestamp: Long
+ )
+
+ fun start() {
+ if (keepAliveJob?.isActive == true) return
+ keepAliveJob = scope.launch {
+ while (isActive) {
+ try {
+ sendPing()
+ } catch (error: Exception) {
+ Log.w(TAG, "Keep-alive ping failed", error)
+ }
+ delay(30_000L)
+ }
+ }
+ }
+
+ fun stop() {
+ keepAliveJob?.cancel()
+ webSocket?.close(1000, "shutdown")
+ scope.cancel()
+ }
+
+ fun isTermuxInstalled(): Boolean {
+ val context = contextRef.get() ?: return false
+ return runCatching {
+ context.packageManager.getPackageInfo("com.termux", 0)
+ true
+ }.getOrDefault(false)
+ }
+
+ fun canUseUnixSocket(): Boolean = File(UNIX_SOCKET_PATH).exists()
+
+ suspend fun relay(dest: String, payload: ByteArray): Result {
+ if (!isTermuxInstalled()) {
+ return Result.failure(IllegalStateException("Termux is not installed"))
+ }
+
+ val timestamp = System.currentTimeMillis()
+ val envelope = HermesEnvelope(dest = dest, payload = payload, timestamp = timestamp)
+ val packed = encodeEnvelope(envelope)
+
+ val unixError = try {
+ return Result.success(sendViaUnixSocket(packed))
+ } catch (error: Exception) {
+ Log.w(TAG, "Unix socket relay failed, attempting websocket fallback", error)
+ error
+ }
+
+ val wsError = try {
+ return Result.success(sendViaWebSocket(envelope, packed))
+ } catch (error: Exception) {
+ error
+ }
+
+ sendRelayBroadcastFallback(packed)
+ return Result.failure(wsError.ifMessageNotBlank() ?: unixError)
+ }
+
+ private suspend fun sendPing() {
+ val pingEnvelope = HermesEnvelope(
+ dest = "nero_core",
+ payload = "ping".toByteArray(),
+ timestamp = System.currentTimeMillis()
+ )
+ val packed = encodeEnvelope(pingEnvelope)
+
+ try {
+ sendViaUnixSocket(packed)
+ } catch (_: Exception) {
+ ensureWebSocketConnected()
+ webSocket?.send(packed.toByteString())
+ }
+ }
+
+ private suspend fun sendViaUnixSocket(packedEnvelope: ByteArray): ByteArray {
+ if (!canUseUnixSocket()) {
+ throw IllegalStateException("Hermes unix socket not found at $UNIX_SOCKET_PATH")
+ }
+
+ val socket = LocalSocket()
+ return try {
+ withTimeout(5_000L) {
+ socket.connect(LocalSocketAddress(UNIX_SOCKET_PATH, LocalSocketAddress.Namespace.FILESYSTEM))
+ val output = DataOutputStream(socket.outputStream)
+ val input = DataInputStream(socket.inputStream)
+
+ output.writeInt(packedEnvelope.size)
+ output.write(packedEnvelope)
+ output.flush()
+
+ val responseSize = input.readInt()
+ require(responseSize in 0..2_000_000) { "Invalid Hermes response size: $responseSize" }
+ val responseBytes = ByteArray(responseSize)
+ input.readFully(responseBytes)
+ responseBytes
+ }
+ } finally {
+ runCatching { socket.close() }
+ }
+ }
+
+ private suspend fun sendViaWebSocket(envelope: HermesEnvelope, packedEnvelope: ByteArray): ByteArray {
+ ensureWebSocketConnected()
+ val ws = webSocket ?: throw IllegalStateException("WebSocket not connected")
+ val responseDeferred = CompletableDeferred()
+ pendingResponses[envelope.timestamp] = responseDeferred
+
+ val sent = ws.send(packedEnvelope.toByteString())
+ if (!sent) {
+ pendingResponses.remove(envelope.timestamp)
+ throw IllegalStateException("Failed to send websocket payload")
+ }
+
+ return try {
+ withTimeout(10_000L) { responseDeferred.await() }
+ } finally {
+ pendingResponses.remove(envelope.timestamp)
+ }
+ }
+
+ private suspend fun ensureWebSocketConnected() = reconnectMutex.withLock {
+ if (wsOpenSignal?.isCompleted == true && webSocket != null) return
+
+ wsOpenSignal = CompletableDeferred()
+ val request = Request.Builder().url(FALLBACK_WS_URL).build()
+ webSocket = wsClient.newWebSocket(request, object : WebSocketListener() {
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ reconnectAttempts = 0
+ wsOpenSignal?.complete(Unit)
+ }
+
+ override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
+ runCatching { decodeEnvelope(bytes.toByteArray()) }
+ .onSuccess { decoded ->
+ pendingResponses[decoded.timestamp]?.complete(decoded.payload)
+ }
+ .onFailure { Log.e(TAG, "Failed to decode MessagePack websocket response", it) }
+ }
+
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+ wsOpenSignal?.completeExceptionally(t)
+ failAllPending(t)
+ scheduleReconnect()
+ }
+
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ failAllPending(IllegalStateException("WebSocket closed: $code - $reason"))
+ scheduleReconnect()
+ }
+ })
+
+ withTimeout(10_000L) {
+ wsOpenSignal?.await()
+ }
+ }
+
+ private fun scheduleReconnect() {
+ if (reconnectAttempts >= MAX_RECONNECT_RETRIES) return
+ reconnectAttempts++
+ val delayMillis = min(30_000.0, 1000.0 * 2.0.pow((reconnectAttempts - 1).toDouble())).toLong()
+ scope.launch {
+ delay(delayMillis)
+ try {
+ ensureWebSocketConnected()
+ } catch (error: Exception) {
+ Log.w(TAG, "Reconnect attempt failed", error)
+ }
+ }
+ }
+
+ private fun failAllPending(error: Throwable) {
+ pendingResponses.values.forEach { deferred ->
+ if (!deferred.isCompleted) deferred.completeExceptionally(error)
+ }
+ pendingResponses.clear()
+ webSocket = null
+ wsOpenSignal = null
+ }
+
+ private fun sendRelayBroadcastFallback(payload: ByteArray) {
+ val context = contextRef.get() ?: return
+ val intent = Intent("com.termux.hermes.RELAY")
+ .setPackage("com.termux")
+ .putExtra("payload", payload)
+ context.sendBroadcast(intent)
+ }
+
+ private fun encodeEnvelope(envelope: HermesEnvelope): ByteArray {
+ val packer = MessagePack.newDefaultBufferPacker()
+ packer.packMapHeader(3)
+ packer.packString("dest")
+ packer.packString(envelope.dest)
+ packer.packString("payload")
+ packer.packBinaryHeader(envelope.payload.size)
+ packer.writePayload(envelope.payload)
+ packer.packString("timestamp")
+ packer.packLong(envelope.timestamp)
+ packer.close()
+ return packer.toByteArray()
+ }
+
+ private fun decodeEnvelope(raw: ByteArray): HermesEnvelope {
+ val unpacker = MessagePack.newDefaultUnpacker(raw)
+ var dest = ""
+ var payload = ByteArray(0)
+ var timestamp = 0L
+
+ val mapSize = unpacker.unpackMapHeader()
+ repeat(mapSize) {
+ when (val key = unpacker.unpackString()) {
+ "dest" -> dest = unpacker.unpackString()
+ "payload" -> {
+ val size = unpacker.unpackBinaryHeader()
+ payload = unpacker.readPayload(size)
+ }
+ "timestamp" -> timestamp = unpacker.unpackLong()
+ else -> {
+ Log.w(TAG, "Unknown MessagePack key: $key")
+ unpacker.skipValue()
+ }
+ }
+ }
+ unpacker.close()
+ return HermesEnvelope(dest = dest, payload = payload, timestamp = timestamp)
+ }
+}
+
+/**
+ * Python reference for Termux `nero_core.py` side MessagePack handling.
+ *
+ * ```python
+ * # pip install msgpack
+ * import msgpack
+ * import socket
+ * import struct
+ *
+ * sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ * sock.bind("/data/data/com.termux/files/usr/tmp/hermes.sock")
+ * sock.listen(1)
+ *
+ * while True:
+ * conn, _ = sock.accept()
+ * with conn:
+ * size_raw = conn.recv(4)
+ * if len(size_raw) < 4:
+ * continue
+ * size = struct.unpack(">I", size_raw)[0]
+ * payload = conn.recv(size)
+ * envelope = msgpack.unpackb(payload, raw=False)
+ * # envelope: {"dest": "...", "payload": b"...", "timestamp": 123}
+ * response = msgpack.packb({
+ * "dest": envelope.get("dest", "nero_core"),
+ * "payload": b"ok",
+ * "timestamp": envelope.get("timestamp", 0)
+ * }, use_bin_type=True)
+ * conn.sendall(struct.pack(">I", len(response)) + response)
+ * ```
+ */
+class TermuxRelayReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent?) {
+ if (intent?.action != "com.termux.hermes.RELAY") return
+ val payload = intent.getByteArrayExtra("payload") ?: return
+
+ val serviceIntent = Intent(context, NeroOverlayService::class.java)
+ .setAction(NeroOverlayService.ACTION_TERMUX_RELAY)
+ .putExtra(NeroOverlayService.EXTRA_TERMUX_PAYLOAD, payload)
+ runCatching { ContextCompat.startForegroundService(context, serviceIntent) }
+ }
+}
+
+private fun Throwable.ifMessageNotBlank(): Throwable? {
+ return if (message.isNullOrBlank()) null else this
+}
diff --git a/app/src/main/java/com/nerosovereign/vision/MainActivity.kt b/app/src/main/java/com/nerosovereign/vision/MainActivity.kt
new file mode 100644
index 00000000000..aec0faf088b
--- /dev/null
+++ b/app/src/main/java/com/nerosovereign/vision/MainActivity.kt
@@ -0,0 +1,111 @@
+package com.nerosovereign.vision
+
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.media.projection.MediaProjectionManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.Settings
+import android.widget.Button
+import android.widget.EditText
+import android.widget.Switch
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var geminiKeyInput: EditText
+ private lateinit var openRouterKeyInput: EditText
+ private lateinit var ollamaSwitch: Switch
+ private lateinit var mediaProjectionManager: MediaProjectionManager
+
+ private val requestScreenCapture = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK && result.data != null) {
+ ScreenCaptureService.setProjectionPermission(result.resultCode, result.data!!)
+ Toast.makeText(this, getString(R.string.capture_permission_granted), Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(this, getString(R.string.capture_permission_denied), Toast.LENGTH_SHORT).show()
+ }
+ finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ mediaProjectionManager = getSystemService(MediaProjectionManager::class.java)
+
+ if (intent?.action == ACTION_REQUEST_CAPTURE_PERMISSION) {
+ requestScreenCapture.launch(mediaProjectionManager.createScreenCaptureIntent())
+ return
+ }
+
+ setContentView(R.layout.activity_main)
+ bindViews()
+ restoreSavedSettings()
+ requestNotificationPermissionIfNeeded()
+ requestOverlayPermissionIfNeeded()
+ setupStartButton()
+ }
+
+ private fun bindViews() {
+ geminiKeyInput = findViewById(R.id.inputGeminiKey)
+ openRouterKeyInput = findViewById(R.id.inputOpenRouterKey)
+ ollamaSwitch = findViewById(R.id.switchOllama)
+ }
+
+ private fun restoreSavedSettings() {
+ val config = ApiClient.loadRuntimeConfig(this)
+ geminiKeyInput.setText(config.geminiApiKey)
+ openRouterKeyInput.setText(config.openRouterApiKey)
+ ollamaSwitch.isChecked = config.useOllama
+ }
+
+ private fun setupStartButton() {
+ findViewById