multiple fixes that could cause DB crashes after initial install
This commit is contained in:
+3
-3
@@ -1146,7 +1146,7 @@ export GEM_PATH=$shortened_path
|
|||||||
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ios/App && xcrun agvtool new-version 72 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.6;/g" App.xcodeproj/project.pbxproj && cd -
|
cd ios/App && xcrun agvtool new-version 74 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.8;/g" App.xcodeproj/project.pbxproj && cd -
|
||||||
# Unfortunately this edits Info.plist directly.
|
# Unfortunately this edits Info.plist directly.
|
||||||
#xcrun agvtool new-marketing-version 0.4.5
|
#xcrun agvtool new-marketing-version 0.4.5
|
||||||
```
|
```
|
||||||
@@ -1434,8 +1434,8 @@ The recommended way to build for Android is using the automated build script:
|
|||||||
##### 1. Bump the version in package.json, then update these versions & run:
|
##### 1. Bump the version in package.json, then update these versions & run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
perl -p -i -e 's/versionCode .*/versionCode 73/g' android/app/build.gradle
|
perl -p -i -e 's/versionCode .*/versionCode 74/g' android/app/build.gradle
|
||||||
perl -p -i -e 's/versionName .*/versionName "1.4.7"/g' android/app/build.gradle
|
perl -p -i -e 's/versionName .*/versionName "1.4.8"/g' android/app/build.gradle
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ android {
|
|||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
// Backports java.time (API 26+) to older Android; the daily notification plugin uses it.
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_21
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
targetCompatibility JavaVersion.VERSION_21
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
@@ -37,8 +39,8 @@ android {
|
|||||||
applicationId "app.timesafari.app"
|
applicationId "app.timesafari.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 73
|
versionCode 74
|
||||||
versionName "1.4.7"
|
versionName "1.4.8"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -101,6 +103,7 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5"
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".TimeSafariApplication"
|
android:name=".TimeSafariApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
"splashFullScreen": true,
|
"splashFullScreen": true,
|
||||||
"splashImmersive": true
|
"splashImmersive": true
|
||||||
},
|
},
|
||||||
"CapSQLite": {
|
"CapacitorSQLite": {
|
||||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
"iosDatabaseLocation": "Documents",
|
||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Backup rules for Android 11 and lower; data_extraction_rules.xml has the same
|
||||||
|
exclusions for Android 12 and higher. Everything not excluded is backed up.
|
||||||
|
|
||||||
|
The SQLite plugin's encrypted preferences are tied to an Android Keystore key,
|
||||||
|
and Keystore keys are never backed up. Restoring the preferences without the
|
||||||
|
key makes the plugin fail to load on every launch.
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||||
|
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Backup and device-transfer rules for Android 12 and higher; backup_rules.xml
|
||||||
|
has the same exclusions for Android 11 and lower. Everything not excluded is
|
||||||
|
backed up and transferred, including the database (which holds the user's keys).
|
||||||
|
|
||||||
|
The SQLite plugin's encrypted preferences are tied to an Android Keystore key,
|
||||||
|
and Keystore keys never leave the device. Restoring the preferences without
|
||||||
|
the key makes the plugin fail to load on every launch.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||||
|
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||||
|
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"splashImmersive": true
|
"splashImmersive": true
|
||||||
},
|
},
|
||||||
"CapacitorSQLite": {
|
"CapacitorSQLite": {
|
||||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
"iosDatabaseLocation": "Documents",
|
||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
|
|||||||
+4
-2
@@ -34,8 +34,10 @@ const config: CapacitorConfig = {
|
|||||||
splashFullScreen: true,
|
splashFullScreen: true,
|
||||||
splashImmersive: true
|
splashImmersive: true
|
||||||
},
|
},
|
||||||
CapSQLite: {
|
// The key must match the plugin's registered name or every setting here is ignored.
|
||||||
iosDatabaseLocation: 'Library/CapacitorDatabase',
|
CapacitorSQLite: {
|
||||||
|
// Installed iOS apps keep their database in Documents; changing this strands existing data.
|
||||||
|
iosDatabaseLocation: 'Documents',
|
||||||
iosIsEncryption: false,
|
iosIsEncryption: false,
|
||||||
iosBiometric: {
|
iosBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Plan: Startup Database Failure — Diagnostics and Degraded Mode
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When the local database cannot be opened, a user should (1) get a useful
|
||||||
|
report to the developers with one tap, and (2) keep using whatever
|
||||||
|
parts of the app do not need local storage, with a clear path to recovery.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
1. A failure report reaches the developers without the user composing an email,
|
||||||
|
and it carries enough to identify the cause on the first report.
|
||||||
|
2. The report is sent only with the user's consent and holds no keys, seed
|
||||||
|
phrases, or contact data.
|
||||||
|
3. A user whose database is down can browse public content (feed, projects,
|
||||||
|
claims) instead of being held on the recovery screen.
|
||||||
|
4. Every action that needs the database or an identity is visibly disabled with
|
||||||
|
the real reason, and points to the recovery screen.
|
||||||
|
5. Recovery steps never delete the database without keeping a copy.
|
||||||
|
|
||||||
|
## Startup failure behavior
|
||||||
|
|
||||||
|
- `CapacitorPlatformService._initialize()`
|
||||||
|
(`src/services/platforms/CapacitorPlatformService.ts`) opens the database in
|
||||||
|
three steps: "create connection", "open database", "run migrations". A
|
||||||
|
failure throws `Failed to initialize database (step: <step>): <cause>`.
|
||||||
|
- `src/utils/startupError.ts` recognizes that error by its message and hands it
|
||||||
|
to `StartView` through `sessionStorage`.
|
||||||
|
- Three places catch it and redirect to `/start`: the router `errorHandler`
|
||||||
|
and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue
|
||||||
|
global error handler (`src/main.common.ts`).
|
||||||
|
- The guard calls `retrieveAccountDids()` for every route except the
|
||||||
|
identity-setup routes, so with the database down every route except
|
||||||
|
`/account`, `/test` and `/logs` lands on `/start`.
|
||||||
|
- `StartView` shows the recovery screen: Try Again, Email/Copy Details,
|
||||||
|
"Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
|
||||||
|
(`resetDatabase()`, which copies the file aside before deleting it).
|
||||||
|
- API reads work without an identity: `getHeaders()`
|
||||||
|
(`src/libs/endorserServer.ts:430`) omits the Authorization header when there
|
||||||
|
is no DID.
|
||||||
|
- Every path that creates, saves, or signs with an identity reads the `secret`
|
||||||
|
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
|
||||||
|
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
|
||||||
|
the database is down.
|
||||||
|
|
||||||
|
## Part A: One-tap diagnostics report
|
||||||
|
|
||||||
|
Contents of the report:
|
||||||
|
|
||||||
|
- the startup error message (step and cause) and stack
|
||||||
|
- app version, platform, OS version, device model
|
||||||
|
- Android: WebView version and installer package (`getInstallerPackageName`)
|
||||||
|
- whether a database file existed before the first connection attempt of this
|
||||||
|
process, which shows whether Android restored a backup at install time
|
||||||
|
- whether the SQLite plugin loaded (a plugin load failure surfaces as a
|
||||||
|
`CapacitorSQLitePlugin:` prefix on the cause)
|
||||||
|
- the `migrations` table contents, when the database opens far enough to read it
|
||||||
|
|
||||||
|
Delivery: a POST to an endpoint on a server the project runs. The recovery
|
||||||
|
screen shows the exact payload and a Send button; the email option stays for
|
||||||
|
users who decline.
|
||||||
|
|
||||||
|
The database-existed flag needs a native-side or `Filesystem.stat` check before
|
||||||
|
`createConnection`; `databaseDirectoryUri()` already resolves the directory on
|
||||||
|
Android and iOS.
|
||||||
|
|
||||||
|
## Part B: Degraded mode
|
||||||
|
|
||||||
|
- Change the router guard so that a database startup error records the error
|
||||||
|
and lets read-only routes through: home feed, projects, project and claim
|
||||||
|
views, and discovery. Identity-setup routes and anything that writes stay
|
||||||
|
redirected to `/start`.
|
||||||
|
- Show a persistent banner on every page while in degraded mode: "Your
|
||||||
|
device's storage isn't working. You can look around, but you can't act yet.
|
||||||
|
Fix it →", linking to the recovery screen.
|
||||||
|
- Disable buttons that need a DID or the database (give, offer, create project,
|
||||||
|
confirm, invite, contacts) with that same reason, instead of the
|
||||||
|
"someone must register you" text an unregistered user sees.
|
||||||
|
- Default `apiServer` in every settings fallback. `$accountSettings()`
|
||||||
|
(`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or
|
||||||
|
the caller's `defaults` (often `{}`) on its early-return and error paths, so
|
||||||
|
`apiServer` can be undefined; `HomeView` works around this on its own.
|
||||||
|
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
|
||||||
|
Copy → Start Over.
|
||||||
|
|
||||||
|
## Part C (optional): An identity that does not need SQLite
|
||||||
|
|
||||||
|
Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`).
|
||||||
|
In degraded mode the app could generate an identity, require the user to write
|
||||||
|
down the seed phrase, and keep the mnemonic in `@capacitor/preferences`
|
||||||
|
(installed, and independent of SQLite). The user would then have a DID someone
|
||||||
|
can register. When the database opens again, the app imports the identity from
|
||||||
|
Preferences and deletes the Preferences copy.
|
||||||
|
|
||||||
|
Trade-off: the mnemonic sits unencrypted in Preferences, about the same
|
||||||
|
protection as the database's secret stored next to the data it encrypts.
|
||||||
|
Worth building only if startup failures keep being reported with the plugin's
|
||||||
|
encryption setting (`androidIsEncryption: false`) in effect.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Which server and endpoint receive diagnostics, and how long reports are kept.
|
||||||
|
- Whether to quarantine a corrupt database automatically ("file is not a
|
||||||
|
database", "malformed") and start fresh. Automatic quarantine trades a
|
||||||
|
visible failure for a silent switch to a new identity, and some "malformed"
|
||||||
|
databases are recoverable with `REINDEX` or `.recover`; the manual Start Over
|
||||||
|
button covers this case.
|
||||||
|
- Whether Android backup should include `databases/`. Including it restores a
|
||||||
|
user's identity on a new phone; it also gives the Google backup a copy of the
|
||||||
|
keys together with the secret that decrypts them. The backup rules
|
||||||
|
(`android/app/src/main/res/xml/backup_rules.xml`,
|
||||||
|
`data_extraction_rules.xml`) include it.
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
- An IndexedDB (absurd-sql) database as a fallback inside the Capacitor
|
||||||
|
WebView: untested there, and two stores that both hold keys risk losing an
|
||||||
|
identity when they disagree.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] A1. Decide the diagnostics endpoint and payload retention.
|
||||||
|
- [ ] A2. Capture "database file existed before first connection" during startup.
|
||||||
|
- [ ] A3. Build the report payload and the Send Diagnostics button with preview.
|
||||||
|
- [ ] B1. Let read-only routes through the router guard on a database startup error.
|
||||||
|
- [ ] B2. Degraded-mode banner and disabled-action reasons.
|
||||||
|
- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks.
|
||||||
|
- [ ] B4. Reorder the recovery screen.
|
||||||
|
- [ ] C1. (Optional) Preferences-backed identity with import on recovery.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 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. Full symptom report:
|
||||||
|
`startup-db-error-symptoms.md`.
|
||||||
|
|
||||||
|
## 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 planned in `doc/CHANGES/PLAN-startup-db-failure-recovery.md`.
|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"splashImmersive": true
|
"splashImmersive": true
|
||||||
},
|
},
|
||||||
"CapacitorSQLite": {
|
"CapacitorSQLite": {
|
||||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
"iosDatabaseLocation": "Documents",
|
||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ const config: CapacitorConfig = {
|
|||||||
splashFullScreen: true,
|
splashFullScreen: true,
|
||||||
splashImmersive: true
|
splashImmersive: true
|
||||||
},
|
},
|
||||||
CapSQLite: {
|
// The key must match the plugin's registered name or every setting here is ignored.
|
||||||
iosDatabaseLocation: 'Library/CapacitorDatabase',
|
CapacitorSQLite: {
|
||||||
|
iosDatabaseLocation: 'Documents',
|
||||||
iosIsEncryption: false,
|
iosIsEncryption: false,
|
||||||
iosBiometric: {
|
iosBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
|
|||||||
@@ -541,7 +541,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -552,7 +552,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.8;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -582,7 +582,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.8;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -600,7 +600,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -614,7 +614,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.8;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
@@ -638,7 +638,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -652,7 +652,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.8;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const MIGRATIONS = [
|
|||||||
secretBase64 TEXT NOT NULL
|
secretBase64 TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
|
INSERT OR IGNORE INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -144,7 +144,7 @@ const MIGRATIONS = [
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
|
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
|
||||||
|
|
||||||
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
|
INSERT OR IGNORE INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -153,6 +153,17 @@ class MigrationRegistry {
|
|||||||
// Create a singleton instance of the migration registry
|
// Create a singleton instance of the migration registry
|
||||||
const migrationRegistry = new MigrationRegistry();
|
const migrationRegistry = new MigrationRegistry();
|
||||||
|
|
||||||
|
/** The migration's SQL followed by the statement that records it as applied. */
|
||||||
|
function withMigrationRecord(migration: Migration): string {
|
||||||
|
// A trailing "--" comment ends at its newline, so check what's left without comments.
|
||||||
|
const terminated = migration.sql
|
||||||
|
.replace(/--[^\n]*/g, "")
|
||||||
|
.trim()
|
||||||
|
.endsWith(";");
|
||||||
|
const name = migration.name.replace(/'/g, "''");
|
||||||
|
return `${migration.sql}\n${terminated ? "" : ";\n"}INSERT INTO migrations (name) VALUES ('${name}');\n`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a migration with the migration service
|
* Register a migration with the migration service
|
||||||
*
|
*
|
||||||
@@ -282,19 +293,15 @@ export async function runMigrations<T>(
|
|||||||
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
|
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
|
||||||
logger.debug(`[Migration] SQL content: ${migration.sql}`);
|
logger.debug(`[Migration] SQL content: ${migration.sql}`);
|
||||||
|
|
||||||
// Execute the migration SQL directly - it should be atomic
|
// The record goes in the same batch as the migration, which the native
|
||||||
// The SQL itself should handle any necessary transactions
|
// plugin runs as one transaction: if the app dies between the two, a
|
||||||
const execResult = await sqlExec(migration.sql);
|
// migration that can't run twice (like 001) would fail every launch.
|
||||||
|
const execResult = await sqlExec(withMigrationRecord(migration));
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[Migration] SQL execution result: ${JSON.stringify(execResult)}`,
|
`[Migration] SQL execution result: ${JSON.stringify(execResult)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Record that the migration was applied
|
|
||||||
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
|
||||||
migration.name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
logger.debug(`✅ [Migration] Successfully applied: ${migration.name}`);
|
logger.debug(`✅ [Migration] Successfully applied: ${migration.name}`);
|
||||||
appliedCount++;
|
appliedCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -462,10 +462,10 @@ export class CapacitorPlatformService
|
|||||||
return uri.replace(/\/files\/?$/, "/databases");
|
return uri.replace(/\/files\/?$/, "/databases");
|
||||||
}
|
}
|
||||||
if (platform === "ios") {
|
if (platform === "ios") {
|
||||||
// Matches iosDatabaseLocation "Library/CapacitorDatabase" in capacitor.config
|
// Matches iosDatabaseLocation "Documents" in capacitor.config
|
||||||
const { uri } = await Filesystem.getUri({
|
const { uri } = await Filesystem.getUri({
|
||||||
path: "CapacitorDatabase",
|
path: "",
|
||||||
directory: Directory.Library,
|
directory: Directory.Documents,
|
||||||
});
|
});
|
||||||
return uri.replace(/\/$/, "");
|
return uri.replace(/\/$/, "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* The SQLite plugin reads its settings only under its registered name,
|
||||||
|
* "CapacitorSQLite". Under any other key the settings are silently ignored and
|
||||||
|
* the plugin defaults apply, which on Android means Keystore-backed encryption
|
||||||
|
* setup that can fail on every launch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const repo = join(__dirname, "..", "..");
|
||||||
|
|
||||||
|
function read(relativePath: string): string {
|
||||||
|
return readFileSync(join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SQLite plugin config", () => {
|
||||||
|
it.each(["capacitor.config.ts", "electron/capacitor.config.ts"])(
|
||||||
|
"%s uses the plugin's registered name",
|
||||||
|
(file) => {
|
||||||
|
const source = read(file);
|
||||||
|
expect(source).toMatch(/^\s*CapacitorSQLite:\s*\{/m);
|
||||||
|
expect(source).toMatch(/androidIsEncryption:\s*false/);
|
||||||
|
expect(source).toMatch(/iosDatabaseLocation:\s*'Documents'/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("the synced Android config uses the plugin's registered name", () => {
|
||||||
|
const config = JSON.parse(
|
||||||
|
read("android/app/src/main/assets/capacitor.config.json"),
|
||||||
|
);
|
||||||
|
expect(config.plugins.CapacitorSQLite.androidIsEncryption).toBe(false);
|
||||||
|
expect(config.plugins.CapacitorSQLite.iosDatabaseLocation).toBe(
|
||||||
|
"Documents",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,8 +40,12 @@ async function newDatabase(): Promise<SqlJsDatabase> {
|
|||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function migrate(db: SqlJsDatabase): Promise<void> {
|
/** @param afterExec runs after each successful sqlExec, e.g. to simulate the app dying */
|
||||||
const sqlExec = async (sql: string, params?: unknown[]): Promise<void> => {
|
async function migrate(
|
||||||
|
db: SqlJsDatabase,
|
||||||
|
afterExec?: (sql: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const rawExec = async (sql: string, params?: unknown[]): Promise<void> => {
|
||||||
if (params && params.length > 0) {
|
if (params && params.length > 0) {
|
||||||
db.run(sql, params);
|
db.run(sql, params);
|
||||||
return;
|
return;
|
||||||
@@ -65,6 +69,10 @@ async function migrate(db: SqlJsDatabase): Promise<void> {
|
|||||||
db.run(sql);
|
db.run(sql);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const sqlExec = async (sql: string, params?: unknown[]): Promise<void> => {
|
||||||
|
await rawExec(sql, params);
|
||||||
|
afterExec?.(sql);
|
||||||
|
};
|
||||||
const sqlQuery = async (
|
const sqlQuery = async (
|
||||||
sql: string,
|
sql: string,
|
||||||
params?: unknown[],
|
params?: unknown[],
|
||||||
@@ -99,6 +107,38 @@ describe("migrations", () => {
|
|||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("survives the app dying right after a migration commits", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await expect(
|
||||||
|
migrate(db, (sql) => {
|
||||||
|
if (sql.includes("CREATE TABLE IF NOT EXISTS accounts")) {
|
||||||
|
throw new Error("app killed");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("app killed");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
const clean = await newDatabase();
|
||||||
|
await migrate(clean);
|
||||||
|
const names = "SELECT name FROM migrations ORDER BY name";
|
||||||
|
expect(rows(db, names)).toEqual(rows(clean, names));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-running 001 keeps the existing secret", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await migrate(db);
|
||||||
|
const secret = rows(db, "SELECT secretBase64 FROM secret");
|
||||||
|
db.run("DELETE FROM migrations WHERE name = '001_initial'");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
expect(rows(db, "SELECT secretBase64 FROM secret")).toEqual(secret);
|
||||||
|
expect(
|
||||||
|
rows(db, "SELECT name FROM migrations WHERE name = '001_initial'"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("008 keeps the newest contact when a DID was saved twice", async () => {
|
it("008 keeps the newest contact when a DID was saved twice", async () => {
|
||||||
const db = await newDatabase();
|
const db = await newDatabase();
|
||||||
await migrate(db);
|
await migrate(db);
|
||||||
|
|||||||
Reference in New Issue
Block a user