Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
90ceaf8
chore: add Drizzle schema and generated migrations for default and pe…
diegolmello Jun 12, 2026
61cea36
feat: add expo-sqlite driver adapter, key service, and live-query hoo…
diegolmello Jun 13, 2026
2c2103b
chore: fix lint on schema index and ignore generated Drizzle migrations
diegolmello Jun 13, 2026
4286748
chore: merge lint fixes from the schema branch
diegolmello Jun 13, 2026
e5e3091
chore: fix lint on driver adapter sources
diegolmello Jun 13, 2026
0b56390
feat: rewrite native database readers for SQLCipher and add native ke…
diegolmello Jun 13, 2026
6aaba61
chore: register iOS key-store in Xcode project and harden native readers
diegolmello Jun 15, 2026
a3b082e
refactor: migrate database key store to a TurboModule
diegolmello Jun 15, 2026
72a719c
fix: validate SQLCipher key on open and prevent NSE cursor leak
diegolmello Jun 15, 2026
5606e6f
fix: fail closed when a stored database key can't be read
diegolmello Jun 15, 2026
600b127
feat: add WatermelonDB-shaped facade over the Drizzle driver
diegolmello Jun 15, 2026
0c5a9fa
fix: model facade Q operators on WatermelonDB's comparison shape
diegolmello Jun 15, 2026
3f0d38c
refactor: cut all database call sites over to the facade
diegolmello Jun 17, 2026
15438be
feat: port client data into the encrypted database at first boot
diegolmello Jun 17, 2026
2bbea8d
ci: match slashed base branches so stacked PRs build
diegolmello Jun 17, 2026
b9c1382
fix: prevent iOS 0xdead10cc kill on the encrypted WAL database
diegolmello Jun 18, 2026
b23efd3
refactor: deduplicate message columns, add notNull constraints, drop …
diegolmello Jun 19, 2026
56d87d8
refactor: single baseline migration with NOT NULL constraints folded …
diegolmello Jun 19, 2026
7a507c5
Merge remote-tracking branch 'origin/feat/native-1275-drizzle-schema'…
diegolmello Jun 19, 2026
8b6befb
fix: harden driver adapter open/key concurrency and validation
diegolmello Jun 19, 2026
ee159ef
refactor: trim forward-looking comment in driver connection
diegolmello Jun 19, 2026
0122f34
Merge remote-tracking branch 'origin/feat/native-1274-driver-adapter'…
diegolmello Jun 19, 2026
8dbe804
fix: correct native reader room lookup and key-store lifecycle
diegolmello Jun 19, 2026
b9809a6
fix: align native reader db-name derivation and cipher pragmas with J…
diegolmello Jun 19, 2026
505200b
Merge remote-tracking branch 'origin/feat/native-1276-native-readers'…
diegolmello Jun 19, 2026
30aee8f
fix: correct facade child-collection typing and write/observe semantics
diegolmello Jun 19, 2026
3ca4574
fix: restore iOS App Group SQLite subdirectory isolation
diegolmello Jun 22, 2026
6aa817b
docs: correct resolveDbDirectory comment to reflect SQLite subdir
diegolmello Jun 22, 2026
f5faca0
Merge branch 'feat/native-1277-facade-cutover' into feat/native-1278-…
diegolmello Jun 22, 2026
4b5fe40
fix: harden SQLCipher migration against partial-state data loss
diegolmello Jun 22, 2026
1ca85bb
Merge remote-tracking branch 'origin/feat/native-1272-sqlcipher-migra…
diegolmello Jun 22, 2026
870fd04
chore: resolve eslint errors in key service and connection driver
diegolmello Jun 22, 2026
02d0623
Merge remote-tracking branch 'origin/feat/native-1276-native-readers'…
diegolmello Jun 23, 2026
a856027
fix: route subscription search clause through the db facade
diegolmello Jun 23, 2026
451e850
fix: satisfy eslint import-order and type-import rules in the db facade
diegolmello Jun 23, 2026
245228a
Merge remote-tracking branch 'origin/feat/native-1277-facade-cutover'…
diegolmello Jun 23, 2026
13449fc
fix: use object destructuring in readState to satisfy prefer-destruct…
diegolmello Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
// Keep the database engine behind the facade. Everything outside app/lib/database/ must import
// from app/lib/database/facade — never the raw engine or the facade's internal modules. The
// migration reader and driver live inside app/lib/database/ and are excluded below.
const reactDefaultImport = {
name: 'react',
importNames: ['default'],
message: 'Import specific named exports from React instead.'
};
const facadeOnlyPatterns = [
{
group: ['@nozbe/watermelondb', '@nozbe/watermelondb/**', 'expo-sqlite', 'expo-sqlite/**', 'drizzle-orm', 'drizzle-orm/**'],
message: 'Do not import the database engine directly. Use the facade at app/lib/database/facade.'
},
{
group: ['**/database/facade/*'],
message: 'Import from the facade barrel (app/lib/database/facade), not its internal modules.'
}
];

