A user emails your support inbox: “the app crashed.” No device model. No OS version. No logs. You spend forty minutes bouncing between five test devices trying to reproduce something that may only happen at 8% battery on a Galaxy A14 with 200MB of free storage. This is mobile bug reporting by default, and it’s why we built Critic’s Android SDK to capture battery status, memory metrics, disk space, network connectivity, OS version, and 500 lines of logcat automatically with every user-submitted report.

This article walks through the architecture decisions, specific Android APIs, and trade-offs behind a feedback SDK that ships at roughly 1,600 lines of Java. If you’re evaluating whether to build device-context capture yourself or adopt an existing tool, this is the technical analysis that will save you from learning the hard way.

Context and Constraints

When we designed the SDK, we started with three non-negotiable requirements:

1. Minimal footprint. The average Android app already integrates 15-17 SDKs for analytics, payments, advertising, and engagement. Google’s own analysis of Play Store data shows that every 6MB increase in APK size reduces install conversion rates by 1%. In emerging markets like India, the impact is steeper: a 10MB reduction correlates with a 2.5% conversion rate increase. Our SDK couldn’t be the one that tipped an app past a download threshold.

2. No background monitoring. Continuous telemetry agents (the kind used by APM tools and session replay platforms) run persistent services, hold wake locks, and drain battery. We needed device context at the moment a user reports a problem, not a 24/7 stream of metrics the developer didn’t ask for. This constraint shaped every architectural decision.

3. One-line initialization. The integration path had to be a single method call in the Application class or main Activity. No XML configuration files, no multi-step setup wizards, no mandatory permissions beyond what the host app already declares.

These constraints ruled out approaches like OpenTelemetry’s mobile instrumentation (designed for always-on collection with batch exports) and heavier SDKs that bundle session replay or crash monitoring alongside feedback capture.

Architecture: Point-in-Time Telemetry vs. Continuous Monitoring

Critic’s SDK uses what we call point-in-time capture: device telemetry is collected at the moment a user initiates a bug report, not continuously in the background. This is the single most important architectural decision in the entire codebase, and it has cascading implications for size, battery impact, and complexity.

Why Point-in-Time Capture Works for Bug Reports

When a user shakes their phone to report a bug, they’re describing something they just experienced. The device state at that moment (battery level, available memory, network type, free disk space) is the state that matters for reproduction. Capturing this snapshot requires a burst of synchronous API calls that complete in single-digit milliseconds. No background threads polling system metrics. No disk buffers accumulating telemetry. No wake locks preventing the CPU from sleeping.

The trade-off is real: we lose what happened before the report. Tools like Bugsee address this with 60-second rolling video buffers, and session replay platforms reconstruct the entire user session. But those capabilities come at a cost: heavier SDKs, higher memory consumption, and battery drain that users notice. For a feedback SDK focused on user-initiated bug reports (not automated crash capture), point-in-time telemetry delivers the vast majority of reproduction value at a fraction of the resource cost.

What the SDK Captures (and the Exact APIs Behind It)

Here’s what arrives in the dashboard when a user submits a report, and the specific Android APIs that produce each data point:

Battery Status via BroadcastReceiver registered for Intent.ACTION_BATTERY_CHANGED

The SDK registers a broadcast receiver during initialization. Because ACTION_BATTERY_CHANGED is a sticky intent, calling registerReceiver(null, intentFilter) returns the current battery state without waiting for a broadcast event. The returned Intent provides:

  • Battery level (percentage calculated from EXTRA_LEVEL / EXTRA_SCALE)
  • Charging status (EXTRA_STATUS: charging, discharging, full, not charging)
  • Charge source (EXTRA_PLUGGED: USB or AC)
  • Battery health (EXTRA_HEALTH: good, overheat, dead, cold, over voltage, unspecified failure)

This is critical for reproduction. A bug that only manifests when the OS throttles CPU under low-battery conditions is invisible without this data.

