Add Android APK fleet nodes with Crucible UI integration and tests.
APK wrapper registers platform=android via AETHERFORGE_PLATFORM; fleet UI shows robot icons, Android Access Depth probes, and a shortened mining onion timeline.
This commit is contained in:
49
android/agent-app/src/main/AndroidManifest.xml
Normal file
49
android/agent-app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<?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>
|
||||
1
android/agent-app/src/main/assets/agent.placeholder
Normal file
1
android/agent-app/src/main/assets/agent.placeholder
Normal file
@@ -0,0 +1 @@
|
||||
Placeholder — replaced by build-apk script with linux/arm64 agent binary at assets/agent.
|
||||
11
android/agent-app/src/main/assets/config.json
Normal file
11
android/agent-app/src/main/assets/config.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"build_id": "android-dev",
|
||||
"fleet_secret_set": false,
|
||||
"mining": {
|
||||
"enabled": false,
|
||||
"mode": "idle",
|
||||
"note": "CPU mining disabled by default; enable via server policy or re-forge."
|
||||
},
|
||||
"server_url": "http://test:8989",
|
||||
"worker_name": "test-node"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.aetherforge.agent
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
|
||||
data class AgentConfig(
|
||||
val workerName: 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 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" },
|
||||
serverUrl = server.ifBlank { "http://127.0.0.1:8989" },
|
||||
fleetSecret = secret,
|
||||
miningEnabled = mining,
|
||||
buildId = buildId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.aetherforge.agent
|
||||
|
||||
import android.util.Log
|
||||
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) {
|
||||
stop()
|
||||
val env = hashMapOf(
|
||||
"HOME" to filesDir.absolutePath,
|
||||
"TMPDIR" to filesDir.absolutePath,
|
||||
"AETHERFORGE_MINER_EXECUTION" to "inprocess",
|
||||
)
|
||||
config.fleetSecret?.let { env["AETHERFORGE_FLEET_SECRET"] = it }
|
||||
|
||||
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 stop() {
|
||||
process?.let {
|
||||
runCatching { it.destroy() }
|
||||
runCatching { it.waitFor() }
|
||||
}
|
||||
process = null
|
||||
}
|
||||
|
||||
fun isAlive(): Boolean = process?.isAlive == true
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 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
|
||||
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()
|
||||
if (pendingPermissions.isEmpty()) {
|
||||
openBatteryOptimizationSettings()
|
||||
startFleetService()
|
||||
return
|
||||
}
|
||||
permissionLauncher.launch(pendingPermissions.toTypedArray())
|
||||
}
|
||||
|
||||
private fun requestNextPermissionBatch() {
|
||||
openBatteryOptimizationSettings()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
3
android/agent-app/src/main/res/mipmap/ic_launcher.xml
Normal file
3
android/agent-app/src/main/res/mipmap/ic_launcher.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:src="@drawable/ic_launcher_foreground" />
|
||||
4
android/agent-app/src/main/res/values/colors.xml
Normal file
4
android/agent-app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0F172A</color>
|
||||
</resources>
|
||||
14
android/agent-app/src/main/res/values/strings.xml
Normal file
14
android/agent-app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?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>
|
||||
9
android/agent-app/src/main/res/values/themes.xml
Normal file
9
android/agent-app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?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>
|
||||
Reference in New Issue
Block a user