Platform Internals
What sits under the SDK - the Linux kernel, Binder IPC, the ART runtime and its compiler, the memory model and GC, JNI, and how apps are hardened with obfuscation and encryption.
All done - nice work.
Most Android questions stop at the SDK surface. Senior and staff interviews go a layer down and ask what the framework is actually sitting on: the kernel, the runtime, the way memory is laid out, how one process talks to another, and how a shipped APK is protected. You rarely write this code, but you are expected to reason about it when an app is slow to start, leaking memory, crashing only in release, or handling something sensitive.
A simple study path
Start with the process model and Binder, because almost every framework call you
make is a Binder transaction into system_server. Then learn the memory model:
Java heap, native heap, and how the garbage collector reclaims one of them. After
that, the compilation pipeline (DEX, dex2oat, JIT and AOT, Baseline Profiles),
JNI, and finally app hardening with R8 and the Keystore.
What gets tested
- Kernel and processes - each app as a Linux user with its own UID, the zygote fork model, the low memory killer, and what “the system killed my process” really means.
- Binder and IPC - how a method call crosses a process boundary, the per-process transaction buffer and
TransactionTooLargeException, and whysystem_serveris on the other end of so many APIs. - Memory and garbage collection - the managed Java heap versus the native heap, where
Bitmappixels live, what triggersOutOfMemoryError, and how ART’s concurrent collector reclaims memory without freezing the UI. - Compilation and startup - the
.dexto.oatjourney throughdex2oat, the interpreter/JIT/AOT mix, and how Baseline Profiles pre-compile the paths that matter for a fast cold start. - Native code and JNI - crossing into C/C++, the local/global/weak-global reference tables, attaching native threads, and the failure modes that only show up under load.
- Security and hardening - what R8 obfuscation does and does not protect, file-based encryption, the Android Keystore and hardware-backed keys, and why a secret shipped in the APK is never really secret.
How interviewers ask
Expect “where does this memory live and who frees it?”, “what happens between
tapping the icon and your first frame?”, and “how would you store this token so a
rooted device cannot read it?”. Strong answers name the exact mechanism (the
Binder driver, dex2oat, a concurrent copying collector, a hardware-backed
Keystore key) and, just as importantly, name the limit of that mechanism.
Prep tip: for every internal you learn, be ready to say what it protects against and what it does not. Obfuscation slows a human reader but does not encrypt anything; the Keystore hides key material but cannot stop a determined attacker on a rooted device from using the key while your app is running.
Frequently asked. Prioritize these in your first pass.
Use it in practice
Common implementation choices, debugging, and trade-offs.
Binder and IPC
What is the Binder transaction limit, and when does TransactionTooLargeException actually fire?
Every process has a Binder transaction buffer of about 1 MB, and that buffer
is shared across all transactions in flight at once for that process. So the
real budget for any single call is smaller than 1 MB, because the framework and
other concurrent calls are using part of it too. When a transaction does not fit,
Binder throws TransactionTooLargeException.
The trap is that it is size-dependent, so it hides in testing. A screen works fine with a short list and then crashes in production when a user has a large one. The exception often surfaces far from the code that caused it, because the failing transaction is the framework serializing your state, not a line you wrote.
The most common source is onSaveInstanceState. That Bundle is sent to
system_server over Binder so it can be handed back after process death. People
put too much in it:
- A large list of parsed model objects instead of a handful of ids.
- A full-resolution
Bitmap. - An entire screen’s worth of data “to avoid reloading.”
The saved-state Bundle is for the small keys needed to rebuild a screen (a selected id, a query string, a scroll position), not for caching data. Reload the heavy data from a database or the network using the saved id.
Other places it shows up:
- Returning a big collection across a bound service or ContentProvider call.
- Putting large extras on an
Intent(the Intent travels through Binder to start the target). - Broadcasting a large payload.
How to avoid it:
- Pass references, not payloads. Send an id and let the receiver fetch.
- Keep
onSaveInstanceStatetiny; move real data to aViewModelfor rotation and to durable storage for process death. - For genuinely large data that must cross a process boundary, stream it through a
file, a
ContentProvider, or shared memory (ashmem/MemoryFile) rather than a single Parcel.
The number to remember is roughly 1 MB per process, shared, so treat a few hundred kilobytes as the practical ceiling for one transaction.
Memory and garbage collection
PSS, RSS, and USS: how do you actually measure how much memory your app is using?
“How much memory does my app use” has no single answer, because a process shares pages (framework code, fonts, native libraries) with every other app. The three metrics you are expected to know separate shared from private memory.
- RSS (Resident Set Size): all physical RAM pages the process currently has mapped, including shared pages counted in full. RSS overcounts, because the same shared framework pages show up in the RSS of every app.
- PSS (Proportional Set Size): private pages plus each shared page divided by the number of processes sharing it. PSS is the fairest single number for one process, because shared cost is split proportionally. If you report one figure, report PSS.
- USS (Unique Set Size): only the private pages, the memory that would be freed if the process died right now. USS is the best measure of what your app alone is responsible for.
So for the same process, USS is smallest, PSS is in the middle, and RSS is largest.
How to read them in practice:
adb shell dumpsys meminfo <package>gives a per-process breakdown: Java heap, native heap, code, stack, graphics, and the totals including PSS. This is the fastest way to see whether growth is on the Java heap, the native heap, or graphics.- The Android Studio Memory Profiler shows the same categories live and lets you capture a heap dump to find what is retained.
Debug.getMemoryInfo()andActivityManager.getProcessMemoryInfo()expose PSS breakdowns programmatically if you want to log them.
Why the distinction matters for debugging. If total PSS climbs but the Java heap is flat, your leak is native (bitmaps since Android 8, direct buffers, a native library), and a Java heap dump will not show it. If the Java heap climbs, capture a heap dump and look for objects retained by a long-lived root. Picking the wrong tool for the wrong heap is the usual reason a “leak” stays unfound.
The summary: RSS overcounts shared memory, USS counts only private memory, and PSS
splits the difference, so PSS is the number to track over time and the category
breakdown from dumpsys meminfo tells you which heap to investigate.
Compilation and startup
What problem do Baseline Profiles solve, and how are they generated and applied?
The problem. On a fresh install, ART compiles almost nothing ahead of time. Your app starts interpreted, the JIT compiles hot methods only after it sees them run, and it takes a few launches before the important startup and scrolling code is AOT-compiled. Those first launches are the slowest, and first impressions are exactly when you can least afford jank.
What a Baseline Profile is. It is a list of classes and methods that matter for
startup and critical user journeys, shipped inside the APK (compiled to a
binary profile the platform reads at install). At install time dex2oat
AOT-compiles the methods on that list, so the paths that drive your first frame are
already native the very first time the app runs. Reported gains are commonly in the
range of 20 to 30 percent faster cold start, plus smoother first scrolls.
How you generate one. You do not hand-write it. You exercise the real journeys and let tooling record what ran:
- Write a Macrobenchmark test using
BaselineProfileRule(or the Baseline Profile Gradle plugin) that launches the app and scrolls the key screens. - Run it on a device or emulator. The tooling captures the executed methods and
writes
baseline-prof.txt. - That profile is bundled into the release build, and Play also carries it so it applies at install.
Two related ideas, so you do not confuse them:
- Startup Profiles additionally influence how classes are laid out in the DEX so startup code is grouped together, reducing I/O during launch. They complement Baseline Profiles.
- Cloud Profiles are aggregated from real users by Play over time. They help, but only after your app has enough installs and usage. A Baseline Profile works from the first install, which is why you ship both rather than relying on the cloud alone.
The honest caveat. A Baseline Profile is only as good as the journeys you record. If your benchmark only opens the home screen, only the home screen gets pre-compiled. Cover the flows users actually hit on launch, and regenerate the profile when those flows change, or it silently goes stale.
The summary line: a Baseline Profile pre-compiles your hot startup paths at install time so the first launch is not stuck warming up, and you produce it by recording a Macrobenchmark run rather than writing it by hand.
Security and hardening
What does R8 obfuscation actually protect against, and what does it not?
Obfuscation is one of the most over-trusted words in Android security. R8’s obfuscation is useful, but it is not encryption and it does not hide your logic. A good answer names both sides honestly.
What R8 obfuscation does. On a release build with minification enabled, R8
renames classes, methods, and fields to short meaningless identifiers (a,
b, a.a). Combined with its shrinking (removing unused code) and optimization
(inlining, dead-code removal), the decompiled result is harder for a human to read
and smaller to ship. It raises the effort required to understand your code and
strips symbol names that would otherwise document it.
What it does not do:
- It is not encryption. The bytecode still runs on the device, so it can be decompiled. Anyone can run a released APK through a decompiler like jadx and get readable, if renamed, code back.
- Strings and resources stay in the clear. Renaming does not touch string constants. A hardcoded API endpoint, key, or token is right there in the output. Shipping a secret in the APK is unsafe no matter how obfuscated the surrounding code is.
- Logic is preserved. Obfuscation must not change behavior, so the algorithm is still fully present. A motivated reader can follow it; they just have to work harder without meaningful names.
The operational cost you must remember. R8 does static analysis, so code
reached only by reflection, JNI, or name (JSON models, reflective DI,
Class.forName) looks unused and gets removed or renamed and then breaks at
runtime. That is what -keep rules are for, and it is why you always test the
minified release build, because these failures never appear in debug. Keep the
mapping.txt file so you can de-obfuscate production crash stack traces; without it
they are unreadable.
What real hardening looks like beyond renaming:
- Keep genuine secrets on the server, not the client.
- Use certificate pinning for your API connections.
- Attest the app and device with the Play Integrity API.
- Accept the threat model: a determined attacker with the APK and a rooted device can reverse most of it. Obfuscation raises their cost; it does not make it impossible.
The one-liner: R8 obfuscation renames and shrinks to slow down a human reader, but it does not encrypt anything, does not hide strings, and does not protect a secret shipped in the APK, so treat it as friction, not as a security boundary.
Optional deep dives
Internals and broader design questions to study after the core material.
Kernel and processes
What does Android's Linux kernel give each app, and what does it really mean when the system kills your process?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Android is a Linux system with a managed runtime and a large framework on top. The kernel is what makes the sandbox real, and understanding it explains a lot of behavior that otherwise looks arbitrary.
Every app is a Linux user. At install time each app is assigned a unique Linux UID. Its process runs under that UID, and normal Linux file permissions plus SELinux policy stop it from reading another app’s private data or the system’s. This is the app sandbox: it is enforced by the kernel, not by the runtime, so native code and JNI cannot escape it either. Two apps signed with the same key can opt into a shared UID (now deprecated), which is the exception that proves the rule.
Each app usually gets its own process and its own virtual machine instance. The process is created by forking the zygote, so the app starts with the framework already loaded. Isolation between apps is therefore process isolation backed by separate UIDs.
The kernel also manages memory pressure. Android does not swap to disk the
way a desktop does. When RAM runs low, a userspace daemon called lmkd (the low
memory killer daemon) reads kernel pressure signals and kills whole processes to
free memory. It chooses victims by an oom_adj score that tracks how important
each process is right now:
- A foreground app the user is looking at is almost never killed.
- A cached process (your app in the background with nothing running) is first in line to die.
- A foreground service sits in between and buys you time, not immunity.
What “the system killed my process” means. Your process was terminated by the
kernel, immediately, to reclaim memory. There is no graceful shutdown: onDestroy
is not guaranteed to run, and any in-memory state is gone. You may have received
onTrimMemory earlier as a warning, but the kill itself has no callback. This is
why durable state belongs in SavedStateHandle, a database, or DataStore, and
why work that must finish belongs in WorkManager rather than a background thread
you assume will keep running.
The practical takeaway for interviews: background survival is a privilege the system grants based on your current importance, and it can be revoked without warning. Design for the process disappearing, because on a low-memory device it will.
How does an Android app process actually start, and what is the zygote?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
When the user taps your icon, no new copy of the framework is loaded from scratch. Android uses a warm-fork model built around a process called the zygote.
The zygote is a template process. At boot, init starts the zygote through
app_process. The zygote loads and initializes the common expensive things every
app needs: the core framework classes, shared resources, and the ART runtime. It
then sits and waits, listening on a socket.
New app processes are forked, not created fresh. When ActivityManager (inside
system_server, which is itself forked from the zygote at boot) needs to launch
your app, it asks the zygote to fork(). The child inherits everything the zygote
preloaded through copy-on-write memory: pages are shared until one side writes
to them, so most of the framework stays shared across every app and costs almost
nothing per process. The forked child then loads your APK’s classes and calls into
ActivityThread.main(), which sets up the main looper and drives your components.
Why this design matters:
- Fast starts. Preloading in the zygote means your process begins with the runtime and framework already warm, so cold start is much cheaper than a full load would be.
- Memory sharing. Copy-on-write keeps shared framework pages from being duplicated per app, which is a big deal on low-RAM devices.
- A consistent baseline. Every app inherits the same initialized state.
Related pieces worth naming:
- There are usually separate 64-bit and 32-bit zygotes so apps of either ABI can be forked.
- An app zygote (a per-app child template) supports isolated processes, useful for hardened components that should run with fewer privileges.
- Because your process is forked and later loaded with your code, anything the
framework did before your
Application.onCreate(content providers initializing, for example) runs during that startup window and counts against your cold start.
In an interview, the phrase that lands is “processes are forked from a preloaded zygote using copy-on-write.” It shows you know why launching an app is fast and why the framework is not reloaded every time.
Binder and IPC
How does Binder let one process call another, and why is system_server on the other end of so many APIs?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Binder is Android’s inter-process communication mechanism. Almost every framework call that touches something outside your process, getting a system service, starting an Activity, showing a notification, is a Binder transaction under the hood.
The shape of a call. Your app holds a proxy object that looks like a normal interface. When you call a method on it:
- The arguments are marshaled into a
Parcel(a flat byte buffer). - The proxy hands that Parcel to the Binder kernel driver at
/dev/binder. - The driver copies the data into the target process and wakes a thread in that process’s Binder thread pool.
- The target unpacks the Parcel, runs the real method, and marshals the result back the same way.
To your calling code it looks synchronous: the calling thread blocks until the
reply comes back, unless the interface is declared oneway, in which case the
call is fire-and-forget and returns immediately.
AIDL generates the boilerplate. When you define an interface in AIDL, the
build generates the proxy (Stub.Proxy) on the caller side and the stub (Stub)
on the implementer side, so you write an interface and the marshaling is handled
for you. This is exactly how bound services and the framework’s own services are
wired.
Why system_server keeps showing up. The core system services, ActivityManager,
PackageManager, WindowManager, and dozens more, all live in a single privileged
process called system_server. Your app cannot touch the screen, the task stack,
or another app directly; it asks a system service to do it, and that service runs
in system_server with the permissions your app lacks. So a huge share of your
Binder traffic terminates there. This is also why a wedged system_server takes
the whole UI down with it.
Two consequences interviewers probe:
- Threads. Incoming Binder calls run on a pool thread, not your main thread, and the pool is bounded (16 threads by default). A slow Binder method can starve the pool, and a service callback you receive arrives on a Binder thread, so you must hop to the main thread before touching UI.
- Size. Every process has a limited Binder transaction buffer, which is why
passing large payloads across Binder throws
TransactionTooLargeException.
The one-liner to remember: Binder is a kernel-mediated, thread-pooled RPC, and
system_server is the privileged process on the far end of most system APIs.
Memory and garbage collection
What is the difference between the Java heap and the native heap, and where do Bitmap pixels actually live?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Your app’s memory is not one pool. The two that matter most in interviews are the Java (managed) heap and the native heap, and they behave differently.
The Java heap holds your Kotlin and Java objects. It is managed by ART and
reclaimed by the garbage collector. Crucially, it is capped per app: the
device sets a limit (you can read it with ActivityManager.getMemoryClass(),
often something like 128 to 512 MB depending on the device, larger with
android:largeHeap). When you allocate more Java objects than that cap allows and
GC cannot free enough, you get an OutOfMemoryError. So an OutOfMemoryError
is specifically a Java-heap failure.
The native heap holds memory allocated by C/C++ through malloc/new: this
includes native libraries, direct ByteBuffers, and allocations made through JNI.
It is bounded by the device’s real RAM and address space, not by the per-app Java
heap cap, and the garbage collector does not manage it. A native allocation
failure usually shows up as a native abort or a crash, not an OutOfMemoryError.
Where Bitmap pixels live is the classic question. This changed:
- Before Android 8.0 (API 26): bitmap pixel data was on the Java heap, so
large images ate directly into that small, capped budget and were a leading
cause of
OutOfMemoryError. - Android 8.0 and later: pixel data moved to the native heap. The small
Bitmapobject stays on the Java heap, but the pixels (the bulk of the memory) are native. This eased OOM crashes, but the memory is still very real; it just fails and is measured differently.
Why this matters in practice:
- If you only watch the Java heap in a profiler, a bitmap-heavy app on modern Android can look fine while native memory balloons. Look at native allocations and total PSS, not just the Java heap.
- Bitmaps are still the biggest single consumer for most apps. Downsample with
inSampleSize, request the right target size, and let a library like Coil or Glide handle pooling and reuse rather than decoding full-resolution images. - Direct
ByteBuffers and memory-mapped files are native too, which is why they can back large buffers without pressuring the Java heap.
The crisp summary: Java heap holds managed objects, is GC’d and per-app capped, and
overflowing it throws OutOfMemoryError; the native heap holds malloc memory and,
since Android 8, Bitmap pixels, and is bounded by device RAM.
How does ART's garbage collector reclaim memory without freezing the UI, and how do you reduce GC pressure?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Garbage collection reclaims Java-heap objects that are no longer reachable. The interview goal is to explain how ART does this with minimal UI impact and what you can do to make it run less often.
Reachability, not reference counting. ART’s collector starts from a set of GC roots (thread stacks and local variables, static fields, and JNI global references) and traces every object reachable from them. Anything not reached is garbage and its memory is reclaimed. Because it traces reachability, plain reference cycles are collected fine; the thing that actually leaks is an object still reachable from a long-lived root, for example a static field or a running callback holding an Activity.
The modern collector is concurrent copying. ART uses a Concurrent Copying (CC) collector. Most of its work runs on a background thread while your app keeps executing, using read barriers to stay correct while it moves objects. Moving (compacting) live objects together reduces fragmentation and makes future allocation cheap (close to a bump pointer). There are still short stop-the-world pauses at the start and end, but they are brief compared with Dalvik’s older mark-and-sweep, which paused far more visibly.
It is generational. Since Android 10 (API 29) the CC collector is generational: it collects the young generation (recently allocated, usually short-lived objects) frequently and cheaply, and only occasionally does a full heap collection. This matches how apps actually allocate, since most objects die young.
Why you still care, even though it is concurrent. GC is cheaper than it was, but it is not free. Frequent collections steal CPU and can still cause dropped frames (“jank”) when they coincide with rendering. The lever you control is allocation rate: fewer allocations means fewer collections.
Reducing GC pressure:
- Do not allocate in hot paths: avoid creating objects inside
onDraw, per-frame animation callbacks, or tight loops. - Watch autoboxing. Using boxed
Integerkeys or aHashMap<Integer, ...>in a hot loop allocates; primitive-specialized structures such asSparseArrayorandroidx.collectionavoid it. - Reuse buffers and objects (object pools,
Bitmapreuse,StringBuilder) instead of churning new ones. - Be careful with short-lived lambdas and iterators in per-frame code.
The line that shows understanding: ART uses a mostly concurrent, generational copying collector, so the fix for GC jank is almost never “tune the GC,” it is “allocate less on the paths that run every frame.”
Compilation and startup
Trace the compilation pipeline from Kotlin source to code running on the device. Where do D8, R8, and dex2oat fit?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
There are two halves to this: what happens at build time on your machine, and what happens on the device at install and run time.
Build time (on your machine):
- Kotlin (or Java) to
.class.kotlincandjavaccompile source to JVM bytecode. .classto.dexvia D8. Android does not run JVM bytecode directly. D8 converts.classfiles into DEX (Dalvik Executable) bytecode, the format ART actually loads, and also desugars newer language features so they run on older devices.- R8 on release builds. R8 replaces the old ProGuard and does shrinking,
optimization, and obfuscation while producing the DEX, using your
proguard-rules.prokeep rules. Debug builds usually skip this.
The APK (or the modules of an AAB) ships this DEX bytecode. It is still bytecode, not native code.
Device time (install and run):
dex2oatat install. When the app is installed, the on-device compilerdex2oatcan compile DEX ahead of time into native code, producing.oat(compiled native code) and.vdex(verified DEX) files, plus an.artimage of preloaded objects. The default compilation mode isspeed-profile, which means “compile the methods a profile says are hot,” not the whole app.- Interpret, then JIT, then AOT. With no profile yet, methods start
interpreted. ART’s JIT compiles methods that turn out to be hot and
records them into a profile. Later, during idle and charging, a background job
runs
dex2oatagain to AOT-compile those hot methods so future launches skip the warmup. This hybrid is why modern installs are fast (little is compiled up front) yet frequently used code becomes native over time.
Where Baseline Profiles plug in. A Baseline Profile ships a ready-made list of
hot methods in the APK, so dex2oat can AOT-compile the important startup and
scrolling paths at install time instead of waiting for the device to learn them
from usage. That is why they improve the very first launches.
The mental model to state: source to DEX happens at build (D8 converts, R8
shrinks and obfuscates), and DEX to native happens on device through dex2oat,
guided by profiles, with a JIT filling in until AOT catches up.
Native code and JNI
What is JNI, and what are the main ways native code goes wrong when you call into it?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
JNI (Java Native Interface) is the bridge that lets Kotlin/Java code call C/C++
and the reverse. You declare an external function, load the library with
System.loadLibrary, and the runtime binds your call to a native function that
receives a JNIEnv* pointer, which is the handle for talking back into the
runtime. The NDK is how you build the native side. People reach for it for CPU-heavy
work (media, crypto, ML), to reuse an existing C/C++ library, or for tight
performance-sensitive loops.
The value of this question is the failure modes, because JNI bugs are native crashes with no Java stack trace, so they are painful to debug.
Reference management is the big one. Objects handed across the boundary are accessed through references, and there are three kinds:
- Local references are the default. They are valid only until the native method
returns and are freed automatically then. The catch: the local reference table
is bounded (a few hundred slots are guaranteed). Create many local refs in a
loop without calling
DeleteLocalRef, and you overflow the table and crash. - Global references (
NewGlobalRef) survive across calls, but you mustDeleteGlobalRefthem yourself. Forgetting is a straightforward leak that also pins the referenced object so GC can never collect it. - Weak global references let you hold something without preventing its collection, so you must check whether it is still alive before use.
Threading is the second trap. A JNIEnv* is per-thread and not shareable.
If native code creates its own thread, it must call AttachCurrentThread to get a
valid JNIEnv for that thread before touching the runtime, and DetachCurrentThread
before the thread exits. Caching a JNIEnv from one thread and using it on another
is undefined behavior.
Other common mistakes:
- Not checking for pending exceptions. After a JNI call that can throw, you must check and clear the exception, or later JNI calls behave unpredictably.
- Signature and ABI errors. A wrong method signature fails at bind time, and shipping the wrong ABI means the library will not load on that device.
- Crossing the boundary too often. Each JNI transition has overhead, so chatty designs that call back and forth per element are slow. Batch the work on one side.
The summary: JNI connects managed and native code through a per-thread JNIEnv, and
the bugs that bite are reference-table overflow, leaked global references, using a
JNIEnv on the wrong thread, and ignoring pending exceptions, all of which surface
as native crashes rather than clean Java errors.
Security and hardening
How do you store a secret so a rooted device cannot trivially read it? Explain the Keystore, file-based encryption, and their limits.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
The honest framing first: you cannot make a client-side secret truly unreadable to someone who controls the device. What you can do is make key material non-extractable and make data unreadable at rest. Knowing both the mechanism and its ceiling is what separates a strong answer from a naive one.
The Android Keystore is the foundation. It lets you generate and use cryptographic keys whose raw bytes your app can never read back. You ask the Keystore to encrypt, sign, or decrypt, and it does the operation without ever handing you the key. On most modern devices the key lives in a hardware-backed store: a Trusted Execution Environment (TEE), or a dedicated secure element via StrongBox on devices that support it. So even on a rooted device an attacker cannot dump the raw key out of your app’s memory or storage. You can also gate a key behind user authentication, so it only unlocks after a biometric or PIN.
File-Based Encryption (FBE) protects data at rest. Since Android 10 it is the standard. Each file is encrypted with keys derived from the user’s credentials, and there are two classes of storage:
- Credential Encrypted (CE): the default, unlocked only after the user first unlocks the device after boot. Most app data lives here.
- Device Encrypted (DE): available before first unlock, for the few things that must run at boot (for example, an alarm or a Direct Boot component).
This is why an attacker who steals a powered-off device cannot read CE data without the credential.
For app-level secrets, the common building block was EncryptedSharedPreferences / EncryptedFile from Jetpack Security, which wraps a master key held in the Keystore. Note that the Jetpack Security Crypto library is now in maintenance mode, so for new work prefer using the Keystore with a vetted crypto library such as Tink directly, and keep the same pattern: the data key is protected by a hardware-backed Keystore key.
The limits you must state out loud:
- The Keystore protects key extraction, not key use. On a rooted device with your app running, an attacker can ask your app (or hook it) to perform the operation the key allows, exactly as your code would.
- Anything shipped inside the APK (an API key in resources, a hardcoded string) can be extracted by decompiling, and obfuscation does not encrypt it.
- Therefore genuine secrets belong on your server. Use the client to hold short-lived tokens, pin certificates, and attest the app and device with the Play Integrity API, and design so that a compromised client cannot do more than that user could anyway.
The summary: use a hardware-backed Keystore key so raw key bytes are never exposed, rely on FBE for data at rest, and remember the ceiling, which is that the device owner can always use a key that is usable on the device, so the real secrets stay on the server.
Discussion
Comments are powered by GitHub Discussions. Sign in with GitHub to join in.