Memory Metrics via ActivityManager.MemoryInfo

ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
activityManager.getMemoryInfo(memoryInfo);

This yields availMem (bytes of available system RAM), totalMem (total device RAM, API 16+), and the lowMemory boolean indicating whether the system considers itself in a low-memory state. The lowMemory flag is particularly valuable: it tells you whether the OS was actively killing background processes when the user hit the bug, a condition that causes timing-dependent failures developers can rarely reproduce on their 12GB development phones.

Disk Space via Environment.getExternalStorageDirectory() + File methods

File storage = Environment.getExternalStorageDirectory();
long freeSpace = storage.getFreeSpace();
long totalSpace = storage.getTotalSpace();
long usableSpace = storage.getUsableSpace();

Rather than using StatFs (which requires careful partition path handling), the SDK calls getFreeSpace(), getTotalSpace(), and getUsableSpace() on the external storage directory. This captures the storage conditions that matter when a bug stems from failed writes, incomplete downloads, or database operations hitting limits.

A transparency note: Environment.getExternalStorageDirectory() is deprecated as of API 29 in favor of scoped storage. The SDK still functions correctly (the method returns a valid path) but this is on our list for modernization.

Network Connectivity via ConnectivityManager + NetworkInfo

The SDK queries ConnectivityManager.getActiveNetworkInfo() to determine whether the device is connected via Wi-Fi or cellular, reporting two booleans: network_wifi_connected and network_cell_connected. It checks for the ACCESS_NETWORK_STATE permission before querying and also captures the carrier name via TelephonyManager.getNetworkOperatorName().

Another transparency note: the SDK currently uses the older NetworkInfo API, which is deprecated in favor of NetworkCapabilities on API 23+. The older API still works but can’t distinguish between “connected to a network” and “has validated internet access.” Migrating to NetworkCapabilities would provide this distinction; something we plan to address in a future release.

Device Hardware and OS via android.os.Build.*

Standard fields: Build.MANUFACTURER, Build.MODEL, Build.VERSION.RELEASE (OS version string), Build.VERSION.SDK_INT (API level). Also captures the app’s version name and version code from PackageInfo. Every report includes these automatically, which eliminates the most common support question in mobile development: “What device are you on?”

A note on CPU usage: Some of Critic’s marketing materials reference CPU metrics. The SDK does not capture CPU usage; we inspected the getDeviceStatusJson() method line by line to confirm this. Battery state and memory pressure are the system-level indicators that actually correlate with reproducible bugs. CPU percentage at a single point in time, without process-level attribution, provides minimal diagnostic value. We chose not to ship a metric just to pad a feature list, and we’ve corrected the marketing materials to match.

The Dependency Decision: Pragmatism Over Purity

Industry guidance on SDK development consistently recommends zero third-party dependencies. Luciq (formerly Instabug) published this as an explicit engineering principle: “no third-party code; if we need something, we create it ourselves.” Auth0’s SDK design guidelines similarly advocate minimizing external dependencies. The reasoning is sound: every dependency you bundle is a dependency you impose on every app that integrates your SDK. Version conflicts with the host app’s own dependencies cause build failures, and transitive dependencies multiply the surface area.

We chose a different path. Critic’s Android SDK depends on five libraries:

Dependency Version Purpose
Retrofit 2.3.0 HTTP client and API interface definition
Retrofit Gson Converter 2.3.0 JSON serialization (brings in Gson transitively)
Seismic 1.0.2 (Square) Shake gesture detection
AppCompat 26.1.0 Backward-compatible Activity base class
ConstraintLayout 1.0.2 Feedback form layout

Retrofit (which brings in OkHttp transitively) eliminates hundreds of lines of manual HTTP connection management, URL building, multipart body construction, and response parsing. Gson handles all JSON serialization for device status payloads, metadata objects, and API responses. Together, they replace roughly 600-800 lines of networking boilerplate we would otherwise have to write, test, and maintain. At a total SDK size of ~1,600 lines (including model classes and layout resources), those 800 lines would have nearly doubled the codebase.

