Home · Android & Kotlin Tutorials

Data Privacy for Android

In this data privacy tutorial for Android with Kotlin, you’ll learn how to protect users’ data.

5/5 1 Rating

Version

  • Kotlin 1.3, Android 10.0, Android Studio 3.5

Security is paramount. The flurry of new privacy laws, such as the CCPA, PIPEDA and GDPR, shows how important security is to users and lawmakers alike. Yet, it remains an often neglected aspect of mobile app development.

To assist developers, Android 10 offers new privacy advancements and device enhancements, including biometric authentication and hardware-backed key storage.

In this tutorial, you’ll learn about:

  • Privacy permissions.
  • Locking down user data.
  • Clearing the cache.
Note: This tutorial assumes your familiarity with the basics of Android development and Android Studio. If Android development is new to you, first read through the Beginning Android Development and Kotlin for Android: An Introduction tutorials.

Getting Started

Download and unzip the materials for this tutorial using the Download Materials button at the top or bottom of this page. Open and run the starter project in Android Studio 3.5.0 or higher. You’ll see a simple sign-up screen. Once you enter an email and select Sign Up, a list of various topics will populate.

report list

If you missed the previous tutorial, this app lets users send anonymous tips about crimes against animals to law enforcement. OK, it doesn’t send the information to law enforcement, so feel free to test it out. :]

Requesting Permissions

Android with a smartphone

As mentioned earlier, Android 10 brought many new privacy features. For example, security updates now occur in the background so users don’t need to reboot their phones.

The settings section offers improved control over a service’s access to user location. Additionally, there’s a consistent place for Google account activity and AutoFill services.

Android 10’s new privacy features also require you to ask for permission before your app can store user’s private data externally. As such, the first question to consider is how much data your app needs to access. A good approach is to avoid accessing data you don’t need.

APIs that access user data require you to declare that access in the manifest file beforehand. In AndroidManifest.xml, you can find the line that reads:

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

In the past, this was enough. Users would see a list of permissions when installing the app. But Marshmallow changed that with Runtime Permissions. Now, your app requests permissions at the time of need. This approach is more transparent because it shows exactly what features the permission is for. It’s also beneficial since it weeds out unnecessary permissions.

Select one of the report categories from the app list and choose Upload Photo. Pick an image, and you should encounter the following:

Permission crash

This crash occurs because you’re required to ask for permission at runtime. In ReportDetailActivity.kt, replace the contents of uploadPhotoPressed() with the following code:

if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) // 1
    != PackageManager.PERMISSION_GRANTED) {
  ActivityCompat.requestPermissions(this, // 2
      arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE,
          Manifest.permission.READ_EXTERNAL_STORAGE), PIC_FROM_GALLERY)
} else {
  val galleryIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) // 3
  startActivityForResult(galleryIntent, PIC_FROM_GALLERY)
}

Here, you make use of runtime permissions by:

  1. Checking if the user has already granted permission for READ_EXTERNAL_STORAGE.
  2. Requesting it if they haven’t.
  3. Then opening the media intent.

When the user first grants permission, Android calls onRequestPermissionResult(). Override that method by adding the following code to end of ReportDetailActivity.kt:

