perf(android): tighten startup path and add perf tooling

This commit is contained in:
Ayaan Zaidi
2026-02-25 21:34:15 +05:30
committed by Ayaan Zaidi
parent 4a07c89816
commit b49c2cbdd9
8 changed files with 454 additions and 59 deletions

View File

@@ -1,9 +1,7 @@
package ai.openclaw.android package ai.openclaw.android
import android.content.pm.ApplicationInfo
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import android.webkit.WebView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
@@ -25,9 +23,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
WebView.setWebContentsDebuggingEnabled(isDebuggable)
NodeForegroundService.start(this)
permissionRequester = PermissionRequester(this) permissionRequester = PermissionRequester(this)
screenCaptureRequester = ScreenCaptureRequester(this) screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this) viewModel.camera.attachLifecycleOwner(this)
@@ -55,6 +50,9 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
// Keep startup path lean: start foreground service after first frame.
window.decorView.post { NodeForegroundService.start(this) }
} }
override fun onStart() { override fun onStart() {

View File

@@ -20,19 +20,21 @@ class SecurePrefs(context: Context) {
val defaultWakeWords: List<String> = listOf("openclaw", "claude") val defaultWakeWords: List<String> = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName" private const val displayNameKey = "node.displayName"
private const val voiceWakeModeKey = "voiceWake.mode" private const val voiceWakeModeKey = "voiceWake.mode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
} }
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val plainPrefs: SharedPreferences =
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
private val masterKey = private val masterKey by lazy {
MasterKey.Builder(context) MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
private val prefs: SharedPreferences by lazy {
createPrefs(appContext, "openclaw.node.secure")
} }
private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId()) private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow<String> = _instanceId val instanceId: StateFlow<String> = _instanceId
@@ -41,52 +43,51 @@ class SecurePrefs(context: Context) {
MutableStateFlow(loadOrMigrateDisplayName(context = context)) MutableStateFlow(loadOrMigrateDisplayName(context = context))
val displayName: StateFlow<String> = _displayName val displayName: StateFlow<String> = _displayName
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true)) private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode = private val _locationMode =
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off"))) MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off")))
val locationMode: StateFlow<LocationMode> = _locationMode val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled = private val _locationPreciseEnabled =
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true)) MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep val preventSleep: StateFlow<Boolean> = _preventSleep
private val _manualEnabled = private val _manualEnabled =
MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false)) MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
val manualEnabled: StateFlow<Boolean> = _manualEnabled val manualEnabled: StateFlow<Boolean> = _manualEnabled
private val _manualHost = private val _manualHost =
MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "") MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
val manualHost: StateFlow<String> = _manualHost val manualHost: StateFlow<String> = _manualHost
private val _manualPort = private val _manualPort =
MutableStateFlow(prefs.getInt("gateway.manual.port", 18789)) MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
val manualPort: StateFlow<Int> = _manualPort val manualPort: StateFlow<Int> = _manualPort
private val _manualTls = private val _manualTls =
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true)) MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow<Boolean> = _manualTls val manualTls: StateFlow<Boolean> = _manualTls
private val _gatewayToken = private val _gatewayToken = MutableStateFlow("")
MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "")
val gatewayToken: StateFlow<String> = _gatewayToken val gatewayToken: StateFlow<String> = _gatewayToken
private val _onboardingCompleted = private val _onboardingCompleted =
MutableStateFlow(prefs.getBoolean("onboarding.completed", false)) MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
private val _lastDiscoveredStableId = private val _lastDiscoveredStableId =
MutableStateFlow( MutableStateFlow(
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
) )
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled = private val _canvasDebugStatusEnabled =
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false)) MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _wakeWords = MutableStateFlow(loadWakeWords()) private val _wakeWords = MutableStateFlow(loadWakeWords())
@@ -95,65 +96,65 @@ class SecurePrefs(context: Context) {
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode()) private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false)) private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
val talkEnabled: StateFlow<Boolean> = _talkEnabled val talkEnabled: StateFlow<Boolean> = _talkEnabled
fun setLastDiscoveredStableId(value: String) { fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim() val trimmed = value.trim()
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
_lastDiscoveredStableId.value = trimmed _lastDiscoveredStableId.value = trimmed
} }
fun setDisplayName(value: String) { fun setDisplayName(value: String) {
val trimmed = value.trim() val trimmed = value.trim()
prefs.edit { putString(displayNameKey, trimmed) } plainPrefs.edit { putString(displayNameKey, trimmed) }
_displayName.value = trimmed _displayName.value = trimmed
} }
fun setCameraEnabled(value: Boolean) { fun setCameraEnabled(value: Boolean) {
prefs.edit { putBoolean("camera.enabled", value) } plainPrefs.edit { putBoolean("camera.enabled", value) }
_cameraEnabled.value = value _cameraEnabled.value = value
} }
fun setLocationMode(mode: LocationMode) { fun setLocationMode(mode: LocationMode) {
prefs.edit { putString("location.enabledMode", mode.rawValue) } plainPrefs.edit { putString("location.enabledMode", mode.rawValue) }
_locationMode.value = mode _locationMode.value = mode
} }
fun setLocationPreciseEnabled(value: Boolean) { fun setLocationPreciseEnabled(value: Boolean) {
prefs.edit { putBoolean("location.preciseEnabled", value) } plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value _locationPreciseEnabled.value = value
} }
fun setPreventSleep(value: Boolean) { fun setPreventSleep(value: Boolean) {
prefs.edit { putBoolean("screen.preventSleep", value) } plainPrefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value _preventSleep.value = value
} }
fun setManualEnabled(value: Boolean) { fun setManualEnabled(value: Boolean) {
prefs.edit { putBoolean("gateway.manual.enabled", value) } plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
_manualEnabled.value = value _manualEnabled.value = value
} }
fun setManualHost(value: String) { fun setManualHost(value: String) {
val trimmed = value.trim() val trimmed = value.trim()
prefs.edit { putString("gateway.manual.host", trimmed) } plainPrefs.edit { putString("gateway.manual.host", trimmed) }
_manualHost.value = trimmed _manualHost.value = trimmed
} }
fun setManualPort(value: Int) { fun setManualPort(value: Int) {
prefs.edit { putInt("gateway.manual.port", value) } plainPrefs.edit { putInt("gateway.manual.port", value) }
_manualPort.value = value _manualPort.value = value
} }
fun setManualTls(value: Boolean) { fun setManualTls(value: Boolean) {
prefs.edit { putBoolean("gateway.manual.tls", value) } plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
_manualTls.value = value _manualTls.value = value
} }
fun setGatewayToken(value: String) { fun setGatewayToken(value: String) {
val trimmed = value.trim() val trimmed = value.trim()
prefs.edit { putString("gateway.manual.token", trimmed) } securePrefs.edit { putString("gateway.manual.token", trimmed) }
_gatewayToken.value = trimmed _gatewayToken.value = trimmed
} }
@@ -162,62 +163,67 @@ class SecurePrefs(context: Context) {
} }
fun setOnboardingCompleted(value: Boolean) { fun setOnboardingCompleted(value: Boolean) {
prefs.edit { putBoolean("onboarding.completed", value) } plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value _onboardingCompleted.value = value
} }
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value _canvasDebugStatusEnabled.value = value
} }
fun loadGatewayToken(): String? { fun loadGatewayToken(): String? {
val manual = _gatewayToken.value.trim() val manual =
_gatewayToken.value.trim().ifEmpty {
val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty()
if (stored.isNotEmpty()) _gatewayToken.value = stored
stored
}
if (manual.isNotEmpty()) return manual if (manual.isNotEmpty()) return manual
val key = "gateway.token.${_instanceId.value}" val key = "gateway.token.${_instanceId.value}"
val stored = prefs.getString(key, null)?.trim() val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() } return stored?.takeIf { it.isNotEmpty() }
} }
fun saveGatewayToken(token: String) { fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}" val key = "gateway.token.${_instanceId.value}"
prefs.edit { putString(key, token.trim()) } securePrefs.edit { putString(key, token.trim()) }
} }
fun loadGatewayPassword(): String? { fun loadGatewayPassword(): String? {
val key = "gateway.password.${_instanceId.value}" val key = "gateway.password.${_instanceId.value}"
val stored = prefs.getString(key, null)?.trim() val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() } return stored?.takeIf { it.isNotEmpty() }
} }
fun saveGatewayPassword(password: String) { fun saveGatewayPassword(password: String) {
val key = "gateway.password.${_instanceId.value}" val key = "gateway.password.${_instanceId.value}"
prefs.edit { putString(key, password.trim()) } securePrefs.edit { putString(key, password.trim()) }
} }
fun loadGatewayTlsFingerprint(stableId: String): String? { fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId" val key = "gateway.tls.$stableId"
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
} }
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) { fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
val key = "gateway.tls.$stableId" val key = "gateway.tls.$stableId"
prefs.edit { putString(key, fingerprint.trim()) } plainPrefs.edit { putString(key, fingerprint.trim()) }
} }
fun getString(key: String): String? { fun getString(key: String): String? {
return prefs.getString(key, null) return securePrefs.getString(key, null)
} }
fun putString(key: String, value: String) { fun putString(key: String, value: String) {
prefs.edit { putString(key, value) } securePrefs.edit { putString(key, value) }
} }
fun remove(key: String) { fun remove(key: String) {
prefs.edit { remove(key) } securePrefs.edit { remove(key) }
} }
private fun createPrefs(context: Context, name: String): SharedPreferences { private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
return EncryptedSharedPreferences.create( return EncryptedSharedPreferences.create(
context, context,
name, name,
@@ -228,21 +234,21 @@ class SecurePrefs(context: Context) {
} }
private fun loadOrCreateInstanceId(): String { private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim() val existing = plainPrefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString() val fresh = UUID.randomUUID().toString()
prefs.edit { putString("node.instanceId", fresh) } plainPrefs.edit { putString("node.instanceId", fresh) }
return fresh return fresh
} }
private fun loadOrMigrateDisplayName(context: Context): String { private fun loadOrMigrateDisplayName(context: Context): String {
val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty() val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
if (existing.isNotEmpty() && existing != "Android Node") return existing if (existing.isNotEmpty() && existing != "Android Node") return existing
val candidate = DeviceNames.bestDefaultNodeName(context).trim() val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" } val resolved = candidate.ifEmpty { "Android Node" }
prefs.edit { putString(displayNameKey, resolved) } plainPrefs.edit { putString(displayNameKey, resolved) }
return resolved return resolved
} }
@@ -250,34 +256,34 @@ class SecurePrefs(context: Context) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords) val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded = val encoded =
JsonArray(sanitized.map { JsonPrimitive(it) }).toString() JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
prefs.edit { putString("voiceWake.triggerWords", encoded) } plainPrefs.edit { putString("voiceWake.triggerWords", encoded) }
_wakeWords.value = sanitized _wakeWords.value = sanitized
} }
fun setVoiceWakeMode(mode: VoiceWakeMode) { fun setVoiceWakeMode(mode: VoiceWakeMode) {
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) } plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
_voiceWakeMode.value = mode _voiceWakeMode.value = mode
} }
fun setTalkEnabled(value: Boolean) { fun setTalkEnabled(value: Boolean) {
prefs.edit { putBoolean("talk.enabled", value) } plainPrefs.edit { putBoolean("talk.enabled", value) }
_talkEnabled.value = value _talkEnabled.value = value
} }
private fun loadVoiceWakeMode(): VoiceWakeMode { private fun loadVoiceWakeMode(): VoiceWakeMode {
val raw = prefs.getString(voiceWakeModeKey, null) val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw) val resolved = VoiceWakeMode.fromRawValue(raw)
// Default ON (foreground) when unset. // Default ON (foreground) when unset.
if (raw.isNullOrBlank()) { if (raw.isNullOrBlank()) {
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
} }
return resolved return resolved
} }
private fun loadWakeWords(): List<String> { private fun loadWakeWords(): List<String> {
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim() val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
if (raw.isNullOrEmpty()) return defaultWakeWords if (raw.isNullOrEmpty()) return defaultWakeWords
return try { return try {
val element = json.parseToJsonElement(raw) val element = json.parseToJsonElement(raw)
@@ -295,5 +301,4 @@ class SecurePrefs(context: Context) {
defaultWakeWords defaultWakeWords
} }
} }
} }

