Skip to content
Picasso Soft
Semua tulisan

I ran real JDBC drivers on Android. Here's everything that broke.

Sean Park · Picasso Soft7 menit baca

Artikel ini ditulis dalam bahasa Inggris.

Android ships java.sql. It has since API 1. So in theory, the same PostgreSQL driver your Spring service uses should load on a phone and speak wire protocol to your database. That theory is what my app DBeast — a full SQL client for Android and iOS — is built on, and it's mostly true. This post is about the "mostly": the four ways stock, unmodified JDBC drivers break on ART, and what actually fixes them.

What works out of the box

More than you'd expect. These dex cleanly and pass a real on-device CREATE/INSERT/SELECT test:

  • PostgreSQL (org.postgresql:postgresql)
  • MySQL / MariaDB (org.mariadb.jdbc:mariadb-java-client)
  • SQL Server (mssql-jdbc, the jre11 classifier)
  • H2, HSQLDB, Derby (embedded, pure Java)
  • JSch for SSH tunneling

A whole debug APK with all of them is about 20 MB. And there's a free multiplier hiding in plain sight: anything that speaks the PostgreSQL wire protocol — CockroachDB, YugabyteDB, TimescaleDB, Greenplum, Redshift — connects through the already-bundled org.postgresql.Driver with just a different default port. Five extra databases, zero extra bytes.

Break #1: the class that isn't there — and only on real phones

The nastiest failure was invisible in development. Everything worked on the emulator; on a real Samsung, PostgreSQL and SQL Server both crashed on connect with:

NoClassDefFoundError: java.lang.management.ManagementFactory

Android has no JMX. Both pgjdbc and mssql-jdbc ship a *MaxResultBufferParser class that calls ManagementFactory.getMemoryMXBean() to size a result buffer — and both drivers touch it on every single connection (PGStream.setMaxResultBuffer, SQLServerConnection). It's not an obscure code path. It is the code path.

The obvious fix — write a stub java.lang.management.ManagementFactory — is forbidden: ART refuses to define classes in java.* packages. You cannot patch the platform from an app.

So I patched the drivers instead. A Gradle Jar task strips exactly one .class file out of each driver jar, and the app compiles a drop-in replacement with the same fully-qualified name that gets its heap number from Runtime.getRuntime().maxMemory() instead of JMX:

// resolve the driver through a non-transitive configuration,
// strip the one class that references JMX, repackage
val patchPostgresql by tasks.registering(Jar::class) { ... }

The non-transitive resolution matters — pull the jar with dependencies and you'll merge checker-qual into your patched artifact and die on duplicate classes.

Verification is one line: strings classes*.dex | grep ManagementFactory. If it prints your replacement class only, you're clean. H2, MariaDB and Derby also contain JMX references, but theirs sit off the connect path (GC stats, connection pooling), so they never fire on a phone.

Break #2: the class that CAN'T be there

Oracle's ojdbc11 dexes fine — and then dies at class-load:

NoClassDefFoundError: java.sql.DriverAction

DriverAction is a JDBC 4.2 type that Android's java.sql simply omits, and OracleDriver references it directly. Combined with the no-java.*-stubs rule, that's checkmate: Oracle's driver cannot load on stock ART, full stop. It's the one database on my list that's an OS limitation rather than an engineering problem. Any driver referencing DriverAction has the same ceiling — check before you promise support.

Break #3: drivers that lie about JDBC

SQLDroid (SQLite via JDBC) taught me that "implements JDBC" is a spectrum. Its two-argument createStatement(type, concurrency) doesn't throw — it returns null. setFetchSize throws. On a real device that surfaced as an NPE three frames away from anything that looked related.

The lesson generalizes: my session layer now calls the no-arg createStatement() as a fallback and wraps every fetchSize in a runCatching. If you're bundling drivers you didn't write, assume partial implementations and test the exact overloads you call — on a device, not on the JVM.

Break #4: packaging landmines

Several modern drivers ship GraalVM native-image metadata (META-INF/native-image/reflect-config.json). Bundle two of them and the Android packager collides on the duplicate path. One line fixes it:

packaging { resources.excludes += "/META-INF/native-image/**" }

Plus the usual -dontwarn entries for the Azure/AAD classes mssql-jdbc references but never loads.

What about the databases that can't be bundled?

For the long tail (ClickHouse, Trino, DuckDB, Snowflake, Firebird, Db2…) the app downloads pre-dexed driver packs at runtime — SHA-256-pinned, loaded through DexClassLoader. On-device dexing of arbitrary Maven jars isn't viable; pre-dexing server-side is.

The checklist

If you want JDBC on Android, this is the whole ritual per driver:

  1. Bundle it, then strings the dex for java/lang/management and DriverAction.
  2. If a JMX reference is on the connect/query path → strip-and-replace the class. If DriverAction → give up; the OS says no.
  3. Test the exact JDBC overloads you call, on a real device — emulators are more forgiving than Samsungs, and null-returning "implemented" methods exist.
  4. Exclude META-INF/native-image/**.

Everything above ships in DBeast — a SQL client with an incident war-room, schema navigator, and an AI assistant that can run fully on-device. The iOS version speaks the wire protocols natively (no JDBC — that's a different post).