override fun onRequestPermissionsResult(requestCode: Int,
                                        permissions: Array<String>, grantResults: IntArray) {
  when (requestCode) {
    PIC_FROM_GALLERY -> {
      // If request is cancelled, the result arrays are empty.
      if ((grantResults.isNotEmpty() 
            && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
        // Permission was granted
        val galleryIntent = Intent(Intent.ACTION_PICK,
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
        startActivityForResult(galleryIntent, PIC_FROM_GALLERY)
      }
      return
    }
    else -> {
      // Ignore all other requests.
    }
  }
}

If permission is granted, the media intent starts. To try it out build and run the project after you have made changes. When prompted for permission, hit Allow. You can now select a JPEG image without a crash. :]

Photo permission dialog

Android 10 adds scoped access to app files and media. It requires you to use the Storage Access Framework to access folders on external storage the app doesn’t own. It’s also recommended to access external media using MediaStore.

Using IPC

Permissions cover the most ground with accessing and passing data outside of the app. But sometimes data is passed via IPC to other apps that you build.

There have been cases where developers have left shared files on the storage or have implemented sockets to exchange sensitive information. This is not secure. Instead, the best practice is to use Intents. You can send data using an Intent by providing the package name like this:

val intent = Intent()
val packageName = "com.example.app" //1
val activityClass = "com.example.app.TheActivity" // 2
intent.component = ComponentName(packageName, activityClass)
intent.putExtra("UserInfo", "Example string") //3
startActivityForResult(intent) //4

Here you’re specifying:

  1. The package name of the app where you’ll send the intent.
  2. The qualified class name in the target app that receives the intent.
  3. Data sent with the intent.
  4. The intent by starting the activity with it and then awaiting for the result.

To broadcast data to more than one app, enforce that only apps signed with your signing key will get the data. Otherwise, any app that registers to receive the broadcast can read the sent information. Likewise, a malicious app can send a broadcast to your app if you have registered to receive its broadcast.

In the manifest file, find protectionLevel — it’s part of the first permission. You’ll notice it’s set to normal. Change it to signature by replacing that line with the following:

android:protectionLevel="signature" />

Other apps access the permission by including the following code in the manifest file:

<uses-permission android:name="com.raywenderlich.android.snitcher.permission.REPORT_DETAIL_ACTIVITY"/>

Apps typically send a broadcast like this:

val intent = Intent()
intent.putExtra("UserInfo", "Example string")
intent.action = "com.example.SOME_NOTIFICATION"
sendBroadcast(intent, "com.example.mypermission")

Alternatively, you can use setPackage(String) when sending a broadcast to restrict it to a set of apps matching the specified package. Also, setting android:exported to false in the manifest file will exclude broadcasts from outside your app.

Opting Out

Using permissions properly offers another benefit: It grants users the ability to revoke permissions in the system settings and opt out of data sharing if they change their minds later.

Deny permission

To keep your users informed, your app will need a privacy policy.

Privacy policies disclose the types of personally identifiable information (PII) apps collect, such as unique device identifiers. If you’re collecting such data intentionally, you must provide a place in your UX where the user can opt out. It’s also prudent to understand the laws in any jurisdiction where your app is available. EU member countries, for example, require explicit consent for data collection.

To learn more about privacy policies, visit the Android Privacy Section and Android’s best practices for unique identifiers page.

Clearing the Cache

If users opt out, you must delete any data you’ve collected. These include temporary files and caches!

Your app or third party libraries may use the cache folder, so it should be cleared when no longer needed. In ReportDetailActivity.kt, add the following function at the end:

override fun onPause() {
  cacheDir.deleteRecursively()
  externalCacheDir?.deleteRecursively()
 
  super.onPause()
}

Here, you told the OS to delete the cache directories when you pause the activity.

Note: You can also delete your shared preferences by removing the /data/data/com.your.package.name/shared_prefs/your_prefs_name.xml and your_prefs_name.bak files and clearing the in-memory preferences with the following code:
context.getSharedPreferences("prefs", Context.MODE_PRIVATE).edit().clear().commit()

Your app also has a keyboard cache for text fields with auto-correct enabled. Android stores user text and learned words here, making it possible to retrieve various words the user has entered in your app. To prevent leaking this information, you need to disable this cache.

To disable the keyboard cache, you’ll need to turn off the auto-correct option. Open activity_report_detail.xml and switch to the Text editing mode tab. Find EditText and replace the android:inputType="textMultiLine" line with the following:

android:inputType="textNoSuggestions|textVisiblePassword|textFilter"

Various devices and OS versions have some bugs where some of these flags do nothing on their own. That means it’s a good idea to implement all of the flags.

Note: Mark password fields as secureTextEntry. Secure text fields don’t display the password or use the keyboard cache.

There are a few other caches to consider. For example, Android caches data sent over the network in memory and on-device storage. You don’t want to leave that data behind.

In sendReportPressed() of the ReportDetailActivity.kt file, replace //TODO: Disable cache here with the code below:

connection.setRequestProperty("Cache-Control", "no-cache")
connection.defaultUseCaches = false
connection.useCaches = false

This disables the cache for the HttpsURLConnection session.

For WebView, you can remove the cache at any time with this code:

webview.clearCache(true)

Check any third-party libraries you use for a way to disable or remove the cache. For example, the popular Glide image loading library allows you to cache photos in memory instead of on storage:

Glide.with(context)
    .load(theURL)
    ...
    .diskCacheStrategy(DiskCacheStrategy.NONE)
    ...
    .into(holder.imageView)

Libraries may leak other data. For example, you’ll want to check if there’s an option to disable logging. Head over to the next section to learn about that.

Disabling Logging of Sensitive Data

Android saves debug logs to a file that you can retrieve for the production builds of your app. Even when you are writing code and debugging your app, be sure not to log sensitive information such as passwords and keys to the console. You may forget to remove the logs before releasing your app.

There’s a class called BuildConfig that contains a flag called DEBUG. It’s set to true when you’re debugging and automatically set to false when you export a release build. Here’s an example:

if (BuildConfig.DEBUG) {
  Log.v(TAG, "Some harmless log...")
}

In theory, that’s good for non-sensitive logging; in practice, it’s dangerous to rely on it. There have been bugs in the build system that caused the flag to be true for release builds. You can define your own constant but then you’re back to the same problem of developers remembering to change it before release.

The solution is not to log sensitive variables. Instead, use a breakpoint to view sensitive variables.

In the same sendReportPressed(), notice Log.d("MY_APP_TAG", "Sanitized report is: $reportString") outputs the entire report to the console. It shouldn’t be there. Select the line and delete it.

Disabling ability to Screenshot

You’ve ensured no traces of the report are left behind, yet it’s still possible for a user to take a screenshot of the entire reporting screen. The OS takes screenshots of your app too. It uses them for the animation that happens when putting an app into the background or for the list of open apps on the task switcher. Those screenshots are stored on the device.

You should disable this feature for views revealing sensitive data. Back in ReportDetailActivity.kt, find onCreate(). Replace //TODO: Disable screenshots with below:

window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)

