diff --git a/.gitignore b/.gitignore
index 9a41443..904178b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.theos/
packages/
obj/
+artifacts/
*.deb
*.ipa
*.dSYM/
diff --git a/README.md b/README.md
index a80ae64..6a8d620 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,9 @@ Build one deployable Eve runtime family:
- iOS: native full-screen renderer, multitouch controller, camera/mic/motion
sensor publisher, and low-latency media display.
- Android: native renderer and sensor publisher with the same CultNet contracts.
+- Flutter: likely native client framework for Android/iOS parity once installed;
+ it should consume the browser-defined CultMesh surface model rather than
+ replacing the browser as ground truth.
- Shared API: apps publish control surfaces and structured data; Eve publishes
commands, pointer/touch input, and timestamped sensor packets.
@@ -32,6 +35,11 @@ Build one deployable Eve runtime family:
The current checked-in client is the iOS Theos app, still named `EveCanvas` at
the bundle level until the wider Eve runtime split exists.
+The Android proof under `android/` is a small native Java client that can be
+built with the installed Android SDK without Gradle or Flutter. It exists to
+put Eve on Periwinkle immediately: it shows the Eve/CultMesh role, polls the
+Mimir dashboard broker, and surfaces timestamped touch/motion sensor data.
+
- `EVEAppDelegate` creates one fullscreen `UIWindow`.
- `EVEViewController` installs:
- `EVEGLView`, a `CAEAGLLayer` backed OpenGL ES render target.
@@ -133,6 +141,26 @@ same VoidBot `swarm-state.json` projection used by the web dashboard.
## Build Shape
+### Android / Periwinkle
+
+Build the Android proof APK from Starfire:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\build-android.ps1
+```
+
+Install to Periwinkle:
+
+```powershell
+& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" -s bad9dd01 install -r .\artifacts\android\eve-debug.apk
+& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" -s bad9dd01 shell monkey -p org.gamecult.eve -c android.intent.category.LAUNCHER 1
+```
+
+On Xiaomi/MIUI devices, ADB install may require enabling developer setting
+`Install via USB` and approving the on-device prompt.
+
+### iOS / EVE
+
The project is a Theos-style iOS application:
```sh
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..9826ec1
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/org/gamecult/eve/MainActivity.java b/android/app/src/main/java/org/gamecult/eve/MainActivity.java
new file mode 100644
index 0000000..43ede9c
--- /dev/null
+++ b/android/app/src/main/java/org/gamecult/eve/MainActivity.java
@@ -0,0 +1,196 @@
+package org.gamecult.eve;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public final class MainActivity extends Activity implements SensorEventListener {
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private final ExecutorService network = Executors.newSingleThreadExecutor();
+
+ private SensorManager sensorManager;
+ private TextView brokerText;
+ private TextView sensorText;
+ private TextView touchText;
+ private long touchSequence;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
+ setContentView(buildUi());
+ startSensors();
+ pollBroker();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (sensorManager != null) {
+ sensorManager.unregisterListener(this);
+ }
+ network.shutdownNow();
+ }
+
+ private ScrollView buildUi() {
+ ScrollView scroll = new ScrollView(this);
+ scroll.setFillViewport(true);
+ scroll.setBackgroundColor(Color.rgb(2, 9, 9));
+
+ LinearLayout root = new LinearLayout(this);
+ root.setOrientation(LinearLayout.VERTICAL);
+ root.setPadding(dp(18), dp(18), dp(18), dp(18));
+ scroll.addView(root, new ScrollView.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+
+ TextView title = label("EVE / PERIWINKLE", 26, 0xff8efcff, true);
+ root.addView(title);
+ root.addView(label("CultMesh streaming UI client\nAndroid native proof", 14, 0xffb7c7c7, false));
+
+ brokerText = card("CultNet broker\nconnecting to Mimir at 192.168.1.66:8795");
+ sensorText = card("timestamped sensors\nwaiting for accelerometer / gyro");
+ touchText = card("touch surface\nwaiting for operator input");
+ root.addView(brokerText);
+ root.addView(sensorText);
+ root.addView(touchText);
+
+ TextView contract = card("contract\nBrowser is the reference renderer. Periwinkle renders the same Eve surface documents natively and publishes timestamped local signals back into the mesh.");
+ root.addView(contract);
+ return scroll;
+ }
+
+ private TextView label(String text, int sp, int color, boolean title) {
+ TextView view = new TextView(this);
+ view.setText(text);
+ view.setTextSize(sp);
+ view.setTextColor(color);
+ view.setGravity(Gravity.START);
+ view.setIncludeFontPadding(true);
+ view.setTypeface(android.graphics.Typeface.MONOSPACE, title ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL);
+ return view;
+ }
+
+ private TextView card(String text) {
+ TextView view = label(text, 14, 0xffe6f1f1, false);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.setMargins(0, dp(14), 0, 0);
+ view.setLayoutParams(params);
+ view.setPadding(dp(14), dp(12), dp(14), dp(12));
+ view.setBackgroundColor(Color.rgb(7, 25, 24));
+ return view;
+ }
+
+ private void startSensors() {
+ if (sensorManager == null) {
+ sensorText.setText("timestamped sensors\nSensorManager unavailable");
+ return;
+ }
+ Sensor accel = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
+ Sensor gyro = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
+ if (accel != null) {
+ sensorManager.registerListener(this, accel, SensorManager.SENSOR_DELAY_GAME);
+ }
+ if (gyro != null) {
+ sensorManager.registerListener(this, gyro, SensorManager.SENSOR_DELAY_GAME);
+ }
+ }
+
+ private void pollBroker() {
+ network.execute(new Runnable() {
+ @Override
+ public void run() {
+ String text;
+ try {
+ URL url = new URL("http://192.168.1.66:8795/health");
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setConnectTimeout(1200);
+ connection.setReadTimeout(1200);
+ int status = connection.getResponseCode();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
+ StringBuilder body = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ body.append(line);
+ }
+ text = "CultNet broker\nHTTP " + status + "\n" + body;
+ } catch (Exception ex) {
+ text = "CultNet broker\nwaiting for Mimir\n" + ex.getClass().getSimpleName() + ": " + ex.getMessage();
+ }
+ final String finalText = text;
+ main.post(new Runnable() {
+ @Override
+ public void run() {
+ brokerText.setText(finalText);
+ }
+ });
+ main.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ pollBroker();
+ }
+ }, 2000);
+ }
+ });
+ }
+
+ @Override
+ public void onSensorChanged(SensorEvent event) {
+ long elapsedNs = SystemClock.elapsedRealtimeNanos();
+ String name = event.sensor.getName();
+ sensorText.setText(String.format(Locale.US,
+ "timestamped sensors\n%s\nsensorTs=%d\nelapsedNs=%d\nx=%+.3f y=%+.3f z=%+.3f",
+ name,
+ event.timestamp,
+ elapsedNs,
+ event.values.length > 0 ? event.values[0] : 0.0f,
+ event.values.length > 1 ? event.values[1] : 0.0f,
+ event.values.length > 2 ? event.values[2] : 0.0f));
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent event) {
+ touchSequence++;
+ touchText.setText(String.format(Locale.US,
+ "touch surface\nseq=%d action=%d pointers=%d eventTime=%d x=%.1f y=%.1f",
+ touchSequence,
+ event.getActionMasked(),
+ event.getPointerCount(),
+ event.getEventTime(),
+ event.getX(),
+ event.getY()));
+ return super.dispatchTouchEvent(event);
+ }
+
+ private int dp(int value) {
+ return (int) (value * getResources().getDisplayMetrics().density + 0.5f);
+ }
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..dbd6444
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Eve
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..3c837c6
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/docs/cultmesh-streaming-ui-framework.md b/docs/cultmesh-streaming-ui-framework.md
index 2175779..b02f6f3 100644
--- a/docs/cultmesh-streaming-ui-framework.md
+++ b/docs/cultmesh-streaming-ui-framework.md
@@ -85,6 +85,15 @@ Android:
- camera, mic, motion, and display timing publisher;
- native rendering where browser embedding would be weaker.
+Flutter:
+
+- likely shared native-client implementation path for Android/iOS parity;
+- useful for matching controls, layout primitives, animation, and local media
+ surfaces across native devices;
+- not the browser ground truth. Flutter Web may be a compatibility target, but
+ the canonical behavior still belongs to the browser Eve runtime and recorded
+ CultMesh surface fixtures.
+
## Current Proof
The current iOS app consumes Mimir's `/eve/deck` WebSocket broker. That broker is
@@ -94,6 +103,12 @@ provider proves that Eve can render an app-specific native cockpit from a
structured mesh snapshot: CTB rail, avatar images, selected Face/status pane,
state tree, and detail panel.
+The Android proof under `android/` builds directly against the installed Android
+SDK and runs on Periwinkle. It is deliberately small: display Eve's role, poll
+the Mimir broker health endpoint, and show timestamped motion/touch samples.
+It is a device-edge proof while the browser reference and Flutter/native shared
+client are still being cut.
+
## Next Cut
1. Extract the shared surface schema from the iOS app and Mimir broker into a
diff --git a/scripts/build-android.ps1 b/scripts/build-android.ps1
new file mode 100644
index 0000000..2a579e2
--- /dev/null
+++ b/scripts/build-android.ps1
@@ -0,0 +1,105 @@
+param(
+ [string] $AndroidSdk = "$env:LOCALAPPDATA\Android\Sdk",
+ [string] $JavaHome = "C:\Program Files\Android\Android Studio\jbr",
+ [string] $Configuration = "debug"
+)
+
+$ErrorActionPreference = "Stop"
+$env:JAVA_HOME = $JavaHome
+$env:PATH = "$(Join-Path $JavaHome 'bin');$env:PATH"
+
+$projectRoot = Split-Path -Parent $PSScriptRoot
+$androidRoot = Join-Path $projectRoot "android\app"
+$outRoot = Join-Path $projectRoot "artifacts\android"
+$packageName = "org.gamecult.eve"
+$buildTools = Get-ChildItem (Join-Path $AndroidSdk "build-tools") -Directory | Sort-Object Name -Descending | Select-Object -First 1
+$platform = Get-ChildItem (Join-Path $AndroidSdk "platforms") -Directory | Sort-Object Name -Descending | Select-Object -First 1
+
+if (-not $buildTools -or -not $platform) {
+ throw "Android SDK build-tools/platforms not found under $AndroidSdk"
+}
+
+$aapt2 = Join-Path $buildTools.FullName "aapt2.exe"
+$d8 = Join-Path $buildTools.FullName "d8.bat"
+$zipalign = Join-Path $buildTools.FullName "zipalign.exe"
+$apksigner = Join-Path $buildTools.FullName "apksigner.bat"
+$javac = Join-Path $JavaHome "bin\javac.exe"
+$keytool = Join-Path $JavaHome "bin\keytool.exe"
+$jar = Join-Path $JavaHome "bin\jar.exe"
+$androidJar = Join-Path $platform.FullName "android.jar"
+
+foreach ($tool in @($aapt2, $d8, $zipalign, $apksigner, $javac, $keytool, $jar, $androidJar)) {
+ if (-not (Test-Path $tool)) {
+ throw "Required Android build input missing: $tool"
+ }
+}
+
+function Invoke-Checked {
+ param(
+ [string] $FilePath,
+ [string[]] $Arguments
+ )
+
+ & $FilePath @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "$FilePath failed with exit code $LASTEXITCODE"
+ }
+}
+
+New-Item -ItemType Directory -Force $outRoot | Out-Null
+$compiled = Join-Path $outRoot "compiled.zip"
+$gen = Join-Path $outRoot "gen"
+$classes = Join-Path $outRoot "classes"
+$classesJar = Join-Path $outRoot "classes.jar"
+$dex = Join-Path $outRoot "dex"
+$unsigned = Join-Path $outRoot "eve-unsigned.apk"
+$aligned = Join-Path $outRoot "eve-aligned.apk"
+$signed = Join-Path $outRoot "eve-$Configuration.apk"
+$keystore = Join-Path $outRoot "eve-debug.keystore"
+
+Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $compiled, $gen, $classes, $classesJar, $dex, $unsigned, $aligned, $signed
+New-Item -ItemType Directory -Force $gen, $classes, $dex | Out-Null
+
+Invoke-Checked $aapt2 @("compile", "--dir", (Join-Path $androidRoot "src\main\res"), "-o", $compiled)
+Invoke-Checked $aapt2 @(
+ "link",
+ "-o", $unsigned,
+ "-I", $androidJar,
+ "--manifest", (Join-Path $androidRoot "src\main\AndroidManifest.xml"),
+ "--java", $gen,
+ "--min-sdk-version", "26",
+ "--target-sdk-version", "35",
+ $compiled)
+
+$sources = @()
+$sources += Get-ChildItem (Join-Path $androidRoot "src\main\java") -Recurse -Filter *.java | Select-Object -ExpandProperty FullName
+$sources += Get-ChildItem $gen -Recurse -Filter *.java | Select-Object -ExpandProperty FullName
+Invoke-Checked $javac (@("-source", "8", "-target", "8", "-bootclasspath", $androidJar, "-d", $classes) + $sources)
+Invoke-Checked $jar @("cf", $classesJar, "-C", $classes, ".")
+Invoke-Checked $d8 @("--lib", $androidJar, "--output", $dex, $classesJar)
+Invoke-Checked $jar @("uf", $unsigned, "-C", $dex, "classes.dex")
+Invoke-Checked $zipalign @("-f", "4", $unsigned, $aligned)
+
+if (-not (Test-Path $keystore)) {
+ Invoke-Checked $keytool @(
+ "-genkeypair", "-v",
+ "-keystore", $keystore,
+ "-storepass", "android",
+ "-keypass", "android",
+ "-alias", "eve-debug",
+ "-keyalg", "RSA",
+ "-keysize", "2048",
+ "-validity", "10000",
+ "-dname", "CN=Eve Debug,O=GameCult,C=US")
+}
+
+Invoke-Checked $apksigner @(
+ "sign",
+ "--ks", $keystore,
+ "--ks-pass", "pass:android",
+ "--key-pass", "pass:android",
+ "--out", $signed,
+ $aligned)
+
+Invoke-Checked $apksigner @("verify", $signed)
+Write-Host "Built $signed"