Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 17 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
60 changes: 60 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
1 change: 1 addition & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Keep default Compose/Kotlin behavior; this is a developer tool with no minify.
33 changes: 33 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<!-- Block-I/O collection runs as root via `su`; no app-level storage perms
are needed because traces land in the app's own external files dir.
The foreground service keeps collection alive while the screen is off. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:allowBackup="false"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.IOTracer">

<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".TracerService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
187 changes: 187 additions & 0 deletions app/src/main/java/com/cachemon/iotracer/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
Loading
Loading