Optimize rendering, fix Android app extraction, and remove Cloudflare token leak
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

This commit is contained in:
Claude Code
2026-07-17 21:11:59 -07:00
parent a9f654c993
commit bb37d8c384
32 changed files with 518 additions and 645 deletions

View File

@@ -1,57 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.aetherforge.agent"
compileSdk = 34
defaultConfig {
applicationId = "com.aetherforge.agent"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0-phase1"
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
}
buildTypes {
release {
isMinifyEnabled = false
}
debug {
applicationIdSuffix = ""
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
applicationVariants.all {
outputs.all {
val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
output.outputFileName = "aetherforge-agent.apk"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
}

View File

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AetherForgeAgent">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".AgentService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -1,43 +0,0 @@
package com.aetherforge.agent
import android.content.Context
import org.json.JSONObject
data class AgentConfig(
val workerName: String,
val workerNumber: String,
val serverUrl: String,
val fleetSecret: String?,
val miningEnabled: Boolean,
val buildId: String,
) {
companion object {
fun load(context: Context, intentExtras: Map<String, String?> = emptyMap()): AgentConfig {
val assetJson = runCatching {
context.assets.open("config.json").bufferedReader().use { it.readText() }
}.getOrNull()
val json = assetJson?.let { JSONObject(it) }
val worker = intentExtras["worker_name"]
?: json?.optString("worker_name").orEmpty()
val workerNumber = intentExtras["worker_number"]
?: json?.optString("worker_number")
?: worker
val server = intentExtras["server_url"]
?: json?.optString("server_url").orEmpty()
val secret = intentExtras["fleet_secret"]
?: json?.optString("fleet_secret").takeUnless { it.isNullOrBlank() }
val mining = json?.optJSONObject("mining")?.optBoolean("enabled") ?: false
val buildId = json?.optString("build_id") ?: "android-dev"
return AgentConfig(
workerName = worker.ifBlank { "android-fleet-node" },
workerNumber = workerNumber.ifBlank { worker.ifBlank { "android-fleet-node" } },
serverUrl = server.ifBlank { "http://127.0.0.1:8989" },
fleetSecret = secret,
miningEnabled = mining,
buildId = buildId,
)
}
}
}

View File

@@ -1,90 +0,0 @@
package com.aetherforge.agent
import android.util.Log
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import java.io.File
object AgentProcess {
private const val TAG = "AetherForge"
@Volatile
private var process: Process? = null
fun start(
binary: File,
filesDir: File,
config: AgentConfig,
probeEnv: Map<String, String> = emptyMap(),
) {
stop()
val env = hashMapOf(
"HOME" to filesDir.absolutePath,
"TMPDIR" to filesDir.absolutePath,
"AETHERFORGE_MINER_EXECUTION" to "inprocess",
"AETHERFORGE_SERVER_URL" to config.serverUrl,
"AETHERFORGE_WORKER_NUMBER" to config.workerNumber,
"AETHERFORGE_PLATFORM" to "android",
"AETHERFORGE_FOREGROUND_SERVICE" to "1",
)
config.fleetSecret?.let { env["AETHERFORGE_FLEET_SECRET"] = it }
env.putAll(probeEnv)
val cmd = listOf(binary.absolutePath, "--run")
Log.i(TAG, "spawning agent: ${cmd.joinToString(" ")}")
val pb = ProcessBuilder(cmd)
.directory(filesDir)
.redirectErrorStream(true)
val merged = pb.environment()
merged.putAll(env)
process = pb.start()
Thread({
process?.inputStream?.bufferedReader()?.use { reader ->
reader.lineSequence().forEach { line ->
Log.i("$TAG:agent", line)
}
}
}, "agent-log-drain").apply {
isDaemon = true
start()
}
}
fun probeEnvironment(context: Context): Map<String, String> {
val wifi = runCatching {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return@runCatching false
val caps = cm.getNetworkCapabilities(network) ?: return@runCatching false
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}.getOrDefault(false)
val batteryOk = runCatching {
val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
level >= 15
} else {
true
}
}.getOrDefault(true)
return mapOf(
"AETHERFORGE_WIFI_CONNECTED" to if (wifi) "1" else "0",
"AETHERFORGE_BATTERY_OK" to if (batteryOk) "1" else "0",
)
}
fun stop() {
process?.let {
runCatching { it.destroy() }
runCatching { it.waitFor() }
}
process = null
}
fun isAlive(): Boolean = process?.isAlive == true
}

View File

@@ -1,114 +0,0 @@
package com.aetherforge.agent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
class AgentService : Service() {
companion object {
private const val TAG = "AetherForge"
const val ACTION_START = "com.aetherforge.agent.START"
const val NOTIFICATION_ID = 41001
private const val CHANNEL_ID = "fleet_sync"
fun start(context: Context, extras: Intent? = null) {
val intent = Intent(context, AgentService::class.java).apply {
action = ACTION_START
extras?.extras?.let { putExtras(it) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
val config = AgentConfig.load(
this,
mapOf(
"worker_name" to intent?.getStringExtra("worker_name"),
"server_url" to intent?.getStringExtra("server_url"),
"fleet_secret" to intent?.getStringExtra("fleet_secret"),
),
)
Log.i(TAG, "starting fleet node worker=${config.workerName} server=${config.serverUrl}")
val binary = BinaryExtractor.ensureBinary(this)
if (binary == null) {
Log.e(TAG, "agent binary missing — rebuild APK with build-apk script")
stopSelf()
return START_NOT_STICKY
}
if (!AgentProcess.isAlive()) {
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
}
return START_STICKY
}
override fun onDestroy() {
AgentProcess.stop()
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.notification_channel_desc)
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val pending = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_body))
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentIntent(pending)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
}
}

View File

@@ -1,47 +0,0 @@
package com.aetherforge.agent
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileOutputStream
object BinaryExtractor {
private const val TAG = "AetherForge"
private const val ASSET_NAME = "agent"
private const val BIN_NAME = "agent-arm64"
fun ensureBinary(context: Context): File? {
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
val outFile = File(outDir, BIN_NAME)
val assetSize = assetSize(context)
if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
outFile.setExecutable(true, false)
outFile.setReadable(true, false)
return outFile
}
return extract(context, outFile)
}
private fun assetSize(context: Context): Long {
return runCatching {
context.assets.openFd(ASSET_NAME).use { it.length }
}.getOrDefault(0L)
}
private fun extract(context: Context, outFile: File): File? {
return try {
context.assets.open(ASSET_NAME).use { input ->
FileOutputStream(outFile).use { output ->
input.copyTo(output)
}
}
outFile.setExecutable(true, false)
outFile.setReadable(true, false)
Log.i(TAG, "extracted agent binary to ${outFile.absolutePath} (${outFile.length()} bytes)")
outFile
} catch (e: Exception) {
Log.e(TAG, "failed to extract agent binary", e)
null
}
}
}

View File

@@ -1,14 +0,0 @@
package com.aetherforge.agent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
Log.i("AetherForge", "BOOT_COMPLETED — starting AgentService")
AgentService.start(context.applicationContext)
}
}