Here, you’ve told the window to have FLAG_SECURE, which prevents explicit and implicit capturing of the screen.

Build and run. Make a report:

Filled out report

Try to take a screenshot, and you’ll notice that you can’t!

Screenshot error notice

Now, users can make anonymous reports without accidentally leaving behind data.

But what about the reporting itself? Is it secure? To find out, first a little theory…

Exploring Hardware Security Modules

Security chip

A Trusted Execution Environment (TEE) is software separate from the OS. It safely sandboxes security operations, and while inside the main processor, it’s cordoned off from the main OS. Security keys that are isolated this way are hardware-backed. You can find out if a key is hardware-backed using KeyInfo.isInsideSecureHardware().

An example of a TEE is the ARM processor that has the TrustZone secure enclave, available in modern Samsung phones.

A Secure Element (SE) takes this a step further by putting the environment on a segregated chip. It has its own CPU, storage and encryption, and random-number generator methods. Security chips that exist outside of the main processor make it harder to attack. Google’s devices contain the Titan M security chip, which is a SE.

In both cases, security operations happen at the hardware level in a separate environment that is less susceptible to software exploits.

Android 9 and above provide the StrongBox Keymaster API for these features. To ensure the key exists inside a segregated secure element, you can call KeyGenParameterSpec.Builder.setIsStrongBoxBacked(true).

Time to put this information to practical use!

