// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. 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 {deleteAsync, documentDirectory, getInfoAsync, makeDirectoryAsync, moveAsync} from 'expo-file-system'; import {DeviceEventEmitter, Platform} from 'react-native'; 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, 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, ConfigModel, } 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 {beforeUpgrade} from '@helpers/database/upgrade'; import {getActiveServer, getServer, getServerByIdentifier} from '@queries/app/servers'; 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'; const {SERVERS} = MM_TABLES.APP; const APP_DATABASE = 'app'; 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 = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${documentDirectory}/databases/`; } /** * init : Retrieves all the servers registered in the default database * @param {string[]} serverUrls * @returns {Promise} */ public init = async (serverUrls: string[]): Promise => { await this.createAppDatabase(); 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, }); }; /** * createAppDatabase: Creates the App database. However, * if a database could not be created, it will return undefined. * @returns {Promise} */ private createAppDatabase = async (): Promise => { try { const databaseName = APP_DATABASE; if (Platform.OS === 'android') { await makeDirectoryAsync(this.databaseDirectory!, {intermediates: 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} */ public createServerDatabase = async ({config}: CreateServerDatabaseArgs): Promise => { 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 adapter = new SQLiteAdapter({ dbName: databaseFilePath, migrationEvents: this.buildMigrationCallbacks(databaseName), migrations, jsi: true, schema, }); // 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) { logError('Error initializing database', e); } } return undefined; }; /** * initServerDatabase : initializes the server database. * @param {string} serverUrl * @returns {Promise} */ private initServerDatabase = async (serverUrl: string): Promise => { 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} */ private addServerToAppDatabase = async ({databaseFilePath, displayName, serverUrl, identifier = ''}: RegisterServerDatabaseArgs): Promise => { 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; }); }); } }; /** * isServerPresent : Confirms if the current serverUrl does not already exist in the database * @param {String} serverUrl * @returns {Promise} */ private isServerPresent = async (serverUrl: string): Promise => { const server = await getServer(serverUrl); return Boolean(server); }; /** * getActiveServerUrl: Get the server url for active server database. * @returns {Promise} */ public getActiveServerUrl = async (): Promise => { const server = await getActiveServer(); return server?.url; }; /** * getActiveServerDisplayName: Get the server display name for active server database. * @returns {Promise} */ public getActiveServerDisplayName = async (): Promise => { const server = await getActiveServer(); return server?.displayName; }; public getServerUrlFromIdentifier = async (identifier: string): Promise => { const server = await getServerByIdentifier(identifier); return server?.url; }; /** * getActiveServerDatabase: Get the record for active server database. * @returns {Promise} */ public getActiveServerDatabase = async (): Promise => { 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; }; /** * setActiveServerDatabase: Set the new active server database. * This method should be called when switching to another server. * @param {string} serverUrl * @returns {Promise} */ 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(); }); } }); } }; /** * 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} */ public deleteServerDatabase = async (serverUrl: string): Promise => { const database = this.appDatabase?.database; if (database) { const server = await getServer(serverUrl); if (server) { database.write(async () => { await server.update((record) => { record.lastActiveAt = 0; record.identifier = ''; }); }); delete this.serverDatabases[serverUrl]; 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} */ public destroyServerDatabase = async (serverUrl: string): Promise => { 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); } } }; /** * deleteServerDatabaseFiles: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. * @param {string} serverUrl * @returns {Promise} */ private deleteServerDatabaseFiles = async (serverUrl: string): Promise => { 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} */ private deleteServerDatabaseFilesByName = async (databaseName: string): Promise => { 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 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`; await deleteAsync(databaseFile, {idempotent: true}); await deleteAsync(databaseShm, {idempotent: true}); await deleteAsync(databaseWal, {idempotent: true}); }; /** * 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} */ private renameDatabase = async (databaseName: string, newDBName: string): Promise => { 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 ((await getInfoAsync(newDatabaseFile)).exists) { // Already renamed, do not try return; } if (!(await getInfoAsync(databaseFile)).exists) { // Nothing to rename, do not try return; } try { await moveAsync({from: databaseFile, to: newDatabaseFile}); await moveAsync({from: databaseShm, to: newDatabaseShm}); await moveAsync({from: databaseWal, to: newDatabaseWal}); } catch (error) { // Do nothing } }; /** * factoryReset: Removes the databases directory and all its contents on the respective platform * @param {boolean} shouldRemoveDirectory * @returns {Promise} */ 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 deleteAsync(androidFilesDir); 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) => { 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); 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__) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore logger.silence(); } export default new DatabaseManager();