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