mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-10 21:20:14 +00:00
* 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>
485 lines
18 KiB
TypeScript
485 lines
18 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 LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs';
|
|
import logger from '@nozbe/watermelondb/utils/common/logger';
|
|
import {File} 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, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel,
|
|
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
|
|
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, PropertyFieldModel, PropertyValueModel, ReactionModel, RoleModel,
|
|
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
|
|
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
|
|
ScheduledPostModel,
|
|
} from '@database/models/server';
|
|
import AppDataOperator from '@database/operator/app_data_operator';
|
|
import ServerDataOperator from '@database/operator/server_data_operator';
|
|
import {schema as appSchema} from '@database/schema/app';
|
|
import {serverSchema} from '@database/schema/server';
|
|
import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunPropertyFieldModel, PlaybookRunPropertyValueModel} from '@playbooks/database/models';
|
|
import {deleteIOSDatabase} from '@utils/mattermost_managed';
|
|
import {urlSafeBase64Encode} from '@utils/security';
|
|
import {removeProtocol} from '@utils/url';
|
|
|
|
import type {AppDatabase, CreateServerDatabaseArgs, Models, RegisterServerDatabaseArgs, ServerDatabase, ServerDatabases} from '@typings/database/database';
|
|
import type {default as ServerModel, PersistenceFlag} from '@typings/database/models/app/servers';
|
|
|
|
const {SERVERS} = MM_TABLES.APP;
|
|
const APP_DATABASE = 'app';
|
|
|
|
if (__DEV__) {
|
|
|
|
// @ts-ignore
|
|
logger.silence();
|
|
}
|
|
|
|
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 = [
|
|
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,
|
|
AiBotModel, AiThreadModel,
|
|
];
|
|
this.databaseDirectory = '';
|
|
}
|
|
|
|
public initAppDatabase = async (): Promise<void> => {
|
|
if (!this.appDatabase) {
|
|
await this.createAppDatabase();
|
|
}
|
|
};
|
|
|
|
public initServerDatabases = async (serverUrls: string[]): Promise<void> => {
|
|
for await (const serverUrl of serverUrls) {
|
|
await this.destroyServerDatabase(serverUrl);
|
|
await this.initServerDatabase(serverUrl);
|
|
}
|
|
this.appDatabase?.operator.handleInfo({
|
|
info: [{
|
|
build_number: '123',
|
|
created_at: Date.now(),
|
|
version_number: '2.0.0',
|
|
}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
};
|
|
|
|
public init = async (serverUrls: string[]): Promise<void> => {
|
|
await this.initAppDatabase();
|
|
await this.initServerDatabases(serverUrls);
|
|
};
|
|
|
|
private createAppDatabase = async (): Promise<AppDatabase|undefined> => {
|
|
try {
|
|
const modelClasses = this.appModels;
|
|
const schema = appSchema;
|
|
|
|
// autosave uses a timeout that doesn't get cleared when we close the database in tests
|
|
// disabling it makes it so the tests don't leak memory
|
|
const adapter = new LokiJSAdapter({dbName: APP_DATABASE, migrations: AppDatabaseMigrations, schema, useWebWorker: false, useIncrementalIndexedDB: true, extraLokiOptions: {autosave: false}});
|
|
|
|
const database = new Database({adapter, modelClasses});
|
|
const operator = new AppDataOperator(database);
|
|
|
|
this.appDatabase = {
|
|
database,
|
|
operator,
|
|
};
|
|
return this.appDatabase;
|
|
} catch (e) {
|
|
// do nothing
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
public createServerDatabase = async ({config}: CreateServerDatabaseArgs): Promise<ServerDatabase|undefined> => {
|
|
const {dbName, displayName, identifier, serverUrl} = config;
|
|
|
|
if (serverUrl) {
|
|
try {
|
|
const databaseFilePath = this.getDatabaseFilePath(dbName);
|
|
const migrations = ServerDatabaseMigrations;
|
|
const modelClasses = this.serverModels;
|
|
const schema = serverSchema;
|
|
|
|
// autosave uses a timeout that doesn't get cleared when we close the database in tests
|
|
// disabling it makes it so the tests don't leak memory
|
|
const adapter = new LokiJSAdapter({dbName, migrations, schema, useWebWorker: false, useIncrementalIndexedDB: true, extraLokiOptions: {autosave: false}});
|
|
|
|
// Registers the new server connection into the DEFAULT database
|
|
await this.addServerToAppDatabase({
|
|
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) {
|
|
// do nothing
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
private initServerDatabase = async (serverUrl: string): Promise<void> => {
|
|
await this.createServerDatabase({
|
|
config: {
|
|
dbName: urlSafeBase64Encode(serverUrl),
|
|
dbType: DatabaseType.SERVER,
|
|
serverUrl,
|
|
},
|
|
});
|
|
};
|
|
|
|
private addServerToAppDatabase = async ({databaseFilePath, displayName, identifier = '', serverUrl}: RegisterServerDatabaseArgs): Promise<void> => {
|
|
try {
|
|
const appDatabase = this.appDatabase?.database;
|
|
if (appDatabase) {
|
|
const isServerPresent = await this.isServerPresent(serverUrl);
|
|
|
|
if (!isServerPresent) {
|
|
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 (identifier) {
|
|
await this.updateServerIdentifier(serverUrl, identifier);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// do nothing
|
|
}
|
|
};
|
|
|
|
public updateServerIdentifier = async (serverUrl: string, identifier: string) => {
|
|
const appDatabase = this.appDatabase?.database;
|
|
if (appDatabase) {
|
|
const server = await this.getServer(serverUrl);
|
|
await appDatabase.write(async () => {
|
|
await server?.update((record) => {
|
|
record.identifier = identifier;
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
public updateServerDisplayName = async (serverUrl: string, displayName: string) => {
|
|
const appDatabase = this.appDatabase?.database;
|
|
if (appDatabase) {
|
|
const server = await this.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 this.getServer(serverUrl);
|
|
await appDatabase.write(async () => {
|
|
await server?.update((record) => {
|
|
record.persistenceFlag = persistenceFlag;
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
private isServerPresent = async (serverUrl: string): Promise<boolean> => {
|
|
const server = await this.getServer(serverUrl);
|
|
return Boolean(server);
|
|
};
|
|
|
|
public getActiveServerUrl = async (): Promise<string|undefined> => {
|
|
const server = await this.getActiveServer();
|
|
return server?.url;
|
|
};
|
|
|
|
public getActiveServerDisplayName = async (): Promise<string|undefined> => {
|
|
const server = await this.getActiveServer();
|
|
return server?.displayName;
|
|
};
|
|
|
|
public getServerUrlFromIdentifier = async (identifier: string): Promise<string|undefined> => {
|
|
const server = await this.getServerByIdentifier(identifier);
|
|
return server?.url;
|
|
};
|
|
|
|
public getAppDatabaseAndOperator = () => {
|
|
const app = this.appDatabase;
|
|
if (!app) {
|
|
throw new Error('App database not found');
|
|
}
|
|
|
|
return app;
|
|
};
|
|
|
|
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];
|
|
};
|
|
|
|
public getActiveServerDatabase = async (): Promise<Database|undefined> => {
|
|
const server = await this.getActiveServer();
|
|
if (server?.url) {
|
|
return this.serverDatabases[server.url]!.database;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
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});
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
public wipeServerData = async (serverUrl: string): Promise<void> => {
|
|
const server = await this.getServer(serverUrl);
|
|
if (server) {
|
|
delete this.serverDatabases[serverUrl];
|
|
await this.deleteServerDatabaseFiles(serverUrl);
|
|
await this.createServerDatabase({
|
|
config: {
|
|
dbName: urlSafeBase64Encode(serverUrl),
|
|
displayName: server.displayName,
|
|
identifier: '',
|
|
serverUrl,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
public deleteServerDatabase = async (serverUrl: string): Promise<void> => {
|
|
const database = this.appDatabase?.database;
|
|
if (database) {
|
|
const server = await this.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);
|
|
}
|
|
}
|
|
};
|
|
|
|
public destroyServerDatabase = async (serverUrl: string): Promise<void> => {
|
|
const database = this.appDatabase?.database;
|
|
if (database) {
|
|
const server = await this.getServer(serverUrl);
|
|
if (server) {
|
|
database.write(async () => {
|
|
await server.destroyPermanently();
|
|
});
|
|
|
|
delete this.serverDatabases[serverUrl];
|
|
this.deleteServerDatabaseFiles(serverUrl);
|
|
}
|
|
}
|
|
};
|
|
|
|
public deleteServerDatabaseFiles = async (serverUrl: string): Promise<void> => {
|
|
const databaseName = urlSafeBase64Encode(serverUrl);
|
|
|
|
if (Platform.OS === 'ios') {
|
|
// On iOS, we'll delete the *.db file under the shared app-group/databases folder
|
|
deleteIOSDatabase({databaseName});
|
|
return;
|
|
}
|
|
|
|
// On Android, we'll delete both the *.db file and the *.db-journal file
|
|
const androidFilesDir = `${this.databaseDirectory}databases/`;
|
|
const databaseFile = `${androidFilesDir}${databaseName}.db`;
|
|
const databaseShm = `${androidFilesDir}${databaseName}.db-shm`;
|
|
const databaseWal = `${androidFilesDir}${databaseName}.db-wal`;
|
|
const databaseJournal = `${androidFilesDir}${databaseName}.db-journal`;
|
|
|
|
try {
|
|
new File(databaseFile).delete();
|
|
} catch {
|
|
// do nothing
|
|
}
|
|
|
|
try {
|
|
new File(databaseShm).delete();
|
|
} catch {
|
|
// do nothing
|
|
}
|
|
|
|
try {
|
|
new File(databaseWal).delete();
|
|
} catch {
|
|
// do nothing
|
|
}
|
|
|
|
try {
|
|
new File(databaseJournal).delete();
|
|
} catch {
|
|
// do nothing
|
|
}
|
|
};
|
|
|
|
factoryReset = (shouldRemoveDirectory: boolean): 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 File(androidFilesDir).delete();
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
private buildMigrationCallbacks = (dbName: string) => {
|
|
const migrationEvents = {
|
|
onSuccess: () => {
|
|
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_SUCCESS, {
|
|
dbName,
|
|
});
|
|
},
|
|
onStart: () => {
|
|
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_STARTED, {
|
|
dbName,
|
|
});
|
|
},
|
|
onError: (error: Error) => {
|
|
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_ERROR, {
|
|
dbName,
|
|
error,
|
|
});
|
|
},
|
|
};
|
|
|
|
return migrationEvents;
|
|
};
|
|
|
|
private getDatabaseFilePath = (dbName: string): string => {
|
|
return Platform.OS === 'ios' ? `${this.databaseDirectory}/${dbName}.db` : `${this.databaseDirectory}${dbName}.db`;
|
|
};
|
|
|
|
public searchUrl = (toFind: string): string | undefined => {
|
|
const toFindWithoutProtocol = removeProtocol(toFind);
|
|
return Object.keys(this.serverDatabases).find((k) => removeProtocol(k) === toFindWithoutProtocol);
|
|
};
|
|
|
|
// This actions already exists but the mock fails when using them cause of cyclic requires
|
|
private getServer = async (serverUrl: string) => {
|
|
try {
|
|
const {database} = this.getAppDatabaseAndOperator();
|
|
const servers = (await database.get<ServerModel>(SERVERS).query(Q.where('url', serverUrl)).fetch());
|
|
return servers?.[0];
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
private getAllServers = async () => {
|
|
try {
|
|
const {database} = this.getAppDatabaseAndOperator();
|
|
return database.get<ServerModel>(MM_TABLES.APP.SERVERS).query().fetch();
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
private getActiveServer = async () => {
|
|
try {
|
|
const servers = await this.getAllServers();
|
|
const server = servers?.filter((s) => s.identifier)?.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a));
|
|
return server;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
private getServerByIdentifier = async (identifier: string) => {
|
|
try {
|
|
const {database} = this.getAppDatabaseAndOperator();
|
|
const servers = (await database.get<ServerModel>(SERVERS).query(Q.where('identifier', identifier)).fetch());
|
|
return servers?.[0];
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
}
|
|
|
|
const DatabaseManager = new DatabaseManagerSingleton();
|
|
export default DatabaseManager;
|