You built the app. It works. But before you ship it, you need to think about security. Can someone decompile your APK and read your code? Is the user’s data safe? Can a man-in-the-middle attack steal API tokens?

In this tutorial, you will learn how to protect your Android app — from code shrinking with R8 to biometric authentication and encrypted storage.

Prerequisites: You should have a working Android app. If you have been following this series, you already have everything you need.


Why App Security Matters

Every APK can be decompiled. Tools like jadx and apktool turn your app back into readable code in seconds. Without protection:

  • Your API keys are visible
  • Your business logic is exposed
  • Competitors can clone your app
  • Attackers can find vulnerabilities

Security is not about making your app impossible to hack. It is about making it hard enough that attackers move on to easier targets.


R8 — Code Shrinking and Obfuscation

R8 is Android’s built-in code optimizer. It does three things:

  1. Shrinking — removes unused classes, methods, and fields
  2. Obfuscation — renames classes and methods to meaningless names
  3. Optimization — inlines code, removes dead branches

R8 replaced ProGuard as the default optimizer in AGP 3.4. You still write “ProGuard rules” but R8 processes them.

Enabling R8

In your app’s build.gradle.kts:

android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}
  • isMinifyEnabled = true — enables code shrinking and obfuscation
  • isShrinkResources = true — removes unused resources (images, layouts)
  • proguard-android-optimize.txt — default rules from the Android SDK
  • proguard-rules.pro — your custom rules

What R8 Does to Your Code

Before R8:

class UserRepository(private val api: UserApi) {
    suspend fun getUsers(): List<User> {
        return api.getUsers().map { it.toDomain() }
    }
}

After R8:

class a(private val b: c) {
    suspend fun d(): List<e> {
        return b.f().map { it.g() }
    }
}

Class names, method names, and variable names become single letters. Anyone decompiling your APK sees meaningless code.

APK Size Impact

R8 typically reduces APK size by 20-40%. For a 10 MB APK:

OptimizationSize
No R810.0 MB
Code shrinking only7.5 MB
+ Resource shrinking6.8 MB
+ R8 full mode6.2 MB

Use APK Analyzer in Android Studio (Build → Analyze APK) to see exactly what takes space.


ProGuard Rules

R8 removes code it thinks is unused. Sometimes it removes code you actually need — like classes accessed through reflection or serialization.

Keep Rules Syntax

# Keep the entire class
-keep class com.example.model.User { *; }

# Keep class members only
-keepclassmembers class com.example.model.User {
    <fields>;
}

# Keep classes with a specific annotation
-keep @com.google.gson.annotations.SerializedName class * { *; }

# Keep class names but allow optimization
-keepnames class com.example.** { *; }

Rules for Common Libraries

Kotlin Serialization

-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt

-keepclassmembers @kotlinx.serialization.Serializable class ** {
    *** Companion;
}

-keepclasseswithmembers class **$$serializer {
    *** INSTANCE;
}

Retrofit

-keepattributes Signature
-keepattributes Exceptions

-keep,allowshrinking,allowobfuscation interface retrofit2.Call
-keep,allowshrinking,allowobfuscation interface retrofit2.Response

-keepclassmembers,allowshrinking,allowobfuscation interface * {
    @retrofit2.http.* <methods>;
}

Room

-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**

Hilt

Hilt generates its own keep rules automatically. You usually don’t need custom rules. But if you use @EntryPoint:

-keep @dagger.hilt.EntryPoint class *

R8 Full Mode

R8 full mode applies more aggressive optimizations:

android {
    buildTypes {
        release {
            // In gradle.properties:
            // android.enableR8.fullMode=true
        }
    }
}

Full mode may break reflection-based libraries. Test your release build thoroughly.

Debugging R8 Issues

When R8 removes something it shouldn’t:

# Build with mapping file
./gradlew assembleRelease

# Find the mapping file
# app/build/outputs/mapping/release/mapping.txt

The mapping file translates obfuscated names back to original names. Upload this to Play Console and Firebase Crashlytics for readable crash reports.

To see what R8 removed:

# Add to proguard-rules.pro
-printusage usage.txt
-printseeds seeds.txt

Encrypted Storage

For sensitive data like tokens, passwords, or API keys, use encrypted storage.

EncryptedSharedPreferences

Note: EncryptedSharedPreferences from androidx.security:security-crypto was deprecated in April 2025. It still works but is no longer actively developed. For new projects, prefer DataStore with a custom encryption layer or Google Tink.