module.exports = {
settings: {
'import/resolver': {
Expand Down Expand Up @@ -165,6 +184,20 @@ module.exports = {
env: {
'react-native/react-native': true
}
},
{
files: ['app/**/*.js'],
excludedFiles: ['app/lib/database/**'],
rules: {
'no-restricted-imports': ['error', { paths: [reactDefaultImport], patterns: facadeOnlyPatterns }]
}
},
{
files: ['app/**/*.{ts,tsx}'],
excludedFiles: ['app/lib/database/**'],
rules: {
'@typescript-eslint/no-restricted-imports': ['error', { paths: [reactDefaultImport], patterns: facadeOnlyPatterns }]
}
}
]
};
5 changes: 5 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ dependencies {
// For SecureKeystore (EncryptedSharedPreferences)
implementation 'androidx.security:security-crypto:1.1.0'

// SQLCipher for reading encrypted databases from the FCM notification path.
// Version locked to match expo-sqlite 16.0.10's vendored SQLCipher 4.7.0.
// @aar ensures Gradle unpacks the AAR (ships libsqlcipher.so for all ABIs).
implementation 'net.zetetic:sqlcipher-android:4.7.0@aar'

testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.14.1'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.nozbe.watermelondb.jsi.WatermelonDBJSIPackage;
import com.bugsnag.android.Bugsnag
import expo.modules.ApplicationLifecycleDispatcher
import chat.rocket.reactnative.networking.SSLPinningTurboPackage;
import chat.rocket.reactnative.storage.DatabaseKeyStoreTurboPackage;
import chat.rocket.reactnative.storage.MMKVKeyManager;
import chat.rocket.reactnative.storage.SecureStoragePackage;
import chat.rocket.reactnative.storage.DatabaseKeyStoreTurboPackage;
Expand Down Expand Up @@ -51,6 +52,7 @@ open class MainApplication : Application(), ReactApplication {
add(PushNotificationTurboPackage())
add(VoipTurboPackage())
add(SecureStoragePackage())
add(DatabaseKeyStoreTurboPackage())
add(InvertedScrollPackage())
add(ExternalInputPackage())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
import chat.rocket.mobilecrypto.algorithms.AESCrypto;
import chat.rocket.mobilecrypto.algorithms.RSACrypto;
import chat.rocket.mobilecrypto.algorithms.CryptoUtils;
import com.nozbe.watermelondb.WMDatabase;
import chat.rocket.reactnative.storage.DatabaseKeyStoreModule;
import net.zetetic.database.sqlcipher.SQLiteDatabase;

import java.security.SecureRandom;
import java.util.Arrays;
Expand Down Expand Up @@ -102,6 +103,10 @@ class RoomKeyResult {
}

class Encryption {
static {
System.loadLibrary("sqlcipher");
}

static class EncryptionContent {
String algorithm;
String ciphertext;
Expand Down Expand Up @@ -172,55 +177,102 @@ private ParsedMessage parseMessage(Ejson.Content content) {
}

public Room readRoom(final Ejson ejson, Context context) {
String dbName = getDatabaseName(ejson.serverURL(), context);
WMDatabase db = null;
String dbName = deriveDbName(ejson.serverURL());
String dbPath = context.getFilesDir().getAbsolutePath() + "/SQLite/" + dbName;

// Read key + salt from the AndroidKeyStore-backed store.
// Storage keys match JS KEY_PREFIX / SALT_PREFIX in keyService.ts.
String storageKey = "db_key_v1:" + dbName;
String saltStorageKey = "db_salt_v1:" + dbName;
String keyHex;
String saltHex;
try {
keyHex = DatabaseKeyStoreModule.getItemInternal(context, storageKey);
} catch (Exception e) {
Log.w(TAG, "Could not read encryption key for " + dbName + " — cannot read room", e);
return null;
}
if (keyHex == null) {
Log.w(TAG, "No encryption key found for " + dbName + " — cannot read room");
return null;
}
try {
saltHex = DatabaseKeyStoreModule.getItemInternal(context, saltStorageKey);
} catch (Exception e) {
Log.w(TAG, "Could not read cipher salt for " + dbName + " — cannot read room", e);
return null;
}
if (saltHex == null) {
Log.w(TAG, "No cipher salt found for " + dbName + " — cannot read room");
return null;
}

// Raw-key string form: "x'<64 hex>'" — skips PBKDF2, matches the JS driver.
// Never use the byte[] overload: it silently PBKDF2-derives and produces
// "file is not a database" even when the bytes match.
String rawKey = "x'" + keyHex + "'";

SQLiteDatabase db = null;
try {
db = WMDatabase.getInstance(dbName, context);
String[] queryArgs = {ejson.rid};

Cursor cursor = db.rawQuery("SELECT * FROM subscriptions WHERE id == ? LIMIT 1", queryArgs);

if (cursor.getCount() == 0) {
cursor.close();
return null;
}

cursor.moveToFirst();
int e2eKeyColumnIndex = cursor.getColumnIndex("e2e_key");
int encryptedColumnIndex = cursor.getColumnIndex("encrypted");

if (e2eKeyColumnIndex == -1) {
Log.e(TAG, "e2e_key column not found in subscriptions table");
cursor.close();
return null;
}

String e2eKey = cursor.getString(e2eKeyColumnIndex);
Boolean encrypted = encryptedColumnIndex != -1 && cursor.getInt(encryptedColumnIndex) > 0;
cursor.close();

return new Room(e2eKey, encrypted);
db = SQLiteDatabase.openDatabase(dbPath, rawKey, null, SQLiteDatabase.OPEN_READONLY, null);

// Mirror the JS driver's open PRAGMAs (connection.ts applyOpenPragmas):
// cipher_plaintext_header_size = 32 — the driver exposes a 32-byte plaintext header
// so iOS grants the background idle-WAL exemption (0xdead10cc); the reader must
// set this too or SQLCipher will attempt to decrypt the header and fail.
// cipher_salt — with a plaintext header SQLCipher no longer stores the salt in the
// file; it must be supplied from the same keychain entry the JS driver wrote.
// busy_timeout — mandatory multi-process WAL safety.
db.execSQL("PRAGMA cipher_plaintext_header_size = 32;");
db.execSQL("PRAGMA cipher_salt = \"x'" + saltHex + "'\";");
db.execSQL("PRAGMA busy_timeout = 500;");

Cursor cursor = db.rawQuery("SELECT * FROM subscriptions WHERE rid = ? LIMIT 1", new String[]{ejson.rid});
try {
if (cursor.getCount() == 0) {
return null;
}

cursor.moveToFirst();
int e2eKeyColumnIndex = cursor.getColumnIndex("e2e_key");
int encryptedColumnIndex = cursor.getColumnIndex("encrypted");

if (e2eKeyColumnIndex == -1) {
Log.e(TAG, "e2e_key column not found in subscriptions table");
return null;
}

String e2eKey = cursor.getString(e2eKeyColumnIndex);
Boolean encrypted = encryptedColumnIndex != -1 && cursor.getInt(encryptedColumnIndex) > 0;
return new Room(e2eKey, encrypted);
} finally {
cursor.close();
}

} catch (Exception e) {
Log.e(TAG, "Error reading room", e);
return null;

} finally {
if (db != null) {
if (db != null && db.isOpen()) {
db.close();
}
}
}

private String getDatabaseName(String serverUrl, Context context) {
// Match JS WatermelonDB naming: strip scheme, replace '/' with '.', and append one ".db".
String name = serverUrl.replaceFirst("^(\\w+:)?//", "").replace("/", ".");
name += ".db";

// Important: return just the name (not an absolute path). WMDatabase will resolve and append its own ".db" internally,
// so the physical file becomes "*.db.db", matching the JS adapter.
return name;
/**
* Derives the clean database filename from a server URL.
* Matches the JS `deriveServerDbName` in connection.ts:
* strip trailing slashes → strip scheme → replace '/' with '_' → append ".db"
*/
private static String deriveDbName(String serverUrl) {
// Strip trailing slashes
String s = serverUrl.replaceAll("/+$", "");
// Strip scheme ("https://", "http://", or bare "//")
s = s.replaceFirst("^(\\w+:)?//", "");
// Replace remaining slashes with underscores (matches JS deriveServerDbName)
s = s.replace("/", "_");
return s + ".db";
}

public String readUserKey(final Ejson ejson) throws Exception {
Expand Down
2 changes: 1 addition & 1 deletion app/containers/Avatar/useAvatarETag.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Q } from '@nozbe/watermelondb';
import { useEffect, useState } from 'react';
import { type Observable, type Subscription } from 'rxjs';

import { Q } from '../../lib/database/facade';
import { type TLoggedUserModel, type TSubscriptionModel, type TUserModel } from '../../definitions';
import database from '../../lib/database';

Expand Down Expand Up @@ -61,7 +61,7 @@
}
};
}
}, [text]);

Check warning on line 64 in app/containers/Avatar/useAvatarETag.ts

View workflow job for this annotation

GitHub Actions / ESLint and Test / run-eslint-and-test

React Hook useEffect has missing dependencies: 'avatarETag', 'id', 'isDirect', 'rid', and 'username'. Either include them or remove the dependency array

return { avatarETag };
};
2 changes: 1 addition & 1 deletion app/containers/MessageComposer/MessageComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { type ReactElement, type Ref, useRef, useImperativeHandle } from 'react';
import { AccessibilityInfo, findNodeHandle, type LayoutChangeEvent } from 'react-native';
import { useBackHandler } from '@react-native-community/hooks';
import { Q } from '@nozbe/watermelondb';
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';

import { Q } from '../../lib/database/facade';
import { useRoomContext } from '../../views/RoomView/context';
import { Autocomplete } from './components';
import { MIN_HEIGHT } from './constants';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { TouchableWithoutFeedback } from 'react-native-gesture-handler';
import { StyleSheet, Text } from 'react-native';
import { useEffect, useRef, type ReactElement } from 'react';
import { type Subscription } from 'rxjs';
import { Q } from '@nozbe/watermelondb';

import { Q } from '../../../lib/database/facade';
import { useRoomContext } from '../../../views/RoomView/context';
import { useAlsoSendThreadToChannel, useMessageComposerApi } from '../context';
import { CustomIcon } from '../../CustomIcon';
Expand Down
2 changes: 1 addition & 1 deletion app/containers/MessageComposer/hooks/useAutocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Q } from '@nozbe/watermelondb';