View File

@@ -0,0 +1,36 @@
plugins {
id("com.android.test")
}
android {
namespace = "ai.openclaw.android.benchmark"
compileSdk = 36
defaultConfig {
minSdk = 31
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR"
}
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
implementation("androidx.test.ext:junit:1.2.1")
implementation("androidx.test.uiautomator:uiautomator:2.4.0-alpha06")
}

View File

@@ -0,0 +1,76 @@
package ai.openclaw.android.benchmark
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class StartupMacrobenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
private val packageName = "ai.openclaw.android"
@Test
fun coldStartup() {
runBenchmarkOrSkip {
benchmarkRule.measureRepeated(
packageName = packageName,
metrics = listOf(StartupTimingMetric()),
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.None(),
iterations = 10,
) {
pressHome()
startActivityAndWait()
}
}
}
@Test
fun startupAndScrollFrameTiming() {
runBenchmarkOrSkip {
benchmarkRule.measureRepeated(
packageName = packageName,
metrics = listOf(FrameTimingMetric()),
startupMode = StartupMode.WARM,
compilationMode = CompilationMode.None(),
iterations = 10,
) {
startActivityAndWait()
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
val x = device.displayWidth / 2
val yStart = (device.displayHeight * 0.8f).toInt()
val yEnd = (device.displayHeight * 0.25f).toInt()
repeat(4) {
device.swipe(x, yStart, x, yEnd, 24)
device.waitForIdle()
}
}
}
}
private fun runBenchmarkOrSkip(run: () -> Unit) {
try {
run()
} catch (err: IllegalStateException) {
val message = err.message.orEmpty()
val knownDeviceIssue =
message.contains("Unable to confirm activity launch completion") ||
message.contains("no renderthread slices", ignoreCase = true)
if (knownDeviceIssue) {
assumeTrue("Skipping benchmark on this device: $message", false)
}
throw err
}
}
}

