Spike: characterize the #359 SQLCipher UnsatisfiedLinkError on SDK 37 / 16 KB pages
Investigation only — no app/src/main crypto change. Adds one on-device harness
(SqlCipherOpenSpikeTest, androidTest) and reports a definitive finding. Draft, no auto-merge.
What the test does
Exercises the exact #359 open path in three isolated stages so a failure pinpoints the break site,
records the in-process page size (Os.sysconf(_SC_PAGESIZE)), and re-raises the full UnsatisfiedLinkError (which .so, cause chain, stacktrace) on failure:
Stage A — System.loadLibrary("sqlcipher") via DatabaseEncryption.ensureNativeLibraryLoaded() (the dlopen).
Stage B — a keyed SQLiteDatabase.openOrCreateDatabase(...) reaching SQLiteConnection.nativeOpen, with a row round-trip.
Stage C — the full production path: plaintext Room → ensureEncrypted → reopen through SupportOpenHelperFactory (mirrors DatabaseModule).
On-device A/B (Pixel 8 Pro husky, real SDK 37 / Android 17, arm64-v8a)
Environment
Stage A (loadLibrary)
Stage B (nativeOpen)
Stage C (Room path)
4 KB pages (getconf PAGE_SIZE=4096)
PASS
PASS
PASS
16 KB pages (arm64)
not testable on this device — see below
—
—
The 4 KB run is a clean baseline on a genuine SDK 37 device: the encrypted cache opens end-to-end,
so "real SDK 37" alone is not the cause. (In-process breadcrumbs confirmed PAGE_SIZE=4096, SDK=37, abis=arm64-v8a.)
Why the 16 KB half is blocked: the Pixel "Boot with 16 KB page size" developer toggle
(Settings.Global enable_16k_pages) is gated behind an unlocked bootloader. The device dialog reads:
"This device needs to have the bootloader unlocked before using the 16 KB developer option … All
user data and settings will be wiped when activating 16 KB mode. … To return the device to
production mode, you would need to switch back to 4 KB mode and then OEM/bootloader lock (which
factory resets) the device."
Setting enable_16k_pages=1 + a plain adb reboot does not switch (ro.boot.hardware.cpu.pagesize
stays 4096 — init ignores the flag with a locked bootloader). Unlocking + activating would factory-wipe a
personal daily-driver — out of scope for "toggle + reboot." The device was left exactly as found
(enable_16k_pages=0, PAGE_SIZE=4096, on the launcher).
Static ELF proof — this refutes the #359 root-cause hypothesis
#359 suspected the bundled .so isn't 16 KB-aligned. Independently checked PT_LOADp_align on every
packaged native library (16 KB pages need p_align >= 0x4000):
Library
arm64-v8a
armeabi-v7a
x86_64
x86
libsqlcipher.so (sqlcipher-android:4.16.0)
0x4000
0x4000
0x4000
0x4000
libandroidx.graphics.path.so
0x4000
—
—
—
libdatastore_shared_counter.so
0x4000
—
—
—
Every bundled .so is already 16 KB-aligned, including arm64-v8a libsqlcipher.so. This exactly
reproduces what the repo's own docs/play-compliance.md §2 already certifies. sqlcipher has been pinned
to 4.16.0 since the original encrypted-cache commit (96c9f71), so the crashing 2026-07-03 build
shipped the same aligned library. Corroborating at runtime: CI's e2e-preview job runs the existing
SQLCipher instrumented tests on the google_apis_ps16k (16 KB) x86_64 emulator and is green — the
aligned x86_64 .so loads and opens a keyed DB on 16 KB pages.
Conclusion / fix direction
The "rebuild/bump sqlcipher-android for 16 KB alignment" fix implied by #359 would not fix
anything — the lib is already correctly 16 KB-aligned on all ABIs, and AGP 9.2 already emits
16 KB zip-aligned uncompressed libs by default (docs/play-compliance.md §2). This closes the
alignment theory and saves that wild-goose chase.
The UnsatisfiedLinkError @ SQLiteConnection.nativeOpen therefore has a different root cause.
Redirect the investigation to:
Library-load ordering (prime suspect) — a path that reaches a keyed nativeOpen without a
successful preceding System.loadLibrary("sqlcipher"). This is the #221/#367 class and has the identical symptom. The dropbox data_app_crash means a nativeOpen escaped #367's fail-closed try/catch: audit every opener of the keyed cache (WorkManager workers, AccountDatabase, the AccountDataMigrator legacy path, cold-open) to confirm each is preceded by ensureNativeLibraryLoaded()and wrapped by that handler.
Delivered-artifact specifics — the crash was a release/app-bundle build on a real 16 KB arm64
device; confirm the delivered split actually contained arm64-v8a libsqlcipher.so and that Play's
re-generated split preserved 16 KB zip-alignment (AGP handles the build-side; Play re-signs).
Capture the real stacktrace — the exact throwable + .so name from a genuine 16 KB arm64 device
(the Pixel 10 Pro XL from the report) will disambiguate 1 vs 2 immediately.
To complete the same-device A/B (what's needed)
A 16 KB arm64 environment: either the crash-report Pixel 10 Pro XL, or an explicitly authorized
bootloader-unlock + factory-wipe of a spare/unlocked Pixel 8. SqlCipherOpenSpikeTest is ready to drop
the verdict (which stage, full stacktrace) the moment it runs there. On the two environments available
today (4 KB arm64 device; 16 KB x86_64 e2e-preview) it passes and doubles as a regression guard against
a future dependency de-aligning the .so or breaking the load-ordering path.
## Spike: characterize the #359 SQLCipher `UnsatisfiedLinkError` on SDK 37 / 16 KB pages
Investigation only — **no `app/src/main` crypto change**. Adds one on-device harness
(`SqlCipherOpenSpikeTest`, androidTest) and reports a definitive finding. Draft, **no auto-merge**.
### What the test does
Exercises the exact #359 open path in three isolated stages so a failure pinpoints the break site,
records the in-process page size (`Os.sysconf(_SC_PAGESIZE)`), and re-raises the full
`UnsatisfiedLinkError` (which `.so`, cause chain, stacktrace) on failure:
- **Stage A** — `System.loadLibrary("sqlcipher")` via `DatabaseEncryption.ensureNativeLibraryLoaded()` (the `dlopen`).
- **Stage B** — a keyed `SQLiteDatabase.openOrCreateDatabase(...)` reaching `SQLiteConnection.nativeOpen`, with a row round-trip.
- **Stage C** — the full production path: plaintext Room → `ensureEncrypted` → reopen through `SupportOpenHelperFactory` (mirrors `DatabaseModule`).
### On-device A/B (Pixel 8 Pro `husky`, **real** SDK 37 / Android 17, arm64-v8a)
| Environment | Stage A (loadLibrary) | Stage B (nativeOpen) | Stage C (Room path) |
|---|---|---|---|
| **4 KB pages** (`getconf PAGE_SIZE`=4096) | PASS | PASS | PASS |
| **16 KB pages** (arm64) | not testable on this device — see below | — | — |
The 4 KB run is a clean baseline on a **genuine** SDK 37 device: the encrypted cache opens end-to-end,
so "real SDK 37" alone is **not** the cause. (In-process breadcrumbs confirmed `PAGE_SIZE=4096`, SDK=37, abis=arm64-v8a.)
**Why the 16 KB half is blocked:** the Pixel "Boot with 16 KB page size" developer toggle
(`Settings.Global enable_16k_pages`) is gated behind an **unlocked bootloader**. The device dialog reads:
> *"This device needs to have the bootloader unlocked before using the 16 KB developer option … **All
> user data and settings will be wiped when activating 16 KB mode.** … To return the device to
> production mode, you would need to switch back to 4 KB mode and then OEM/bootloader lock (which
> factory resets) the device."*
Setting `enable_16k_pages=1` + a plain `adb reboot` does **not** switch (`ro.boot.hardware.cpu.pagesize`
stays 4096 — init ignores the flag with a locked bootloader). Unlocking + activating would factory-wipe a
personal daily-driver — out of scope for "toggle + reboot." The device was left exactly as found
(`enable_16k_pages=0`, `PAGE_SIZE=4096`, on the launcher).
### Static ELF proof — **this refutes the #359 root-cause hypothesis**
#359 suspected the bundled `.so` isn't 16 KB-aligned. Independently checked `PT_LOAD` `p_align` on every
packaged native library (16 KB pages need `p_align >= 0x4000`):
| Library | arm64-v8a | armeabi-v7a | x86_64 | x86 |
|---|---|---|---|---|
| **libsqlcipher.so** (`sqlcipher-android:4.16.0`) | `0x4000` | `0x4000` | `0x4000` | `0x4000` |
| libandroidx.graphics.path.so | `0x4000` | — | — | — |
| libdatastore_shared_counter.so | `0x4000` | — | — | — |
**Every bundled `.so` is already 16 KB-aligned, including arm64-v8a `libsqlcipher.so`.** This exactly
reproduces what the repo's own `docs/play-compliance.md` §2 already certifies. `sqlcipher` has been pinned
to `4.16.0` since the original encrypted-cache commit (`96c9f71`), so the crashing 2026-07-03 build
shipped the **same aligned** library. Corroborating at runtime: CI's `e2e-preview` job runs the existing
SQLCipher instrumented tests on the `google_apis_ps16k` (16 KB) **x86_64** emulator and is green — the
aligned x86_64 `.so` loads and opens a keyed DB on 16 KB pages.
### Conclusion / fix direction
- The "rebuild/bump `sqlcipher-android` for 16 KB alignment" fix implied by #359 would **not** fix
anything — the lib is already correctly 16 KB-aligned on all ABIs, and AGP 9.2 already emits
16 KB **zip**-aligned uncompressed libs by default (`docs/play-compliance.md` §2). This closes the
alignment theory and saves that wild-goose chase.
- The `UnsatisfiedLinkError @ SQLiteConnection.nativeOpen` therefore has a **different** root cause.
Redirect the investigation to:
1. **Library-load ordering (prime suspect)** — a path that reaches a keyed `nativeOpen` without a
successful preceding `System.loadLibrary("sqlcipher")`. This is the #221/#367 class and has the
*identical* symptom. The dropbox `data_app_crash` means a `nativeOpen` escaped #367's fail-closed
`try/catch`: audit every opener of the keyed cache (WorkManager workers, `AccountDatabase`, the
`AccountDataMigrator` legacy path, cold-open) to confirm each is preceded by
`ensureNativeLibraryLoaded()` **and** wrapped by that handler.
2. **Delivered-artifact specifics** — the crash was a release/app-bundle build on a real 16 KB arm64
device; confirm the delivered split actually contained arm64-v8a `libsqlcipher.so` and that Play's
re-generated split preserved 16 KB zip-alignment (AGP handles the build-side; Play re-signs).
3. **Capture the real stacktrace** — the exact throwable + `.so` name from a genuine 16 KB arm64 device
(the Pixel 10 Pro XL from the report) will disambiguate 1 vs 2 immediately.
### To complete the same-device A/B (what's needed)
A **16 KB arm64** environment: either the crash-report Pixel 10 Pro XL, or an explicitly authorized
bootloader-unlock + factory-wipe of a spare/unlocked Pixel 8. `SqlCipherOpenSpikeTest` is ready to drop
the verdict (which stage, full stacktrace) the moment it runs there. On the two environments available
today (4 KB arm64 device; 16 KB x86_64 `e2e-preview`) it passes and doubles as a regression guard against
a future dependency de-aligning the `.so` or breaking the load-ordering path.
Informs #359 (does not fix).
You are not authorized to merge this pull request.
This pull request can be merged automatically.
This pull request is marked as a work in progress.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Spike: characterize the #359 SQLCipher
UnsatisfiedLinkErroron SDK 37 / 16 KB pagesInvestigation only — no
app/src/maincrypto change. Adds one on-device harness(
SqlCipherOpenSpikeTest, androidTest) and reports a definitive finding. Draft, no auto-merge.What the test does
Exercises the exact #359 open path in three isolated stages so a failure pinpoints the break site,
records the in-process page size (
Os.sysconf(_SC_PAGESIZE)), and re-raises the fullUnsatisfiedLinkError(which.so, cause chain, stacktrace) on failure:System.loadLibrary("sqlcipher")viaDatabaseEncryption.ensureNativeLibraryLoaded()(thedlopen).SQLiteDatabase.openOrCreateDatabase(...)reachingSQLiteConnection.nativeOpen, with a row round-trip.ensureEncrypted→ reopen throughSupportOpenHelperFactory(mirrorsDatabaseModule).On-device A/B (Pixel 8 Pro
husky, real SDK 37 / Android 17, arm64-v8a)getconf PAGE_SIZE=4096)The 4 KB run is a clean baseline on a genuine SDK 37 device: the encrypted cache opens end-to-end,
so "real SDK 37" alone is not the cause. (In-process breadcrumbs confirmed
PAGE_SIZE=4096, SDK=37, abis=arm64-v8a.)Why the 16 KB half is blocked: the Pixel "Boot with 16 KB page size" developer toggle
(
Settings.Global enable_16k_pages) is gated behind an unlocked bootloader. The device dialog reads:Setting
enable_16k_pages=1+ a plainadb rebootdoes not switch (ro.boot.hardware.cpu.pagesizestays 4096 — init ignores the flag with a locked bootloader). Unlocking + activating would factory-wipe a
personal daily-driver — out of scope for "toggle + reboot." The device was left exactly as found
(
enable_16k_pages=0,PAGE_SIZE=4096, on the launcher).Static ELF proof — this refutes the #359 root-cause hypothesis
#359 suspected the bundled
.soisn't 16 KB-aligned. Independently checkedPT_LOADp_alignon everypackaged native library (16 KB pages need
p_align >= 0x4000):sqlcipher-android:4.16.0)0x40000x40000x40000x40000x40000x4000Every bundled
.sois already 16 KB-aligned, including arm64-v8alibsqlcipher.so. This exactlyreproduces what the repo's own
docs/play-compliance.md§2 already certifies.sqlcipherhas been pinnedto
4.16.0since the original encrypted-cache commit (96c9f71), so the crashing 2026-07-03 buildshipped the same aligned library. Corroborating at runtime: CI's
e2e-previewjob runs the existingSQLCipher instrumented tests on the
google_apis_ps16k(16 KB) x86_64 emulator and is green — thealigned x86_64
.soloads and opens a keyed DB on 16 KB pages.Conclusion / fix direction
sqlcipher-androidfor 16 KB alignment" fix implied by #359 would not fixanything — the lib is already correctly 16 KB-aligned on all ABIs, and AGP 9.2 already emits
16 KB zip-aligned uncompressed libs by default (
docs/play-compliance.md§2). This closes thealignment theory and saves that wild-goose chase.
UnsatisfiedLinkError @ SQLiteConnection.nativeOpentherefore has a different root cause.Redirect the investigation to:
nativeOpenwithout asuccessful preceding
System.loadLibrary("sqlcipher"). This is the #221/#367 class and has theidentical symptom. The dropbox
data_app_crashmeans anativeOpenescaped #367's fail-closedtry/catch: audit every opener of the keyed cache (WorkManager workers,AccountDatabase, theAccountDataMigratorlegacy path, cold-open) to confirm each is preceded byensureNativeLibraryLoaded()and wrapped by that handler.device; confirm the delivered split actually contained arm64-v8a
libsqlcipher.soand that Play'sre-generated split preserved 16 KB zip-alignment (AGP handles the build-side; Play re-signs).
.soname from a genuine 16 KB arm64 device(the Pixel 10 Pro XL from the report) will disambiguate 1 vs 2 immediately.
To complete the same-device A/B (what's needed)
A 16 KB arm64 environment: either the crash-report Pixel 10 Pro XL, or an explicitly authorized
bootloader-unlock + factory-wipe of a spare/unlocked Pixel 8.
SqlCipherOpenSpikeTestis ready to dropthe verdict (which stage, full stacktrace) the moment it runs there. On the two environments available
today (4 KB arm64 device; 16 KB x86_64
e2e-preview) it passes and doubles as a regression guard againsta future dependency de-aligning the
.soor breaking the load-ordering path.Informs #359 (does not fix).
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.