package {{PACKAGE_NAME}}
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.ActivityManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Debug
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.ResultReceiver
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.sun.jna.Library
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.Structure
import org.json.JSONArray
import org.json.JSONObject
private const val DEFAULT_FUNCTION = "{{DEFAULT_FUNCTION}}"
private const val DEFAULT_ITERATIONS = 20u
private const val DEFAULT_WARMUP = 3u
private const val DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS = {{ANDROID_BENCHMARK_TIMEOUT_SECS}}L
private const val DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS = {{ANDROID_HEARTBEAT_INTERVAL_SECS}}L
private const val FUNCTION_EXTRA = "bench_function"
private const val ITERATIONS_EXTRA = "bench_iterations"
private const val WARMUP_EXTRA = "bench_warmup"
private const val TIMEOUT_EXTRA = "bench_timeout_secs"
private const val HEARTBEAT_EXTRA = "bench_heartbeat_interval_secs"
private const val SPEC_ASSET = "bench_spec.json"
private const val RUN_BENCHMARK_ACTION = "{{PACKAGE_NAME}}.RUN_BENCHMARK"
private const val RESULT_RECEIVER_EXTRA = "bench_result_receiver"
private const val RESULT_DISPLAY_EXTRA = "bench_display"
private const val RESULT_ERROR_EXTRA = "bench_error"
private const val RESULT_FAILURE_JSON_EXTRA = "bench_failure_json"
private const val RESULT_PID_EXTRA = "bench_pid"
private const val RESULT_PROCESS_NAME_EXTRA = "bench_process_name"
private const val FOREGROUND_NOTIFICATION_ID = 1001
private const val FOREGROUND_CHANNEL_ID = "mobench_benchmark"
private const val FOREGROUND_CHANNEL_NAME = "Mobench benchmark"
private const val WORKER_PROCESS_SUFFIX = ":mobench_worker"
private object MobenchResultCode {
const val SUCCESS = 1
const val ERROR = 2
const val HEARTBEAT = 3
}
class MainActivity : AppCompatActivity() {
@Volatile private var benchmarkComplete = false
@Volatile private var benchmarkFailed = false
@Volatile private var failureJson: String? = null
@Volatile private var workerPid: Int? = null
@Volatile private var workerProcessName: String? = null
@Volatile private var lastProgressAtMs: Long = 0L
@Volatile private var startedAtMs: Long = 0L
private lateinit var params: BenchParams
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val resultText = findViewById<TextView>(R.id.result_text)
resultText?.text = "Running benchmark..."
params = resolveBenchParams()
startedAtMs = android.os.SystemClock.elapsedRealtime()
lastProgressAtMs = startedAtMs
val resultReceiver = object : ResultReceiver(Handler(Looper.getMainLooper())) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == MobenchResultCode.HEARTBEAT) {
workerPid = resultData?.getInt(RESULT_PID_EXTRA)?.takeIf { it > 0 }
workerProcessName = resultData?.getString(RESULT_PROCESS_NAME_EXTRA)
lastProgressAtMs = android.os.SystemClock.elapsedRealtime()
return
}
if (resultCode == MobenchResultCode.SUCCESS) {
val display = resultData?.getString(RESULT_DISPLAY_EXTRA)
?: "Benchmark worker completed"
resultText?.text = display
benchmarkComplete = true
android.util.Log.i("BenchRunner", "Benchmark worker completed")
} else {
val payload = resultData?.getString(RESULT_FAILURE_JSON_EXTRA)
val display = resultData?.getString(RESULT_DISPLAY_EXTRA)
?: resultData?.getString(RESULT_ERROR_EXTRA)
?: "Benchmark worker returned no result"
resultText?.text = display
benchmarkFailed = true
benchmarkComplete = true
failureJson = payload
if (payload == null) {
emitFailure("benchmark_error", display)
}
android.util.Log.e("BenchRunner", display)
}
}
}
try {
val intent = Intent(this, BenchmarkWorkerService::class.java).apply {
action = RUN_BENCHMARK_ACTION
putExtra(FUNCTION_EXTRA, params.function)
putExtra(ITERATIONS_EXTRA, params.iterations.toInt())
putExtra(WARMUP_EXTRA, params.warmup.toInt())
putExtra(TIMEOUT_EXTRA, params.timeoutSecs)
putExtra(HEARTBEAT_EXTRA, params.heartbeatIntervalSecs)
putExtra(RESULT_RECEIVER_EXTRA, resultReceiver)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
} catch (e: Exception) {
android.util.Log.e("BenchRunner", "Failed to run benchmark worker", e)
resultText?.text = "Failed to run benchmark worker: ${e.message}"
emitFailure("exception", "Failed to run benchmark worker: ${e.message}")
}
}
fun isBenchmarkComplete(): Boolean = benchmarkComplete
fun isBenchmarkFailed(): Boolean = benchmarkFailed
fun getBenchmarkFailureJson(): String? = failureJson
fun benchmarkTimeoutSecs(): Long = params.timeoutSecs
fun heartbeatIntervalSecs(): Long = params.heartbeatIntervalSecs
fun checkWorkerExit(): String? {
if (benchmarkComplete) {
return failureJson
}
val expectedProcessName = workerProcessName ?: "$packageName$WORKER_PROCESS_SUFFIX"
val pid = workerPid
val alive = runningAppProcesses().any {
(pid != null && it.pid == pid) || it.processName == expectedProcessName
}
val graceMs = params.heartbeatIntervalSecs.coerceAtLeast(1L) * 3_000L
val stale = android.os.SystemClock.elapsedRealtime() - lastProgressAtMs > graceMs
if (!alive && stale) {
emitFailure("worker_exit", "Benchmark worker process exited before BENCH_JSON was emitted")
}
return failureJson
}
fun emitTimeoutFailureFromTest(): String {
if (failureJson == null) {
emitFailure("timeout", "Timed out waiting ${params.timeoutSecs}s for benchmark completion")
}
return failureJson ?: "{}"
}
private fun emitFailure(kind: String, message: String) {
if (failureJson != null) {
return
}
failureJson = JSONObject()
.put("schema_version", 1)
.put("kind", kind)
.put("message", message)
.put("elapsed_ms", android.os.SystemClock.elapsedRealtime() - startedAtMs)
.put("pid", workerPid ?: JSONObject.NULL)
.put("process_name", workerProcessName ?: "$packageName$WORKER_PROCESS_SUFFIX")
.toString()
benchmarkFailed = true
benchmarkComplete = true
android.util.Log.e("BenchRunner", "BENCH_FAILURE_JSON ${failureJson}")
}
private fun resolveBenchParams(): BenchParams {
val assetParams = loadBenchParamsFromAssets()
val defaults = assetParams ?: BenchParams(
DEFAULT_FUNCTION,
DEFAULT_ITERATIONS,
DEFAULT_WARMUP,
DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS,
DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS,
)
val intentFunction = intent?.getStringExtra(FUNCTION_EXTRA)?.takeUnless { it.isBlank() }
val intentIterations = intent?.let {
val value = it.getIntExtra(ITERATIONS_EXTRA, -1)
if (value >= 0) value.toUInt() else null
}
val intentWarmup = intent?.let {
val value = it.getIntExtra(WARMUP_EXTRA, -1)
if (value >= 0) value.toUInt() else null
}
val timeoutSecs = intent?.getLongExtra(TIMEOUT_EXTRA, defaults.timeoutSecs)
?: defaults.timeoutSecs
val heartbeatIntervalSecs = intent?.getLongExtra(
HEARTBEAT_EXTRA,
defaults.heartbeatIntervalSecs,
) ?: defaults.heartbeatIntervalSecs
val fn = intentFunction ?: defaults.function
val iterations = intentIterations ?: defaults.iterations
val warmup = intentWarmup ?: defaults.warmup
android.util.Log.i("BenchRunner", "Resolved params: function=$fn, iterations=$iterations, warmup=$warmup")
return BenchParams(fn, iterations, warmup, timeoutSecs, heartbeatIntervalSecs)
}
private fun loadBenchParamsFromAssets(): BenchParams? {
return try {
val raw = assets.open(SPEC_ASSET).bufferedReader().use { it.readText() }
if (raw.isBlank()) {
android.util.Log.w("BenchRunner", "bench_spec.json exists but is empty, using defaults")
null
} else {
val json = JSONObject(raw)
val function = json.optString("function", DEFAULT_FUNCTION)
val iterations = json.optInt("iterations", DEFAULT_ITERATIONS.toInt()).toUInt()
val warmup = json.optInt("warmup", DEFAULT_WARMUP.toInt()).toUInt()
android.util.Log.i("BenchRunner", "Loaded config from bench_spec.json: function=$function, iterations=$iterations, warmup=$warmup")
BenchParams(
function,
iterations,
warmup,
DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS,
DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS,
)
}
} catch (e: java.io.FileNotFoundException) {
android.util.Log.d("BenchRunner", "No bench_spec.json in assets, will use intent extras or defaults")
null
} catch (e: Exception) {
android.util.Log.e("BenchRunner", "Failed to parse bench_spec.json from assets", e)
null
}
}
}
class BenchmarkWorkerService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != RUN_BENCHMARK_ACTION) {
stopSelf(startId)
return START_NOT_STICKY
}
val resultReceiver = intent.resultReceiverExtra()
val params = BenchParams(
function = intent.getStringExtra(FUNCTION_EXTRA)?.takeUnless { it.isBlank() } ?: DEFAULT_FUNCTION,
iterations = intent.getIntExtra(ITERATIONS_EXTRA, DEFAULT_ITERATIONS.toInt()).toUInt(),
warmup = intent.getIntExtra(WARMUP_EXTRA, DEFAULT_WARMUP.toInt()).toUInt(),
timeoutSecs = intent.getLongExtra(TIMEOUT_EXTRA, DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS),
heartbeatIntervalSecs = intent.getLongExtra(
HEARTBEAT_EXTRA,
DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS,
),
)
startBenchmarkForeground()
resultReceiver?.send(MobenchResultCode.HEARTBEAT, Bundle().apply {
putInt(RESULT_PID_EXTRA, android.os.Process.myPid())
putString(RESULT_PROCESS_NAME_EXTRA, "$packageName$WORKER_PROCESS_SUFFIX")
})
Thread {
try {
val result = runBenchmarkInWorker(params)
val bundle = Bundle().apply {
putString(RESULT_DISPLAY_EXTRA, result.displayText)
result.errorMessage?.let { putString(RESULT_ERROR_EXTRA, it) }
result.failureJson?.let { putString(RESULT_FAILURE_JSON_EXTRA, it) }
}
resultReceiver?.send(
if (result.errorMessage == null) MobenchResultCode.SUCCESS else MobenchResultCode.ERROR,
bundle
)
} finally {
stopBenchmarkForeground()
stopSelf(startId)
}
}.apply {
name = "mobench-benchmark-worker"
start()
}
return START_NOT_STICKY
}
private fun startBenchmarkForeground() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(
NotificationChannel(
FOREGROUND_CHANNEL_ID,
FOREGROUND_CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
)
)
}
startForeground(FOREGROUND_NOTIFICATION_ID, buildBenchmarkNotification())
}
@Suppress("DEPRECATION")
private fun stopBenchmarkForeground() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(Service.STOP_FOREGROUND_REMOVE)
} else {
stopForeground(true)
}
}
@Suppress("DEPRECATION")
private fun buildBenchmarkNotification(): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, FOREGROUND_CHANNEL_ID)
} else {
Notification.Builder(this).setPriority(Notification.PRIORITY_LOW)
}
return builder
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Running benchmark")
.setContentText("Mobench isolated worker is measuring this run")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
}
private fun runBenchmarkInWorker(params: BenchParams): WorkerBenchmarkResult {
val display = try {
val processMemorySampler = ProcessMemorySampler()
var runProcessPeakMemoryKb: Long?
processMemorySampler.start()
val rawReport = try {
NativeBenchRunner.run(params)
} finally {
runProcessPeakMemoryKb = processMemorySampler.stop()
}
val json = buildNativeBenchReportJson(rawReport, runProcessPeakMemoryKb)
logBenchJson(json.toString())
formatNativeBenchReport(json)
} catch (e: Throwable) {
android.util.Log.e("BenchRunner", "Benchmark error: ${e.message}", e)
val payload = JSONObject()
.put("schema_version", 1)
.put("kind", "benchmark_error")
.put("message", "Benchmark error: ${e.message}")
.toString()
android.util.Log.e("BenchRunner", "BENCH_FAILURE_JSON $payload")
return WorkerBenchmarkResult(
displayText = "Benchmark error: ${e.message}",
errorMessage = "Benchmark error: ${e.message}",
failureJson = payload,
)
}
return WorkerBenchmarkResult(displayText = display, errorMessage = null, failureJson = null)
}
}
private fun logBenchJson(json: String) {
// Stay below logcat's byte limit even when a chunk contains multibyte UTF-8.
val chunkSize = 1000
android.util.Log.i("BenchRunner", "BENCH_JSON_START")
var offset = 0
while (offset < json.length) {
var end = minOf(offset + chunkSize, json.length)
if (end < json.length && Character.isHighSurrogate(json[end - 1]) && Character.isLowSurrogate(json[end])) {
end -= 1
}
android.util.Log.i("BenchRunner", "BENCH_JSON_CHUNK ${json.substring(offset, end)}")
offset = end
}
android.util.Log.i("BenchRunner", "BENCH_JSON_END")
}
private data class BenchParams(
val function: String,
val iterations: UInt,
val warmup: UInt,
val timeoutSecs: Long,
val heartbeatIntervalSecs: Long,
)
private data class WorkerBenchmarkResult(
val displayText: String,
val errorMessage: String?,
val failureJson: String?,
)
private object NativeBenchRunner {
fun run(params: BenchParams): JSONObject {
val spec = JSONObject()
.put("name", params.function)
.put("iterations", params.iterations.toInt())
.put("warmup", params.warmup.toInt())
val specBytes = spec.toString().toByteArray(Charsets.UTF_8)
val out = MobenchBuf()
val status = MobenchNativeLibrary.api.mobench_run_benchmark_json(
specBytes,
specBytes.size.toLong(),
out
)
out.read()
if (status != 0) {
val error = MobenchNativeLibrary.lastError()
MobenchNativeLibrary.api.mobench_free_buf(out)
throw IllegalStateException(error)
}
return try {
val ptr = out.ptr ?: throw IllegalStateException("native benchmark returned a null report buffer")
val bytes = ptr.getByteArray(0, out.len.toInt())
JSONObject(bytes.toString(Charsets.UTF_8))
} finally {
MobenchNativeLibrary.api.mobench_free_buf(out)
}
}
}
private interface MobenchNativeAbi : Library {
fun mobench_run_benchmark_json(specPtr: ByteArray, specLen: Long, out: MobenchBuf): Int
fun mobench_free_buf(buf: MobenchBuf)
fun mobench_last_error_message(): Pointer?
}
@Suppress("unused")
private class MobenchBuf : Structure() {
@JvmField var ptr: Pointer? = null
@JvmField var len: Long = 0
@JvmField var cap: Long = 0
override fun getFieldOrder(): List<String> = listOf("ptr", "len", "cap")
}
private object MobenchNativeLibrary {
val api: MobenchNativeAbi by lazy {
Native.load("{{LIBRARY_NAME}}", MobenchNativeAbi::class.java)
}
fun lastError(): String {
return api.mobench_last_error_message()?.getString(0) ?: "native benchmark failed"
}
}
@Suppress("DEPRECATION")
private fun Intent.resultReceiverExtra(): ResultReceiver? {
return if (Build.VERSION.SDK_INT >= 33) {
getParcelableExtra(RESULT_RECEIVER_EXTRA, ResultReceiver::class.java)
} else {
getParcelableExtra(RESULT_RECEIVER_EXTRA) as? ResultReceiver
}
}
private fun Context.runningAppProcesses(): List<ActivityManager.RunningAppProcessInfo> {
return try {
val manager = getSystemService(ActivityManager::class.java)
manager?.runningAppProcesses ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
private class ProcessMemorySampler(private val sampleIntervalMs: Long = 1000L) {
@Volatile private var running = false
@Volatile private var peakKb = 0L
private var samplerThread: Thread? = null
fun start() {
if (running) {
return
}
running = true
recordCurrent()
samplerThread = Thread {
while (running) {
recordCurrent()
try {
Thread.sleep(sampleIntervalMs)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
break
}
}
recordCurrent()
}.apply {
name = "mobench-process-memory-sampler"
isDaemon = true
start()
}
}
fun stop(): Long? {
running = false
try {
samplerThread?.join(sampleIntervalMs * 2)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
recordCurrent()
return peakKb.takeIf { it > 0L }
}
@Synchronized
private fun recordCurrent() {
currentProcessPssKb()?.let { observedKb ->
if (observedKb > peakKb) {
peakKb = observedKb
}
}
}
}
private fun currentProcessPssKb(): Long? {
readProcMemoryKb("/proc/self/smaps_rollup", "Pss:")?.let { return it }
readProcMemoryKb("/proc/self/status", "VmHWM:")?.let { return it }
readProcMemoryKb("/proc/self/status", "VmRSS:")?.let { return it }
val memInfo = Debug.MemoryInfo()
return try {
Debug.getMemoryInfo(memInfo)
memInfo.totalPss.toLong().takeIf { it > 0L }
} catch (e: Exception) {
android.util.Log.d("BenchRunner", "Unable to read process memory from Debug.getMemoryInfo", e)
null
}
}
private fun readProcMemoryKb(path: String, label: String): Long? {
return try {
java.io.File(path).bufferedReader().use { reader ->
var line: String? = reader.readLine()
while (line != null) {
if (line.startsWith(label)) {
return@use parseMemoryKb(line)
}
line = reader.readLine()
}
null
}
} catch (e: Exception) {
null
}
}
private fun parseMemoryKb(line: String): Long? {
val value = line
.substringAfter(':', "")
.trim()
.split(' ')
.firstOrNull { it.isNotBlank() }
return value?.toLongOrNull()?.takeIf { it > 0L }
}
private fun median(values: List<Long>): Long {
val sorted = values.sorted()
if (sorted.isEmpty()) {
return 0L
}
val middle = sorted.size / 2
return if (sorted.size % 2 == 0) {
(sorted[middle - 1] + sorted[middle]) / 2
} else {
sorted[middle]
}
}
private fun formatDuration(ns: Long): String {
val ms = ns.toDouble() / 1_000_000.0
return if (ms >= 1000.0) {
String.format("%.3fs", ms / 1000.0)
} else {
String.format("%.3fms", ms)
}
}
private fun buildNativeBenchReportJson(report: JSONObject, runProcessPeakMemoryKb: Long?): JSONObject {
val json = JSONObject(report.toString())
val spec = json.optJSONObject("spec") ?: JSONObject()
val function = spec.optString("name", DEFAULT_FUNCTION)
json.put("function", function)
val samples = json.optJSONArray("samples") ?: JSONArray()
val durations = mutableListOf<Long>()
val cpuSamplesMs = mutableListOf<Long>()
val peakSamplesKb = mutableListOf<Long>()
val processPeakSamplesKb = mutableListOf<Long>()
val samplesNs = JSONArray()
for (index in 0 until samples.length()) {
val sample = samples.optJSONObject(index) ?: continue
val duration = sample.optLong("duration_ns", -1L)
if (duration >= 0L) {
durations.add(duration)
samplesNs.put(duration)
}
optionalLong(sample, "cpu_time_ms")?.let { cpuSamplesMs.add(it) }
optionalLong(sample, "peak_memory_kb")?.let { peakSamplesKb.add(it) }
optionalLong(sample, "process_peak_memory_kb")?.let { processPeakSamplesKb.add(it) }
}
json.put("samples_ns", samplesNs)
if (durations.isNotEmpty()) {
val stats = JSONObject()
stats.put("min_ns", durations.minOrNull() ?: 0L)
stats.put("max_ns", durations.maxOrNull() ?: 0L)
stats.put("avg_ns", durations.sum().toDouble() / durations.size.toDouble())
stats.put("mean_ns", (durations.sum().toDouble() / durations.size.toDouble()).toLong())
stats.put("median_ns", median(durations))
json.put("stats", stats)
}
val resources = JSONObject()
resources.put("platform", "android")
resources.put("timestamp_ms", System.currentTimeMillis())
resources.put("memory_process", "isolated_worker")
if (cpuSamplesMs.isNotEmpty()) {
val total = cpuSamplesMs.sum()
resources.put("cpu_total_ms", total)
resources.put("cpu_median_ms", median(cpuSamplesMs))
resources.put("elapsed_cpu_ms", total)
}
peakSamplesKb.maxOrNull()?.let {
resources.put("peak_memory_kb", it)
resources.put("peak_memory_growth_kb", it)
}
(processPeakSamplesKb.maxOrNull() ?: runProcessPeakMemoryKb)?.let {
resources.put("process_peak_memory_kb", it)
}
json.put("resources", resources)
return json
}
private fun optionalLong(json: JSONObject, key: String): Long? {
if (!json.has(key) || json.isNull(key)) {
return null
}
return json.optLong(key).takeIf { it >= 0L }
}
private fun formatNativeBenchReport(report: JSONObject): String = buildString {
val spec = report.optJSONObject("spec") ?: JSONObject()
val function = spec.optString("name", DEFAULT_FUNCTION)
val iterations = spec.optInt("iterations", DEFAULT_ITERATIONS.toInt())
val warmup = spec.optInt("warmup", DEFAULT_WARMUP.toInt())
val samples = report.optJSONArray("samples") ?: JSONArray()
appendLine("=== Benchmark Results ===")
appendLine()
appendLine("Function: $function")
appendLine("Iterations: $iterations")
appendLine("Warmup: $warmup")
appendLine()
appendLine("Samples (${samples.length()}):")
for (index in 0 until samples.length()) {
val duration = samples.optJSONObject(index)?.optLong("duration_ns", 0L) ?: 0L
appendLine(" ${index + 1}. ${formatDuration(duration)}")
}
val stats = report.optJSONObject("stats")
if (stats != null) {
appendLine()
appendLine("Statistics:")
appendLine(" Min: ${formatDuration(stats.optLong("min_ns", 0L))}")
appendLine(" Max: ${formatDuration(stats.optLong("max_ns", 0L))}")
appendLine(" Avg: ${formatDuration(stats.optLong("mean_ns", 0L))}")
}
}