Android - Code Standards
Your module ships inside every financial institution's (FI's) build of the Q2 Mobile App. When Google Play measures that app, your module's code, keep rules, and memory use count along with everything else. This page lists the standards your module code must meet, how Q2 checks them, and how to check them yourself before you open a merge request.
Why These Standards Exist
Google Play's technical quality requirements, announced in August 2026, are enforced from February 2027. Apps that miss them risk reduced Play Store visibility and publishing restrictions. Two of the requirements depend on module code:
| Requirement | What Google measures | Limit |
|---|---|---|
| DEX code optimization | Share of the app's code that R8 can shrink, optimize, and obfuscate | Fails below 25% for any of the three, for apps with over 10 MB of DEX |
| Reduced memory usage | Total memory the app uses, and memory held by bitmaps, on real devices | Depends on device RAM. For bitmaps, fails above 200 MB in the background |
The memory limits apply to the whole app process, and your module runs in that process. One leaking screen or one full-resolution photo held in memory can push the app over the limit for every FI.
- Lint must pass before Q2 approves your merge request.
- The DEX check measures your keep rules. It currently only warns. See Enforcement.
- Bitmap and memory standards are not checked by CI. Nothing in the pipeline detects leaks or oversized bitmaps. These are code standards that you are responsible for meeting. Use the local memory checks to verify your module before you submit it.
DEX Code Optimization
R8 can only optimize code that no keep rule protects. Your module affects the app's score through
the rules in your consumer-rules.pro, and through the consumer rules of any dependency you add.
A single package-wide keep, such as -keep class com.yourcompany.** { *; }, can stop R8 from
touching thousands of classes.
For how to write narrow, specific rules, see Consumer ProGuard Rules.
The Merge Request DEX Check
Every merge request to master, main, or testing runs a dex-optimization-check job. It
builds the DevApp's proguardTest variant with the
R8 Configuration Analyzer
turned on, then scores how much your rules block.
- Only your rules count. Code kept only by your module's rules, or by dependencies that only your module brings in, counts against you. Rules from the DevApp and Q2's libraries don't.
- Findings point at your code. Each finding, with its file, line, and a suggested fix,
appears in the job log and in the merge request's code quality widget. The full report is saved
as the job's
dex-check/report.htmlartifact.
The app has three scores: shrinking, optimization, and obfuscation. The check warns if your rules lower any score by more than 0.5 points (for example, from 31.0% to 30.5%), and fails if they lower it by more than 2 points.
Enforcement
A failing DEX check currently shows as a warning and does not block your merge request. Q2 will turn on enforcement, and from then on a failing check blocks the merge. Fix warnings now so that your module isn't blocked later.
Run the DEX Check Locally
You don't have to wait for the pipeline. The DevApp in your module's repo is the same app the check builds, so you can run the R8 Configuration Analyzer on your own machine.
The analyzer needs R8 9.3.7 or later, and the DevApp's Android Gradle Plugin bundles an older one. An init script swaps in R8 9.4.14 for this one build without changing your project files.
-
Save this file outside your repo, for example as
~/r8-analyzer.init.gradle:beforeSettings { settings ->
settings.buildscript.repositories.google()
settings.buildscript.dependencies.add('classpath', 'com.android.tools:r8:9.4.14')
} -
From your repo's root, run the R8 step of the
proguardTestbuild with the analyzer turned on.--rerunmakes R8 run again even when nothing changed, so a report is always written../gradlew :devapp:minifyProguardTestWithR8 --rerun \
--init-script ~/r8-analyzer.init.gradle \
-Dcom.android.tools.r8.dumpkeepradiushtmltodirectory="$PWD/build/dex-check" -
Open the newest
report*.htmlfile inbuild/dex-check/in a browser. The top of the report shows the app's three scores. -
Find your own rules. The report lists every keep rule in the app, not just yours. Search for your module's package name. A rule from your
consumer-rules.proshows an origin under<your module>/build/intermediates/, because Gradle exports a copy of the file there. Click a rule to see every class, field, and method it keeps. -
Narrow or remove the rules that keep the most, and delete any rule the report marks as unused or subsumed. Then run step 2 again. Unlike CI, the local report doesn't separate your rules from everyone else's, so aim for no package-wide rules from your module and scores that go up. CI confirms the result when you push.
-
Confirm that nothing your module needs was stripped. Build the
proguardTestvariant, install it, and exercise every feature of your module. See Test With Obfuscation../gradlew assembleProguardTest
What Good Keep Rules Look Like
- Keep only what R8 can't see you use: classes reached by reflection, JNI, or serialization.
- Name exact classes and members instead of whole packages or
{ *; }. - Don't keep other libraries' classes (Q2, AndroidX, Google, Kotlin). They ship their own rules, and the app's rules already cover your module's entry point.
- Never use global options such as
-dontoptimize,-dontshrink, or-dontobfuscate. They affect the whole app, and the DEX check always fails them. - Add only the dependencies you need. Each one adds DEX code, and its consumer rules count against your module. Check what rules a dependency ships before you add it.
Bitmap Management
Bitmaps are the fastest way to use a lot of memory. A 12-megapixel photo decoded at full size takes about 48 MB, whatever its file size on disk.
Don't Load Full-Size Photos
Decode images at the size they're displayed, not at the size of the source file. A thumbnail in a 100 dp list row doesn't need a 4000 × 3000 bitmap behind it. This matters most for camera captures and gallery picks, which are always full resolution.
Use an Image Loading Library
Use an image loading library such as Coil instead of decoding bitmaps yourself. Coil sizes each image to its target view, caches it, decodes it off the main thread, and cancels the request when the view goes away.
Coil is in the Q2 version catalog, so you don't need to add a version:
dependencies {
// Jetpack Compose
implementation q2libs.coil
// Views
implementation q2libs.coil.view
}
// Compose
AsyncImage(model = imageUrl, contentDescription = null)
// Views
imageView.load(imageUrl)
If you must decode a bitmap yourself, read its bounds first with
BitmapFactory.Options.inJustDecodeBounds and set inSampleSize so that the decoded bitmap is
close to the display size. See
Bitmaps and memory.
Let Go of Images When the Screen Closes
Don't hold bitmaps in fields that outlive the screen that shows them. Clear image references in
onDestroyView() for fragments and onDestroy() for activities. If you keep your own bitmap
cache, size it to the device and clear it in onTrimMemory() when the app's UI is hidden.
Avoid Passing Images as Base64
A base64 string is about a third larger than the image bytes. Each time it's built, copied, decoded, or passed across the JavaScript bridge, the app holds another copy in memory. Where possible, pass a file path, URI, or ID and load the image where it's shown. If you must send an image to the web layer as base64, downscale and compress it first, and send it once.
Memory Management
Never Store UI Objects or Bitmaps in Static Fields
Don't store a Context, Activity, Fragment, View, or Bitmap in a static field. In
Kotlin, that includes properties on an object and in a companion object. A static field lives
as long as the process, so everything the object references stays in memory with it. For an
Activity, that's its entire view hierarchy.
If you need a Context that outlives a screen, use context.applicationContext.
// ❌ Leaks the activity and all of its views
companion object {
var currentActivity: Activity? = null
}
// ✅ The application context lives as long as the process anyway
class TokenStore(context: Context) {
private val appContext = context.applicationContext
}
Unregister What You Register
Unregister listeners, callbacks, observers, broadcast receivers, and similar registrations in the
lifecycle method that matches where you registered them. Register in onStart() and unregister in
onStop(), or register in onCreate() and unregister in onDestroy(). Anything left registered
keeps your screen in memory after it closes.
Where an API offers it, prefer a lifecycle-aware version that cleans up for you, such as
LiveData.observe(viewLifecycleOwner, ...) or collecting a Flow inside
repeatOnLifecycle(Lifecycle.State.STARTED).
Close Streams, Files, Cursors, and Database Connections
Close every stream, file, cursor, and database connection when you're finished with it. In
Kotlin, use closes the resource even if an exception is thrown:
context.contentResolver.openInputStream(uri)?.use { input ->
// read from input
}
Cancel Background Work When the Screen Goes Away
Start coroutines in viewModelScope or lifecycleScope. Both cancel their coroutines when the
ViewModel is cleared or the lifecycle is destroyed. Don't use GlobalScope, and don't create a
CoroutineScope that nothing ever cancels.
// ✅ Cancelled automatically when the screen goes away
lifecycleScope.launch {
val result = repository.load()
render(result)
}
Check Memory Use Locally
Run these checks on your module in the DevApp, on a device or emulator.
Look for Leaked Screens with dumpsys meminfo
-
Open the DevApp on a screen that isn't part of your module, and run:
adb shell dumpsys meminfo -a com.q2.devappIn the Objects section, note the
ActivitiesandViewscounts. -
Open and close each of your module's screens about five times.
-
Return to the same screen as in step 1 and run the command again.
The Activities and Views counts should come back to roughly where they started. If they keep
growing each time you repeat the test, something is holding on to your screens. The Graphics
line also grows with bitmap memory.
Inspect the Heap in Android Studio
- Run the DevApp from Android Studio.
- Open View > Tool Windows > Profiler and select the DevApp process.
- Exercise your module, then capture a heap dump.
- Filter the heap dump by your module's package to see what it holds. Filter by
Bitmapto see every bitmap in memory and its size.
Look for activities and fragments that should have been destroyed, and for bitmaps much larger than the views that show them. See Quick assessment tools for more ways to measure memory.
Detect Leaks Automatically with LeakCanary
LeakCanary watches destroyed activities, fragments, and
views, and shows a notification with the reference chain when one leaks. Add it to
devapp/build.gradle only, never to your module:
dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
}
Lint and General Standards
Android Lint
Every merge request runs ./gradlew lint, and it must pass before Q2 approves your merge
request. Run it on just your module while you develop:
./gradlew :your_module:lint
Open the HTML report at your_module/build/reports/lint-results-debug.html.
Several lint checks catch the memory problems described on this page. Fix these rather than suppressing them:
| Lint check | What it catches |
|---|---|
StaticFieldLeak | A Context or View stored in a static field |
Recycle | A Cursor, TypedArray, or similar resource that isn't closed |
DrawAllocation | Objects, including bitmaps, allocated inside onDraw() |
HandlerLeak | A non-static Handler that can keep its outer class alive |
Your repo includes a lint-baseline.xml file that filters out issues that existed before. Don't
add new issues to the baseline to get a merge request through. Fix the issue, or suppress it in
code with @Suppress and a comment that explains why.
For code style, auto-formatting, and general clean-code practices, see Linting and Best Practices.
General Standards
- Keep work off the main thread. Network calls, disk access, and database queries belong in a
coroutine on
Dispatchers.IO. - Remove debug logging. Don't ship
Log.d,Log.v, orprintlncalls, and never log personal or account data. - Use the version catalog. Use
q2libsdependencies wherever one exists, so that your module uses the same versions as the app. - Keep dependencies lean. See What Good Keep Rules Look Like.
Before You Open a Merge Request
-
./gradlew :your_module:lintpasses with no new issues. - The local DEX check shows no package-wide or global rules from your module.
-
./gradlew assembleProguardTestbuilds, and your module works in that build. - Opening and closing your screens doesn't increase
ActivitiesorViewsindumpsys meminfo. - Images are loaded at display size, and no bitmaps are held after their screen closes.