The Trade-Off We Accepted

Using Retrofit means the host app can’t use an incompatible Retrofit version without dependency resolution. In practice, this is rarely a problem; Retrofit 2.x has been stable for years, and most apps that use Retrofit are on a compatible version. But it’s a real constraint, and developers evaluating the SDK should know about it.

The alternative (a zero-dependency HTTP layer using HttpURLConnection) would have meant writing our own multipart body encoder, our own JSON parser, our own retry logic, and our own header interceptor chain. For a bootstrapped team maintaining SDKs across four platforms (Android, iOS, Flutter, JavaScript), that’s maintenance cost we chose to avoid.

A Note on Seismic

Square’s Seismic library is deprecated with no planned successor. Square’s recommendation is to fork the repo or copy the single source file. Seismic is a compact implementation: it checks whether more than 75% of accelerometer samples in the past 0.5 seconds indicate acceleration, with configurable sensitivity levels (LIGHT, MEDIUM, HARD). The library is small enough to vendor directly, and its deprecation means we will likely fold the shake detection logic into the SDK itself in a future release rather than continuing to depend on an unmaintained artifact.

Shake Detection: From Accelerometer Data to Bug Report

The user-facing flow is simple: shake the phone, confirm “Do you want to send us feedback?” in a dialog, type a description, and hit submit. Under the hood, this involves lifecycle-aware sensor management.

Lifecycle-Aware Listener Registration

The SDK registers an Application.ActivityLifecycleCallbacks listener during initialization. When an Activity enters the foreground (onActivityResumed), the SDK starts Seismic’s ShakeDetector via SensorManager. When the Activity goes to the background (onActivityPaused), it stops the detector.

This is essential for battery efficiency. The accelerometer is one of the highest-power sensors on an Android device. An SDK that registers a sensor listener in onCreate and never unregisters it will drain battery even when the app is in the background; a mistake that earns the host app one-star reviews for battery consumption.

The Shake-to-Dialog Pipeline

When Seismic detects a shake, the SDK’s inner Shakes class (implementing ShakeDetector.Listener) fires:

  1. Checks a boolean flag to prevent duplicate dialogs (rapid shakes can fire multiple events)
  2. Displays an AlertDialog asking the user to confirm they want to send feedback
  3. On confirmation, launches FeedbackReportActivity: a standalone Activity with a description text field, progress spinner, and submit button
  4. On submit, an AsyncTask collects device telemetry via getDeviceStatusJson(), captures logcat, and sends the multipart request on a background thread

The built-in UI is deliberately minimal: a text field and a submit button. Developers who want a custom feedback experience can skip the shake-to-report flow entirely and use BugReportCreator directly, attaching their own UI, their own metadata, and their own file attachments.

Logcat Capture: Why 500 Lines, and How It Works

When a report is submitted, the SDK’s Logs.java utility (~35 lines of code) captures the last 500 logcat entries:

Process process = Runtime.getRuntime().exec(new String[]{
    "logcat", "--pid=" + android.os.Process.myPid(), "-t", "500", "-v", "threadtime"
});

Three details matter here:

Process filtering (--pid). The --pid flag restricts output to the current app’s process ID. Combined with Android 4.1+’s restriction that apps can only read their own logs, this ensures the SDK captures only the host app’s log entries, not system-wide activity from other apps.

The -t 500 flag. This requests the 500 most recent entries and exits immediately (unlike -f, which would tail the stream). The output is read line-by-line with a BufferedReader, written to a temporary logcat.txt file in the app’s external files directory, and attached to the report as a multipart upload.

The threadtime format. Each line includes the date, time, PID, TID, log level, and tag; giving developers the exact chronological sequence of events leading up to the report.

Why 500 Lines

