RizTech Academy logo
RizTech Academy
Storing Data on DeviceLesson 5 of 530 min

Files, and where secrets go

Module 9 connects Mandi Bhav to a real prices server, and somebody on the team will ask where to put the API key. Module 2 saw it coming: local.properties is where secrets tend to end up, and BuildConfig is how they get into the code. This lesson answers the question properly, by opening the app's own APK and finding a key in it, then shows where the app's files actually live on the phone, which kind of data belongs in a file, and how to keep the one kind of secret an app can keep, with Android's Keystore. Most of it is not code for Mandi Bhav. The app changes in one place: what it lets Android copy to a new phone.

Where an app's data lives

A scratch build logged the directories Context hands out, on the emulator:

D/Storage (19380): filesDir=/data/user/0/com.riztechacademy.mandibhav.verify/files
D/Storage (19380): cacheDir=/data/user/0/com.riztechacademy.mandibhav.verify/cache
D/Storage (19380): database=/data/user/0/com.riztechacademy.mandibhav.verify/databases/mandi-bhav.db
D/Storage (19380): externalFilesDir=/storage/emulated/0/Android/data/com.riztechacademy.mandibhav.verify/files

and adb shell run-as com.riztechacademy.mandibhav ls -l files/datastore databases on the real app showed what this module put there:

databases:
-rw------- 1 u0_a229 u0_a229 40960 2026-11-08 22:10 mandi-bhav.db
-rw------- 1 u0_a229 u0_a229 32768 2026-11-08 22:10 mandi-bhav.db-shm
-rw------- 1 u0_a229 u0_a229     0 2026-11-08 22:10 mandi-bhav.db-wal
-rw------- 1 u0_a229 u0_a229     0 2026-11-08 22:10 mandi-bhav.db.lck

files/datastore:
-rw------- 1 u0_a229 u0_a229 33 2026-11-08 22:09 user_preferences.preferences_pb

-rw------- and the owner u0_a229: each app runs as its own Linux user, and only that user can read these files. That is the app sandbox, and it is why the database needed no permission. The -wal, -shm and .lck files belong to SQLite and Room; copy or delete the .db alone and you can lose the latest writes.

Where API Who can read it Removed on uninstall Use for
App-specific, internal filesDir, cacheDir, getDatabasePath, DataStore, Room Only the app Yes Almost everything an app keeps
App-specific, external getExternalFilesDir, externalCacheDir Only the app, on Android 11 and later; storage may be removable Yes Large files that need not be on internal storage
Cache cacheDir Only the app; the system may delete it when space is low Yes Anything you can download again
Shared media MediaStore Other apps, with permission No Photos, audio and video the user would expect in the gallery
Documents Storage Access Framework, the system file picker Whoever the user chooses No A file the user saves or opens, such as an exported price report

An app targeting Android 10 or later gets scoped storage: it sees its own external directory and the media it created, not the whole of /sdcard. Mandi Bhav needs no storage permission at all, and that is the normal case. An app asking for broad storage access is an app the user is right to be suspicious of.

A file, a database or DataStore

You are keeping Use Because
Rows you search, sort, join or update one at a time: prices, alerts Room Queries, indexes, transactions, migrations
A handful of settings: the home market DataStore Small, read whole, a flow, transactional edits
One blob you read and write whole: a downloaded PDF circular, an image, an export A file in filesDir or cacheDir No structure to query; a database row is the wrong shape for 2 MB
Something the user should find in their gallery or Downloads MediaStore or the file picker It is theirs, not the app's
A screen's in-progress state Saved state, module 5 It should die when the user closes the task

The common mistakes are a JSON file used as a database, read whole and parsed on every change until it is slow and one crash mid-write corrupts it, and large blobs in database columns, which make every query that touches the row move megabytes.

Private is not encrypted

The sandbox stops other apps. It does not stop someone with the phone. On a debuggable build, which every build you run from Android Studio is, run-as reads any file the app owns, and the DataStore file is not encrypted:

0000000  \n 037  \n  \v   h   o   m   e   _   m   a   r   k   e   t 022
0000020 020   * 016   L   a   s   a   l   g   a   o   n       A   P   M
0000040   C

That is od -c on user_preferences.preferences_pb: the key and "Lasalgaon APMC" in plain text. The same is true of the database. A release build is not debuggable, so run-as refuses it; a rooted phone, a backup, or a forensic tool does not. For a home market that does not matter. For a sign-in token, a phone number or anything you would not print on a receipt, it does.

Backups: what goes to a new phone

android:allowBackup="true", from the project template, lets Android copy the app's files to the user's Google account and to a new phone. By default that is everything in the sandbox: the home market, and also the price database. Restoring a three-week-old price cache onto a new phone is worse than useless, and lesson 4's label would at least flag it, but there is a sharper reason to be deliberate. Android's own reference for the old encrypted-preferences class warns that encrypted files should not be backed up, because when the file is restored the key that encrypted it will likely not be there; Keystore keys stay on the device they were made on. A restored ciphertext with no key is a file the app can never read.

So Mandi Bhav says exactly what to copy. res/xml/data_extraction_rules.xml, used on Android 12 and later:

