Aperture is an Android library that inspects a running app from a browser. It started with HTTP traffic and now reaches the rest of the app's local state: preferences, DataStore, databases and files. It reads them live, and with one line of config it edits them too.
Everything runs on the device. There is no desktop app, no proxy and no adb wrangling beyond a single port forward.
| Panel | Reads | Writes |
|---|---|---|
| Network | Every request and response through your OkHttp client, live | Mocks a response for a URL |
| Preferences | Every file in shared_prefs, with each value's real type |
Edit, add and remove keys; clear a file |
| DataStore | Preferences DataStore instances you register | Edit, add and remove keys |
| Databases | Tables, schema and paged rows in your SQLite databases; a query box | Edit a cell, run a statement |
| Files | The app sandbox: files, cache, no_backup and external directories | Edit text, delete, download |
| Logcat | This app's own log, live, coloured by level | — |
- 🌐 Web console - the whole app's state from any browser on your network
- 📡 Live updates - requests, preference changes and row edits arrive as they happen
- 📜 Live Logcat - your app's log in the browser, coloured by level, with filters
- ✍️ Read and write - change app state and watch the app pick it up, opt-in per app
- 🎭 Response mocking - mock API responses without changing your code
- ⌘ Command palette and keyboard - reach any panel, file or table without the mouse
- 🎨 Light and dark - follows the system, with a toggle that overrides it
- 📱 Responsive - three panes on a laptop, a stack with a tab bar on a phone
- 🔒 Optional authentication - token auth, including for the live stream
- 🚀 Zero overhead in release - no-op implementation for production builds
- 🛡️ Safe to drop in - never crashes your app, never blocks its main thread, never fails a request
Add to your app's build.gradle.kts:
dependencies {
debugImplementation("io.github.desertstack:aperture:1.2.0")
releaseImplementation("io.github.desertstack:aperture-no-op:1.2.0")
// Required: Aperture hooks into your OkHttp client and does not bundle it
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}Aperture's own dependencies (Ktor, Room, coroutines, serialization) come in transitively — you do not need to declare them.
Aperture embeds a Ktor/Netty server, which ships metadata files that collide at
packaging time. Add this to the same build.gradle.kts, inside android { }:
packaging {
resources {
excludes += listOf(
"META-INF/INDEX.LIST",
"META-INF/io.netty.versions.properties"
)
}
}Without it the build fails with a duplicate-resource error. This cannot be supplied by the library, since packaging options are application-level.
Put every Aperture call inside one BuildConfig.DEBUG check:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Aperture.initialize(
context = this,
config = ApertureConfig(
port = 8080,
autoStart = true
)
)
}
}
}Why the check, when the release build is already a no-op. The release variant's methods are
inline and empty, so the calls themselves compile to nothing. Kotlin still evaluates their
arguments. Without the guard, ApertureConfig(...) is allocated in release, and
Aperture.registerDatabase("app.db", AppDatabase.get(this)) // ← opens the database in releaseopens a Room database purely to hand it to a method that discards it. One check around the whole block keeps all of it out.
Call initialize() once, from Application.onCreate(). The system also runs that method when
your process starts in the background, for a push message, a background job or a widget update.
Aperture handles that case by itself. See
Background process starts.
val client = OkHttpClient.Builder()
.addInterceptor(Aperture.getInterceptor())
.build()getInterceptor() never throws. If initialize() did not run, or it failed, the call logs and
returns a pass-through interceptor, so your OkHttp client works either way.
That's it! Make any HTTP request using your OkHttp client, and Aperture will capture it.
Preferences and files need nothing: Aperture finds them and reads them through the same instances your app is using. Databases and DataStore need one line each.
if (BuildConfig.DEBUG) {
Aperture.registerDatabase("app.db", appDatabase) // RoomDatabase or SupportSQLiteDatabase
Aperture.registerDataStore("settings", settingsDataStore) // DataStore<Preferences>
Aperture.registerSharedPreferences("secure", encryptedPrefs)
}Why registration is needed, and not merely preferred:
- Databases. A second read-write connection makes Android issue
PRAGMA journal_mode=PERSIST, and your database loses WAL permanently. Room's invalidation also runs onCREATE TEMP TRIGGER, and TEMP objects belong to one connection, so a write from anywhere else could never wake your Flows. Registered, Aperture writes through your own connection and callsrefreshVersionsAsync(), so your UI updates while you type in the browser. Unregistered databases are still listed and browsed, read-only. - DataStore.
SingleProcessDataStorekeeps a process-wide record of the files it has open and throws if a second instance is built over one of them, so Aperture opening its own would crash your app. It also caches values in memory and never re-reads the file. Unregistered stores are listed by name and size only. - EncryptedSharedPreferences. Read from disk it is ciphertext, because your wrapper holds the keys. Register the wrapper and the console shows plain text. Plain preferences files need no registration.
All three calls are safe to make after initialize(), and none of them throw.
Aperture is read-only until you say otherwise:
ApertureConfig(allowWrites = true)With it off, every route that would change app state answers 403 and the console hides its edit controls. With it on, edits are still deliberate: a field is read-only until you click Edit, a save shows the old value beside the new one, reversible writes offer Undo, and a destructive action names its target on a button labelled with the action.
Statements that would damage your app are refused whatever this setting says: ATTACH (which
silently drops your database out of WAL), VACUUM INTO (an arbitrary file write), BEGIN and
the PRAGMAs that reconfigure a database.
Filter your Logcat on the Aperture tag. The server prints where to reach it:
Aperture: ═══════════════════════════════════════
Aperture: 🌐 Aperture Server Started
Aperture: ═══════════════════════════════════════
Aperture: 📱 Same Network: http://192.168.1.100:8080
Aperture: 🔌 ADB Forward: http://localhost:8080
Aperture:
Aperture: 💻 To access from computer when on cellular:
Aperture: Run: adb forward tcp:8080 tcp:8080
Aperture: Open: http://localhost:8080
Aperture: ═══════════════════════════════════════
The server binds its port on a background thread, so this banner appears a moment after your app starts.
Open this URL in any browser on your network to inspect traffic!
Aperture provides multiple ways to access the web UI:
When your phone and computer are on the same WiFi network:
http://192.168.1.100:8080 ← Check Logcat for your device's IP
The server binds to 0.0.0.0, making it accessible from any device on your local network.
When your phone is on cellular data or a different network:
- Connect device via USB
- Run the ADB forward command (shown in Logcat):
adb forward tcp:8080 tcp:8080
- Open in browser:
http://localhost:8080
The ADB port forwarding tunnels the device's port to your computer's localhost!
// Get network URL (for same WiFi)
val networkUrl = Aperture.getServerUrl()
// Example: http://192.168.1.100:8080
// Get localhost URL (for ADB forwarding)
val localUrl = Aperture.getLocalhostUrl()
// Always: http://localhost:8080
// Get ADB command
val adbCommand = Aperture.getAdbForwardCommand()
// Example: adb forward tcp:8080 tcp:8080When showNotification = true (default), Aperture runs in a foreground service with a persistent notification showing:
- 📡 Request Count - Total captured requests (updates every 5 seconds)
- 🌐 Network URL - For same WiFi access
- 🔌 ADB URL - For port forwarding access
- 💻 ADB Command - Ready to copy and run
- Tap to Open - Opens the network URL in your browser
- Quick Actions:
- Stop - Stops the server and service
- Clear - Clears all captured data
Example notification:
📡 42 requests captured
🌐 Network: http://192.168.1.100:8080
🔌 ADB: http://localhost:8080
💻 Port forward: adb forward tcp:8080 tcp:8080
Tap to open in browser
This ensures:
- ✅ Server survives when app goes to background
- ✅ System won't kill the process unexpectedly
- ✅ Easy access to URLs and ADB command
- ✅ Works with cellular data via ADB forwarding
- ✅ Complies with Android foreground service requirements
Aperture is a debug tool inside someone else's app, so it holds to three rules:
- It does not crash the host app. Every entry point catches its own failures. If
initialize()fails,getInterceptor()returns a pass-through interceptor and the app keeps its network stack. - It does not block the main thread. Ktor binds its socket on the calling thread, so Aperture starts and stops the server on a background thread. The notification reads cached values, never the database.
- It does not fail your requests. A capture error is logged and dropped. Your request goes out once and your response comes back untouched.
Android 12+ (API 31) refuses a foreground service start while the app is in the background.
Aperture.initialize() runs from Application.onCreate(), which the system also calls when the
process starts for a push message, a background job or a widget update. Aperture handles the
refusal: it starts the server in the app process, and moves it into the foreground service when
the app shows an activity. The host app does not crash, and the inspector stays available.
Aperture.initialize(
context = this,
config = ApertureConfig(
enabled = true, // Master enable/disable
port = 8080, // Server port
autoStart = true, // Start server automatically
maxRecords = 1000, // Max transactions to store
retentionDays = 7, // Auto-delete after N days
maxBodySize = 5 * 1024 * 1024, // 5 MB max body size
requireAuth = false, // Require authentication
showNotification = true, // Show status notification
localhostOnly = false, // Bind to localhost only
headersToRedact = setOf("Authorization", "Cookie"), // Redact sensitive headers
allowWrites = false, // Let the console change app state
inspectors = ApertureInspector.ALL // Which panels to offer
)
)// Traffic only, the way Aperture worked before 1.2
ApertureConfig(inspectors = ApertureInspector.NETWORK_ONLY)
// Everything except the network
ApertureConfig(inspectors = ApertureInspector.STORAGE)
// Exactly what you want
ApertureConfig(inspectors = setOf(ApertureInspector.NETWORK, ApertureInspector.PREFS))
// Everything except the log
ApertureConfig(inspectors = ApertureInspector.ALL - ApertureInspector.LOGCAT)A panel you leave out registers no routes at all, so its data never leaves the device.
Aperture binds every network interface and asks for no token by default. That was captured traffic; it is now your app's preferences, databases, files and log. Log lines often hold tokens and personal data. Aperture says so at startup:
Aperture: Storage inspection is open at http://192.168.1.100:8080
Aperture: Anyone on this network can read this app's
Aperture: preferences, databases, files and logs.
Aperture: Set requireAuth = true or localhostOnly = true
Either setting closes it. The console also shows a warning chip while the port is open.
// Default configuration
Aperture.initialize(this, ApertureConfig.DEFAULT)
// Minimal (reduced resource usage)
Aperture.initialize(this, ApertureConfig.MINIMAL)
// Localhost only (enhanced security)
Aperture.initialize(this, ApertureConfig.LOCALHOST_ONLY)- Click on any transaction in the web UI
- Toggle "Enable Mock Response"
- Set custom status code, headers, and body
- Click "Save Mock"
- Future requests to the same URL will return your mocked response!
// Enable mock for a transaction
Aperture.getRepository()?.setMockEnabled(transactionId, true)
// Update mock response
Aperture.getRepository()?.updateMockResponse(
id = transactionId,
responseCode = 200,
headers = """{"Content-Type": "application/json"}""",
body = """{"mocked": true}"""
)// Server control
Aperture.startServer()
Aperture.stopServer()
val isRunning = Aperture.isServerRunning()
val url = Aperture.getServerUrl()
// Data management
Aperture.clearAllData()
Aperture.clearOldData(olderThanDays = 7)
// Registration, for what Aperture cannot reach on its own
Aperture.registerDatabase("app.db", appDatabase)
Aperture.registerDataStore("settings", settingsDataStore)
Aperture.registerSharedPreferences("secure", encryptedPrefs)
// Get statistics
val count = Aperture.getTransactionCount() // suspending, reads the database
val lastCount = Aperture.getTransactionCountSnapshot() // last known count, safe on any thread
val transaction = Aperture.getTransaction(id)startServer() and stopServer() return at once and do their work on a background thread,
because Ktor binds and releases its port on the thread that calls it. isServerRunning()
therefore turns true shortly after startServer() returns, not immediately.
None of these calls throw. Before initialize(), or after it failed, they log and do nothing.
Enable token authentication to secure your inspection server:
Aperture.initialize(
context = this,
config = ApertureConfig(
requireAuth = true,
customToken = "your-secret-token" // Optional, random token generated if not provided
)
)The auth token will be logged to Logcat:
Aperture: Auth token: abc123xyz
Include it in API requests:
Authorization: Bearer abc123xyz
A three-pane instrument: an icon rail of panels, a list, and a detail view. Under 820 px the rail becomes a bottom tab bar and the two panes stack.
| Key | What it does |
|---|---|
⌘K / Ctrl K |
Command palette: any panel, preferences file, database table or recent request |
/ |
Focus the list search |
j / k |
Move down and up the list |
g then a letter |
Jump to a panel |
Esc |
Close whatever is open |
? |
The shortcut list |
Every view has an address. /db/notes.db/notes?page=2 survives a reload and can be pasted to
someone else.
-
Network — live list with search, method and status filters that apply together. The detail view shows headers, bodies, timing and the mock editor. The list carries summaries only, so a captured body never reaches memory for a view that does not draw it.
-
Preferences and DataStore — every key with its real type. An edit keeps that type unless you say otherwise, because writing a Long into a key the app reads with
getIntthrows inside your app. -
Databases — tables, schema, paged rows and a query box. Large cells are cut short inside SQLite so a row never exceeds the CursorWindow, with the whole value a click away.
-
Files — the sandbox as a tree, with text editing, download and delete. The console never sends a path: it sends a signed id the device issued.
-
Logcat — this app's own log, live. Each level has its own colour: verbose grey, debug blue, info green, warn amber, error red and assert purple. Pick the lowest level to show, filter by text, or type
tag:Name(or click a tag) for one tag. A stack trace shows its header once. Pause holds the view while lines keep arriving; scroll up and the view stops following until you scroll back down. Save writes the shown lines to a text file.Nothing reads the log until the panel opens. Then one
logcatprocess reads for the app's own process, needs no permission, and runs until the server stops. The device keeps the last 5,000 lines, so a tab that reconnects gets only what it missed. Lines travel on their own stream, so a busy log never pushes network or storage events aside.
Aperture is built with modern Android technologies:
- Kotlin - 100% Kotlin codebase
- Room - Local database for transaction storage
- OkHttp - Interceptor for capturing HTTP traffic
- Ktor - Embedded web server
- Kotlin Coroutines - Async/concurrent operations
- Kotlinx Serialization - JSON serialization
- Server-Sent Events (SSE) - Real-time updates
- Min SDK: 21 (Android 5.0 Lollipop)
- Target SDK: 36
- OkHttp: 4.x or 5.x
- Kotlin: 1.9+
| Feature | Aperture | Chucker |
|---|---|---|
| Network inspection | ✅ | ✅ |
| Preferences, DataStore, databases, files | ✅ | ❌ |
| Editing app state | ✅ | ❌ |
| Inspection UI | Web browser | In-app |
| Screen size | Any device | Mobile only |
| Real-time updates | ✅ SSE | ✅ |
| Response mocking | ✅ | ❌ |
| Network access | ✅ | ❌ |
| Multi-device viewing | ✅ | ❌ |
| Dark mode | ✅ Auto | ✅ |
| Zero dependencies in release | ✅ | ✅ |
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/AmazingFeature) - Commit your changes (
git commit -m 'Add some AmazingFeature') - Push to the branch (
git push origin feature/AmazingFeature) - Open a Pull Request
Copyright 2026 Aperture
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
- Inspired by Chucker - excellent in-app HTTP inspector
- Database schema influenced by Chucker's proven design
- Built with ❤️ for the Android community
Aperture now inspects the rest of the app, not only its traffic, and the console was rebuilt to carry it.
Added
- Preferences, DataStore, databases and files, read live and edited with
allowWrites = true. Aperture.registerDatabase,registerDataStoreandregisterSharedPreferences. Registration is what lets Aperture write through the app's own connection, so Room's invalidation fires and encrypted preferences read as plain text.- A Logcat panel: this app's own log, streamed live and coloured by level
(
ApertureInspector.LOGCAT). ApertureConfig.allowWrites(defaultfalse) andApertureConfig.inspectors(default every panel).- A new console: three panes, a command palette, keyboard navigation, a density control, and a theme toggle over a light and dark palette.
- A startup warning when storage inspection is reachable on the network with no token.
Fixed
- Captured content can no longer run as markup. Every view built itself by pasting URLs,
headers and response bodies into
innerHTML. The console now sets text, never markup. - The status filter works. The console sent
4xxand the server read it withtoIntOrNull(), which is null, so the filter silently did nothing. - Filters combine. Choosing a method used to discard the search text, on both sides.
- Authentication works with the console.
requireAuth = trueused to lock the page out of its own API, andEventSourcecannot send a header at all, so the live stream could never connect. The console now asks for the token and sends it both ways. - The list pages. It was pinned to the first 100 rows.
- The console is cached and compressed instead of being re-read from the APK on every request.
Changed
- Network endpoints moved from
/api/transactionsand/api/statsto/api/network/transactionsand/api/network/stats, so every panel sits under its own name. - Cross-origin requests are limited to loopback origins.
Access-Control-Allow-Origin: *over captured traffic was already generous; over app databases it is not defensible. - Requests to
/apimust arrive by IP address or localhost. A host name is refused, which is what stops a web page from pointing a domain it controls at the device and reading the reply.
Fixes a crash that reached back to 1.0.0. Upgrade from any earlier version.
Fixed
-
A large captured body no longer crashes the app. Aperture stored bodies of up to 5 MB, and base64 grew binary ones by a third on top of that. Android reads a row through a CursorWindow of about 2 MB, so one large response made every query that touched it throw
SQLiteBlobTooBigException. A stored body is now capped at 512 KB, and a truncated body says so. -
A database error no longer kills the host app. The coroutine that watches for new transactions ran with no exception handler, so the failure above reached the default handler. It now logs and stops watching.
-
The real-time watcher reads one row, not the whole table. It selected every transaction, with every body, on every insert, and then kept only the newest one.
-
A database written by an earlier version is repaired, not abandoned.
initialize()replaces bodies stored above the ceiling, so transactions captured by 1.0.0 or 1.1.0 become readable again. Their metadata, headers and timings survive; only the oversized body goes. -
Stopping the server releases its database watcher. A stop and start cycle leaked one collector per start.
-
The list endpoint carries no bodies.
GET /api/transactionsreturned every body of every listed row, up to 500 of them, held in memory and serialized to JSON. It now answers with metadata only, read through a column projection, so the bodies never leave SQLite. The live stream carries the same summary.
Behaviour changes
GET /api/transactionsand thenew_transactionstream event no longer carryrequestBody,responseBody,requestHeaders,responseHeaders,mockResponseBodyormockResponseHeaders. Read one transaction fromGET /api/transactions/{id}for those. The bundled web UI already did.maxBodySizeabove 512 KB has no effect. Android cannot read back a larger row, so the ceiling wins. Use the setting to store less, not more.
Removed
TransactionRepository.getAllAsFlow(), replaced bygetLatestSummaryAsFlow(). Unreachable through the public API, sinceAperture.getRepository()is internal.
Aperture 1.0.0 could crash the app it was inspecting. This release makes the library safe to drop into any app. Upgrade from 1.0.0.
Fixed
- The host app no longer crashes on a background process start. Android 12+ refuses a
foreground service start while the app is in the background, and threw
ForegroundServiceStartNotAllowedExceptionout ofApplication.onCreate(). Aperture now catches the refusal, runs the server in the app process, and moves it into the service when the app shows an activity. - The main thread does no server work. Ktor binds and releases its port on the thread that calls it. Aperture starts and stops the server on a background thread, so app startup does not wait for it.
- The notification reads no database. The transaction count and the IP address are cached and refreshed on the notification timer thread.
- The service survives the Android 15 budget. A
dataSyncforeground service loses its daily budget after six hours. Aperture now stops the service ononTimeout()and keeps the server in the process, instead of leaving the app to an ANR. - The service no longer restarts itself into a refused state.
START_STICKYbecameSTART_NOT_STICKY.
Behaviour changes
getInterceptor()no longer throwsIllegalStateExceptionbeforeinitialize(). It logs and returns a pass-through interceptor, so your OkHttp client keeps working.startServer()andgetServerUrl()degrade the same way.getServerUrl()returns an empty string when Aperture is not initialized.initialize()catches its own failures. A broken database no longer takes the app down.isServerRunning()turns true shortly afterstartServer()returns, because the start now happens on a background thread.
Added
getTransactionCountSnapshot(), the last known transaction count, with no database read. Present in both the full and the no-op artifact.
First release.
- ✅ HTTP/HTTPS traffic capture
- ✅ Web-based UI
- ✅ Response mocking
- ✅ Real-time updates
- ✅ Statistics dashboard
- ✅ Safe to initialize from a background process start
- ✅ No server work on the main thread
- WebSocket inspection
- GraphQL query parsing
- Request replay functionality
- HAR export
- Performance metrics dashboard
- Request filtering by domain/size
- Response delay simulation
Check your Logcat for the server URL. The format is http://<your-device-ip>:8080. Open this in any browser on the same network.
No! Aperture is designed for debug builds only. Use the debugImplementation and releaseImplementation configuration shown above. The no-op version has zero overhead.
Yes! Retrofit uses OkHttp under the hood. Just add the interceptor to your OkHttp client and Retrofit will work automatically.
Use the headersToRedact configuration:
ApertureConfig(
headersToRedact = setOf("Authorization", "Cookie", "X-Api-Key")
)Aperture works normally with SSL pinning since it operates as an OkHttp interceptor, not a proxy.
Two causes, and neither stops the inspector.
Your process started in the background, so Android refused the foreground service. Aperture
logs Foreground service refused, starting server in-process and runs the server anyway. Open
your app once and the notification appears. Until then, reach the web UI over
adb forward tcp:8080 tcp:8080.
Or the app has no notification permission on Android 13+. See the next question.
For Android 13+ (API 33+), you need to request the POST_NOTIFICATIONS permission at runtime to show the foreground service notification. Aperture doesn't handle this automatically - you need to request it in your app:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQUEST_CODE
)
}
}Alternatively, set showNotification = false to run without a foreground service (not recommended for production use).
Made with 📡 by the Aperture team