This number balances diagnostic value against payload size:

  • 100 lines is too few. On a busy app with verbose logging, 100 lines might cover the last 2-3 seconds, which isn’t enough to trace the event sequence leading to a bug.
  • All available logcat is too much. The buffer can hold thousands of entries. Shipping 50KB+ of log data per report increases upload time on slow networks, inflates storage costs, and buries relevant entries in noise.
  • 500 lines typically covers 30-90 seconds of application activity, depending on log verbosity. That’s enough to see API calls, state transitions, and error messages leading up to the issue.

Privacy Considerations

Developers should be aware that their own logs may contain sensitive data: authentication tokens, user identifiers, PII from API responses, or internal URLs. The SDK captures whatever the app has written to logcat. If your app logs request bodies or user data, those entries will appear in bug reports. The mitigation is straightforward: avoid logging sensitive data in production builds. But this responsibility falls on the host app developer, not the SDK.

Multipart Report Submission: What Gets Sent

Every bug report is submitted as a single multipart HTTP POST via Retrofit to /api/v2/bug_reports. The BugReportCreator class assembles these parts:

api_token               → Product access token (authentication)
app_install[id]         → Persistent device identifier (UUID, stored in SharedPreferences)
bug_report[description] → User-entered text
bug_report[metadata]    → Arbitrary JSON object (developer-defined)
device_status           → JSON payload: battery, memory, disk, network, OS, device info
bug_report[attachments][] → File attachments (logcat .log file + any developer-added files)

The metadata field accepts any valid JSON object: user IDs, feature flags, A/B test variants, order IDs, session identifiers, star ratings. Unlike rigid custom field systems with predefined schemas, Critic’s metadata is schemaless. Whatever JSON you attach at report time arrives in the dashboard exactly as sent.

