mattermost-mobile-upstream/app/database/manager/index.ts
Nick Misasi f663d0f520
perf: skip Android Keystore listing on cold start (#10056)
* perf: skip Android Keystore listing on cold start

Open the app DB first and decrypt credentials only for known server URLs so logged-in launches no longer enumerate every generic-password service.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf: load server credentials in one parallel pass

Drop the extra preauth Set and second keystore probe so cold start decrypts each known URL once, and read active servers from the existing query.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: retry failed startup and cache credentials only after Keychain writes

Reset base initialization so a rejected launch can retry, and keep the in-memory credential cache aligned with successful secure-storage writes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: drop mock-harness init tests and unused session cache

The initialize() retry test only asserted mock wiring. Remove leftover
hasCachedCredentials/session_cache APIs from closed stack floors, and
keep credential tests that lock listing-skip and cache behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop leftover launch profiler instrumentation

Cold-start timing marks were debug logInfo breadcrumbs, not part of the Keystore skip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: assert empty known-URL fallback still returns credentials

Listing-called alone would miss a path that enumerates Keystore then drops the results.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 10:58:22 +08:00

656 lines
27 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BoardViewModel} from '@boards/database/models';
import {Database, Q} from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import logger from '@nozbe/watermelondb/utils/common/logger';
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application';
import {Directory, File, Paths} from 'expo-file-system';
import {DeviceEventEmitter, Platform} from 'react-native';
import {AiBotModel, AiThreadModel} from '@agents/database/models';
import {Events} from '@constants';
import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
import AppDatabaseMigrations from '@database/migration/app';
import ServerDatabaseMigrations from '@database/migration/server';
import {InfoModel, GlobalModel, ServersModel} from '@database/models/app';
import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel,
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, PropertyFieldModel, PropertyValueModel, ReactionModel, RoleModel,
ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, ConfigModel,
} from '@database/models/server';
import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
import {attemptServerDatabaseRecovery} from '@database/recovery';
import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server';
import {beforeUpgrade} from '@helpers/database/upgrade';
import {removePreauthSecret} from '@init/credentials';
import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunPropertyFieldModel, PlaybookRunPropertyValueModel} from '@playbooks/database/models';
import {getActiveServer, getServer, getServerByIdentifier} from '@queries/app/servers';
import {deleteFile} from '@utils/file';
import {logDebug, logError} from '@utils/log';
import {deleteIOSDatabase, getIOSAppGroupDetails, renameIOSDatabase} from '@utils/mattermost_managed';
import {urlSafeBase64Encode} from '@utils/security';
import {removeProtocol} from '@utils/url';
import type {AppDatabase, CreateServerDatabaseArgs, RegisterServerDatabaseArgs, Models, ServerDatabase, ServerDatabases} from '@typings/database/database';
import type {PersistenceFlag} from '@typings/database/models/app/servers';
const {SERVERS} = MM_TABLES.APP;
const APP_DATABASE = 'app';
class DatabaseManagerSingleton {
public appDatabase?: AppDatabase;
public serverDatabases: ServerDatabases = {};
private readonly appModels: Models;
private readonly databaseDirectory: string | null;
private readonly serverModels: Models;
constructor() {
this.appModels = [InfoModel, GlobalModel, ServersModel];
this.serverModels = [
AiBotModel, AiThreadModel,
CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel,
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, PropertyFieldModel, PropertyValueModel, ReactionModel, RoleModel,
ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, BoardViewModel,
PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunPropertyFieldModel, PlaybookRunPropertyValueModel,
];
this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${Paths.document.uri}/databases/`;
}
public initAppDatabase = async (): Promise<void> => {
if (!this.appDatabase) {
await this.createAppDatabase();
}
};
public initServerDatabases = async (serverUrls: string[]): Promise<void> => {
const buildNumber = nativeBuildVersion;
const versionNumber = nativeApplicationVersion;
await beforeUpgrade.call(this, serverUrls, versionNumber, buildNumber);
for await (const serverUrl of serverUrls) {
await this.initServerDatabase(serverUrl);
}
this.appDatabase?.operator.handleInfo({
info: [{
build_number: buildNumber || '',
created_at: Date.now(),
version_number: versionNumber || '',
}],
prepareRecordsOnly: false,
});
};
public init = async (serverUrls: string[]): Promise<void> => {
logDebug('DatabaseManager: Initializing');
await this.initAppDatabase();
await this.initServerDatabases(serverUrls);
};
/**
* createAppDatabase: Creates the App database. However,
* if a database could not be created, it will return undefined.
* @returns {Promise<AppDatabase|undefined>}
*/
private createAppDatabase = async (): Promise<AppDatabase|undefined> => {
try {
const databaseName = APP_DATABASE;
if (Platform.OS === 'android') {
new Directory(this.databaseDirectory!).create({intermediates: true, idempotent: true});
}
const databaseFilePath = this.getDatabaseFilePath(databaseName);
const modelClasses = this.appModels;
const schema = appSchema;
const adapter = new SQLiteAdapter({
dbName: databaseFilePath,
migrationEvents: this.buildMigrationCallbacks(databaseName),
migrations: AppDatabaseMigrations,
jsi: true,
schema,
});
const database = new Database({adapter, modelClasses});
const operator = new AppDataOperator(database);
this.appDatabase = {
database,
operator,
};
return this.appDatabase;
} catch (e) {
logError('Unable to create the App Database!!', e);
}
return undefined;
};
/**
* createServerDatabase: Creates a server database and registers the the server in the app database. However,
* if a database connection could not be created, it will return undefined.
* @param {CreateServerDatabaseArgs} createServerDatabaseArgs
*
* @returns {Promise<ServerDatabase|undefined>}
*/
public createServerDatabase = async ({config}: CreateServerDatabaseArgs): Promise<ServerDatabase|undefined> => {
const {dbName, displayName, identifier, serverUrl} = config;
if (serverUrl) {
try {
const databaseName = urlSafeBase64Encode(serverUrl);
const databaseFilePath = this.getDatabaseFilePath(databaseName);
const migrations = ServerDatabaseMigrations;
const modelClasses = this.serverModels;
const schema = serverSchema;
const server = await getServer(serverUrl);
const zpm = server?.persistenceFlag === 'zero-persistence';
if (zpm) {
try {
await this.deleteServerDatabaseFiles(serverUrl);
} catch {
logDebug('createServerDatabase: no stale SQLite file to delete', serverUrl);
}
}
// WatermelonDB's own in-memory form; bare `:memory:` doesn't survive its reset/VACUUM path.
const memoryDbName = `file:zpm_${databaseName}?mode=memory&cache=shared`;
const adapter = new SQLiteAdapter({
dbName: zpm ? memoryDbName : databaseFilePath,
migrationEvents: this.buildMigrationCallbacks(databaseName, serverUrl),
migrations,
jsi: true,
schema,
});
// Registers the new server connection into the DEFAULT database
await this.addServerToAppDatabase({
databaseFilePath: zpm ? '' : databaseFilePath,
displayName: displayName || dbName,
identifier,
serverUrl,
});
const database = new Database({adapter, modelClasses});
const operator = new ServerDataOperator(database);
const serverDatabase = {database, operator};
this.serverDatabases[serverUrl] = serverDatabase;
return serverDatabase;
} catch (e) {
logError('Error initializing database', e);
const recovered = await attemptServerDatabaseRecovery(serverUrl, e, 'createServerDatabase', {resync: false});
if (recovered) {
return this.serverDatabases[serverUrl];
}
}
}
return undefined;
};
/**
* initServerDatabase : initializes the server database.
* @param {string} serverUrl
* @returns {Promise<void>}
*/
private initServerDatabase = async (serverUrl: string): Promise<void> => {
await this.createServerDatabase({
config: {
dbName: serverUrl,
dbType: DatabaseType.SERVER,
serverUrl,
},
});
};
/**
* addServerToAppDatabase: Adds a record in the 'app' database - into the 'servers' table - for this new server connection
* @param {string} databaseFilePath
* @param {string} displayName
* @param {string} serverUrl
* @param {string} identifier
* @returns {Promise<void>}
*/
private addServerToAppDatabase = async ({databaseFilePath, displayName, serverUrl, identifier = ''}: RegisterServerDatabaseArgs): Promise<void> => {
try {
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const serverModel = await getServer(serverUrl);
if (!serverModel) {
await appDatabase.write(async () => {
const serversCollection = appDatabase.collections.get(SERVERS);
await serversCollection.create((server: ServersModel) => {
server.dbPath = databaseFilePath;
server.displayName = displayName;
server.url = serverUrl;
server.identifier = identifier;
server.lastActiveAt = 0;
});
});
} else if (serverModel.dbPath !== databaseFilePath) {
await appDatabase.write(async () => {
await serverModel.update((s) => {
s.dbPath = databaseFilePath;
});
});
} else if (identifier) {
await this.updateServerIdentifier(serverUrl, identifier, displayName);
}
}
} catch (e) {
logError('Error adding server to App database', e);
}
};
public updateServerIdentifier = async (serverUrl: string, identifier: string, displayName?: string) => {
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const server = await getServer(serverUrl);
await appDatabase.write(async () => {
await server?.update((record) => {
record.identifier = identifier;
if (displayName) {
record.displayName = displayName;
}
});
});
}
};
public updateServerDisplayName = async (serverUrl: string, displayName: string) => {
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const server = await getServer(serverUrl);
await appDatabase.write(async () => {
await server?.update((record) => {
record.displayName = displayName;
});
});
}
};
public updatePersistenceFlag = async (serverUrl: string, persistenceFlag: PersistenceFlag) => {
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const server = await getServer(serverUrl);
await appDatabase.write(async () => {
await server?.update((record) => {
record.persistenceFlag = persistenceFlag;
});
});
}
};
/**
* isServerPresent : Confirms if the current serverUrl does not already exist in the database
* @param {String} serverUrl
* @returns {Promise<boolean>}
*/
private isServerPresent = async (serverUrl: string): Promise<boolean> => {
const server = await getServer(serverUrl);
return Boolean(server);
};
/**
* getActiveServerUrl: Get the server url for active server database.
* @returns {Promise<string|undefined>}
*/
public getActiveServerUrl = async (): Promise<string|undefined> => {
const server = await getActiveServer();
return server?.url;
};
/**
* getActiveServerDisplayName: Get the server display name for active server database.
* @returns {Promise<string|undefined>}
*/
public getActiveServerDisplayName = async (): Promise<string|undefined> => {
const server = await getActiveServer();
return server?.displayName;
};
public getServerUrlFromIdentifier = async (identifier: string): Promise<string|undefined> => {
const server = await getServerByIdentifier(identifier);
return server?.url;
};
/**
* getActiveServerDatabase: Get the record for active server database.
* @returns {Promise<Database|undefined>}
*/
public getActiveServerDatabase = async (): Promise<Database|undefined> => {
const server = await getActiveServer();
if (server?.url) {
return this.serverDatabases[server.url]?.database;
}
return undefined;
};
/**
* getAppDatabaseAndOperator: Helper function that returns App the database and operator.
* use within a try/catch block
* @returns AppDatabase
* @throws Error
*/
public getAppDatabaseAndOperator = () => {
const app = this.appDatabase;
if (!app) {
throw new Error('App database not found');
}
return app;
};
/**
* getServerDatabaseAndOperator: Helper function that returns the database and operator
* for a specific server.
* use within a try/catch block
* @param serverUrl the url of the server
* @returns ServerDatabase
* @throws Error
*/
public getServerDatabaseAndOperator = (serverUrl: string) => {
const server = this.serverDatabases[serverUrl];
if (!server) {
throw new Error(`${serverUrl} database not found`);
}
return server;
};
public getServerUrlForDatabase = (database: Database): string | undefined => {
const databaseName = (database.adapter as {dbName?: string} | undefined)?.dbName;
return Object.entries(this.serverDatabases).find(([, serverDatabase]) => {
if (!serverDatabase) {
return false;
}
if (serverDatabase.database === database) {
return true;
}
return (serverDatabase.database.adapter as {dbName?: string} | undefined)?.dbName === databaseName;
})?.[0];
};
/**
* setActiveServerDatabase: Set the new active server database.
* This method should be called when switching to another server.
* @param {string} serverUrl
* @returns {Promise<void>}
*/
public setActiveServerDatabase = async (serverUrl: string, options?: ActiveServerOptions): Promise<void> => {
if (this.appDatabase?.database) {
const database = this.appDatabase?.database;
await database.write(async () => {
const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch();
if (servers.length) {
await servers[0].update((server: ServersModel) => {
server.lastActiveAt = Date.now();
});
DeviceEventEmitter.emit(Events.ACTIVE_SERVER_CHANGED, {serverUrl, options});
}
});
}
};
/**
* wipeServerData: Removes the *.db file from the App-Group directory for iOS or the files directory on Android,
* and drops the in-memory WatermelonDB client. The 'servers' row is left untouched so the wiped server
* keeps its place in the active-server selection (used by Mobile Ephemeral Mode purge).
* @param {string} serverUrl
* @returns {Promise<void>}
*/
public wipeServerData = async (serverUrl: string): Promise<void> => {
const server = await getServer(serverUrl);
if (!server) {
return;
}
delete this.serverDatabases[serverUrl];
await this.deleteServerDatabaseFiles(serverUrl);
const db = await this.createServerDatabase({
config: {
dbName: serverUrl,
displayName: server.displayName,
identifier: server.identifier,
serverUrl,
},
});
if (!db) {
throw new Error(`wipeServerData: failed to re-create database for ${serverUrl}`);
}
};
/**
* deleteServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android.
* Also, it sets the last_active_at to '0' entry in the 'servers' table from the APP database
* @param {string} serverUrl
* @returns {Promise<void>}
*/
public deleteServerDatabase = async (serverUrl: string): Promise<void> => {
const database = this.appDatabase?.database;
if (database) {
const server = await getServer(serverUrl);
if (server) {
await database.write(async () => {
await server.update((record) => {
record.lastActiveAt = 0;
record.identifier = '';
});
});
delete this.serverDatabases[serverUrl];
await this.deleteServerDatabaseFiles(serverUrl);
}
}
};
/**
* destroyServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android.
* Also, removes the entry in the 'servers' table from the APP database
* @param {string} serverUrl
* @returns {Promise<void>}
*/
public destroyServerDatabase = async (serverUrl: string): Promise<void> => {
const database = this.appDatabase?.database;
if (database) {
const server = await getServer(serverUrl);
if (server) {
database.write(async () => {
await server.destroyPermanently();
});
delete this.serverDatabases[serverUrl];
this.deleteServerDatabaseFiles(serverUrl);
// Remove pre-auth secret when server is destroyed
await removePreauthSecret(serverUrl);
}
}
};
/**
* deleteServerDatabaseFiles: Removes the *.db file from the App-Group directory for iOS or the files directory on Android.
* @param {string} serverUrl
* @returns {Promise<void>}
*/
private deleteServerDatabaseFiles = async (serverUrl: string): Promise<void> => {
const databaseName = urlSafeBase64Encode(serverUrl);
return this.deleteServerDatabaseFilesByName(databaseName);
};
/**
* deleteServerDatabaseFilesByName: Removes the *.db file from the App-Group directory for iOS or the files directory for Android, given the database name
* @param {string} databaseName
* @returns {Promise<void>}
*/
private deleteServerDatabaseFilesByName = async (databaseName: string): Promise<void> => {
if (Platform.OS === 'ios') {
// On iOS, we'll delete the *.db file under the shared app-group/databases folder
await deleteIOSDatabase({databaseName});
return;
}
// On Android, we'll delete the *.db, the *.db-shm, the *.db-wal and *.db-journal files
const androidFilesDir = this.databaseDirectory;
const databaseFile = `${androidFilesDir}${databaseName}.db`;
const databaseShm = `${androidFilesDir}${databaseName}.db-shm`;
const databaseWal = `${androidFilesDir}${databaseName}.db-wal`;
const databaseJournal = `${androidFilesDir}${databaseName}.db-journal`;
deleteFile(databaseFile);
deleteFile(databaseShm);
deleteFile(databaseWal);
deleteFile(databaseJournal);
};
/**
* renameDatabase: Renames the *.db file from the App-Group directory for iOS or the files directory for Android
* @param {string} databaseName
* @param {string} newDBName
* @returns {Promise<void>}
*/
private renameDatabase = async (databaseName: string, newDBName: string): Promise<void> => {
if (Platform.OS === 'ios') {
// On iOS, we'll move the *.db file under the shared app-group/databases folder
renameIOSDatabase(databaseName, newDBName);
return;
}
// On Android, we'll move the *.db, the *.db-shm and *.db-wal files
const androidFilesDir = this.databaseDirectory;
const databaseFile = `${androidFilesDir}${databaseName}.db`;
const databaseShm = `${androidFilesDir}${databaseName}.db-shm`;
const databaseWal = `${androidFilesDir}${databaseName}.db-wal`;
const newDatabaseFile = `${androidFilesDir}${newDBName}.db`;
const newDatabaseShm = `${androidFilesDir}${newDBName}.db-shm`;
const newDatabaseWal = `${androidFilesDir}${newDBName}.db-wal`;
if (new File(newDatabaseFile).info().exists) {
// Already renamed, do not try
return;
}
if (!new File(databaseFile).info().exists) {
// Nothing to rename, do not try
return;
}
try {
new File(databaseFile).move(new File(newDatabaseFile));
new File(databaseShm).move(new File(newDatabaseShm));
new File(databaseWal).move(new File(newDatabaseWal));
} catch (error) {
// Do nothing
}
};
/**
* factoryReset: Removes the databases directory and all its contents on the respective platform
* @param {boolean} shouldRemoveDirectory
* @returns {Promise<boolean>}
*/
factoryReset = async (shouldRemoveDirectory: boolean): Promise<boolean> => {
try {
//On iOS, we'll delete the databases folder under the shared AppGroup folder
if (Platform.OS === 'ios') {
deleteIOSDatabase({shouldRemoveDirectory});
return true;
}
// On Android, we'll remove the databases folder under the Document Directory
const androidFilesDir = `${this.databaseDirectory}databases/`;
new Directory(androidFilesDir).delete();
return true;
} catch (e) {
return false;
}
};
/**
* buildMigrationCallbacks: Creates a set of callbacks that can be used to monitor the migration process.
* For example, we can display a processing spinner while we have a migration going on. Moreover, we can also
* hook into those callbacks to assess how many of our servers successfully completed their migration.
* @param {string} dbName
* @returns {MigrationEvents}
*/
private buildMigrationCallbacks = (dbName: string, serverUrl?: string) => {
const migrationEvents = {
onSuccess: () => {
logDebug('DB Migration success', dbName);
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_SUCCESS, {
dbName,
});
},
onStart: () => {
logDebug('DB Migration start', dbName);
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_STARTED, {
dbName,
});
},
onError: (error: Error) => {
logDebug('DB Migration error', dbName);
if (serverUrl) {
attemptServerDatabaseRecovery(serverUrl, error, `migration:${dbName}`, {resync: false});
}
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_ERROR, {
dbName,
error,
});
},
};
return migrationEvents;
};
/**
* getDatabaseFilePath: Using the database name, this method will return the database file path for each platform.
* On iOS, it will point towards the AppGroup shared directory while on Android, it will point towards the Files Directory.
* Please note that in each case, the *.db files will be created/grouped under a 'databases' sub-folder.
* iOS Simulator : appGroup => /Users/{username}/Library/Developer/CoreSimulator/Devices/DA6F1C73/data/Containers/Shared/AppGroup/ACA65327/databases"}
* Android Device: file:///data/user/0/com.mattermost.rnbeta/files/databases
*
* @param {string} dbName
* @returns {string}
*/
private getDatabaseFilePath = (dbName: string): string => {
return Platform.OS === 'ios' ? `${this.databaseDirectory}/${dbName}.db` : `${this.databaseDirectory}${dbName}.db`;
};
/**
* searchUrl returns the serverUrl that matches the passed string among the servers currently loaded.
* Returns undefined if none found.
*
* @param {string} toFind
* @returns {string|undefined}
*/
public searchUrl = (toFind: string): string | undefined => {
const toFindWithoutProtocol = removeProtocol(toFind);
return Object.keys(this.serverDatabases).find((k) => removeProtocol(k) === toFindWithoutProtocol);
};
}
if (!__DEV__) {
// @ts-ignore
logger.silence();
}
const DatabaseManager = new DatabaseManagerSingleton();
export default DatabaseManager;