Tuesday, June 13, 2023

Android Network Monitoring: Measuring Per-App Data Usage

Understanding and managing data consumption is a critical feature for many modern Android applications, from device utility tools to parental control suites. Users are more conscious than ever of their mobile data limits, and providing them with clear insights into which apps are consuming their data can significantly enhance their experience. Fortunately, Android provides a powerful, albeit nuanced, API for this very purpose: the NetworkStatsManager. This article provides a comprehensive walkthrough of how to leverage this API using Kotlin to accurately measure data usage for any specific application installed on a device.

We will move beyond a simple code snippet and explore the entire process, including the essential prerequisites like handling permissions, understanding Android's security model with UIDs, and structuring the code in a robust and reusable manner. By the end, you will have a solid foundation for building sophisticated network monitoring features into your own applications.

The Foundation: Understanding NetworkStatsManager and UIDs

At the heart of Android's network usage tracking is the NetworkStatsManager class. This system service acts as a gateway to the historical network data collected by the operating system. It doesn't provide real-time packet-by-packet analysis; instead, it offers aggregated data totals over specified time intervals for different network types (like Wi-Fi or mobile data).

A crucial concept to grasp is that Android doesn't track network statistics by package name (e.g., com.example.app). Instead, it attributes all system resource usage, including network activity, to a **UID (User ID)**. When an application is installed, the Android OS assigns it a unique UID. This UID is used by the Linux kernel underneath to manage permissions and track resource consumption. Therefore, to query the data usage for a specific app, we must first find its unique UID.

The First Hurdle: Requesting the Necessary Permissions

Access to detailed app usage data is sensitive information. As such, Android gatekeeps the NetworkStatsManager API behind a special permission: PACKAGE_USAGE_STATS. This is not a standard runtime permission that can be requested with a simple dialog. It's an "app-op" (application operation) permission that the user must grant manually from a special screen within the device's settings.

Your application's responsibility is to detect if it has been granted this permission and, if not, guide the user to the correct settings screen to enable it.

1. Declare the Permission in the Manifest

First, you must declare your intention to use this permission in your AndroidManifest.xml file.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- Required permission to access network usage stats -->
    <uses-permission
        android:name="android.permission.PACKAGE_USAGE_STATS"
        tools:ignore="ProtectedPermissions" />

    <application
        ...>
        ...
    </application>
</manifest>

Note the use of tools:ignore="ProtectedPermissions". This is necessary because Android Studio will flag this as a signature/system-level permission, but it is indeed available to regular apps, provided the user grants access.

2. Check for Permission and Redirect the User

Next, you need to implement a check in your code. Before attempting to query any data, you must verify that the user has enabled access for your app. If they haven't, you should launch an Intent to take them directly to the appropriate settings page.

Here’s a helper function in Kotlin to perform this check:

import android.app.AppOpsManager
import android.content.Context
import android.content.Intent
import android.os.Process
import android.provider.Settings

fun hasUsageStatsPermission(context: Context): Boolean {
    val appOpsManager = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
    val mode = appOpsManager.checkOpNoThrow(
        AppOpsManager.OPSTR_GET_USAGE_STATS,
        Process.myUid(),
        context.packageName
    )
    return mode == AppOpsManager.MODE_ALLOWED
}

fun requestUsageStatsPermission(context: Context) {
    // Redirect the user to the system settings screen
    val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
    context.startActivity(intent)
}

In your Activity or Fragment, you would use this logic like so:

override fun onResume() {
    super.onResume()
    if (!hasUsageStatsPermission(this)) {
        // Optionally show a dialog explaining why the permission is needed
        // before redirecting the user.
        requestUsageStatsPermission(this)
    } else {
        // Permission is granted, proceed with fetching data.
        fetchDataUsage()
    }
}

This proactive approach ensures a smooth user experience and prevents your app from crashing due to a SecurityException when trying to access the API without permission.