File attachments use MIME type detection via MimeTypeMap.getSingleton().getMimeTypeFromExtension(), with a hardcoded fallback to text/plain for .log files and */* for unknown types.

Before the first report, the SDK sends a POST /api/v2/ping request to register the device installation and validate the API token. This ping returns an app_install_id that’s used for all subsequent report submissions.

What We Chose Not to Build

The SDK omits:

  • Offline queuing. If the device has no connectivity when the user submits, the submission fails. There is no disk-based queue that retries when connectivity returns.
  • Automatic retry. A failed HTTP request is a failed HTTP request.
  • Background upload. Reports are submitted via AsyncTask on a background thread, but not via WorkManager; they don’t survive process death.

These are deliberate omissions. Offline queuing requires disk persistence, encryption of stored reports (they contain user-entered text and device data), retry scheduling, and conflict resolution if the app is updated between queue and send. That’s significant infrastructure: essential for analytics pipelines and crash reporters, but overkill for a user-initiated feedback SDK where the user is actively in the app and can retry.

The honest trade-off: if your users frequently submit bug reports in subway tunnels or airplane mode, Critic will lose those reports. In practice, users submit feedback when they’re actively using the app, which almost always means they have connectivity.

Performance: What the SDK Costs Your App

APK size impact: The SDK adds approximately 100-150KB to the final APK after ProGuard/R8 shrinking. For context, a single high-resolution image asset often exceeds 500KB. Full observability SDKs can add 5-15MB.

Runtime memory: Negligible at idle. The SDK allocates memory only during shake detection (sensor event processing) and report submission (logcat buffer read, multipart body construction). No persistent in-memory caches, no event queues, no session state objects.

Battery impact: Zero measurable impact beyond the accelerometer listener active while the app is in the foreground. No background services, no wake locks, no periodic network calls. The listener unregisters whenever the Activity pauses.

Network: One initial ping request at initialization to validate the API token. One multipart POST per report submission. No heartbeats, no telemetry uploads, no analytics callbacks.

Trade-Offs and Alternatives Considered

Build-Your-Own vs. SDK

The most common alternative to adopting a feedback SDK is building the feature in-house. Here’s what that actually involves:

Component Effort Ongoing Maintenance
Shake detection with lifecycle management 2-3 days Sensor API changes, new device quirks
Battery/memory/disk/network capture 1-2 days API deprecations (we’re already tracking three)
Logcat capture with process filtering 1-2 days Permission model changes across Android versions
Feedback UI (form, progress, error states) 2-3 days Material Design updates, screen size support
Multipart API endpoint + file uploads 2-3 days Server-side maintenance, storage, authentication
Web dashboard for viewing reports 5-10 days Ongoing feature development
Total 13-23 developer-days Ongoing across every Android release

At typical senior mobile engineer rates, that’s $20,000-$37,000 in initial development, plus ongoing maintenance every time Google deprecates an API, changes a permission model, or introduces a new storage framework. Critic costs $20/month.

Continuous Monitoring vs. Point-in-Time

We considered and rejected always-on telemetry collection. The resources required (a persistent background service, a circular buffer for metrics, periodic disk flushes, wake locks for reliable delivery) are appropriate for APM tools. They’re overkill for a feedback SDK whose purpose is capturing context when a user decides to report something.

Point-in-time capture provides the vast majority of diagnostic value at a fraction of the resource cost. What you lose is pre-report session context, exact reproduction timeline, and performance waterfall data; all genuinely valuable for debugging complex state-dependent issues. If you need that, pair Critic with a crash reporter like Firebase Crashlytics (free) and you cover both bases for under $25/month total.

Technical Debt We’re Tracking

We believe in being transparent about the SDK’s rough edges:

  • AsyncTask is deprecated as of API 30. A migration to Kotlin coroutines or java.util.concurrent executors is planned.
  • Environment.getExternalStorageDirectory() is deprecated as of API 29. Moving to Context.getExternalFilesDir() or scoped storage APIs.
  • NetworkInfo is deprecated as of API 29. Moving to NetworkCapabilities for richer connectivity data.
  • View.getDrawingCache() in the Screenshots utility is deprecated. Moving to PixelCopy API.

None of these deprecations break functionality today; Android maintains backward compatibility. But modernizing them improves reliability on newer devices and prepares for the day Google removes the legacy APIs.

Lessons Learned

Marketing claims should match source code. Our early materials mentioned CPU usage capture. The SDK doesn’t capture CPU usage, and we’ve corrected the record. Trust erodes fast when developers read the docs, inspect the source, and find discrepancies. The SDK is open source on GitHub; anyone can verify every claim in this article.

Deprecated dependencies need a plan. Square’s Seismic is deprecated with no replacement. The library is small enough (~150 lines) that vendoring is straightforward, but we should have done this proactively rather than waiting for the deprecation notice. If you depend on a small, focused library, have a plan for the day they stop maintaining it.

The built-in UI should be skippable. Our most sophisticated users never see the shake-to-report dialog. They build custom feedback flows using BugReportCreator directly, attaching metadata and files programmatically. The default UI exists for the 80% case: developers who want feedback collection working in five minutes. But the API that powers it must be clean enough to use standalone.

500 lines of logcat is a starting point. Some apps need more; some need less. A configurable log depth parameter is on our roadmap. But shipping a sensible default and iterating based on real usage data beats building configuration options nobody has asked for yet.

Point-in-time capture is the right default for feedback tools. After years of bug reports across Critic’s user base, we’ve seen almost no cases where the device state at report time differed from the state when the bug occurred. Users report bugs in the moment; they rarely wait until the next day when their battery is charged and their network has changed. The snapshot is reliable.

The Source Is Open

Every technical claim in this article can be verified against the SDK source code on GitHub. The library directory contains the complete implementation: battery capture, memory queries, disk checks, network detection, shake handling, logcat collection, multipart submission. Roughly 1,600 lines of Java across 15 source files and layout resources, MIT-licensed, shipping since January 2018.

If you’re building your own device-context capture, the source serves as a reference implementation. If you’d rather not build it yourself, Critic captures all of this automatically with a single line of initialization code; $20/month per app, with a 30-day free trial, no credit card required.

The bug report your users meant to send (with full device telemetry, 500 lines of logs, and arbitrary metadata) is one line of code away.