Auditing Alternatives

“Audit” as a term sometimes has negative connotations (“you have been cordially invited to attend your upcoming tax audit…”). Really, though, an audit is simply a form of testing, confirming that everything is working as you might expect. It’s just that testing usually occurs in development, while auditing is something that you apply in production.

Android has had some auditing options in the past, such as using TrafficStats or NetworkStatsManager to get a sense of how much bandwidth your app is using. Android 11 adds two more auditing options, for determining what sorts of protected services you might be accessing, and why your application’s process is terminated.

Data Access Auditing

If your app uses dangerous permissions — the ones we need to request at runtime — Android 11 lets you find out when and where your app uses those permissions. That includes both direct uses in your own code and uses by any libraries that you add as dependencies.

If you collect this data and get it back to your organization, you can determine if your app is using these permissions in an expected fashion. Or, conversely, you might find that some third-party library that you are using is siphoning off user data in ways that your users (and your qualified legal counsel) might not appreciate.

Collecting the Data

Android has had an AppOpsManager system service for a few releases. In Android 11, it now has a setNotedAppOpsCollector() method. This takes an instance of an AppOpsManager.AppOpsCollector abstract class, which serves as your callback for the various events. Since there is one collector per process, this is the sort of thing that you might configure in your custom Application class.

We looked at the PermissionCheck sample module in the book’s sample project in the chapter on permission changes. This module also demonstrates the data access auditing code. Specifically, in MainApp, along with setting up Koin for dependency inversion, we also register an AppOpsManager.OnOpNotedCallback:

package com.commonsware.android.r.permcheck

import android.app.AppOpsManager
import android.app.Application
import android.app.AsyncNotedAppOp
import android.app.SyncNotedAppOp
import android.util.Log
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.dsl.module
import java.util.concurrent.Executors

private const val TAG = "PermissionCheck"
private const val FEATURE_ID = "awesome-stuff"

class MainApp : Application() {
  private val module = module {
    viewModel { MainMotor(androidContext().createAttributionContext(FEATURE_ID)) }
  }
  private val executor = Executors.newSingleThreadExecutor()

  override fun onCreate() {
    super.onCreate()

    startKoin {
      androidLogger()
      androidContext(this@MainApp)
      modules(module)
    }

    getSystemService(AppOpsManager::class.java)
      ?.setOnOpNotedCallback(executor, object : AppOpsManager.OnOpNotedCallback() {
        override fun onNoted(op: SyncNotedAppOp) {
          Log.d(TAG, "onNoted: ${op.toDebugString()}")
          RuntimeException().printStackTrace(System.out)
        }

        override fun onSelfNoted(op: SyncNotedAppOp) {
          Log.d(TAG, "onSelfNoted: ${op.toDebugString()}")
          RuntimeException().printStackTrace(System.out)
        }

        override fun onAsyncNoted(op: AsyncNotedAppOp) {
          Log.d(TAG, "onAsyncNoted: ${op.toDebugString()}")
          RuntimeException().printStackTrace(System.out)
        }
      })
  }

  private fun SyncNotedAppOp.toDebugString() =
    "SyncNotedAppOp[attributionTag = $attributionTag, op = $op"

  private fun AsyncNotedAppOp.toDebugString() =
    "AsyncNotedAppOp[attributionTag = $attributionTag, op = $op, time = $time, uid = $notingUid, message = $message"
}

There are three methods that you need to implement on your OnOpNotedCallback:

In all three cases, MainApp dumps a “debug string” to Logcat, along with the current stack trace (culled from a RuntimeException instance).

If you run the app and request a location, you will get data access information in Logcat (with some portions replaced with ... for the sake of brevity):