The Core Implementation: Querying Network Stats

With permissions handled, we can now dive into the main logic of fetching the data. The process can be broken down into several distinct steps: obtaining the UID, defining the query parameters, executing the query, and processing the results.

Step 1: Get the UID for a Target Package

As established, we need the UID. The PackageManager is the tool for this job. Given a package name string, we can retrieve the corresponding ApplicationInfo object, which contains the UID.

import android.content.Context
import android.content.pm.PackageManager

fun getUidForPackage(context: Context, packageName: String): Int? {
    return try {
        val pm = context.packageManager
        val appInfo = pm.getApplicationInfo(packageName, 0)
        appInfo.uid
    } catch (e: PackageManager.NameNotFoundException) {
        // The package is not installed on the device
        e.printStackTrace()
        null
    }
}

This function gracefully handles the case where the requested package name is not installed on the device, returning null.

Step 2: Define Query Parameters

The primary query method we'll use is NetworkStatsManager.queryDetailsForUid(). It requires several key parameters to define the scope of our search:

  • Network Type: This specifies whether we want to query for Wi-Fi, mobile data, or other network types. The constants are defined in ConnectivityManager, such as ConnectivityManager.TYPE_WIFI and ConnectivityManager.TYPE_MOBILE.
  • Subscriber ID: This is used to identify a specific SIM card's mobile data usage. For Wi-Fi queries, this parameter is ignored and should be null. For mobile data, you would typically get this from TelephonyManager. However, for general mobile data usage across all SIMs, you can often pass null, and the system will aggregate the data.

    Note: Accessing the subscriber ID (IMSI) often requires the READ_PHONE_STATE permission, which is a sensitive runtime permission. Passing null is often sufficient and less intrusive.

  • Start and End Time: These are Long values representing the time interval for the query in milliseconds since the epoch. You can use System.currentTimeMillis() or a Calendar instance to define periods like "the last 24 hours" or "the current month."
  • UID: The unique user ID of the target application, which we obtained in the previous step.

Step 3: Execute the Query and Process the Results

The queryDetailsForUid() method returns a NetworkStats object. This object isn't a simple data class; it's an iterable cursor-like object that contains a series of NetworkStats.Bucket objects. Each bucket represents a snapshot of data usage for the given UID within a sub-interval of the total time range you specified.

Therefore, we must iterate through all available buckets and sum their usage data to get the total for our desired period. The key data points in each bucket are rxBytes (received bytes) and txBytes (transmitted bytes).

Let's combine these steps into a cohesive function.

import android.app.usage.NetworkStats
import android.app.usage.NetworkStatsManager
import android.net.ConnectivityManager
import android.os.Build
import android.content.Context