Implementing Biometrics

If hackers guess your account password, they’ll be able to see your reports. To ensure you are you, modern devices have some form of biometric readers. Face, retina and fingerprint scanners are all examples. You’ll implement a biometric prompt to log in to the app so that only you can report crimes on your device.

To prevent crashes and give the user a chance for an alternative, first check that the device can use biometrics. In MainActivity.kt, replace the contents of loginPressed() with the below code block:

val email = login_email.text.toString()
if ( !isSignedUp && !isValidEmailString(email)) {
  toast("Please enter a valid email.")
} else {
  val biometricManager = BiometricManager.from(this)
  when (biometricManager.canAuthenticate()) {
    BiometricManager.BIOMETRIC_SUCCESS ->
      displayLogin(view,false)
    BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
      displayLogin(view,true)
    BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
      toast("Biometric features are currently unavailable.")
    BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
      toast("Please associate a biometric credential with your account.")
    else ->
      toast("An unknown error occurred. Please check your Biometric settings")
  }
}

The app calls displayLogin() if the device can perform biometric authentication with BIOMETRIC_SUCCESS. Otherwise, the fallback flag is set to true, allowing for password or pin authentication.

Add the following variables to the class:

private lateinit var biometricPrompt: BiometricPrompt
private lateinit var promptInfo: BiometricPrompt.PromptInfo

where BiometricPrompt is a class from AndroidX.

Next, replace the contents of displayLogin() with the following:

val executor = Executors.newSingleThreadExecutor()
biometricPrompt = BiometricPrompt(this, executor, // 1
    object : BiometricPrompt.AuthenticationCallback() {
      override fun onAuthenticationError(errorCode: Int,
                                         errString: CharSequence) {
        super.onAuthenticationError(errorCode, errString)
        runOnUiThread {
          toast("Authentication error: $errString")
        }
      }

      override fun onAuthenticationFailed() {
        super.onAuthenticationFailed()
        runOnUiThread {
          toast("Authentication failed")
        }
      }

      override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {// 2
        super.onAuthenticationSucceeded(result)

        runOnUiThread {
          toast("Authentication succeeded!")
          if (!isSignedUp) {
            generateSecretKey() // 3
          }
          performLoginOperation(view)
        }
      }
    })

if (fallback) {
  promptInfo = BiometricPrompt.PromptInfo.Builder()
      .setTitle("Biometric login for my app")
      .setSubtitle("Log in using your biometric credential")
      // Cannot call setNegativeButtonText() and
      // setDeviceCredentialAllowed() at the same time.
      // .setNegativeButtonText("Use account password")
      .setDeviceCredentialAllowed(true) // 4
      .build()
} else {
  promptInfo = BiometricPrompt.PromptInfo.Builder()
      .setTitle("Biometric login for my app")
      .setSubtitle("Log in using your biometric credential")
      .setNegativeButtonText("Use account password")
      .build()
}
biometricPrompt.authenticate(promptInfo)

Here’s what’s happening:

  1. You create an object, BiometricPrompt, for authentication.
  2. You override onAuthenticationSucceeded to determine a successful authentication.
  3. You create a secret key that’s tied to the authentication for first-time users.
  4. You allow a fallback to password authentication by calling .setDeviceCredentialAllowed(true), if desired.

Be sure you have a face, fingerprint or similar biometric scanner on your device. Build and run. You should be able to log in with your credential:

Biometric login prompt

On successful authentication, you’ll see the report list:

Report list

Congrats! You’ve secured access to the app with biometric security!

Lock screen

Even though access is limited now, the data isn’t encrypted. That is not good. You will fix that next!

Hardening User Data

In the previous tutorial, you discovered that the app stores sensitive reports in the clear. You’ll change that now by using MasterKeys to generate a key in the KeyStore. This will encrypt the reports.

As you learned above, the benefit of storing a key in the KeyStore is that it allows the OS to operate on it without exposing the secret contents of that key. Key data do not enter the app space.

