Skip to main content

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:

RequirementWhat Google measuresLimit
DEX code optimizationShare of the app's code that R8 can shrink, optimize, and obfuscateFails below 25% for any of the three, for apps with over 10 MB of DEX
Reduced memory usageTotal memory the app uses, and memory held by bitmaps, on real devicesDepends 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.

What blocks your merge request
  • 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.html artifact.

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.

  1. 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')
    }
  2. From your repo's root, run the R8 step of the proguardTest build with the analyzer turned on. --rerun makes 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"
  3. Open the newest report*.html file in build/dex-check/ in a browser. The top of the report shows the app's three scores.

  4. 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.pro shows 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.

  5. 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.

  6. Confirm that nothing your module needs was stripped. Build the proguardTest variant, 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​

  1. Open the DevApp on a screen that isn't part of your module, and run:

    adb shell dumpsys meminfo -a com.q2.devapp

    In the Objects section, note the Activities and Views counts.

  2. Open and close each of your module's screens about five times.

  3. 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​

  1. Run the DevApp from Android Studio.
  2. Open View > Tool Windows > Profiler and select the DevApp process.
  3. Exercise your module, then capture a heap dump.
  4. Filter the heap dump by your module's package to see what it holds. Filter by Bitmap to 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 checkWhat it catches
StaticFieldLeakA Context or View stored in a static field
RecycleA Cursor, TypedArray, or similar resource that isn't closed
DrawAllocationObjects, including bitmaps, allocated inside onDraw()
HandlerLeakA 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, or println calls, and never log personal or account data.
  • Use the version catalog. Use q2libs dependencies 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:lint passes with no new issues.
  • The local DEX check shows no package-wide or global rules from your module.
  • ./gradlew assembleProguardTest builds, and your module works in that build.
  • Opening and closing your screens doesn't increase Activities or Views in dumpsys meminfo.
  • Images are loaded at display size, and no bitmaps are held after their screen closes.

Further Reading​