Modern mobile applications often operate within constrained data environments. Whether building a system utility, a parental control application, or an enterprise fleet management tool, blindly trusting data availability is a recipe for poor user experience. Users demand transparency regarding which applications consume their limited bandwidth. For Android engineers, relying on legacy methods like TrafficStats is insufficient for historical analysis, as that data resets upon device reboot. The industry-standard approach for persistent, granular tracking is the NetworkStatsManager API.
1. Kernel-Level Tracking and UIDs
To implement robust monitoring, one must understand how Android—fundamentally a Linux distribution—accounts for resource usage. The operating system does not natively track network packets by Java package name strings (e.g., com.google.android.youtube). Instead, the Linux kernel tracks socket ownership via UIDs (User IDs).
When an APK is installed, the Android Package Manager assigns it a unique, permanent UID. The NetworkStatsManager service aggregates network traffic logs against these UIDs. Therefore, the first step in our engineering pipeline is mapping the user-facing package name to the kernel-level UID.
Here is the helper function to resolve a UID from a package name. Note the error handling for cases where the package might not exist:
fun getUidForPackage(context: Context, packageName: String): Int {
val packageManager = context.packageManager
return try {
// In API 33+, consider using PackageManager.PackageInfoFlags
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
applicationInfo.uid
} catch (e: PackageManager.NameNotFoundException) {
-1 // Handle package not found gracefully
}
}
PACKAGE_USAGE_STATS permission. This is a "Signature or System" level protection that users must grant manually via the Usage Access Settings screen. It cannot be granted via a standard runtime dialog.
2. Querying with NetworkStatsManager
The core logic resides in querying the NetworkStatsManager. This service enables querying usage buckets over a specific time range. Unlike TrafficStats, which provides a monotonically increasing counter since boot, NetworkStatsManager allows for historical queries (e.g., "data used in the last 30 days").
Handling Network Interfaces
You must query distinct network interfaces separately. The system treats Wi-Fi (`NetworkCapabilities.TRANSPORT_WIFI`) and Cellular (`NetworkCapabilities.TRANSPORT_CELLULAR`) as separate buckets. Aggregating these requires two distinct calls.
The following implementation demonstrates how to extract the summary for a specific UID over a defined time window.
import android.app.usage.NetworkStats
import android.app.usage.NetworkStatsManager
import android.net.NetworkCapabilities
import android.content.Context
import android.os.RemoteException
data class AppDataUsage(
val rxBytes: Long,
val txBytes: Long
)
fun queryAppUsage(
context: Context,
uid: Int,
startTime: Long,
endTime: Long
): AppDataUsage {
val networkStatsManager = context.getSystemService(Context.NETWORK_STATS_SERVICE)
as NetworkStatsManager
var totalRx = 0L
var totalTx = 0L
// Network templates to query
val networkTypes = listOf(
NetworkCapabilities.TRANSPORT_WIFI,
NetworkCapabilities.TRANSPORT_CELLULAR
)
for (networkType in networkTypes) {
val subscriberId: String? = null // See Architecture Note below
try {
// querySummaryForUid aggregates data into a single bucket for the range
val bucket = networkStatsManager.querySummaryForUid(
networkType,
subscriberId,
startTime,
endTime,
uid
)
totalRx += bucket.rxBytes
totalTx += bucket.txBytes
} catch (e: SecurityException) {
// Handle permission denial (missing PACKAGE_USAGE_STATS)
} catch (e: RemoteException) {
// Handle system service communication failure
}
}
return AppDataUsage(totalRx, totalTx)
}
null is generally sufficient for aggregate queries on modern devices, as the system will aggregate across all subscriber identities.
3. Data Granularity and Trade-offs
When designing a monitoring dashboard, choosing the right query method is essential for performance. There are two primary methods:
| Method | Use Case | Performance Cost |
|---|---|---|
querySummaryForUid |
Total usage over a long period (e.g., Monthly Total). | Low (Returns a single bucket). |
queryDetailsForUid |
Time-series charts (e.g., Hourly usage breakdown). | High (Returns a cursor iterating over time buckets). |
Using queryDetailsForUid allows you to inspect the NetworkStats.Bucket.STATE_FOREGROUND and NetworkStats.Bucket.STATE_BACKGROUND. This distinction is critical for identifying battery-draining apps that consume heavy data while not in active use.
Permission Enforcement Flow
Since the user cannot grant the required permission at runtime, your app logic must check the AppOpsManager status and redirect the user if necessary.
fun hasUsageStatsPermission(context: Context): Boolean {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS,
android.os.Process.myUid(),
context.packageName
)
return mode == AppOpsManager.MODE_ALLOWED
}
fun requestUsageStatsPermission(context: Context) {
// Direct user to the specific settings page
val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
context.startActivity(intent)
}
Conclusion
The NetworkStatsManager provides a persistent, history-aware mechanism for tracking application data usage, far superior to the ephemeral TrafficStats. However, it introduces complexity regarding user permissions and OS-level privacy restrictions on subscriber identifiers. Engineers must handle the SecurityException gracefully and design the UI to explain why "Usage Access" is required. By properly implementing UID mapping and aggregating across network interfaces, you can build diagnostic tools that offer genuine value in data-constrained markets.
Post a Comment