For devices that don’t have a security chip, permissions for private keys only allow for your app to access the keys — and only after user authorization. This means that a lock screen must be set up on the device before you can make use of the credential storage. This makes it more difficult to extract keys from a device, called extraction prevention.

The security library contains two new classes, EncryptedFile and EncryptedSharedPreferences. In Encryption.kt, replace the entire encryptFile() with this:

fun encryptFile(context: Context, file: File) : EncryptedFile {
  val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC
  val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec) // 1
  return EncryptedFile.Builder(
      file,
      context,
      masterKeyAlias,
      EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB // 2
  ).build()
}

Here’s what’s happening:

  1. You either create a new master key or retrieve one already created.
  2. You encrypt the file using the popular secure AES encryption algorithm.

In ReportDetailActivity.kt, find sendReportPressed(). Replace the two lines right after //TODO: Replace below for encrypting file with the below code block:

val file = File(filesDir.absolutePath, "$reportID.txt") //1
val encryptedFile = encryptFile(baseContext, file) // 2
encryptedFile.openFileOutput().bufferedWriter().use {
  it.write(reportString) //3
}

Here’s what’s happening:

  1. You create a file with a name "$reportID.txt".
  2. You create an EncryptedFile instance using the file object created in the last step.
  3. You use the EncryptedFile instance to write to file all the report data.
Note: Biometrics do come with a few concerns. People can use biometrics maliciously. An example of that is when someone steals and holds your phone up to your face while you’re unconscious, or when law enforcement holds your device to your finger after they handcuff you. Or, if someone cuts off your hand when you’re distracted and sends it to the Mob. Always check to make sure your hand is there!

Awesome! You’ve hardened the data stored on the device. To make the app more secure, you’ll next authenticate your biometric credentials with a server.

Authenticating With Biometrics

You can auto-generate a key in KeyStore that is protected by your biometric credential. The key will encrypt a password for server authentication, and if the device becomes compromised, the password will be encrypted.

In Encryption.kt, add the following to generateSecretKey():

val keyGenParameterSpec = KeyGenParameterSpec.Builder(
    KEYSTORE_ALIAS,
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM) // 1
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true) // 2
    .setUserAuthenticationValidityDurationSeconds(120) // 3
    .build()
val keyGenerator = KeyGenerator.getInstance(
    KeyProperties.KEY_ALGORITHM_AES, PROVIDER) // 4
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKey()

Here’s what’s happening:

  1. You chose GCM, a popular and safe-block mode that the encryption uses.
  2. You require a lock screen to be set up and the key locked until the user authenticates by passing in .setUserAuthenticationRequired(true). Enabling the requirement for authentication also revokes the key when the user removes or changes the lock screen.
  3. You made the key available for 120 seconds from password authentication with .setUserAuthenticationValidityDurationSeconds(120). Passing in -1 requires fingerprint authentication every time you want to access the key.
  4. You create KeyGenerator with the above settings and set it the AndroidKeyStore PROVDER.

There are a few more options worth mentioning:

  • setRandomizedEncryptionRequired(true) enables the requirement that there’s enough randomization. If you encrypt the same data a second time, that encrypted output will still be different. This prevents an attacker from gaining clues about the ciphertext based on feeding in the same data.
  • Another option is .setUserAuthenticationValidWhileOnBody(boolean remainsValid). It locks the key once the device has detected it is no longer on the person.

Because you use the same key and cipher in different parts of the app, add the following helper functions to Encryption.kt, under the companion code block:

private fun getSecretKey(): SecretKey {
  val keyStore = KeyStore.getInstance(PROVIDER)

  // Before the keystore can be accessed, it must be loaded.
  keyStore.load(null)
  return keyStore.getKey(KEYSTORE_ALIAS, null) as SecretKey
}