fun getPackageDataUsage(
    context: Context,
    uid: Int,
    startTime: Long,
    endTime: Long
): Map<String, Long> {

    val networkStatsManager =
        context.getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager

    var totalWifiRx: Long = 0
    var totalWifiTx: Long = 0
    var totalMobileRx: Long = 0
    var totalMobileTx: Long = 0

    // --- Query for Wi-Fi usage ---
    try {
        val wifiStats: NetworkStats = networkStatsManager.queryDetailsForUid(
            ConnectivityManager.TYPE_WIFI,
            null, // subscriberId - null for Wi-Fi
            startTime,
            endTime,
            uid
        )
        
        // Use a 'use' block for automatic resource management (closes wifiStats)
        wifiStats.use { stats ->
            val bucket = NetworkStats.Bucket()
            while (stats.hasNextBucket()) {
                stats.getNextBucket(bucket)
                totalWifiRx += bucket.rxBytes
                totalWifiTx += bucket.txBytes
            }
        }
    } catch (e: Exception) {
        // Handle exceptions, e.g., SecurityException if permission is missing
        e.printStackTrace()
    }

    // --- Query for Mobile data usage ---
    try {
        val mobileStats: NetworkStats = networkStatsManager.queryDetailsForUid(
            ConnectivityManager.TYPE_MOBILE,
            null, // subscriberId - null aggregates for all SIMs
            startTime,
            endTime,
            uid
        )

        mobileStats.use { stats ->
            val bucket = NetworkStats.Bucket()
            while (stats.hasNextBucket()) {
                stats.getNextBucket(bucket)
                totalMobileRx += bucket.rxBytes
                totalMobileTx += bucket.txBytes
            }
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
    
    return mapOf(
        "wifiReceived" to totalWifiRx,
        "wifiSent" to totalWifiTx,
        "mobileReceived" to totalMobileRx,
        "mobileSent" to totalMobileTx,
        "total" to (totalWifiRx + totalWifiTx + totalMobileRx + totalMobileTx)
    )
}

This implementation is more robust than a simple single-network function. It queries both Wi-Fi and mobile data separately and returns a detailed map of the results. Crucially, it uses Kotlin's .use { ... } block, which is equivalent to a `try-with-resources` statement in Java. This ensures that the NetworkStats object (which holds system resources) is automatically closed after use, preventing resource leaks.

Putting It All Together: A Reusable Utility Class

To make this functionality easy to use throughout an application, it's best to encapsulate it within a utility object or class. This class can manage the context, handle permissions, and provide simple, high-level functions.

Here is a complete example of a `DataUsageMonitor` object:
import android.app.AppOpsManager
import android.app.usage.NetworkStats
import android.app.usage.NetworkStatsManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Process
import android.provider.Settings
import java.util.Calendar

object DataUsageMonitor {

    // A data class to hold the results in a structured way
    data class UsageData(
        val wifiReceivedBytes: Long = 0,
        val wifiSentBytes: Long = 0,
        val mobileReceivedBytes: Long = 0,
        val mobileSentBytes: Long = 0
    ) {
        val totalWifiBytes: Long get() = wifiReceivedBytes + wifiSentBytes
        val totalMobileBytes: Long get() = mobileReceivedBytes + mobileSentBytes
        val totalBytes: Long get() = totalWifiBytes + totalMobileBytes
    }

    /**
     * Checks if the app has the necessary PACKAGE_USAGE_STATS permission.
     */
    fun hasPermission(context: Context): Boolean {
        val appOpsManager = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
        val mode = appOpsManager.checkOpNoThrow(
            AppOpsManager.OPSTR_GET_USAGE_STATS,
            Process.myUid(),
            context.packageName
        )
        return mode == AppOpsManager.MODE_ALLOWED
    }

    /**
     * Redirects the user to the settings screen to grant the usage stats permission.
     */
    fun requestPermission(context: Context) {
        val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        context.startActivity(intent)
    }

    /**
     * Retrieves data usage for a specific package name over a given time interval.
     * Returns null if permission is not granted or the package is not found.
     */
    fun getUsageForApp(context: Context, packageName: String, startTime: Long, endTime: Long): UsageData? {
        if (!hasPermission(context)) {
            return null
        }
        
        val uid = getUidForPackage(context, packageName) ?: return null
        val nsm = context.getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager

        val wifiUsage = queryUsageForUid(nsm, ConnectivityManager.TYPE_WIFI, uid, startTime, endTime)
        val mobileUsage = queryUsageForUid(nsm, ConnectivityManager.TYPE_MOBILE, uid, startTime, endTime)

        return UsageData(
            wifiReceivedBytes = wifiUsage.first,
            wifiSentBytes = wifiUsage.second,
            mobileReceivedBytes = mobileUsage.first,
            mobileSentBytes = mobileUsage.second
        )
    }
    
    /**
     * A helper function to query the NetworkStatsManager for a specific UID and network type.
     * Returns a Pair of (receivedBytes, sentBytes).
     */
    private fun queryUsageForUid(
        networkStatsManager: NetworkStatsManager,
        networkType: Int,
        uid: Int,
        startTime: Long,
        endTime: Long
    ): Pair<Long, Long> {
        var receivedBytes = 0L
        var sentBytes = 0L
        try {
            networkStatsManager.queryDetailsForUid(
                networkType,
                null, // subscriberId
                startTime,
                endTime,
                uid
            ).use { networkStats ->
                val bucket = NetworkStats.Bucket()
                while (networkStats.hasNextBucket()) {
                    networkStats.getNextBucket(bucket)
                    receivedBytes += bucket.rxBytes
                    sentBytes += bucket.txBytes
                }
            }
        } catch (e: Exception) {
            // Can be SecurityException or RemoteException
            e.printStackTrace()
        }
        return Pair(receivedBytes, sentBytes)
    }

    private fun getUidForPackage(context: Context, packageName: String): Int? {
        return try {
            context.packageManager.getApplicationInfo(packageName, 0).uid
        } catch (e: PackageManager.NameNotFoundException) {
            null
        }
    }
    
    /**
     * Helper to get the start of the current day in milliseconds.
     */
    fun getStartOfTodayMillis(): Long {
        val calendar = Calendar.getInstance()
        calendar.set(Calendar.HOUR_OF_DAY, 0)
        calendar.set(Calendar.MINUTE, 0)
        calendar.set(Calendar.SECOND, 0)
        calendar.set(Calendar.MILLISECOND, 0)
        return calendar.timeInMillis
    }
}

Example Usage

Using this utility class in your Activity becomes incredibly clean and readable.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Example: Check permission and fetch data for Google Chrome
        val targetPackage = "com.android.chrome"
        
        if (DataUsageMonitor.hasPermission(this)) {
            val startTime = DataUsageMonitor.getStartOfTodayMillis()
            val endTime = System.currentTimeMillis()

            val usageData = DataUsageMonitor.getUsageForApp(this, targetPackage, startTime, endTime)

            usageData?.let {
                val totalMB = it.totalBytes / (1024.0 * 1024.0)
                Log.d("DataUsage", "$targetPackage has used ${"%.2f".format(totalMB)} MB of data today.")
                Log.d("DataUsage", "  - Wi-Fi: ${it.totalWifiBytes / 1024} KB")
                Log.d("DataUsage", "  - Mobile: ${it.totalMobileBytes / 1024} KB")
            } ?: Log.e("DataUsage", "Could not retrieve usage for $targetPackage.")
            
        } else {
            // Guide user to grant permission
            Log.d("DataUsage", "Permission not granted. Requesting...")
            DataUsageMonitor.requestPermission(this)
        }
    }
}