import { Q } from '../../../lib/database/facade';
import {
type IAutocompleteEmoji,
type IAutocompleteUserRoom,
Expand Down
2 changes: 1 addition & 1 deletion app/containers/MessageErrorActions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { forwardRef, useImperativeHandle } from 'react';
import type Model from '@nozbe/watermelondb/Model';

import type { Model } from '../lib/database/facade';
import database from '../lib/database';
import protectedFunction from '../lib/methods/helpers/protectedFunction';
import { useActionSheet } from './ActionSheet';
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IEmoji.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IFrequentlyUsedEmoji {
content: string;
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/ILoggedUser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';

import type { Model } from '../lib/database/facade';
import { type IUserEmail, type IUserSettings } from './IUser';
import { type TUserStatus } from './TUserStatus';

Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IMessage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type Model from '@nozbe/watermelondb/Model';
import { type Root } from '@rocket.chat/message-parser';

import type { Model } from '../lib/database/facade';
import { type MessageTypeLoad } from '../lib/constants/messageTypeLoad';
import { type IAttachment } from './IAttachment';
import { type IReaction } from './IReaction';
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IPermission.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IPermission {
_id: string;
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IRole.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IRole {
id: string;
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/IRoom.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';

import type { Model } from '../lib/database/facade';
import { type IMessage } from './IMessage';
import { type IRocketChatRecord } from './IRocketChatRecord';
import { type IServedBy } from './IServedBy';
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/IServer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';

import type { Model } from '../lib/database/facade';
import { type IEnterpriseModules } from '../reducers/enterpriseModules';

export type TSVStatus = 'supported' | 'expired' | 'warn';
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IServerHistory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IServerHistory {
id: string;
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/ISettings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface ISettings {
id: string;
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/ISlashCommand.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface ISlashCommand {
id: string;
Expand Down
4 changes: 1 addition & 3 deletions app/definitions/ISubscription.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type Relation from '@nozbe/watermelondb/Relation';

import type { Model, Relation } from '../lib/database/facade';
import { type ILastMessage, type TMessageModel } from './IMessage';
import { type IRocketChatRecord } from './IRocketChatRecord';
import { type IOmnichannelSource, type RoomID, type RoomType, type TUserWaitingForE2EKeys } from './IRoom';
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IThread.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type Model from '@nozbe/watermelondb/Model';
import { type Root } from '@rocket.chat/message-parser';

import type { Model } from '../lib/database/facade';
import { type IAttachment } from './IAttachment';
import { type IMessage, type IUserChannel, type IUserMention, type IUserMessage } from './IMessage';
import { type IUrl } from './IUrl';
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/IThreadMessage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';

import type { Model } from '../lib/database/facade';
import { type IMessage } from './IMessage';

export interface IThreadMessage extends IMessage {
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IUpload.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IUpload {
id?: string;
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/IUser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';

import type { Model } from '../lib/database/facade';
import { type TUserStatus } from './TUserStatus';
import { type IRocketChatRecord } from './IRocketChatRecord';
import { type ILoggedUser } from './ILoggedUser';
Expand Down
Loading
Loading