View File

@@ -1,132 +0,0 @@
package com.aetherforge.agent
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity() {
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
private var permissionIndex = 0
private lateinit var pendingPermissions: List<String>
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
val denied = results.filterValues { !it }.keys
if (denied.isNotEmpty()) {
Toast.makeText(
this,
"Some permissions were denied — fleet diagnostics may be limited.",
Toast.LENGTH_LONG,
).show()
}
requestNextPermissionBatch()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(buildLayout())
if (!prefs.getBoolean("permissions_requested", false)) {
prefs.edit().putBoolean("permissions_requested", true).apply()
beginPermissionFlow()
} else {
startFleetService()
}
}
private fun buildLayout(): LinearLayout {
val pad = (24 * resources.displayMetrics.density).toInt()
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
addView(TextView(context).apply {
text = getString(R.string.permission_intro_title)
textSize = 22f
setTextColor(0xFFE2E8F0.toInt())
})
addView(TextView(context).apply {
text = getString(R.string.permission_intro_body)
textSize = 15f
setTextColor(0xFF94A3B8.toInt())
setPadding(0, pad / 2, 0, pad)
})
addView(TextView(context).apply {
text = getString(R.string.battery_hint)
textSize = 14f
setTextColor(0xFF64748B.toInt())
setPadding(0, 0, 0, pad)
})
addView(Button(context).apply {
text = getString(R.string.open_battery_settings)
setOnClickListener { openBatteryOptimizationSettings() }
})
}
}
private fun requiredRuntimePermissions(): List<String> {
val perms = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
perms += Manifest.permission.POST_NOTIFICATIONS
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
perms += Manifest.permission.NEARBY_WIFI_DEVICES
}
}
perms += Manifest.permission.ACCESS_FINE_LOCATION
perms += Manifest.permission.ACCESS_COARSE_LOCATION
return perms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
}
private fun beginPermissionFlow() {
pendingPermissions = requiredRuntimePermissions()
permissionIndex = 0
requestNextPermissionBatch()
}
private fun requestNextPermissionBatch() {
if (permissionIndex >= pendingPermissions.size) {
openBatteryOptimizationSettings()
startFleetService()
return
}
val batch = pendingPermissions.drop(permissionIndex).take(3)
permissionIndex += batch.size
if (batch.isNotEmpty()) {
permissionLauncher.launch(batch.toTypedArray())
} else {
startFleetService()
}
}
private fun openBatteryOptimizationSettings() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
runCatching { startActivity(intent) }
}
}
private fun startFleetService() {
val serviceIntent = Intent(this, AgentService::class.java).apply {
action = AgentService.ACTION_START
intent?.extras?.let { putExtras(it) }
}
AgentService.start(this, serviceIntent)
Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
}
}

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#22D3EE"
android:pathData="M54,24 L78,42 L78,66 L54,84 L30,66 L30,42 Z" />
<path
android:fillColor="#0F172A"
android:pathData="M54,38 L66,48 L66,60 L54,70 L42,60 L42,48 Z" />
</vector>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@drawable/ic_launcher_foreground" />

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0F172A</color>
</resources>

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">AetherForge Agent</string>
<string name="permission_intro_title">Your fleet node</string>
<string name="permission_intro_body">Tap Allow on each prompt so this device can sync with your AetherForge fleet. A persistent notification keeps the agent alive in the background.</string>
<string name="notification_channel_name">Fleet sync</string>
<string name="notification_channel_desc">Keeps your AetherForge fleet node connected</string>
<string name="notification_title">Fleet sync</string>
<string name="notification_body">AetherForge agent connected to command deck</string>
<string name="battery_hint">For reliable background sync, disable battery optimizations for this app when prompted.</string>
<string name="service_started">Fleet agent service started</string>
<string name="service_failed">Could not start fleet agent — see logcat</string>
<string name="open_battery_settings">Battery optimization settings</string>
</resources>

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AetherForgeAgent" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">#111827</item>
<item name="android:navigationBarColor">#111827</item>
<item name="android:windowBackground">#111827</item>
<item name="colorPrimary">#22d3ee</item>
</style>
</resources>