D/PermissionCheck: onNoted: SyncNotedAppOp[attributionTag = awesome-stuff, op = android:fine_location I/System.out: java.lang.RuntimeException I/System.out: at ...MainApp$onCreate$2.onNoted(MainApp.kt:53) I/System.out: at android.app.AppOpsManager.readAndLogNotedAppops(AppOpsManager.java:8154) ... I/System.out: at ...MainMotor$fetchLocationAsync$2.invokeSuspend(MainMotor.kt:75) ... D/PermissionCheck: onAsyncNoted: AsyncNotedAppOp[attributionTag = awesome-stuff, op = android:fine_location, time = 1588186761021, uid = 1000, message = Location sent to ...MainMotor$fetchLocationAsync$2$invokeSuspend$$... I/System.out: java.lang.RuntimeException I/System.out: at ...MainApp$onCreate$2.onAsyncNoted(MainApp.kt:63) ...

We have one onNoted() call. As part of the SyncNotedAppOp that we receive, we know that the op was android:fine_location, meaning that we did something that required ACCESS_FINE_LOCATION permission. The stack trace shows that this came from the getCurrentLocation() call on a LocationManager inside of MainMotor, our viewmodel:

package com.commonsware.android.r.permcheck

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import java.util.concurrent.Executors
import java.util.function.Consumer
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

sealed class MainViewState {
  data class Content(
    val hasPermission: Boolean,
    val location: Location? = null
  ) :
    MainViewState()

  object Error : MainViewState()
}

class MainMotor(private val context: Context) : ViewModel() {
  private val _states = MutableLiveData<MainViewState>()
  val states: LiveData<MainViewState> = _states

  fun checkPermission() {
    _states.value = MainViewState.Content(hasLocationPermission())
  }

  fun fetchLocation() {
    viewModelScope.launch(Dispatchers.Main) {
      _states.value =
        MainViewState.Content(hasLocationPermission(), fetchLocationAsync())
    }
  }

  private fun hasLocationPermission() =
    context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
        PackageManager.PERMISSION_GRANTED

  private suspend fun fetchLocationAsync(): Location {
    val locationManager =
      context.getSystemService(LocationManager::class.java)!!
    val executor = Executors.newSingleThreadExecutor()

    return withContext(executor.asCoroutineDispatcher()) {
      suspendCoroutine<Location> { continuation ->
        val consumer =
          Consumer<Location?> { location ->
            if (isActive && location != null) continuation.resume(location)
          }

        locationManager.getCurrentLocation(
          LocationManager.GPS_PROVIDER,
          null,
          executor,
          consumer
        )
      }
    }
  }
}

We also have one onAsyncNoted() call. Here, the op is also android:fine_location. The message property shows that the operation came from something inside MainMotor, but it is buried in the suspendCoroutine() lambda expression. Probably, this is being triggered when our Consumer receives a location, but that is just a guess, given that we have no line number to use.

Identifying Uses by Attribution

Both logs show an attributionTag of awesome-stuff. By default, you will not have an attributionTag value. And for many apps, that’s fine. However, if you want to be able to tag your data access, you can do so by setting the value to appear in that attributionTag to some string.

To do this, you need to use createAttributionContext(), a method available on Context. This gives you another Context, one with your specified attributionTag value. You then use that augmented Context when doing things involving the data access, such as requesting the LocationManager system service.

In this app, we do that as part of our Koin setup in MainApp:

  private val module = module {
    viewModel { MainMotor(androidContext().createAttributionContext(FEATURE_ID)) }
  }

When we inject a Context into MainMotor, we get the Koin androidContext(), then call createAttributionContext() on that Context. The MainMotor gets the Context from createAttributionContext() and uses that to get the LocationManager. And, since we set the attributionTag to awesome-stuff in the createAttributionContext() call, that is why we get awesome-stuff as the attributionTag in our output.

What To Do With the Results?

You might consider collecting the data and sending it to your backend, to get a sense of what is using protected data and how frequently. You could send all of the data, or use heuristics to determine expected-vs.-unexpected scenarios and report them differently.

One imagines that future versions of crash logging or analytics libraries will “bake in” the ability to gather this data as part of their normal operation.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.