Advanced Considerations and Final Thoughts

  • Foreground vs. Background Usage: The NetworkStats.Bucket object also contains state information via bucket.state. You can check this against constants like NetworkStats.Bucket.STATE_FOREGROUND and NetworkStats.Bucket.STATE_DEFAULT to differentiate between data used while the app was actively on screen versus in the background.
  • Aggregated Device Data: If you want to get the total data usage for the entire device (not just one app), you can use NetworkStatsManager.querySummaryForDevice(). This method works similarly but doesn't require a UID.
  • Data Accuracy: The data provided by NetworkStatsManager is historically aggregated by the system. There can be a slight delay before the most recent usage is reflected in the query results. It is not a real-time monitoring tool for instantaneous bandwidth.
  • Removed and Uninstalled Apps: The system may still retain usage data for apps that have been uninstalled. The data is keyed by UID, and if that UID has not been reused, you might still see its historical data if you query all UIDs.

By using the NetworkStatsManager, you can build powerful and user-centric features related to data management. The key to a successful implementation lies in a deep understanding of the Android permission model, the distinction between UIDs and package names, and careful resource management when handling the API's results. The comprehensive utility class provided here serves as a production-ready starting point for integrating this functionality into any modern Android application written in Kotlin.


0 개의 댓글:

Post a Comment