// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Database, Q} from '@nozbe/watermelondb'; import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'; import logger from '@nozbe/watermelondb/utils/common/logger'; import {DeviceEventEmitter, Platform} from 'react-native'; import FileSystem from 'react-native-fs'; 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, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, } 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 {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 ServerModel from '@typings/database/models/app/servers'; const {SERVERS} = MM_TABLES.APP; const APP_DATABASE = 'app'; if (__DEV__) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore logger.silence(); } class DatabaseManager { 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, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, ]; this.databaseDirectory = ''; } public init = async (serverUrls: string[]): Promise => { await this.createAppDatabase(); for await (const serverUrl of serverUrls) { await this.initServerDatabase(serverUrl); } this.appDatabase?.operator.handleInfo({ info: [{ build_number: '123', created_at: Date.now(), version_number: '2.0.0', }], prepareRecordsOnly: false, }); }; private createAppDatabase = async (): Promise => { try { const modelClasses = this.appModels; const schema = appSchema; const adapter = new LokiJSAdapter({dbName: APP_DATABASE, migrations: AppDatabaseMigrations, schema, useWebWorker: false, useIncrementalIndexedDB: true}); 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 => { const {dbName, displayName, identifier, serverUrl} = config; if (serverUrl) { try { const databaseFilePath = this.getDatabaseFilePath(dbName); const migrations = ServerDatabaseMigrations; const modelClasses = this.serverModels; const schema = serverSchema; const adapter = new LokiJSAdapter({dbName, migrations, schema, useWebWorker: false, useIncrementalIndexedDB: true}); // 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 => { await this.createServerDatabase({ config: { dbName: urlSafeBase64Encode(serverUrl), dbType: DatabaseType.SERVER, serverUrl, }, }); }; private addServerToAppDatabase = async ({databaseFilePath, displayName, identifier = '', serverUrl}: RegisterServerDatabaseArgs): Promise => { 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; }); }); } }; private isServerPresent = async (serverUrl: string): Promise => { const server = await this.getServer(serverUrl); return Boolean(server); }; public getActiveServerUrl = async (): Promise => { const server = await this.getActiveServer(); return server?.url; }; public getActiveServerDisplayName = async (): Promise => { const server = await this.getActiveServer(); return server?.displayName; }; public getServerUrlFromIdentifier = async (identifier: string): Promise => { 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 getActiveServerDatabase = async (): Promise => { const server = await this.getActiveServer(); if (server?.url) { return this.serverDatabases[server.url]!.database; } return undefined; }; public setActiveServerDatabase = async (serverUrl: string): Promise => { 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) { servers[0].update((server: ServersModel) => { server.lastActiveAt = Date.now(); }); } }); } }; public deleteServerDatabase = async (serverUrl: string): Promise => { const database = this.appDatabase?.database; if (database) { const server = await this.getServer(serverUrl); if (server) { database.write(async () => { await server.update((record) => { record.lastActiveAt = 0; record.identifier = ''; }); }); delete this.serverDatabases[serverUrl]; this.deleteServerDatabaseFiles(serverUrl); } } }; public destroyServerDatabase = async (serverUrl: string): Promise => { 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); } } }; private deleteServerDatabaseFiles = async (serverUrl: string): Promise => { 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 databaseJournal = `${androidFilesDir}${databaseName}.db-journal`; try { await FileSystem.unlink(databaseFile); } catch { // do nothing } try { await FileSystem.unlink(databaseJournal); } catch { // do nothing } }; factoryReset = async (shouldRemoveDirectory: boolean): Promise => { 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/`; await FileSystem.unlink(androidFilesDir); 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(SERVERS).query(Q.where('url', serverUrl)).fetch()); return servers?.[0]; } catch { return undefined; } }; private getAllServers = async () => { try { const {database} = this.getAppDatabaseAndOperator(); return database.get(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(SERVERS).query(Q.where('identifier', identifier)).fetch()); return servers?.[0]; } catch { return undefined; } }; } export default new DatabaseManager();