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 @@ + + + +