View File

@@ -46,6 +46,7 @@ object AgentProcess {
process?.inputStream?.bufferedReader()?.use { reader ->
reader.lineSequence().forEach { line ->
Log.i("$TAG:agent", line)
LogBuffer.add(line)
}
}
}, "agent-log-drain").apply {

View File

@@ -20,6 +20,10 @@ class AgentService : Service() {
const val NOTIFICATION_ID = 41001
private const val CHANNEL_ID = "fleet_sync"
@Volatile
var isRunning = false
internal set
fun start(context: Context, extras: Intent? = null) {
val intent = Intent(context, AgentService::class.java).apply {
action = ACTION_START
@@ -72,10 +76,12 @@ class AgentService : Service() {
if (!AgentProcess.isAlive()) {
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
}
isRunning = true
return START_STICKY
}
override fun onDestroy() {
isRunning = false
AgentProcess.stop()
super.onDestroy()
}

View File

@@ -13,19 +13,25 @@ object BinaryExtractor {
fun ensureBinary(context: Context): File? {
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
val outFile = File(outDir, BIN_NAME)
val assetSize = assetSize(context)
if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
val packageInfo = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0)
}.getOrNull()
val lastUpdate = packageInfo?.lastUpdateTime ?: 0L
val prefs = context.getSharedPreferences("aetherforge_agent", Context.MODE_PRIVATE)
val lastExtractedUpdate = prefs.getLong("last_extracted_update", 0L)
if (outFile.exists() && lastExtractedUpdate == lastUpdate && lastUpdate != 0L) {
outFile.setExecutable(true, false)
outFile.setReadable(true, false)
return outFile
}
return extract(context, outFile)
}
private fun assetSize(context: Context): Long {
return runCatching {
context.assets.openFd(ASSET_NAME).use { it.length }
}.getOrDefault(0L)
val result = extract(context, outFile)
if (result != null && lastUpdate != 0L) {
prefs.edit().putLong("last_extracted_update", lastUpdate).apply()
}
return result
}
private fun extract(context: Context, outFile: File): File? {

View File

@@ -0,0 +1,25 @@
package com.aetherforge.agent
import java.util.concurrent.CopyOnWriteArrayList
object LogBuffer {
private val buffer = CopyOnWriteArrayList<String>()
@Volatile
private var listener: ((String) -> Unit)? = null
fun add(line: String) {
buffer.add(line)
if (buffer.size > 200) {
buffer.removeAt(0)
}
listener?.invoke(line)
}
fun getLogs(): List<String> = buffer
@Synchronized
fun setListener(l: ((String) -> Unit)?) {
listener = l
}
}

View File

@@ -3,13 +3,24 @@ package com.aetherforge.agent
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import android.view.Gravity
import android.view.View
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.widget.Button
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@@ -20,6 +31,26 @@ class MainActivity : AppCompatActivity() {
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
private lateinit var pendingPermissions: List<String>
private lateinit var statusText: TextView
private lateinit var statusDot: View
private lateinit var logConsole: TextView
private lateinit var logScrollView: ScrollView
private lateinit var batteryCard: LinearLayout
private lateinit var toggleButton: Button
private lateinit var configServerVal: TextView
private lateinit var configNodeVal: TextView
private lateinit var configBuildVal: TextView
private val handler = Handler(Looper.getMainLooper())
private val uiUpdateRunnable = object : Runnable {
override fun run() {
updateStatusUi()
checkBatteryOptimizationCard()
handler.postDelayed(this, 1000)
}
}
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
val denied = results.filterValues { !it }.keys
@@ -36,6 +67,22 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(buildLayout())
// Start live log collection UI callback
LogBuffer.setListener { line ->
handler.post {
appendConsoleLog(line)
}
}
// Initialize display with existing logs
val existingLogs = LogBuffer.getLogs()
if (existingLogs.isNotEmpty()) {
val sb = StringBuilder()
existingLogs.forEach { sb.append(it).append("\n") }
logConsole.text = sb.toString()
scrollToBottom()
}
if (!prefs.getBoolean("permissions_requested", false)) {
prefs.edit().putBoolean("permissions_requested", true).apply()
@@ -45,32 +92,318 @@ class MainActivity : AppCompatActivity() {
}
}
private fun buildLayout(): LinearLayout {
val pad = (24 * resources.displayMetrics.density).toInt()
return LinearLayout(this).apply {
override fun onResume() {
super.onResume()
handler.post(uiUpdateRunnable)
scrollToBottom()
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(uiUpdateRunnable)
}
override fun onDestroy() {
LogBuffer.setListener(null)
super.onDestroy()
}
private fun buildLayout(): View {
val density = resources.displayMetrics.density
val pad = (20 * density).toInt()
val padHalf = (10 * density).toInt()
// Root container
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(0xFF0F172A.toInt()) // Deep Dark Slate
setPadding(pad, pad, pad, pad)
}
// Top Status Header Card
val headerCard = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(pad, padHalf, pad, padHalf)
background = GradientDrawable().apply {
setColor(0xFF1E293B.toInt()) // Slate 800
cornerRadius = 8 * density
}
}
statusDot = View(this).apply {
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(0xFF64748B.toInt()) // Start with Offline (Slate 500)
}
val size = (12 * density).toInt()
layoutParams = LinearLayout.LayoutParams(size, size).apply {
marginEnd = (12 * density).toInt()
}
// Pulse animation
startAnimation(AlphaAnimation(0.4f, 1.0f).apply {
duration = 800
repeatMode = Animation.REVERSE
repeatCount = Animation.INFINITE
})
}
headerCard.addView(statusDot)
statusText = TextView(this).apply {
text = "AGENT OFFLINE"
textSize = 15f
typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
setTextColor(0xFF94A3B8.toInt())
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
}
headerCard.addView(statusText)
toggleButton = Button(this).apply {
text = "START"
textSize = 13f
setTextColor(Color.WHITE)
background = GradientDrawable().apply {
setColor(0xFF0EA5E9.toInt()) // Cyan 500
cornerRadius = 4 * density
}
setPadding(padHalf, 0, padHalf, 0)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
(36 * density).toInt()
)
setOnClickListener { toggleAgentService() }
}
headerCard.addView(toggleButton)
root.addView(headerCard)
// Battery Optimization Warning Card
batteryCard = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
addView(TextView(context).apply {
text = getString(R.string.permission_intro_title)
textSize = 22f
setTextColor(0xFFE2E8F0.toInt())
})
addView(TextView(context).apply {
text = getString(R.string.permission_intro_body)
textSize = 15f
setTextColor(0xFF94A3B8.toInt())
setPadding(0, pad / 2, 0, pad)
})
addView(TextView(context).apply {
text = getString(R.string.battery_hint)
textSize = 14f
background = GradientDrawable().apply {
setColor(0xFF334155.toInt()) // Slate 700
cornerRadius = 8 * density
setStroke((1 * density).toInt(), 0xFFF59E0B.toInt()) // Amber Border
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
topMargin = padHalf
}
visibility = View.GONE // Hidden by default; shown if needed at runtime
}
batteryCard.addView(TextView(this).apply {
text = "BACKGROUND SYNC EXEMPTION REQUIRED"
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
setTextColor(0xFFF59E0B.toInt()) // Amber 500
})
batteryCard.addView(TextView(this).apply {
text = getString(R.string.battery_hint)
textSize = 13f
setTextColor(0xFFCBD5E1.toInt()) // Slate 300
setPadding(0, padHalf / 2, 0, padHalf)
})
batteryCard.addView(Button(this).apply {
text = getString(R.string.open_battery_settings)
textSize = 12f
setTextColor(Color.WHITE)
background = GradientDrawable().apply {
setColor(0xFFD97706.toInt()) // Amber 600
cornerRadius = 4 * density
}
setOnClickListener { openBatteryOptimizationSettings() }
})
root.addView(batteryCard)
// Monospace Terminal Console Section
val consoleTitleLayout = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
topMargin = pad
bottomMargin = padHalf / 2
}
}
consoleTitleLayout.addView(TextView(this).apply {
text = "LIVE AGENT CONSOLE"
textSize = 12f
typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
setTextColor(0xFF38BDF8.toInt()) // Light Blue / Cyan 400
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
})
consoleTitleLayout.addView(Button(this).apply {
text = "CLEAR"
textSize = 11f
setTextColor(0xFF94A3B8.toInt())
background = GradientDrawable().apply {
setColor(0xFF1E293B.toInt()) // Slate 800
cornerRadius = 4 * density
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
(28 * density).toInt()
)
setOnClickListener { logConsole.text = "" }
})
root.addView(consoleTitleLayout)
logScrollView = ScrollView(this).apply {
background = GradientDrawable().apply {
setColor(0xFF1E293B.toInt()) // Dark Console background
cornerRadius = 6 * density
}
setPadding(padHalf, padHalf, padHalf, padHalf)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1.0f
)
}
// Horizontal Scroll for long log lines
val hscroll = HorizontalScrollView(this).apply {
isFillViewport = true
}
logConsole = TextView(this).apply {
textSize = 11f
typeface = Typeface.MONOSPACE
setTextColor(0xFF34D399.toInt()) // Emerald Green text
setLineSpacing(2f, 1.1f)
text = "Initializing AetherForge Fleet Console...\n"
}
hscroll.addView(logConsole)
logScrollView.addView(hscroll)
root.addView(logScrollView)
// Config Info details footer
val footerCard = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
background = GradientDrawable().apply {
setColor(0xFF1E293B.toInt())
cornerRadius = 8 * density
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
topMargin = pad
}
}
val addConfigRow = { label: String, keyText: String ->
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 2 * (density).toInt(), 0, 2 * (density).toInt())
}
row.addView(TextView(this).apply {
text = label
textSize = 11f
setTextColor(0xFF64748B.toInt())
setPadding(0, 0, 0, pad)
})
addView(Button(context).apply {
text = getString(R.string.open_battery_settings)
setOnClickListener { openBatteryOptimizationSettings() }
layoutParams = LinearLayout.LayoutParams((100 * density).toInt(), LinearLayout.LayoutParams.WRAP_CONTENT)
})
val valView = TextView(this).apply {
text = keyText
textSize = 11f
typeface = Typeface.MONOSPACE
setTextColor(0xFF94A3B8.toInt())
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
}
row.addView(valView)
footerCard.addView(row)
valView
}
// Populate dynamic config rows
val defaultCfg = AgentConfig.load(this)
configServerVal = addConfigRow("Server URL:", defaultCfg.serverUrl)
configNodeVal = addConfigRow("Fleet Node:", defaultCfg.workerName)
configBuildVal = addConfigRow("Build ID:", defaultCfg.buildId)
root.addView(footerCard)
return root
}
private fun updateStatusUi() {
val defaultCfg = AgentConfig.load(this)
configServerVal.text = defaultCfg.serverUrl
configNodeVal.text = defaultCfg.workerName
configBuildVal.text = defaultCfg.buildId
val density = resources.displayMetrics.density
if (AgentService.isRunning && AgentProcess.isAlive()) {
statusText.text = "AGENT CONNECTED"
statusText.setTextColor(0xFF34D399.toInt()) // Emerald Green
statusDot.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(0xFF34D399.toInt())
}
toggleButton.text = "STOP"
toggleButton.background = GradientDrawable().apply {
setColor(0xFFEF4444.toInt()) // Red 500
cornerRadius = 4 * density
}
} else {
statusText.text = "AGENT OFFLINE"
statusText.setTextColor(0xFF94A3B8.toInt())
statusDot.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(0xFF64748B.toInt())
}
toggleButton.text = "START"
toggleButton.background = GradientDrawable().apply {
setColor(0xFF0EA5E9.toInt()) // Cyan 500
cornerRadius = 4 * density
}
}
}
private fun checkBatteryOptimizationCard() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) {
batteryCard.visibility = View.GONE
} else {
batteryCard.visibility = View.VISIBLE
}
}
private fun toggleAgentService() {
if (AgentService.isRunning) {
val intent = Intent(this, AgentService::class.java)
stopService(intent)
Toast.makeText(this, "Stopped fleet service", Toast.LENGTH_SHORT).show()
} else {
startFleetService()
}
updateStatusUi()
}
private fun appendConsoleLog(line: String) {
logConsole.append(line + "\n")
val txt = logConsole.text
if (txt.length > 30000) {
val idx = txt.indexOf('\n', txt.length - 20000)
if (idx != -1) {
logConsole.text = txt.subSequence(idx + 1, txt.length)
}
}
scrollToBottom()
}
private fun scrollToBottom() {
logScrollView.post {
logScrollView.fullScroll(View.FOCUS_DOWN)
}
}
@@ -90,15 +423,22 @@ class MainActivity : AppCompatActivity() {
private fun beginPermissionFlow() {
pendingPermissions = requiredRuntimePermissions()
if (pendingPermissions.isEmpty()) {
openBatteryOptimizationSettings()
checkBatteryOptimizationSettingsFlow()
startFleetService()
return
}
permissionLauncher.launch(pendingPermissions.toTypedArray())
}
private fun checkBatteryOptimizationSettingsFlow() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
openBatteryOptimizationSettings()
}
}
private fun requestNextPermissionBatch() {
openBatteryOptimizationSettings()
checkBatteryOptimizationSettingsFlow()
startFleetService()
}
@@ -118,6 +458,5 @@ class MainActivity : AppCompatActivity() {
intent?.extras?.let { putExtras(it) }
}
AgentService.start(this, serviceIntent)
Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
}
}