fun createEncryptedPrefs(context: Context): SharedPreferences {
    val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    return EncryptedSharedPreferences.create(
        context,
        "secure_prefs",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
}

Use it like regular SharedPreferences:

val prefs = createEncryptedPrefs(context)

// Write
prefs.edit().putString("auth_token", "abc123").apply()

// Read
val token = prefs.getString("auth_token", null)

Encrypted Storage Wrapper

For a cleaner API, wrap encrypted preferences in a helper class:

class EncryptedStorage(private val context: Context) {

    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val encryptedPrefs = EncryptedSharedPreferences.create(
        context,
        "encrypted_datastore",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun saveToken(token: String) {
        encryptedPrefs.edit().putString("auth_token", token).apply()
    }

    fun getToken(): String? {
        return encryptedPrefs.getString("auth_token", null)
    }

    fun clearToken() {
        encryptedPrefs.edit().remove("auth_token").apply()
    }
}

Provide it with Hilt:

@Module
@InstallIn(SingletonComponent::class)
object SecurityModule {

    @Provides
    @Singleton
    fun provideEncryptedDataStore(
        @ApplicationContext context: Context
    ): EncryptedStorage = EncryptedStorage(context)
}

What to Encrypt

DataStorageEncrypt?
Auth tokensEncryptedSharedPreferencesYes
API keysBuildConfig or encrypted storageYes
User preferences (theme, language)DataStoreNo
Cache dataRoom / filesNo
Credit card numbersDon’t store locally
PasswordsNever store — use tokens

Biometric Authentication

BiometricPrompt lets users authenticate with fingerprint or face recognition.

Setup

dependencies {
    implementation("androidx.biometric:biometric:1.1.0")  // Latest stable — alpha builds (1.4.0-alpha05) add Compose-specific APIs but are unstable
}

Checking Biometric Availability

fun canUseBiometrics(context: Context): Boolean {
    val biometricManager = BiometricManager.from(context)
    return biometricManager.canAuthenticate(
        BiometricManager.Authenticators.BIOMETRIC_STRONG
    ) == BiometricManager.BIOMETRIC_SUCCESS
}

Showing the Biometric Prompt

@Composable
fun BiometricScreen() {
    val context = LocalContext.current
    var isAuthenticated by remember { mutableStateOf(false) }
    var errorMessage by remember { mutableStateOf<String?>(null) }

    val activity = context as FragmentActivity

    fun showBiometricPrompt() {
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Verify Identity")
            .setSubtitle("Use your fingerprint to access the app")
            .setNegativeButtonText("Cancel")
            .setAllowedAuthenticators(
                BiometricManager.Authenticators.BIOMETRIC_STRONG
            )
            .build()

        val biometricPrompt = BiometricPrompt(
            activity,
            ContextCompat.getMainExecutor(context),
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(
                    result: BiometricPrompt.AuthenticationResult
                ) {
                    isAuthenticated = true
                }

                override fun onAuthenticationError(
                    errorCode: Int,
                    errString: CharSequence
                ) {
                    errorMessage = errString.toString()
                }

                override fun onAuthenticationFailed() {
                    errorMessage = "Authentication failed. Try again."
                }
            }
        )

        biometricPrompt.authenticate(promptInfo)
    }

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        if (isAuthenticated) {
            Text(
                text = "Welcome! You are authenticated.",
                style = MaterialTheme.typography.headlineMedium
            )
        } else {
            Icon(
                imageVector = Icons.Default.Lock,
                contentDescription = null,
                modifier = Modifier.size(64.dp),
                tint = MaterialTheme.colorScheme.primary
            )

            Spacer(modifier = Modifier.height(16.dp))

            Text(
                text = "Authentication required",
                style = MaterialTheme.typography.titleLarge
            )

            Spacer(modifier = Modifier.height(8.dp))

            Button(onClick = { showBiometricPrompt() }) {
                Text("Unlock with Biometrics")
            }

            errorMessage?.let {
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = it,
                    color = MaterialTheme.colorScheme.error,
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

Biometrics + Encrypted Storage

Combine biometrics with encrypted storage for maximum security. Authenticate first, then read the encrypted data:

class SecureVault(private val context: Context) {

    private val encryptedPrefs = createEncryptedPrefs(context)

    fun saveSecret(key: String, value: String) {
        encryptedPrefs.edit().putString(key, value).apply()
    }

    fun getSecret(
        key: String,
        activity: FragmentActivity,
        onSuccess: (String?) -> Unit,
        onError: (String) -> Unit
    ) {
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Access Secure Data")
            .setSubtitle("Authenticate to view sensitive information")
            .setNegativeButtonText("Cancel")
            .build()

        val callback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(
                result: BiometricPrompt.AuthenticationResult
            ) {
                val value = encryptedPrefs.getString(key, null)
                onSuccess(value)
            }

            override fun onAuthenticationError(
                errorCode: Int, errString: CharSequence
            ) {
                onError(errString.toString())
            }
        }

        BiometricPrompt(
            activity,
            ContextCompat.getMainExecutor(context),
            callback
        ).authenticate(promptInfo)
    }
}

SSL/TLS Pinning

SSL pinning prevents man-in-the-middle attacks by verifying the server’s certificate matches a known value.

With OkHttp CertificatePinner

val certificatePinner = CertificatePinner.Builder()
    .add(
        "api.example.com",
        "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    )
    .add(
        "api.example.com",
        "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
    )
    .build()

val okHttpClient = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

Important: Always pin at least two certificates (primary and backup). If one expires and you only have one pin, your app stops working.

Network Security Configuration

For more control, use network_security_config.xml:

<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <!-- Disallow cleartext (HTTP) traffic -->
    <base-config cleartextTrafficPermitted="false" />

    <!-- Pin certificates for your API -->
    <domain-config>
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2027-01-01">
            <pin digest="SHA-256">AAAA...=</pin>
            <pin digest="SHA-256">BBBB...=</pin>
        </pin-set>
    </domain-config>

    <!-- Allow cleartext for debug only -->
    <debug-overrides>
        <trust-anchors>
            <certificates src="user" />
        </trust-anchors>
    </debug-overrides>
</network-security-config>

Reference it in the manifest:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>

Play Integrity API

The Play Integrity API helps you verify that:

  • The app is the genuine version from Google Play
  • The device is a real device (not an emulator or rooted)
  • The user has a valid Google Play license

This prevents unauthorized API access from modified APKs.

class IntegrityChecker(private val context: Context) {

    suspend fun getIntegrityToken(): String? {
        return try {
            val integrityManager = IntegrityManagerFactory.create(context)

            val request = IntegrityTokenRequest.builder()
                .setNonce(generateNonce())
                .build()

            val response = integrityManager
                .requestIntegrityToken(request)
                .await()

            response.token()
        } catch (e: Exception) {
            null
        }
    }

    private fun generateNonce(): String {
        val bytes = ByteArray(32)
        SecureRandom().nextBytes(bytes)
        return Base64.encodeToString(bytes, Base64.NO_WRAP)
    }
}

Send the token to your server. Your server verifies it with Google’s API and decides whether to trust the request.


Measuring APK Size

Before and after applying security measures, check your APK size:

APK Analyzer

In Android Studio: Build → Analyze APK

This shows:

  • Total APK size and download size
  • Size of each component (code, resources, native libraries)
  • Which classes and methods take the most space

Command Line

# Build release APK
./gradlew assembleRelease

# Check size
ls -lh app/build/outputs/apk/release/app-release.apk

# Build AAB (for Play Store)
./gradlew bundleRelease
ls -lh app/build/outputs/bundle/release/app-release.aab

Mapping Files

Always upload mapping.txt to:

  • Play Console — for readable crash reports in Android Vitals
  • Firebase Crashlytics — for deobfuscated stack traces
# Find the mapping file
find app/build/outputs/mapping -name "mapping.txt"

Common Mistakes

1. Hardcoding API Keys

// Bad — visible after decompilation
const val API_KEY = "sk_live_abc123"

// Better — use BuildConfig from local.properties
val apiKey = BuildConfig.API_KEY

// Best — fetch from server after authentication

Even with R8, string constants are not obfuscated. Use BuildConfig fields loaded from local.properties (which is in .gitignore).

2. Disabling R8 for “Easier Debugging”

Some teams disable R8 in release builds because it causes issues. Fix the ProGuard rules instead. R8 is essential for both size and security.

3. Not Testing Release Builds

Your debug build works fine, but the release build crashes because R8 removed a class used by reflection. Test release builds on every PR.

4. Storing Sensitive Data in Plain Text

SharedPreferences are stored as XML files that can be read on rooted devices. Use EncryptedSharedPreferences for anything sensitive.

5. Pinning Only One Certificate

If your only pinned certificate expires, your app can’t connect to the server. Always pin at least a primary and backup certificate, and set an expiration date.


What’s Next?

In this tutorial, you learned:

  • How R8 shrinks, obfuscates, and optimizes your code
  • Writing ProGuard rules for common libraries
  • Encrypting sensitive data with EncryptedSharedPreferences
  • Adding biometric authentication with BiometricPrompt
  • SSL/TLS pinning with OkHttp and network security config
  • Verifying app integrity with Play Integrity API
  • Measuring and reducing APK size

Next up: Android Tutorial #17: Firebase — Auth, Crashlytics, Analytics, Remote Config — where you will learn how to add authentication, crash reporting, and feature flags to your app.



This is part 16 of the Android Development Tutorial series.