diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 0000000..d11941b
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,38 @@
+name: Android CI
+
+on:
+ push:
+ branches: [ main, "claude/**" ]
+ paths: [ "app/**", "build.gradle.kts", "settings.gradle.kts", "gradle.properties", ".github/workflows/android.yml" ]
+ pull_request:
+ paths: [ "app/**", "build.gradle.kts", "settings.gradle.kts", "gradle.properties", ".github/workflows/android.yml" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - name: Set up Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ gradle-version: "8.7"
+
+ - name: Unit tests + assemble debug APK
+ run: gradle :app:testDebugUnitTest :app:assembleDebug --no-daemon --stacktrace
+
+ - name: Upload APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: io-tracer-debug-apk
+ path: app/build/outputs/apk/debug/*.apk
+ if-no-files-found: warn
diff --git a/.gitignore b/.gitignore
index 075e79f..44b7981 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,24 @@
+# Python
__pycache__/
*.pyc
-*.csv
-*.csv.zst
-*.tar.zst
.pytest_cache/
.venv/
venv/
+
+# Trace output
+*.csv
+*.csv.zst
+*.csv.gz
+*.tar.zst
trace/
*_trace_*/
+
+# Android / Gradle
+.gradle/
+build/
+app/build/
+local.properties
+*.iml
+.idea/
+captures/
+.cxx/
diff --git a/README.md b/README.md
index f8caa99..69d29aa 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,12 @@ The on-disk column layout is byte-for-byte compatible with the Linux tracer (see
[`src/tracer/schema.py`](src/tracer/schema.py)), and every record carries a
`mono_ns` (CLOCK_MONOTONIC) column for cross-stream correlation.
+> **Prefer a UI?** There is also a native **Android app** (`app/` module) that
+> runs the same rooted block-I/O collection with a Start/Stop screen and a
+> foreground service — no host/`adb` driving required. See
+> [docs/ANDROID_APP.md](docs/ANDROID_APP.md). The CLI below remains the reference
+> collector and host driver.
+
## Requirements
- An Android device with **root** (the block-I/O collector needs `CAP_SYS_ADMIN`
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..1f20493
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,60 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.cachemon.iotracer"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.cachemon.iotracer"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ // Compose compiler matching Kotlin 1.9.24.
+ kotlinCompilerExtensionVersion = "1.5.14"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
+ implementation("androidx.lifecycle:lifecycle-service:2.8.3")
+ implementation("androidx.activity:activity-compose:1.9.0")
+
+ val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
+ implementation(composeBom)
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ testImplementation("junit:junit:4.13.2")
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..ecdeb06
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Keep default Compose/Kotlin behavior; this is a developer tool with no minify.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0509d58
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/cachemon/iotracer/MainActivity.kt b/app/src/main/java/com/cachemon/iotracer/MainActivity.kt
new file mode 100644
index 0000000..cfcdf06
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/MainActivity.kt
@@ -0,0 +1,187 @@
+package com.cachemon.iotracer
+
+import android.Manifest
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.cachemon.iotracer.ui.IOTracerTheme
+import kotlin.concurrent.thread
+
+class MainActivity : ComponentActivity() {
+
+ private val requestNotif =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ requestNotif.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+
+ // Probe root off the main thread once at launch.
+ if (TracerState.status.value.rootAvailable == null) {
+ thread(isDaemon = true) {
+ val ok = TraceEngine.checkRoot()
+ TracerState.update { it.copy(rootAvailable = ok) }
+ }
+ }
+
+ setContent {
+ IOTracerTheme {
+ TracerScreen(
+ onStart = { anon, interval -> TracerService.start(this, anon, interval) },
+ onStop = { TracerService.stop(this) },
+ onShare = { shareLatest() },
+ )
+ }
+ }
+ }
+
+ /** Zip nothing fancy — just share the session directory path via ACTION_SEND text. */
+ private fun shareLatest() {
+ val dir = TracerState.status.value.sessionDir ?: return
+ val send = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, "IO Tracer session saved at:\n$dir")
+ }
+ startActivity(Intent.createChooser(send, "Share trace location"))
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TracerScreen(
+ onStart: (Boolean, Int) -> Unit,
+ onStop: () -> Unit,
+ onShare: () -> Unit,
+) {
+ val status by TracerState.status.collectAsState()
+ var anonymous by remember { mutableStateOf(false) }
+ var intervalMin by remember { mutableStateOf(5f) }
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("IO Tracer (Android)") }) }
+ ) { padding ->
+ Column(
+ Modifier
+ .padding(padding)
+ .padding(16.dp)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ // Root status.
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp)) {
+ Text("Root access", style = MaterialTheme.typography.titleMedium)
+ Text(
+ when (status.rootAvailable) {
+ null -> "Checking…"
+ true -> "Available — full block-I/O tracing enabled."
+ false -> "Not available — needs a rooted/userdebug device. " +
+ "Block I/O cannot be traced without root."
+ }
+ )
+ }
+ }
+
+ // Options.
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp)) {
+ Text("Options", style = MaterialTheme.typography.titleMedium)
+ Row(
+ Modifier.fillMaxWidth().padding(top = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Anonymize names/paths")
+ Switch(checked = anonymous, onCheckedChange = { anonymous = it }, enabled = !status.running)
+ }
+ Spacer(Modifier.padding(4.dp))
+ Text("Process snapshot interval: ${intervalMin.toInt()} min")
+ Slider(
+ value = intervalMin,
+ onValueChange = { intervalMin = it },
+ valueRange = 1f..30f,
+ steps = 28,
+ enabled = !status.running,
+ )
+ }
+ }
+
+ // Live status.
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp)) {
+ Text("Status", style = MaterialTheme.typography.titleMedium)
+ Text(if (status.running) "● Tracing" else "○ Idle")
+ Divider(Modifier.padding(vertical = 8.dp))
+ Text("Block I/O rows: ${status.dsRows}")
+ Text("Lines parsed: ${status.linesSeen}")
+ Text("Process rows: ${status.processRows}")
+ status.sessionDir?.let { Text("\nSession:\n$it") }
+ if (status.lastMessage.isNotEmpty()) {
+ Text("\n${status.lastMessage}", style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+
+ // Controls.
+ if (!status.running) {
+ Button(
+ onClick = { onStart(anonymous, (intervalMin.toInt() * 60)) },
+ enabled = status.rootAvailable != null,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Start tracing") }
+ } else {
+ Button(onClick = onStop, modifier = Modifier.fillMaxWidth()) {
+ Text("Stop tracing")
+ }
+ }
+
+ Button(
+ onClick = onShare,
+ enabled = status.sessionDir != null,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Share session location") }
+
+ Text(
+ "Traces are saved under the app's external files dir " +
+ "(Android/data/com.cachemon.iotracer/files/traces). Pull them with adb " +
+ "or a file manager. CSV columns match the Linux io-tracer schema.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/TraceEngine.kt b/app/src/main/java/com/cachemon/iotracer/TraceEngine.kt
new file mode 100644
index 0000000..3772c27
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/TraceEngine.kt
@@ -0,0 +1,161 @@
+package com.cachemon.iotracer
+
+import com.cachemon.iotracer.ftrace.FtraceControl
+import com.cachemon.iotracer.io.Csv
+import com.cachemon.iotracer.io.TimeFmt
+import com.cachemon.iotracer.io.TraceWriter
+import com.cachemon.iotracer.parse.BlockPairer
+import com.cachemon.iotracer.parse.FtraceParser
+import com.cachemon.iotracer.root.RootShell
+import com.cachemon.iotracer.snap.ProcSnapper
+import com.cachemon.iotracer.snap.SystemSnapper
+import com.cachemon.iotracer.snap.simpleHash
+import java.io.BufferedReader
+import java.io.File
+import java.io.InputStreamReader
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.concurrent.thread
+
+/**
+ * Owns one tracing session: streams block events from ftrace via root, pairs
+ * them into `ds` rows, runs periodic process/system snapshots, and writes the
+ * gzip CSV streams + manifest. Lifecycle is driven by [TracerService].
+ *
+ * @param onProgress invoked (on background threads) after counters change so the
+ * service can refresh its notification.
+ */
+class TraceEngine(
+ private val outputRoot: File,
+ private val machineId: String,
+ private val anonymous: Boolean,
+ private val processIntervalSec: Int,
+ private val onProgress: (TraceStatus) -> Unit,
+) {
+ private val root = RootShell()
+ private val active = AtomicBoolean(false)
+ private val pairer = BlockPairer()
+
+ private var streamProc: Process? = null
+ private var readerThread: Thread? = null
+ private var snapshotThread: Thread? = null
+ private lateinit var writer: TraceWriter
+
+ @Volatile private var dsRows = 0L
+ @Volatile private var linesSeen = 0L
+ @Volatile private var processRows = 0L
+
+ val sessionDir: File get() = writer.sessionDir
+
+ fun start() {
+ active.set(true)
+ val sessionName = "android_trace_${machineId}_${stamp()}"
+ val dir = File(outputRoot, sessionName).apply { mkdirs() }
+ writer = TraceWriter(dir, machineId, realOffset)
+
+ emit("Session: ${dir.absolutePath}")
+
+ // System spec once.
+ runCatching { SystemSnapper(root, writer).capture() }
+
+ // Periodic process snapshots.
+ val procSnapper = ProcSnapper(root, writer, anonymous)
+ snapshotThread = thread(name = "iotracer-snap", isDaemon = true) {
+ while (active.get()) {
+ runCatching {
+ processRows += procSnapper.takeSnapshot().toLong()
+ emit("Process snapshot captured")
+ }
+ var slept = 0
+ while (active.get() && slept < processIntervalSec) {
+ Thread.sleep(1000); slept++
+ }
+ }
+ }
+
+ // ftrace block stream.
+ if (FtraceControl.blockEventsPresent(root)) {
+ val proc = root.stream(FtraceControl.streamScript())
+ streamProc = proc
+ readerThread = thread(name = "iotracer-ftrace", isDaemon = true) {
+ runCatching {
+ BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
+ while (active.get()) {
+ val line = r.readLine() ?: break
+ handleLine(line)
+ }
+ }
+ }
+ }
+ emit("Tracing block I/O…")
+ } else {
+ emit("Block tracepoints unavailable; running snapshot-only.")
+ }
+ }
+
+ private fun handleLine(line: String) {
+ linesSeen++
+ val common = FtraceParser.parseCommon(line) ?: return
+ when (common.event) {
+ "block_rq_issue" -> pairer.onIssue(common)
+ "block_rq_complete" -> {
+ val row = pairer.onComplete(common) ?: return
+ val command = if (anonymous) simpleHash(row.command) else row.command
+ val ts = monoToWall(row.monoNs)
+ writer.append("ds", Csv.row(
+ ts, row.operation, row.pid, row.tid, command, row.sector,
+ row.size, row.latencyMs, row.device, row.flags, row.cpuId,
+ row.ppid, row.queueLatencyMs, row.commandFlags,
+ row.operationCode, row.requestId, row.monoNs,
+ ))
+ dsRows++
+ if (dsRows % 1000L == 0L) emit("ds rows: $dsRows")
+ }
+ }
+ }
+
+ fun stop() {
+ active.set(false)
+ runCatching { root.exec(FtraceControl.teardownScript()) }
+ runCatching { streamProc?.destroy() }
+ runCatching { readerThread?.join(2000) }
+ runCatching { snapshotThread?.join(2000) }
+ runCatching {
+ writer.flushAll()
+ writer.writeManifest(
+ mapOf(
+ "lines_seen" to linesSeen,
+ "block_inflight_unmatched" to pairer.inflightCount(),
+ )
+ )
+ }
+ emit("Stopped. Saved to ${writer.sessionDir.absolutePath}")
+ }
+
+ private fun emit(msg: String) {
+ onProgress(
+ TraceStatus(
+ running = active.get(),
+ rootAvailable = true,
+ dsRows = dsRows,
+ linesSeen = linesSeen,
+ processRows = processRows,
+ sessionDir = if (::writer.isInitialized) writer.sessionDir.absolutePath else null,
+ lastMessage = msg,
+ )
+ )
+ }
+
+ private val realOffset: Long = System.currentTimeMillis() * 1_000_000L - System.nanoTime()
+ private fun monoToWall(monoNs: Long): String {
+ // Reuses a thread-local formatter (see TimeFmt) — this runs once per block
+ // I/O completion, which can be thousands per second.
+ val realNs = monoNs + realOffset
+ return TimeFmt.wallMillis(realNs / 1_000_000L)
+ }
+
+ private fun stamp(): String = TimeFmt.sessionStamp()
+
+ companion object {
+ fun checkRoot(): Boolean = RootShell().available()
+ }
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/TracerService.kt b/app/src/main/java/com/cachemon/iotracer/TracerService.kt
new file mode 100644
index 0000000..0f74ec8
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/TracerService.kt
@@ -0,0 +1,153 @@
+package com.cachemon.iotracer
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import android.provider.Settings
+import java.io.File
+import java.security.MessageDigest
+import kotlin.concurrent.thread
+
+/**
+ * Foreground service that owns a [TraceEngine] for the lifetime of a tracing
+ * session. Started/stopped from the UI via [ACTION_START]/[ACTION_STOP]; keeps a
+ * persistent notification so collection survives the screen turning off.
+ */
+class TracerService : Service() {
+
+ private var engine: TraceEngine? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> startTracing(intent)
+ ACTION_STOP -> stopTracing()
+ }
+ return START_NOT_STICKY
+ }
+
+ private fun startTracing(intent: Intent) {
+ if (engine != null) return
+ createChannel()
+ startForeground(NOTIF_ID, buildNotification("Starting…"))
+
+ val anonymous = intent.getBooleanExtra(EXTRA_ANON, false)
+ val interval = intent.getIntExtra(EXTRA_INTERVAL, 300)
+ val outDir = File(getExternalFilesDir(null), "traces").apply { mkdirs() }
+ val machineId = machineId()
+
+ TracerState.update { it.copy(running = true, lastMessage = "Starting…") }
+
+ // Engine start touches `su`, so run it off the main thread.
+ thread(name = "iotracer-start", isDaemon = true) {
+ val e = TraceEngine(
+ outputRoot = outDir,
+ machineId = machineId,
+ anonymous = anonymous,
+ processIntervalSec = interval,
+ onProgress = { status ->
+ TracerState.update { status }
+ updateNotification(status.lastMessage)
+ },
+ )
+ engine = e
+ runCatching { e.start() }.onFailure {
+ TracerState.update { s -> s.copy(running = false, lastMessage = "Error: ${it.message}") }
+ stopSelf()
+ }
+ }
+ }
+
+ private fun stopTracing() {
+ val e = engine
+ engine = null
+ thread(name = "iotracer-stop", isDaemon = true) {
+ runCatching { e?.stop() }
+ TracerState.update { it.copy(running = false) }
+ stopForegroundCompat()
+ stopSelf()
+ }
+ }
+
+ override fun onDestroy() {
+ engine?.let { e -> runCatching { e.stop() } }
+ engine = null
+ super.onDestroy()
+ }
+
+ // --- notification ---------------------------------------------------- //
+
+ private fun createChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val mgr = getSystemService(NotificationManager::class.java)
+ if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
+ mgr.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID, "I/O Tracing", NotificationManager.IMPORTANCE_LOW
+ )
+ )
+ }
+ }
+ }
+
+ private fun buildNotification(text: String): Notification {
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(this, CHANNEL_ID)
+ } else {
+ @Suppress("DEPRECATION") Notification.Builder(this)
+ }
+ return builder
+ .setContentTitle("IO Tracer")
+ .setContentText(text)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setOngoing(true)
+ .build()
+ }
+
+ private fun updateNotification(text: String) {
+ val mgr = getSystemService(NotificationManager::class.java)
+ mgr.notify(NOTIF_ID, buildNotification(text))
+ }
+
+ private fun stopForegroundCompat() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ @Suppress("DEPRECATION") stopForeground(true)
+ }
+ }
+
+ private fun machineId(): String {
+ val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) ?: "unknown"
+ val d = MessageDigest.getInstance("SHA-256").digest(androidId.toByteArray())
+ return d.joinToString("") { "%02x".format(it) }.take(16).uppercase()
+ }
+
+ companion object {
+ const val ACTION_START = "com.cachemon.iotracer.START"
+ const val ACTION_STOP = "com.cachemon.iotracer.STOP"
+ const val EXTRA_ANON = "anonymous"
+ const val EXTRA_INTERVAL = "interval"
+ private const val CHANNEL_ID = "io_tracing"
+ private const val NOTIF_ID = 1001
+
+ fun start(ctx: Context, anonymous: Boolean, intervalSec: Int) {
+ val i = Intent(ctx, TracerService::class.java).apply {
+ action = ACTION_START
+ putExtra(EXTRA_ANON, anonymous)
+ putExtra(EXTRA_INTERVAL, intervalSec)
+ }
+ ctx.startForegroundService(i)
+ }
+
+ fun stop(ctx: Context) {
+ ctx.startService(Intent(ctx, TracerService::class.java).apply { action = ACTION_STOP })
+ }
+ }
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/TracerState.kt b/app/src/main/java/com/cachemon/iotracer/TracerState.kt
new file mode 100644
index 0000000..44f6a6b
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/TracerState.kt
@@ -0,0 +1,30 @@
+package com.cachemon.iotracer
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/** Immutable snapshot of tracing progress, observed by the UI. */
+data class TraceStatus(
+ val running: Boolean = false,
+ val rootAvailable: Boolean? = null, // null = not yet checked
+ val dsRows: Long = 0,
+ val linesSeen: Long = 0,
+ val processRows: Long = 0,
+ val sessionDir: String? = null,
+ val lastMessage: String = "",
+)
+
+/** Process-wide tracing state shared between the service and the UI. */
+object TracerState {
+ private val _status = MutableStateFlow(TraceStatus())
+ val status: StateFlow = _status.asStateFlow()
+
+ fun update(transform: (TraceStatus) -> TraceStatus) {
+ // Atomic compare-and-set retry loop; safe under concurrent writers
+ // (reader/snapshot/main threads) where a plain read-modify-write could
+ // drop updates.
+ _status.update(transform)
+ }
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/ftrace/FtraceControl.kt b/app/src/main/java/com/cachemon/iotracer/ftrace/FtraceControl.kt
new file mode 100644
index 0000000..98c679c
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/ftrace/FtraceControl.kt
@@ -0,0 +1,47 @@
+package com.cachemon.iotracer.ftrace
+
+import com.cachemon.iotracer.root.RootShell
+
+/**
+ * Builds the root shell scripts that configure tracefs and stream block events.
+ *
+ * Tracefs is normally at /sys/kernel/tracing (older devices use the debugfs
+ * path). The scripts resolve it at runtime so a single build works on both.
+ */
+object FtraceControl {
+
+ private const val RESOLVE =
+ "T=/sys/kernel/tracing; [ -d \$T ] || T=/sys/kernel/debug/tracing; "
+
+ /** Script that enables block tracepoints (mono clock) and streams trace_pipe. */
+ fun streamScript(): String = RESOLVE + buildString {
+ append("echo mono > \$T/trace_clock 2>/dev/null; ")
+ append("echo 0 > \$T/tracing_on 2>/dev/null; ")
+ append("echo > \$T/trace 2>/dev/null; ")
+ append("echo 1 > \$T/events/block/block_rq_issue/enable 2>/dev/null; ")
+ append("echo 1 > \$T/events/block/block_rq_complete/enable 2>/dev/null; ")
+ append("echo 1 > \$T/tracing_on 2>/dev/null; ")
+ append("cat \$T/trace_pipe")
+ }
+
+ /** Script that disables the block tracepoints and restores the clock. */
+ fun teardownScript(): String = RESOLVE + buildString {
+ append("echo 0 > \$T/events/block/block_rq_issue/enable 2>/dev/null; ")
+ append("echo 0 > \$T/events/block/block_rq_complete/enable 2>/dev/null; ")
+ append("echo 0 > \$T/tracing_on 2>/dev/null; ")
+ append("echo local > \$T/trace_clock 2>/dev/null; ")
+ // Kill any lingering `cat trace_pipe` reader: some `su` implementations
+ // don't forward the parent's SIGTERM to the child, which would otherwise
+ // keep holding the ring buffer open after stop.
+ append("pkill -f trace_pipe 2>/dev/null; ")
+ append("true")
+ }
+
+ /** Best-effort check that block tracepoints exist on this kernel. */
+ fun blockEventsPresent(root: RootShell): Boolean {
+ val (code, out) = root.exec(
+ RESOLVE + "ls \$T/events/block/block_rq_complete/enable 2>/dev/null"
+ )
+ return code == 0 && out.contains("enable")
+ }
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/io/Schema.kt b/app/src/main/java/com/cachemon/iotracer/io/Schema.kt
new file mode 100644
index 0000000..5cde1f3
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/io/Schema.kt
@@ -0,0 +1,43 @@
+package com.cachemon.iotracer.io
+
+/**
+ * On-disk trace schema — the single source of truth for the app's CSV output.
+ *
+ * Kept in lock-step with the Python tracer's `src/tracer/schema.py`
+ * (SCHEMA_VERSION 3): identical column names and order, so a trace captured by
+ * this app is byte-for-byte parseable by the same downstream tooling as the CLI
+ * and the Linux tracer. The only difference is the compression container
+ * (`.csv.gz` here vs `.csv.zst` in the CLI); columns are unchanged.
+ *
+ * If you change a column here, change it in schema.py too and bump VERSION.
+ */
+object Schema {
+ const val VERSION = 3
+
+ val DS = listOf(
+ "timestamp", "operation", "pid", "tid", "command", "sector", "size",
+ "latency_ms", "device", "flags", "cpu_id", "ppid", "queue_latency_ms",
+ "command_flags", "operation_code", "request_id", "mono_ns",
+ )
+
+ val PROCESS = listOf(
+ "timestamp", "pid", "name", "cmdline", "vms_kb", "rss_kb",
+ "creation_time", "cpu_5s", "cpu_2m", "cpu_1h", "status", "mono_ns",
+ )
+
+ fun header(columns: List): String = columns.joinToString(",")
+}
+
+/** Minimal RFC-4180 CSV field/row encoding (matches Python's csv.writer output). */
+object Csv {
+ fun field(value: Any?): String {
+ val s = value?.toString() ?: ""
+ return if (s.any { it == ',' || it == '"' || it == '\n' || it == '\r' }) {
+ "\"" + s.replace("\"", "\"\"") + "\""
+ } else {
+ s
+ }
+ }
+
+ fun row(vararg values: Any?): String = values.joinToString(",") { field(it) }
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/io/TimeFmt.kt b/app/src/main/java/com/cachemon/iotracer/io/TimeFmt.kt
new file mode 100644
index 0000000..03fbf6f
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/io/TimeFmt.kt
@@ -0,0 +1,31 @@
+package com.cachemon.iotracer.io
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * Thread-local [SimpleDateFormat] instances. SimpleDateFormat is not thread-safe
+ * and allocating one per event is wasteful on the hot tracing path, so each
+ * thread reuses its own formatter.
+ */
+object TimeFmt {
+ private val wall = ThreadLocal.withInitial {
+ SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
+ }
+ private val fileStampFmt = ThreadLocal.withInitial {
+ SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US)
+ }
+ private val sessionStampFmt = ThreadLocal.withInitial {
+ SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
+ }
+
+ /** Wall-clock string padded to the schema's microsecond shape (trailing 000). */
+ fun wall(date: Date): String = wall.get()!!.format(date) + "000"
+
+ fun wallMillis(epochMillis: Long): String = wall(Date(epochMillis))
+
+ fun fileStamp(): String = fileStampFmt.get()!!.format(Date())
+
+ fun sessionStamp(): String = sessionStampFmt.get()!!.format(Date())
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/io/TraceWriter.kt b/app/src/main/java/com/cachemon/iotracer/io/TraceWriter.kt
new file mode 100644
index 0000000..d7f7062
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/io/TraceWriter.kt
@@ -0,0 +1,126 @@
+package com.cachemon.iotracer.io
+
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.io.FileOutputStream
+import java.util.zip.GZIPOutputStream
+
+/**
+ * Buffers trace rows per stream and rotates them into gzip-compressed CSV files,
+ * plus a self-describing `manifest.json`. The on-disk layout mirrors the Python
+ * tracer (only the compression container differs: `.csv.gz` vs `.csv.zst`).
+ *
+ * /
+ * manifest.json
+ * ds/ds__.csv.gz
+ * process/process__.csv.gz
+ * system_spec/ (cpu/memory/os .json files)
+ */
+class TraceWriter(
+ val sessionDir: File,
+ private val machineId: String,
+ private val monoToRealOffsetNs: Long,
+ private val flushThreshold: Int = 50_000,
+) {
+ private val buffers = mutableMapOf(
+ "ds" to mutableListOf(),
+ "process" to mutableListOf(),
+ )
+ private val headers = mapOf(
+ "ds" to Schema.header(Schema.DS),
+ "process" to Schema.header(Schema.PROCESS),
+ )
+ private val seq = mutableMapOf("ds" to 0, "process" to 0)
+ val rowsWritten = mutableMapOf("ds" to 0L, "process" to 0L)
+ val filesWritten = mutableMapOf("ds" to 0, "process" to 0)
+
+ private val locks = mapOf("ds" to Any(), "process" to Any())
+
+ init {
+ File(sessionDir, "ds").mkdirs()
+ File(sessionDir, "process").mkdirs()
+ File(sessionDir, "system_spec").mkdirs()
+ }
+
+ /** Append a CSV row to a stream; auto-rotates the `ds` stream when full. */
+ fun append(stream: String, row: String) {
+ val lock = locks[stream] ?: return
+ val full: Boolean
+ synchronized(lock) {
+ val buf = buffers.getValue(stream)
+ buf.add(row)
+ full = buf.size >= flushThreshold
+ }
+ if (full && stream == "ds") flush(stream)
+ }
+
+ fun flush(stream: String) {
+ val lock = locks[stream] ?: return
+ val rows: List
+ synchronized(lock) {
+ val buf = buffers.getValue(stream)
+ if (buf.isEmpty()) return
+ rows = buf.toList()
+ buf.clear()
+ seq[stream] = seq.getValue(stream) + 1
+ }
+ val ts = TimeFmt.fileStamp()
+ val name = "${stream}_${ts}_${"%04d".format(seq.getValue(stream))}.csv.gz"
+ val file = File(File(sessionDir, stream), name)
+ // Buffer the file stream so GZIP's compressed output is written in large
+ // chunks rather than many small syscalls.
+ GZIPOutputStream(FileOutputStream(file).buffered()).bufferedWriter(Charsets.UTF_8).use { w ->
+ w.append(headers.getValue(stream)).append('\n')
+ for (r in rows) {
+ w.append(r); w.append('\n')
+ }
+ }
+ rowsWritten[stream] = rowsWritten.getValue(stream) + rows.size
+ filesWritten[stream] = filesWritten.getValue(stream) + 1
+ }
+
+ fun flushAll() {
+ flush("ds")
+ flush("process")
+ }
+
+ /** Write a JSON file directly into the system_spec subdirectory. */
+ fun writeSpec(filename: String, content: String) {
+ File(File(sessionDir, "system_spec"), filename).writeText(content)
+ }
+
+ fun writeManifest(extra: Map = emptyMap()) {
+ val streams = JSONObject()
+ streams.put("ds", streamSpec("ds", Schema.DS, "Block-device I/O completions (ftrace block_rq_*)."))
+ streams.put("process", streamSpec("process", Schema.PROCESS, "Periodic process snapshots (ps/proc)."))
+
+ val clock = JSONObject()
+ .put("mono_to_real_offset_ns", monoToRealOffsetNs)
+ .put("note", "wall_clock_ns = mono_ns + mono_to_real_offset_ns")
+
+ val manifest = JSONObject()
+ .put("schema_version", Schema.VERSION)
+ .put("platform", "android")
+ .put("collector", "ftrace+proc (rooted app)")
+ .put("compression", "gzip")
+ .put("machine_id", machineId)
+ .put("clock", clock)
+ .put("rows_written", JSONObject(rowsWritten as Map<*, *>))
+ .put("files_written", JSONObject(filesWritten as Map<*, *>))
+ .put("streams", streams)
+ for ((k, v) in extra) manifest.put(k, v)
+
+ File(sessionDir, "manifest.json").writeText(manifest.toString(2))
+ }
+
+ private fun streamSpec(subdir: String, cols: List, desc: String): JSONObject {
+ val arr = JSONArray()
+ cols.forEach { arr.put(it) }
+ return JSONObject()
+ .put("subdir", subdir)
+ .put("filename_prefix", subdir)
+ .put("description", desc)
+ .put("columns", arr)
+ }
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/parse/BlockPairer.kt b/app/src/main/java/com/cachemon/iotracer/parse/BlockPairer.kt
new file mode 100644
index 0000000..172e957
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/parse/BlockPairer.kt
@@ -0,0 +1,127 @@
+package com.cachemon.iotracer.parse
+
+/**
+ * Correlate `block_rq_issue` with `block_rq_complete` to recover device latency.
+ * Direct port of the Python `BlockPairer`.
+ *
+ * Keyed by (device, sector) — CPU is intentionally excluded because a request
+ * may be issued on one CPU and completed via an interrupt on another. A
+ * monotonic [requestId] assigned at issue keeps I/Os that reuse a (dev, sector)
+ * pair distinguishable.
+ */
+class BlockPairer(
+ // Cap on outstanding issues awaiting completion. Issues whose completion is
+ // never seen (lost events, merges, re-keys) would otherwise accumulate
+ // unbounded over a long trace; the oldest is evicted past this limit.
+ private val maxInflight: Int = 100_000,
+) {
+
+ /** A completed block I/O, ready to be written as a `ds` row. */
+ data class DsRow(
+ val operation: String,
+ val pid: Int?, // null when the issue was not seen
+ val tid: String,
+ val command: String,
+ val sector: Long,
+ val size: Long,
+ val latencyMs: Double?, // null when unknown
+ val device: String,
+ val flags: String,
+ val cpuId: Int,
+ val ppid: String,
+ val queueLatencyMs: String,
+ val commandFlags: String,
+ val operationCode: String,
+ val requestId: Long?, // null when the issue was not seen
+ val monoNs: Long,
+ )
+
+ private data class Inflight(
+ val issueMonoNs: Long,
+ val pid: Int,
+ val comm: String,
+ val bytes: Long,
+ val nsect: Long,
+ val rwbs: String,
+ val requestId: Long,
+ )
+
+ // LinkedHashMap preserves insertion order so the oldest entry is evicted first.
+ private val inflight = LinkedHashMap()
+ private var nextRequestId = 1L
+
+ fun inflightCount(): Int = inflight.size
+
+ private fun key(device: String, sector: Long) = "$device|$sector"
+
+ fun onIssue(c: FtraceParser.Common) {
+ val info = FtraceParser.parseIssue(c.body) ?: return
+ inflight[key(info.device, info.sector)] = Inflight(
+ issueMonoNs = c.monoNs,
+ pid = c.pid,
+ comm = c.comm,
+ bytes = info.bytes,
+ nsect = info.nsect,
+ rwbs = info.rwbs,
+ requestId = nextRequestId++,
+ )
+ // Bound memory: drop the oldest outstanding issue once over the cap.
+ if (inflight.size > maxInflight) {
+ val it = inflight.keys.iterator()
+ if (it.hasNext()) {
+ it.next()
+ it.remove()
+ }
+ }
+ }
+
+ fun onComplete(c: FtraceParser.Common): DsRow? {
+ val info = FtraceParser.parseComplete(c.body) ?: return null
+ val issued = inflight.remove(key(info.device, info.sector))
+
+ val rwbs = issued?.rwbs?.takeIf { it.isNotEmpty() } ?: info.rwbs
+ val (operation, flags) = FtraceParser.decodeRwbs(rwbs)
+
+ return if (issued != null) {
+ val latency = (c.monoNs - issued.issueMonoNs) / 1e6
+ DsRow(
+ operation = operation,
+ pid = issued.pid,
+ tid = "",
+ command = issued.comm.take(16),
+ sector = info.sector,
+ size = if (issued.bytes != 0L) issued.bytes else issued.nsect * 512,
+ latencyMs = Math.round(latency * 1000.0) / 1000.0,
+ device = info.device,
+ flags = flags,
+ cpuId = c.cpu,
+ ppid = "",
+ queueLatencyMs = "",
+ commandFlags = "",
+ operationCode = "",
+ requestId = issued.requestId,
+ monoNs = c.monoNs,
+ )
+ } else {
+ // Completion with no recorded issue (started before tracing began).
+ DsRow(
+ operation = operation,
+ pid = null,
+ tid = "",
+ command = c.comm.take(16),
+ sector = info.sector,
+ size = info.nsect * 512,
+ latencyMs = null,
+ device = info.device,
+ flags = flags,
+ cpuId = c.cpu,
+ ppid = "",
+ queueLatencyMs = "",
+ commandFlags = "",
+ operationCode = "",
+ requestId = null,
+ monoNs = c.monoNs,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/parse/FtraceParser.kt b/app/src/main/java/com/cachemon/iotracer/parse/FtraceParser.kt
new file mode 100644
index 0000000..431a363
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/parse/FtraceParser.kt
@@ -0,0 +1,94 @@
+package com.cachemon.iotracer.parse
+
+import kotlin.math.roundToLong
+
+/**
+ * Pure parsing of ftrace `trace_pipe` lines — a direct port of the Python
+ * tracer's `parsers.py`. No Android dependencies, so it is unit-testable on the
+ * JVM.
+ *
+ * Common line format:
+ *
+ * - [] : :
+ */
+object FtraceParser {
+
+ /** Parsed ftrace common header. */
+ data class Common(
+ val comm: String,
+ val pid: Int,
+ val cpu: Int,
+ val event: String,
+ val monoNs: Long,
+ val body: String,
+ )
+
+ // comm may contain dashes/slashes/colons, so the pid is anchored as the
+ // digit run immediately preceding the " [cpu]" field.
+ private val LINE = Regex(
+ """^\s*(.+?)-(\d+)\s+\[(\d+)]\s+(\S+)\s+(\d+\.\d+):\s+([\w:]+):\s*(.*)$"""
+ )
+
+ fun parseCommon(line: String): Common? {
+ val m = LINE.matchEntire(line) ?: return null
+ val (comm, pid, cpu, _flags, ts, event, body) = m.destructured
+ val monoNs = (ts.toDouble() * 1e9).roundToLong()
+ return Common(comm, pid.toInt(), cpu.toInt(), event, monoNs, body)
+ }
+
+ // --- rwbs decoding ----------------------------------------------------- //
+
+ private val RWBS_OP = mapOf(
+ 'R' to "read", 'W' to "write", 'D' to "discard",
+ 'E' to "secure_erase", 'F' to "flush", 'N' to "none",
+ )
+ private val RWBS_FLAG = mapOf(
+ 'S' to "sync", 'M' to "meta", 'A' to "ahead",
+ 'P' to "prio", 'B' to "barrier", 'F' to "fua",
+ )
+
+ /** Decode an rwbs string into (baseOperation, flagsJoined). */
+ fun decodeRwbs(rwbs: String): Pair {
+ if (rwbs.isEmpty()) return "" to ""
+ val op = RWBS_OP[rwbs[0]] ?: ""
+ val flags = rwbs.drop(1).mapNotNull { RWBS_FLAG[it] }.joinToString("|")
+ return op to flags
+ }
+
+ // --- block_rq_issue / block_rq_complete bodies ------------------------- //
+
+ data class Issue(
+ val device: String, val rwbs: String, val bytes: Long,
+ val sector: Long, val nsect: Long,
+ )
+
+ data class Complete(
+ val device: String, val rwbs: String,
+ val sector: Long, val nsect: Long, val errno: Int,
+ )
+
+ // "8,0 W 8192 () 779 + 16 [comm]"
+ private val ISSUE = Regex(
+ """^(\d+,\d+)\s+(\S+)\s+(\d+)\s+\(.*?\)\s+(\d+)\s+\+\s+(\d+)"""
+ )
+ // "8,0 W () 779 + 16 [0]"
+ private val COMPLETE = Regex(
+ """^(\d+,\d+)\s+(\S+)\s+\(.*?\)\s+(\d+)\s+\+\s+(\d+)\s+\[(-?\d+)]"""
+ )
+
+ private fun devToMajMin(dev: String) = dev.replace(",", ":")
+
+ fun parseIssue(body: String): Issue? {
+ // parseCommon already strips leading space before `body`, and trailing
+ // space doesn't affect find(), so no trim() allocation is needed.
+ val m = ISSUE.find(body) ?: return null
+ val (dev, rwbs, bytes, sector, nsect) = m.destructured
+ return Issue(devToMajMin(dev), rwbs, bytes.toLong(), sector.toLong(), nsect.toLong())
+ }
+
+ fun parseComplete(body: String): Complete? {
+ val m = COMPLETE.find(body) ?: return null
+ val (dev, rwbs, sector, nsect, err) = m.destructured
+ return Complete(devToMajMin(dev), rwbs, sector.toLong(), nsect.toLong(), err.toInt())
+ }
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/root/RootShell.kt b/app/src/main/java/com/cachemon/iotracer/root/RootShell.kt
new file mode 100644
index 0000000..3d56ab6
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/root/RootShell.kt
@@ -0,0 +1,43 @@
+package com.cachemon.iotracer.root
+
+import java.io.BufferedReader
+import java.io.InputStreamReader
+
+/**
+ * Thin wrapper around the device `su` binary (Magisk / standard root).
+ *
+ * Two modes:
+ * - [available]/[exec] run a one-shot command and wait.
+ * - [stream] starts a long-lived `su` process (e.g. `cat trace_pipe`) whose
+ * stdout the caller reads line by line; [destroy] it to stop.
+ */
+class RootShell {
+
+ /** True if a root shell can be obtained (uid 0). */
+ fun available(): Boolean = try {
+ val p = ProcessBuilder("su", "-c", "id -u")
+ .redirectErrorStream(true)
+ .start()
+ val out = BufferedReader(InputStreamReader(p.inputStream)).readText().trim()
+ p.waitFor()
+ out.lineSequence().any { it.trim() == "0" }
+ } catch (e: Exception) {
+ false
+ }
+
+ /** Run [script] as root, returning (exitCode, combinedOutput). */
+ fun exec(script: String): Pair = try {
+ val p = ProcessBuilder("su", "-c", script)
+ .redirectErrorStream(true)
+ .start()
+ val out = BufferedReader(InputStreamReader(p.inputStream)).readText()
+ val code = p.waitFor()
+ code to out
+ } catch (e: Exception) {
+ -1 to (e.message ?: "su failed")
+ }
+
+ /** Start a streaming root command; caller reads stdout and later [destroy]s it. */
+ fun stream(script: String): Process =
+ ProcessBuilder("su", "-c", script).start()
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/snap/Snappers.kt b/app/src/main/java/com/cachemon/iotracer/snap/Snappers.kt
new file mode 100644
index 0000000..097b43d
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/snap/Snappers.kt
@@ -0,0 +1,143 @@
+package com.cachemon.iotracer.snap
+
+import com.cachemon.iotracer.io.Csv
+import com.cachemon.iotracer.io.TraceWriter
+import com.cachemon.iotracer.root.RootShell
+import org.json.JSONObject
+import java.security.MessageDigest
+
+/** Hash helper for anonymization (matches the Python simple_hash). */
+fun simpleHash(s: String, length: Int = 12): String {
+ val d = MessageDigest.getInstance("SHA-256").digest(s.toByteArray())
+ return d.joinToString("") { "%02x".format(it) }.take(length)
+}
+
+/**
+ * Periodic process snapshot using toybox `ps` (run as root so every process is
+ * visible despite hidepid). Emits the `process` schema; CPU-interval and
+ * creation-time columns are placeholders, matching the CLI's on-device limits.
+ */
+class ProcSnapper(
+ private val root: RootShell,
+ private val writer: TraceWriter,
+ private val anonymous: Boolean,
+) {
+ fun takeSnapshot(): Int {
+ val ts = wallNow()
+ val monoNs = System.nanoTime()
+ val (code, out) = root.exec("ps -A -o PID,PPID,RSS,VSZ,S,NAME 2>/dev/null")
+ if (code != 0) return 0
+ var count = 0
+ val lines = out.lineSequence().toList()
+ for (line in lines.drop(1)) { // drop header
+ val row = parsePsLine(line, ts, monoNs, anonymous) ?: continue
+ writer.append("process", row)
+ count++
+ }
+ writer.flush("process")
+ return count
+ }
+
+ companion object {
+ /**
+ * Parse one `ps -A -o PID,PPID,RSS,VSZ,S,NAME` line into a `process` CSV
+ * row, or null if it isn't a usable data line. Pure (no I/O) so it is
+ * unit-testable without a device.
+ */
+ fun parsePsLine(line: String, ts: String, monoNs: Long, anonymous: Boolean): String? {
+ val cols = line.trim().split(Regex("\\s+"))
+ if (cols.size < 6) return null
+ val pid = cols[0].toIntOrNull() ?: return null
+ val rssKb = cols[2].toDoubleOrNull() ?: 0.0
+ val vszKb = cols[3].toDoubleOrNull() ?: 0.0
+ val status = cols[4]
+ var name = cols.subList(5, cols.size).joinToString(" ")
+ if (anonymous) name = simpleHash(name)
+ return Csv.row(ts, pid, name, name, vszKb, rssKb, "", 0.0, 0.0, 0.0, status, monoNs)
+ }
+ }
+
+ private fun wallNow(): String =
+ com.cachemon.iotracer.io.TimeFmt.wall(java.util.Date())
+}
+
+/** Captures device hardware and software specs as JSON in the system_spec dir. */
+class SystemSnapper(
+ private val root: RootShell,
+ private val writer: TraceWriter,
+) {
+ fun capture() {
+ val props = getprops()
+ val cpu = JSONObject()
+ .put("brand", readProcField("/proc/cpuinfo", listOf("Hardware", "Processor", "model name"))
+ .ifEmpty { props["ro.board.platform"] ?: "" })
+ .put("soc_manufacturer", props["ro.soc.manufacturer"] ?: "")
+ .put("soc_model", props["ro.soc.model"] ?: "")
+ .put("cores_logical", Runtime.getRuntime().availableProcessors())
+ .put("abi", props["ro.product.cpu.abi"] ?: "")
+ writer.writeSpec("cpu_info.json", cpu.toString(2))
+
+ val mem = meminfo()
+ writer.writeSpec("memory_info.json", JSONObject()
+ .put("total_bytes", (mem["MemTotal"] ?: 0L) * 1024)
+ .put("available_bytes", (mem["MemAvailable"] ?: 0L) * 1024)
+ .put("swap_total_bytes", (mem["SwapTotal"] ?: 0L) * 1024)
+ .toString(2))
+
+ writer.writeSpec("os_info.json", JSONObject()
+ .put("system", "Android")
+ .put("kernel", readFirstLine("/proc/version"))
+ .put("android_release", props["ro.build.version.release"] ?: "")
+ .put("android_sdk", props["ro.build.version.sdk"] ?: "")
+ .put("build_fingerprint", props["ro.build.fingerprint"] ?: "")
+ .put("manufacturer", props["ro.product.manufacturer"] ?: "")
+ .put("model", props["ro.product.model"] ?: "")
+ .put("device", props["ro.product.device"] ?: "")
+ .toString(2))
+ }
+
+ private fun getprops(): Map {
+ val (code, out) = root.exec("getprop")
+ if (code != 0) return emptyMap()
+ val map = HashMap()
+ for (line in out.lineSequence()) {
+ if (line.startsWith("[") && line.contains("]: [")) {
+ val k = line.substringAfter("[").substringBefore("]")
+ val v = line.substringAfter("]: [").substringBeforeLast("]")
+ map[k] = v
+ }
+ }
+ return map
+ }
+
+ private fun meminfo(): Map {
+ val map = HashMap()
+ runCatching {
+ java.io.File("/proc/meminfo").forEachLine { line ->
+ val parts = line.split(Regex(":\\s+"))
+ if (parts.size >= 2) {
+ val num = parts[1].trim().split(" ").firstOrNull()?.toLongOrNull()
+ if (num != null) map[parts[0]] = num
+ }
+ }
+ }
+ return map
+ }
+
+ private fun readProcField(path: String, keys: List): String {
+ // useLines (unlike forEachLine) is inline, so the non-local return is legal.
+ runCatching {
+ java.io.File(path).useLines { seq ->
+ for (line in seq) {
+ for (k in keys) if (line.startsWith(k) && line.contains(":")) {
+ return line.substringAfter(":").trim()
+ }
+ }
+ }
+ }
+ return ""
+ }
+
+ private fun readFirstLine(path: String): String =
+ runCatching { java.io.File(path).readLines().firstOrNull() ?: "" }.getOrDefault("")
+}
diff --git a/app/src/main/java/com/cachemon/iotracer/ui/Theme.kt b/app/src/main/java/com/cachemon/iotracer/ui/Theme.kt
new file mode 100644
index 0000000..9d26f60
--- /dev/null
+++ b/app/src/main/java/com/cachemon/iotracer/ui/Theme.kt
@@ -0,0 +1,18 @@
+package com.cachemon.iotracer.ui
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+
+private val DarkColors = darkColorScheme()
+private val LightColors = lightColorScheme()
+
+@Composable
+fun IOTracerTheme(content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
+ content = content,
+ )
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..7c0f70b
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ IO Tracer
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..86112f4
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/app/src/test/java/com/cachemon/iotracer/FtraceParserTest.kt b/app/src/test/java/com/cachemon/iotracer/FtraceParserTest.kt
new file mode 100644
index 0000000..a08d8d0
--- /dev/null
+++ b/app/src/test/java/com/cachemon/iotracer/FtraceParserTest.kt
@@ -0,0 +1,137 @@
+package com.cachemon.iotracer
+
+import com.cachemon.iotracer.parse.BlockPairer
+import com.cachemon.iotracer.parse.FtraceParser
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** JVM unit tests mirroring the Python tracer's test_parsers.py. */
+class FtraceParserTest {
+
+ @Test
+ fun parsesCommonHeader() {
+ val line = " <...>-1234 [002] d..1 1234.567890: " +
+ "block_rq_issue: 8,0 W 8192 () 779 + 16 [kworker/2:1]"
+ val c = FtraceParser.parseCommon(line)!!
+ assertEquals(1234, c.pid)
+ assertEquals(2, c.cpu)
+ assertEquals("block_rq_issue", c.event)
+ assertEquals(1234567890000L, c.monoNs)
+ assertTrue(c.body.startsWith("8,0 W 8192"))
+ }
+
+ @Test
+ fun parsesCommWithDashesAndSlashes() {
+ val line = "kworker/2:1-1234 [002] .... 100.000001: block_rq_complete: 8,0 R () 5 + 8 [0]"
+ val c = FtraceParser.parseCommon(line)!!
+ assertEquals("kworker/2:1", c.comm)
+ assertEquals(1234, c.pid)
+ }
+
+ @Test
+ fun rejectsJunk() {
+ assertNull(FtraceParser.parseCommon("not an ftrace line"))
+ assertNull(FtraceParser.parseCommon(""))
+ }
+
+ @Test
+ fun decodesRwbs() {
+ assertEquals("read" to "", FtraceParser.decodeRwbs("R"))
+ assertEquals("write" to "sync", FtraceParser.decodeRwbs("WS"))
+ assertEquals("read" to "meta", FtraceParser.decodeRwbs("RM"))
+ assertEquals("write" to "meta|ahead", FtraceParser.decodeRwbs("WMA"))
+ assertEquals("discard" to "", FtraceParser.decodeRwbs("D"))
+ assertEquals("" to "", FtraceParser.decodeRwbs(""))
+ }
+
+ @Test
+ fun parsesIssueAndComplete() {
+ val issue = FtraceParser.parseIssue("8,0 W 8192 () 779 + 16 [kworker/2:1]")!!
+ assertEquals("8:0", issue.device)
+ assertEquals(8192L, issue.bytes)
+ assertEquals(779L, issue.sector)
+ assertEquals(16L, issue.nsect)
+ val comp = FtraceParser.parseComplete("8,0 W () 779 + 16 [0]")!!
+ assertEquals("8:0", comp.device)
+ assertEquals(779L, comp.sector)
+ assertEquals(0, comp.errno)
+ }
+
+ @Test
+ fun pairerMatchesAndComputesLatency() {
+ val pairer = BlockPairer()
+ pairer.onIssue(FtraceParser.parseCommon(
+ "app-100 [001] .... 10.000000: block_rq_issue: 259,0 WS 4096 () 2048 + 8 [app]")!!)
+ assertEquals(1, pairer.inflightCount())
+ val row = pairer.onComplete(FtraceParser.parseCommon(
+ "swapper-0 [003] d.h. 10.002000: block_rq_complete: 259,0 WS () 2048 + 8 [0]")!!)!!
+ assertEquals(0, pairer.inflightCount())
+ assertEquals("write", row.operation)
+ assertEquals("sync", row.flags)
+ assertEquals(100, row.pid) // submitter from issue
+ assertEquals("app", row.command)
+ assertEquals(4096L, row.size)
+ assertEquals("259:0", row.device)
+ assertEquals(2048L, row.sector)
+ assertEquals(2.0, row.latencyMs!!, 1e-6)
+ assertEquals(3, row.cpuId) // completion CPU
+ assertEquals(1L, row.requestId)
+ }
+
+ @Test
+ fun pairerHandlesCompleteWithoutIssue() {
+ val pairer = BlockPairer()
+ val row = pairer.onComplete(FtraceParser.parseCommon(
+ "x-1 [000] .... 5.0: block_rq_complete: 8,0 R () 99 + 8 [0]")!!)!!
+ assertNull(row.latencyMs)
+ assertNull(row.pid)
+ assertEquals(8L * 512, row.size)
+ assertEquals("read", row.operation)
+ assertNull(row.requestId)
+ }
+
+ @Test
+ fun pairerDistinguishesReusedSector() {
+ val pairer = BlockPairer()
+ pairer.onIssue(FtraceParser.parseCommon(
+ "a-1 [0] .... 1.0: block_rq_issue: 8,0 R 512 () 10 + 1 [a]")!!)
+ val r1 = pairer.onComplete(FtraceParser.parseCommon(
+ "a-1 [0] .... 1.001: block_rq_complete: 8,0 R () 10 + 1 [0]")!!)!!
+ pairer.onIssue(FtraceParser.parseCommon(
+ "b-2 [0] .... 2.0: block_rq_issue: 8,0 R 512 () 10 + 1 [b]")!!)
+ val r2 = pairer.onComplete(FtraceParser.parseCommon(
+ "b-2 [0] .... 2.001: block_rq_complete: 8,0 R () 10 + 1 [0]")!!)!!
+ assertEquals(1L, r1.requestId)
+ assertEquals(2L, r2.requestId)
+ assertEquals(1, r1.pid)
+ assertEquals(2, r2.pid)
+ }
+
+ @Test
+ fun pairerEvictsOldestPastCap() {
+ val pairer = BlockPairer(maxInflight = 2)
+ pairer.onIssue(FtraceParser.parseCommon(
+ "a-1 [0] .... 1.0: block_rq_issue: 8,0 R 512 () 10 + 1 [a]")!!)
+ pairer.onIssue(FtraceParser.parseCommon(
+ "b-2 [0] .... 1.1: block_rq_issue: 8,0 R 512 () 20 + 1 [b]")!!)
+ // Third issue evicts the oldest (sector 10).
+ pairer.onIssue(FtraceParser.parseCommon(
+ "c-3 [0] .... 1.2: block_rq_issue: 8,0 R 512 () 30 + 1 [c]")!!)
+ assertEquals(2, pairer.inflightCount())
+
+ // Completion for the evicted sector 10 is unmatched (no latency / pid).
+ val r10 = pairer.onComplete(FtraceParser.parseCommon(
+ "x-9 [0] .... 1.3: block_rq_complete: 8,0 R () 10 + 1 [0]")!!)!!
+ assertNull(r10.latencyMs)
+ assertNull(r10.pid)
+
+ // Sector 30 is still tracked, so it pairs normally.
+ val r30 = pairer.onComplete(FtraceParser.parseCommon(
+ "x-9 [0] .... 1.4: block_rq_complete: 8,0 R () 30 + 1 [0]")!!)!!
+ assertEquals(3, r30.pid)
+ assertNotNull(r30.latencyMs)
+ }
+}
diff --git a/app/src/test/java/com/cachemon/iotracer/ProcLineTest.kt b/app/src/test/java/com/cachemon/iotracer/ProcLineTest.kt
new file mode 100644
index 0000000..6aebcd5
--- /dev/null
+++ b/app/src/test/java/com/cachemon/iotracer/ProcLineTest.kt
@@ -0,0 +1,42 @@
+package com.cachemon.iotracer
+
+import com.cachemon.iotracer.snap.ProcSnapper
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/** Unit tests for the pure `ps` line parser used by ProcSnapper. */
+class ProcLineTest {
+
+ // process columns: timestamp,pid,name,cmdline,vms_kb,rss_kb,creation_time,
+ // cpu_5s,cpu_2m,cpu_1h,status,mono_ns
+ @Test
+ fun parsesPsLine() {
+ val row = ProcSnapper.parsePsLine(
+ "123 1 4096 8192 S com.example.app", "2026-01-01 00:00:00.000000", 555L, false)!!
+ val f = row.split(",")
+ assertEquals("123", f[1]) // pid
+ assertEquals("com.example.app", f[2]) // name
+ assertEquals("8192.0", f[4]) // vms_kb = VSZ
+ assertEquals("4096.0", f[5]) // rss_kb = RSS
+ assertEquals("S", f[10]) // status
+ assertEquals("555", f[11]) // mono_ns
+ }
+
+ @Test
+ fun rejectsShortAndHeaderLines() {
+ assertNull(ProcSnapper.parsePsLine("too few cols", "t", 0L, false))
+ // Header row: PID column isn't numeric.
+ assertNull(ProcSnapper.parsePsLine("PID PPID RSS VSZ S NAME", "t", 0L, false))
+ }
+
+ @Test
+ fun anonymizesName() {
+ val row = ProcSnapper.parsePsLine(
+ "123 1 4096 8192 S com.example.app", "t", 0L, true)!!
+ val name = row.split(",")[2]
+ assertEquals(12, name.length) // 12-char hash
+ assertNotEquals("com.example.app", name)
+ }
+}
diff --git a/app/src/test/java/com/cachemon/iotracer/TraceWriterTest.kt b/app/src/test/java/com/cachemon/iotracer/TraceWriterTest.kt
new file mode 100644
index 0000000..be56824
--- /dev/null
+++ b/app/src/test/java/com/cachemon/iotracer/TraceWriterTest.kt
@@ -0,0 +1,54 @@
+package com.cachemon.iotracer
+
+import com.cachemon.iotracer.io.Schema
+import com.cachemon.iotracer.io.TraceWriter
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.File
+import java.io.FileInputStream
+import java.nio.file.Files
+import java.util.zip.GZIPInputStream
+
+/**
+ * Exercises the gzip CSV output path (no org.json / manifest, so it runs under
+ * plain JVM unit tests). Mirrors the Python tracer's writer tests.
+ */
+class TraceWriterTest {
+
+ private fun tempDir(): File = Files.createTempDirectory("tw").toFile()
+
+ private fun readGz(file: File): List =
+ GZIPInputStream(FileInputStream(file)).bufferedReader(Charsets.UTF_8).readLines()
+
+ @Test
+ fun rotatesDsOnThresholdWithHeader() {
+ val dir = tempDir()
+ val wm = TraceWriter(dir, "MID", 0L, flushThreshold = 2)
+ wm.append("ds", "row-a")
+ wm.append("ds", "row-b") // reaching the threshold auto-rotates the ds stream
+ val files = File(dir, "ds").listFiles { f -> f.name.endsWith(".csv.gz") }!!
+ assertEquals(1, files.size)
+ val lines = readGz(files[0])
+ assertEquals(Schema.header(Schema.DS), lines[0])
+ assertEquals(listOf("row-a", "row-b"), lines.drop(1))
+ assertEquals(2L, wm.rowsWritten["ds"])
+ assertEquals(1, wm.filesWritten["ds"])
+ }
+
+ @Test
+ fun snapshotStreamFlushesOnlyOnDemand() {
+ val dir = tempDir()
+ val wm = TraceWriter(dir, "MID", 0L, flushThreshold = 2)
+ // Snapshot streams do not auto-rotate; nothing is written until flush().
+ wm.append("process", "p1")
+ wm.append("process", "p2")
+ wm.append("process", "p3")
+ assertEquals(0, File(dir, "process").listFiles { f -> f.name.endsWith(".csv.gz") }!!.size)
+ wm.flush("process")
+ val files = File(dir, "process").listFiles { f -> f.name.endsWith(".csv.gz") }!!
+ assertEquals(1, files.size)
+ val lines = readGz(files[0])
+ assertEquals(Schema.header(Schema.PROCESS), lines[0])
+ assertEquals(3, lines.size - 1)
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..df8870c
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+// Top-level build file. Plugin versions are declared here and applied in :app.
+plugins {
+ id("com.android.application") version "8.5.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+}
diff --git a/docs/ANDROID_APP.md b/docs/ANDROID_APP.md
new file mode 100644
index 0000000..bc7b451
--- /dev/null
+++ b/docs/ANDROID_APP.md
@@ -0,0 +1,84 @@
+# Android App (rooted block-I/O tracer)
+
+A native Android app (`app/` module) that runs the block-I/O collector on-device
+with a Start/Stop UI, instead of driving the Python CLI over `adb`. It produces
+the **same CSV schema** as the Python tracer and the Linux tracer; the only
+difference is the compression container (`.csv.gz` here vs `.csv.zst`).
+
+## Requirements
+
+- A **rooted** (or `userdebug`) device — block tracing reads `/sys/kernel/tracing`
+ via `su`. Without root the app still runs but can only do process/system
+ snapshots (no block I/O).
+- Android 8.0+ (minSdk 26).
+
+## What it collects
+
+| Stream | Source | Output |
+|--------|--------|--------|
+| `ds` (block I/O) | ftrace `block_rq_issue`/`block_rq_complete` via `su`, paired for latency | `ds/ds_*.csv.gz` |
+| `process` | `ps -A` via `su`, on an interval | `process/process_*.csv.gz` |
+| system spec | `getprop` + `/proc` | `system_spec/*.json` |
+| manifest | schema + machine id + clock offset + counts | `manifest.json` |
+
+Output is written under the app's external files dir:
+`Android/data/com.cachemon.iotracer/files/traces//`.
+
+## Architecture
+
+```
+MainActivity (Compose UI)
+ │ Start/Stop, options, live counters
+ ▼
+TracerService (foreground service)
+ │ owns the engine, posts the ongoing notification
+ ▼
+TraceEngine
+ ├─ RootShell ............. su exec + streaming
+ ├─ FtraceControl ......... enable/stream/teardown tracefs scripts
+ ├─ FtraceParser .......... parse trace_pipe lines (port of parsers.py)
+ ├─ BlockPairer ........... issue→complete latency (port of BlockPairer)
+ ├─ ProcSnapper / SystemSnapper
+ └─ TraceWriter ........... gzip CSV streams + manifest.json
+```
+
+`FtraceParser`, `BlockPairer`, and `Schema` are 1:1 ports of the Python modules
+and **must be kept in sync** (same `SCHEMA_VERSION = 3`, same column order). The
+JVM unit tests in `app/src/test` mirror `tests/test_parsers.py`.
+
+## Build
+
+The repo has no committed Gradle wrapper jar; build with Android Studio (which
+generates the wrapper) or with a system Gradle 8.7:
+
+```bash
+# Android Studio: open the repo root, let it sync, Run ▶
+# or CLI:
+gradle :app:assembleDebug # APK -> app/build/outputs/apk/debug/
+gradle :app:testDebugUnitTest # run the parser/pairer unit tests
+```
+
+CI (`.github/workflows/android.yml`) runs the unit tests and assembles the debug
+APK on every push/PR and uploads the APK as an artifact.
+
+## Run
+
+1. Install the debug APK on a rooted device (`adb install app-debug.apk`).
+2. Launch **IO Tracer**; grant the `su` request when prompted.
+3. Set options (anonymize, snapshot interval) and tap **Start tracing**.
+4. Reproduce your workload; the notification shows live event counts.
+5. Tap **Stop** — buffers flush, ftrace state is restored, `manifest.json` is written.
+6. Pull the session:
+ ```bash
+ adb pull /sdcard/Android/data/com.cachemon.iotracer/files/traces ./traces
+ ```
+
+## Reading the output
+
+Same as the CLI, but gzip instead of zstd:
+
+```python
+import glob, pandas as pd
+df = pd.concat(pd.read_csv(f) for f in glob.glob("traces/*/ds/ds_*.csv.gz"))
+print(df.groupby("operation")["latency_ms"].describe())
+```
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..71e8721
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.caching=true
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..2019817
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "IOTracerAndroid"
+include(":app")