<data-extraction-rules>
    <cloud-backup>
        <include domain="file" path="datastore/user_preferences.preferences_pb" />
    </cloud-backup>
    <device-transfer>
        <include domain="file" path="datastore/user_preferences.preferences_pb" />
    </device-transfer>
</data-extraction-rules>

res/xml/backup_rules.xml, the older format for Android 11 and earlier, which Mandi Bhav's minSdk 26 still reaches:

<full-backup-content>
    <include domain="file" path="datastore/user_preferences.preferences_pb" />
</full-backup-content>

and on the manifest's <application>:

android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

Once a rule says include, only what is included is copied: the home market goes to the new phone, and the prices are downloaded again there. The app builds and lintDebug passes with both files in place. This course has not run a real backup and restore against them, because that means changing the emulator's backup settings; Google's Auto Backup page, linked below, shows how to test one with adb shell bmgr on a device you can change. This is the only change this lesson makes to Mandi Bhav.

The trap: a secret in the APK

The tempting way to give module 9 its key is module 2's buildConfigField, with the value read from local.properties so it stays out of git. A scratch build adds a harmless dummy, and a dummy in a string resource for good measure:

// Experiment: a dummy "secret", to see where it ends up.
buildConfigField("String", "PRICES_API_KEY", "\"demo-key-not-real-7f3a9c\"")
<string name="maps_api_key" translatable="false">demo-resource-key-4b1e</string>

An APK is a zip file. Android Studio's APK Analyzer, or its command-line form apkanalyzer in the SDK's cmdline-tools/latest/bin, reads it without unzipping:

apkanalyzer dex code --class com.riztechacademy.mandibhav.BuildConfig app-debug.apk
.field public static final PRICES_API_KEY:Ljava/lang/String; = "demo-key-not-real-7f3a9c"
apkanalyzer resources value --config default --name maps_api_key --type string app-debug.apk
demo-resource-key-4b1e

"That is a debug build; release is minified." So the same project was built as a release, with isMinifyEnabled = true, and R8 did its work: the release APK was 2.0 MB against the debug build's 13.6 MB, and apkanalyzer dex packages found no BuildConfig class in it at all; R8 had removed the class and kept only the constant, where the code used it. A search of the raw bytes of every file in the zip still found demo-key-not-real-7f3a9c in classes.dex, and the resource key in resources.arsc. Minifying renames and removes code. It does not hide strings, and obfuscation tools that scramble them only slow down someone who has the APK, which is everyone who installed the app.

Anything in the APK is public. Keeping a key out of git, with local.properties or Google's secrets Gradle plugin, is good practice, and Google's API key guidance recommends it; it stops the key leaking from your repository. It does nothing about the APK.

Where secrets go instead

There are three kinds of secret, and each has a different home:

Secret Example Where it goes
A key that lets the holder spend your money or read everyone's data A paid prices API's master key, a database password Your server. The app calls your server; your server holds the key and calls the paid API. It never reaches a phone
A key that is meant to be in the app, but should only work for your app A Maps SDK key The APK is fine, but restrict it in the provider's console to your package name and signing certificate, and to the APIs you use
The user's own secret, obtained at run time A session token after sign-in Encrypted on the phone with a Keystore key, as below

For Mandi Bhav, the rule for module 9 is the first row: whatever key the prices server needs stays on a server, and the app asks that server, never the paid service directly.

Encrypting with the Android Keystore

The Android Keystore holds keys in a system process, and on most phones in secure hardware, so the app can use a key without ever having its bytes. Google's Keystore page puts it plainly: the key material is non-exportable, and even if the app is compromised, an attacker can use the key on the phone but not take it away.

For years the convenient wrapper was Jetpack Security's EncryptedSharedPreferences. On 8 November 2026 the release notes for androidx.security:security-crypto said, for 1.1.0-alpha07 in April 2025 and carried into the 1.1.0 stable release: "Deprecated all APIs in favour of existing platform APIs and direct use of Android Keystore." Its reference page marks the class deprecated in 1.1.0. You will see it in existing apps; do not add it to new ones. What replaces it is a few lines of platform code, verified on the emulator in a scratch build, data/SecretBox.kt:

/**
 * Encrypts small secrets, such as a sign-in token, with an AES key that
 * lives in the Android Keystore. The key never enters the app's memory;
 * the app hands the Keystore bytes and gets bytes back.
 *
 * The output is the 12-byte IV followed by the ciphertext and its tag.
 * Store it anywhere private: a file, a DataStore, a database column.
 */
class SecretBox(private val alias: String = "mandi_bhav_secrets") {

    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }

    private fun key(): SecretKey {
        (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
        generator.init(
            KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                .setKeySize(256)
                .build(),
        )
        return generator.generateKey()
    }

    fun encrypt(plain: ByteArray): ByteArray {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        // The Keystore chooses a fresh random IV for every encryption.
        cipher.init(Cipher.ENCRYPT_MODE, key())
        return cipher.iv + cipher.doFinal(plain)
    }

    fun decrypt(box: ByteArray): ByteArray {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, box, 0, IV_SIZE))
        return cipher.doFinal(box, IV_SIZE, box.size - IV_SIZE)
    }

    private companion object {
        const val IV_SIZE = 12
    }
}