View File

@@ -1,5 +1,6 @@
plugins { plugins {
id("com.android.application") version "9.0.1" apply false id("com.android.application") version "9.0.1" apply false
id("com.android.test") version "9.0.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
} }

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
RESULTS_DIR="$ANDROID_DIR/benchmark/results"
CLASS_FILTER="ai.openclaw.android.benchmark.StartupMacrobenchmark#coldStartup"
BASELINE_JSON=""
usage() {
cat <<'EOF'
Usage:
./scripts/perf-startup-benchmark.sh [--baseline <benchmarkData.json>]
Runs cold-start macrobenchmark only, then prints a compact summary.
Also saves a timestamped snapshot JSON under benchmark/results/.
If --baseline is omitted, compares against latest previous snapshot when available.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--baseline)
BASELINE_JSON="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown arg: $1" >&2
usage >&2
exit 2
;;
esac
done
if ! command -v jq >/dev/null 2>&1; then
echo "jq required but missing." >&2
exit 1
fi
if ! command -v adb >/dev/null 2>&1; then
echo "adb required but missing." >&2
exit 1
fi
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
if [[ "$device_count" -lt 1 ]]; then
echo "No connected Android device (adb state=device)." >&2
exit 1
fi
mkdir -p "$RESULTS_DIR"
run_log="$(mktemp -t openclaw-android-bench.XXXXXX.log)"
trap 'rm -f "$run_log"' EXIT
cd "$ANDROID_DIR"
./gradlew :benchmark:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class="$CLASS_FILTER" \
--console=plain \
>"$run_log" 2>&1
latest_json="$(
find "$ANDROID_DIR/benchmark/build/outputs/connected_android_test_additional_output/debug/connected" \
-name '*benchmarkData.json' -type f \
| while IFS= read -r file; do
printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
done \
| sort -nr \
| head -n1 \
| cut -f2-
)"
if [[ -z "$latest_json" || ! -f "$latest_json" ]]; then
echo "benchmarkData.json not found after run." >&2
tail -n 120 "$run_log" >&2
exit 1
fi
timestamp="$(date +%Y%m%d-%H%M%S)"
snapshot_json="$RESULTS_DIR/startup-$timestamp.json"
cp "$latest_json" "$snapshot_json"
median_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$snapshot_json")"
min_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.minimum' "$snapshot_json")"
max_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.maximum' "$snapshot_json")"
cov="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.coefficientOfVariation' "$snapshot_json")"
device="$(jq -r '.context.build.model' "$snapshot_json")"
sdk="$(jq -r '.context.build.version.sdk' "$snapshot_json")"
runs_count="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.runs | length' "$snapshot_json")"
printf 'startup.cold.median_ms=%.3f min_ms=%.3f max_ms=%.3f cov=%.4f runs=%s device=%s sdk=%s\n' \
"$median_ms" "$min_ms" "$max_ms" "$cov" "$runs_count" "$device" "$sdk"
echo "snapshot_json=$snapshot_json"
if [[ -z "$BASELINE_JSON" ]]; then
BASELINE_JSON="$(
find "$RESULTS_DIR" -name 'startup-*.json' -type f \
| while IFS= read -r file; do
if [[ "$file" == "$snapshot_json" ]]; then
continue
fi
printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
done \
| sort -nr \
| head -n1 \
| cut -f2-
)"
fi
if [[ -n "$BASELINE_JSON" ]]; then
if [[ ! -f "$BASELINE_JSON" ]]; then
echo "Baseline file missing: $BASELINE_JSON" >&2
exit 1
fi
base_median="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$BASELINE_JSON")"
delta_ms="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { printf "%.3f", (a-b) }')"
delta_pct="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { if (b==0) { print "nan" } else { printf "%.2f", ((a-b)/b)*100 } }')"
echo "baseline_median_ms=$base_median delta_ms=$delta_ms delta_pct=$delta_pct%"
fi

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
PACKAGE="ai.openclaw.android"
ACTIVITY=".MainActivity"
DURATION_SECONDS="10"
OUTPUT_PERF_DATA=""
usage() {
cat <<'EOF'
Usage:
./scripts/perf-startup-hotspots.sh [--package <pkg>] [--activity <activity>] [--duration <sec>] [--out <perf.data>]
Captures startup CPU profile via simpleperf (app_profiler.py), then prints concise hotspot summaries.
Default package/activity target OpenClaw Android startup.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--package)
PACKAGE="${2:-}"
shift 2
;;
--activity)
ACTIVITY="${2:-}"
shift 2
;;
--duration)
DURATION_SECONDS="${2:-}"
shift 2
;;
--out)
OUTPUT_PERF_DATA="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown arg: $1" >&2
usage >&2
exit 2
;;
esac
done
if ! command -v uv >/dev/null 2>&1; then
echo "uv required but missing." >&2
exit 1
fi
if ! command -v adb >/dev/null 2>&1; then
echo "adb required but missing." >&2
exit 1
fi
if [[ -z "$OUTPUT_PERF_DATA" ]]; then
OUTPUT_PERF_DATA="/tmp/openclaw-startup-$(date +%Y%m%d-%H%M%S).perf.data"
fi
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
if [[ "$device_count" -lt 1 ]]; then
echo "No connected Android device (adb state=device)." >&2
exit 1
fi
simpleperf_dir=""
if [[ -n "${ANDROID_NDK_HOME:-}" && -f "${ANDROID_NDK_HOME}/simpleperf/app_profiler.py" ]]; then
simpleperf_dir="${ANDROID_NDK_HOME}/simpleperf"
elif [[ -n "${ANDROID_NDK_ROOT:-}" && -f "${ANDROID_NDK_ROOT}/simpleperf/app_profiler.py" ]]; then
simpleperf_dir="${ANDROID_NDK_ROOT}/simpleperf"
else
latest_simpleperf="$(ls -d "${HOME}/Library/Android/sdk/ndk/"*/simpleperf 2>/dev/null | sort -V | tail -n1 || true)"
if [[ -n "$latest_simpleperf" && -f "$latest_simpleperf/app_profiler.py" ]]; then
simpleperf_dir="$latest_simpleperf"
fi
fi
if [[ -z "$simpleperf_dir" ]]; then
echo "simpleperf not found. Set ANDROID_NDK_HOME or install NDK under ~/Library/Android/sdk/ndk/." >&2
exit 1
fi
app_profiler="$simpleperf_dir/app_profiler.py"
report_py="$simpleperf_dir/report.py"
ndk_path="$(cd -- "$simpleperf_dir/.." && pwd)"
tmp_dir="$(mktemp -d -t openclaw-android-hotspots.XXXXXX)"
trap 'rm -rf "$tmp_dir"' EXIT
capture_log="$tmp_dir/capture.log"
dso_csv="$tmp_dir/dso.csv"
symbols_csv="$tmp_dir/symbols.csv"
children_txt="$tmp_dir/children.txt"
cd "$ANDROID_DIR"
./gradlew :app:installDebug --console=plain >"$tmp_dir/install.log" 2>&1
if ! uv run --no-project python3 "$app_profiler" \
-p "$PACKAGE" \
-a "$ACTIVITY" \
-o "$OUTPUT_PERF_DATA" \
--ndk_path "$ndk_path" \
-r "-e task-clock:u -f 1000 -g --duration $DURATION_SECONDS" \
>"$capture_log" 2>&1; then
echo "simpleperf capture failed. tail(capture_log):" >&2
tail -n 120 "$capture_log" >&2
exit 1
fi
uv run --no-project python3 "$report_py" \
-i "$OUTPUT_PERF_DATA" \
--sort dso \
--csv \
--csv-separator "|" \
--include-process-name "$PACKAGE" \
>"$dso_csv" 2>"$tmp_dir/report-dso.err"
uv run --no-project python3 "$report_py" \
-i "$OUTPUT_PERF_DATA" \
--sort dso,symbol \
--csv \
--csv-separator "|" \
--include-process-name "$PACKAGE" \
>"$symbols_csv" 2>"$tmp_dir/report-symbols.err"
uv run --no-project python3 "$report_py" \
-i "$OUTPUT_PERF_DATA" \
--children \
--sort dso,symbol \
-n \
--percent-limit 0.2 \
--include-process-name "$PACKAGE" \
>"$children_txt" 2>"$tmp_dir/report-children.err"
clean_csv() {
awk 'BEGIN{print_on=0} /^Overhead\|/{print_on=1} print_on==1{print}' "$1"
}
echo "perf_data=$OUTPUT_PERF_DATA"
echo
echo "top_dso_self:"
clean_csv "$dso_csv" | tail -n +2 | awk -F'|' 'NR<=10 {printf " %s %s\n", $1, $2}'
echo
echo "top_symbols_self:"
clean_csv "$symbols_csv" | tail -n +2 | awk -F'|' 'NR<=20 {printf " %s %s :: %s\n", $1, $2, $3}'
echo
echo "app_path_clues_children:"
rg 'androidx\.compose|MainActivity|NodeRuntime|NodeForegroundService|SecurePrefs|WebView|libwebviewchromium' "$children_txt" | awk 'NR<=20 {print}' || true

View File

@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "OpenClawNodeAndroid" rootProject.name = "OpenClawNodeAndroid"
include(":app") include(":app")
include(":benchmark")