private fun getCipher(): Cipher {
  return Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/"
      + KeyProperties.BLOCK_MODE_GCM + "/"
      + KeyProperties.ENCRYPTION_PADDING_NONE)
}

The first function returns the secret key from the keystore. The second one returns a pre-configured Cipher.

Encrypting Data

You’ve stored the key in the KeyStore. Next, you’ll update the login method to encrypt the user’s generated password using the Cipher object, given the SecretKey. In the Encryption class, replace the contents of createLoginPassword() with the following:

val cipher = getCipher()
val secretKey = getSecretKey()
val random = SecureRandom() // 1
val passwordBytes = ByteArray(256)
random.nextBytes(passwordBytes)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val ivParameters = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java) // 2
val iv = ivParameters.iv
PreferencesHelper.saveIV(context, iv)
return cipher.doFinal(passwordBytes) // 3

Here’s what’s happening:

  1. You create a random password using SecureRandom.
  2. You gather a randomized initialization vector (IV) required to decrypt the data and save it into the shared preferences.
  3. Your return a ByteArray containing the encrypted data.

Decrypting to a Byte Array

You’ve encrypted the password, so now you’ll need to decrypt it when the user authenticates with a server. Replace the contents of decryptPassword() with below:

val cipher = getCipher()
val secretKey = getSecretKey()
val iv = PreferencesHelper.iv(context) // 1
val ivParameters = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameters) // 2
return cipher.doFinal(password) // 3

Here’s what’s happening:

  1. You retrieve the IV required to decrypt the data.
  2. You initialize Cipher using DECRYPT_MODE.
  3. You return a decrypted ByteArray.

Back in MainActivity.kt, find performLoginOperation(). Replace the call to createDataSource where it says //TODO: Replace with encrypted data source below:

val encryptedInfo = createLoginPassword(this)
createDataSource(it, encryptedInfo)

On sign up, you create a password for the account. Right after the //TODO: Replace below with implementation that decrypts password, replace success = true with the following:

val password = decryptPassword(this,
    Base64.decode(firstUser.password, Base64.NO_WRAP))
if (password.isNotEmpty()) {
  //Send password to authenticate with server etc
  success = true
}

On log in, you retrieve the password to authenticate with a server. The app shouldn’t work without the key. Build and run. Then try to log in. You should encounter the following exception:

No key error present

That’s because no key was created on the previous sign up.

Delete the app to remove the old saved state. Then rebuild and run the app. You should now be able to log in. :]

Report list

You’ll notice most security functions work with ByteArray or CharArray, instead of objects such as String. That’s because String is immutable. There’s no control over how the system copies or garbage collects it.

If you’re working with sensitive strings or data, it’s better — though not foolproof — to store them in a mutable array. Overwrite sensitive arrays when you’re done with them like this:

Arrays.fill(array, 0.toByte())

You’ve created an encrypted password that will only be available once you’ve authenticated with your credentials. Your data is safely guarded.

Guard Dog

Where to Go From Here?

Congratulations! You’ve discovered a lot about data privacy. Your users can now trust you to protect their data and following the best practices you’ve learned will help you repay that confidence.

Feel free to download the completed final project using the Download Materials button at the top or bottom of this tutorial.

In this tutorial, you used encryption at a high level to protect data. To learn the finer details of advanced encryption, see the Encryption Tutorial for Android tutorial.

You also tightened the user’s data at rest. To protect it during transit, see the Securing Network Data tutorial.

Check out SafetyNet API for some cool features. These include device integrity checking, a Safe Browsing API to check for malicious URLs, and a reCAPTCHA API to protect your app from spammers and other malicious traffic.

Finally, when an OS deletes a file, it only removes the reference, not the data. To completely remove that data, you must overwrite the file with random data beforehand. Explore the Null Safety tutorial for sample code that wipes over files with data.

Last but not least, feel free to comment in the discussion below!

Average Rating

5/5

Add a rating for this content

1 rating

More like this

Contributors

Comments