AES in GCM mode encrypts and also authenticates: the last 16 bytes are a tag, and any change to the stored bytes makes decryption fail rather than return garbage. The IV, a value that must never repeat with the same key, is chosen by the Keystore and stored in front of the ciphertext; it is not secret. The scratch build sealed a dummy token, wrote it to files/session.bin, and checked what it could:

D/Storage (19380): sealed 56 bytes, same token sealed twice equal=false, opened=demo-session-token-for-priya
D/Storage (19380): key.encoded=null
D/Storage (19380): securityLevel=0
D/Storage (19380): one byte changed: javax.crypto.AEADBadTagException
Line Means
56 bytes 12 of IV, 28 of token, 16 of tag
equal=false A fresh IV each time, so the same token never encrypts to the same bytes
key.encoded=null The app cannot read the key's bytes; it can only ask the Keystore to use them
securityLevel=0 KeyProperties.SECURITY_LEVEL_SOFTWARE: the emulator has no secure hardware. A real phone usually reports 1, the trusted execution environment, or 2, StrongBox
AEADBadTagException One changed byte, and decryption refused

What it does not protect against, so you can say it in a design review: anyone who can run code as your app on the phone can ask the Keystore to decrypt, so a rooted phone running your app is not protected; clearing the app's storage or uninstalling deletes the key, and every ciphertext with it, so design the app to sign in again; the key does not travel in a backup, which is why the backup rules above leave such files out; and Keystore calls can be slow, so Google's page says to keep them off the main thread, as with any disk work. If you need more than a small token, Google's API key guidance points to Tink, Google's cryptography library, with a Keystore key protecting Tink's keys.

Check your work

Where Room and DataStore put their files, and who can read them. In the app's private directories, databases/ and files/datastore/, readable only by the app's own user.

Why run-as could read the DataStore file. The build was debuggable; private is not encrypted.

What the backup rules change. Only the DataStore file is copied to a new phone; the price cache is downloaded again there.

Why encrypted data must not be backed up. The Keystore key stays on the old phone, so the restored ciphertext cannot be decrypted.

What R8 did to BuildConfig, and what it did not do. Deleted the class and inlined the constant; the string was still in classes.dex.

Where a key that must stay secret goes. On your server, behind an API your app calls; never in the APK.

What replaced EncryptedSharedPreferences. Direct use of the Android Keystore, as the deprecation note says; here, an AES-GCM key and Cipher.

What key.encoded=null proves. The app can use the key but cannot read it.

Practice 4. apkanalyzer resources value prints demo-resource-key-4b1e for both the debug and the minified release APK. Minifying works on code; resources live in resources.arsc, and even resource shrinking only removes resources nothing uses. A key the app reads is, by definition, used.

Practice

  1. Add the two backup-rule files and the two manifest attributes. Build and run lintDebug.
  2. With run-as, list your app's databases and files directories on the emulator, and read the DataStore file with od -c. Find your home market.
  3. Add a dummy buildConfigField to a scratch copy, build debug and release with isMinifyEnabled = true, and find the string in both with apkanalyzer or by searching the unzipped classes.dex. Then delete the experiment.
  4. Do the same with a string resource and apkanalyzer resources value, and explain why minifying does not remove it.
  5. Write SecretBox in a scratch copy, seal a dummy token into files/session.bin, read it back, then change one byte of the file with run-as and read the exception. Clear the app's storage and try to decrypt an old copy of the file: say what happened to the key.
  6. Harder: design how Mandi Bhav would keep a sign-in token for module 9, if it had sign-in. Where is the encrypted token stored, what happens on a new phone, what happens when decryption fails, and which backup rule changes. Write it as the comment you would put on the class.

Official documentation

Next: module 9, Networking.


That is module 8. You can now:

  • choose where a piece of data should live, from saved state to DataStore, Room, a file or nowhere, by what it must survive and what it must not;
  • keep settings in Preferences DataStore with one instance per file, handle read failures, and move users off SharedPreferences;
  • model tables with Room 3 entities, type converters, DAOs that return flows, upserts and transactions, and test them under Robolectric;
  • write queries that use an index, read related rows with a relation, and change a schema on phones that already have data, with a tested manual migration, an automatic one, and a clear view of what destructive fallback costs;
  • make the database the single source of truth, so the app shows what it has, says how old it is, and survives a failed refresh without a crash;
  • find a key in an APK, say where each kind of secret belongs, and encrypt the user's own secrets with a Keystore key.

Mandi Bhav now keeps its prices, their history and its markets on the phone, puts the farmer's home market first, says when the prices were fetched, and keeps working in airplane mode. Its "network" is still a one-second pause that returns sample prices. Module 9, Networking, replaces it with a real API through Retrofit, handles timeouts, server errors and retries, covers coroutines and dispatchers properly, and decides what a first launch with an empty database and a bad connection should show, writing everything it fetches into the tables this module built.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship