125 lines
6.5 KiB
Markdown
125 lines
6.5 KiB
Markdown
# Fix: Startup Database Error on Android
|
|
|
|
## Symptom
|
|
|
|
On some Android installs from the Play Store, the app showed a "Startup
|
|
Error" on every launch:
|
|
|
|
```
|
|
[CapacitorPlatformService] Failed to initialize database
|
|
```
|
|
|
|
Reinstalling did not help. The failure never appeared on developer devices
|
|
installed through adb or Android Studio.
|
|
|
|
## Causes
|
|
|
|
### 1. The SQLite plugin ignored its configuration (main cause)
|
|
|
|
`capacitor.config.ts` listed the SQLite settings under the key `CapSQLite`.
|
|
The plugin registers as `CapacitorSQLite` and reads only that key, so every
|
|
setting under `CapSQLite` was ignored and the plugin defaults applied:
|
|
|
|
- **Android:** `isEncryption` defaults to true, so on every launch the plugin
|
|
created an Android Keystore master key and opened an
|
|
`EncryptedSharedPreferences` file (`sqlite_encrypted_shared_prefs`) to hold a
|
|
database passphrase. If that step throws, the plugin fails to load and every
|
|
`createConnection` call is rejected.
|
|
- **iOS:** the database location defaults to `Documents`, not the configured
|
|
`Library/CapacitorDatabase`.
|
|
- **Electron:** the plugin reads `plugins.CapacitorSQLite` directly, so a
|
|
bundled config with the wrong key would throw at plugin startup.
|
|
|
|
The Keystore step fails on Android in two ways:
|
|
|
|
- **Restore without the key.** Android Auto Backup and device-to-device
|
|
transfer restore the encrypted preferences file, but Keystore keys never
|
|
leave the device. The restored file can't be decrypted, so the plugin fails on
|
|
every launch, and a reinstall from Play restores the same file again.
|
|
- **Device Keystore bugs.** Some manufacturers' Keystore implementations fail
|
|
without any restore.
|
|
|
|
The app never used plugin encryption: it opens the database with
|
|
`encrypted = false` and mode `"no-encryption"`, and never stores a passphrase.
|
|
The Keystore setup protected nothing.
|
|
|
|
### 2. A migration could be applied but not recorded
|
|
|
|
The migration runner ran each migration's SQL in one transaction and recorded
|
|
its name in the `migrations` table in a second one. If the app died between the
|
|
two, the migration ran again on the next launch. Migration 001's
|
|
`INSERT INTO secret (id, ...) VALUES (1, ...)` then failed with
|
|
`UNIQUE constraint failed: secret.id` on every launch. The window is
|
|
milliseconds, but once hit the failure is permanent, and Auto Backup carries it
|
|
to a reinstall.
|
|
|
|
## Changes
|
|
|
|
| Area | Change |
|
|
|---|---|
|
|
| `capacitor.config.ts`, `electron/capacitor.config.ts`, `android/app/src/main/assets/capacitor.config.json` | Key renamed to `CapacitorSQLite`, so `androidIsEncryption: false` takes effect and the Keystore step never runs. `iosDatabaseLocation` set to `Documents`, the location every iOS install uses. |
|
|
| `capacitor.config.json`, `electron/capacitor.config.json` | `iosDatabaseLocation` set to `Documents` to match. These files already used the correct key. |
|
|
| `src/services/platforms/CapacitorPlatformService.ts` | `databaseDirectoryUri()` points at `Documents` on iOS, so "Save a Copy of My Data" and "Start Over" find the real database file. |
|
|
| `src/services/migrationService.ts` | Each migration's `INSERT INTO migrations` is appended to its own statement batch, which the native plugin runs as one transaction. |
|
|
| `src/db-sql/migration.ts` | Migration 001 uses `INSERT OR IGNORE` for the `secret` and `settings` rows, so a re-run never replaces the existing secret. |
|
|
| `android/app/src/main/AndroidManifest.xml`, `res/xml/backup_rules.xml`, `res/xml/data_extraction_rules.xml` | Cloud backup and device transfer exclude the plugin's encrypted preference files. Everything else is backed up, including the database. |
|
|
| `src/test/migrations.test.ts` | Tests for a crash right after a migration commits, and for a re-run of 001 keeping the secret. |
|
|
| `src/test/capacitorConfig.test.ts` | Fails if the plugin config key or the iOS location is wrong. |
|
|
|
|
## Security
|
|
|
|
Encryption of user keys is unchanged. The identity and seed phrase are encrypted
|
|
by the app (`simpleEncrypt` with the random secret from migration 001) and stored
|
|
in `accounts`. The plugin's encryption flag only controlled whole-database
|
|
SQLCipher encryption, which the app never used; the database file was plain
|
|
SQLite before and after this change. App sandboxing keeps the file private to
|
|
the app on both platforms.
|
|
|
|
## Compatibility with existing installs
|
|
|
|
- **Android:** the database has no password whether plugin encryption is on or
|
|
off, so it opens the same way. The leftover encrypted preferences file is
|
|
never read again.
|
|
- **iOS:** the database stays in `Documents`. Setting the location back to
|
|
`Library/CapacitorDatabase` would not be safe: the plugin moves databases into
|
|
a custom location only when that folder does not exist yet, and installs from
|
|
between May and August 2025 have that folder with an outdated database in it.
|
|
- **Migrations:** existing installs already have 001 recorded, so its edit
|
|
never runs for them. The recording change affects only migrations that have
|
|
not run.
|
|
|
|
## Verification
|
|
|
|
- **Reproduced before the fix** on an Android emulator: run the app once, copy
|
|
`shared_prefs` out with `run-as app.timesafari.app`, uninstall (which deletes
|
|
the Keystore key), reinstall, put the copied `shared_prefs` back, and launch.
|
|
The startup error appeared.
|
|
- **Unit tests:** `pkgx npm run test:unit -- src/test/migrations.test.ts
|
|
src/test/capacitorConfig.test.ts`. The two migration tests fail on the
|
|
earlier code and pass on the fixed code.
|
|
- **Android resources:** `./gradlew :app:processDebugResources` builds, and
|
|
the merged manifest carries both backup-rule attributes.
|
|
- **After the fix:** run the same emulator steps on a fixed build, and also
|
|
restore `shared_prefs` captured from a pre-fix build. The app should start
|
|
normally either way.
|
|
|
|
## Follow-up
|
|
|
|
The one-tap diagnostics report and a degraded mode for users without a working
|
|
database are designed, but not built, in
|
|
`PLAN-startup-db-failure-recovery.md` in this directory.
|
|
|
|
## History
|
|
|
|
- 2025-05-27: `capacitor.config.json` set `iosDatabaseLocation` to
|
|
`Library/CapacitorDatabase` under the correct `CapacitorSQLite` key
|
|
(commit 7b4f9d65).
|
|
- 2025-08-14: the config moved to `capacitor.config.ts` under the key
|
|
`CapSQLite`, which the plugin does not read (commit a2840675). Plugin
|
|
encryption setup ran on Android from then on, and iOS databases moved to the
|
|
default `Documents` location.
|
|
- 2026-10-10: commit d6ccf0e4 added the failed step and underlying cause to the
|
|
startup error, made migration 008 tolerate duplicate contact DIDs and labels
|
|
pointing at deleted contacts, and added the recovery screen.
|
|
- 2026-10-10: this fix (config key, atomic migration records, backup rules).
|