mattermost-mobile-upstream/app/database/manager/__mocks__/index.ts
Pablo Vélez 7df4a0ab97
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
Mm 68219 show placeholder for redacted files (#9719)
* MM-68219 - Add redacted files placeholder and related functionality

* add feature flag control and e2e tests

* adjust placeholder design, polish code, fix permalink preview issue

* polish code and add missing test

* use import path with right path

* gate post refetch behind the feature flag, refetch the open thread and avoid observeCount

* restore filesInfo for the permalink files prop added upstream

* fix stale redaction on background servers, consume the flag on every fetch path and share the viewport emitter

* replace the in-memory redaction flag with a persisted per-post epoch so ABAC file decisions survive restarts and stale posts stay hidden

* raise the redaction epoch on every resync, capture the epoch counter, only invalidate channels on role changes, and store the newer epoch when a re-verified post is otherwise unchanged

* store superseded responses unverified instead of dropping them, keep the since-fetch when re-verifying, verify permalink embeds against the linked channel, and capture the websocket epoch on arrival

* nvalidate file decisions when a user attribute field changes or all its values are cleared

* batch channel invalidations, close file viewers only when affected, reset the epoch on unstamped redaction changes, and only  re-verify the newest page's attachment posts

* re-check older channel posts a page at a time instead of one request per post

* skip the unstamped epoch lookup for posts with no files, redacted count or permalink

* keep the attribute-view retry after later invalidations, retry linked posts unless refused for the current epoch, and stop older payloads from rolling back edits via the redaction check

* MM-70905 - disable and grey out the attachment button when an ABAC pe… (#10195)

* MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel

* only back off render permission fetches when the server request fails, and log other errors without retrying

* remove the disableServer helper and have removeServer do both steps

* split defaultAllowed into allowWhenUnenforced and allowWhenNotEvaluated, and treat render permissions as unenforced  on servers without the decisions API

* MM-69627: Add playbook task requirements support on mobile (#10035)

* MM-69627: Add playbook task requirements support on mobile

Allow filling required task fields when checking off checklist items during
a run, gated by the same Playbooks beta setting used on web.

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

* MM-69627: Address PR review feedback for task requirements

Propagate settings write failures, harden fill-requirements save/title handling, and add missing unit coverage.

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

* MM-69627: Fix TypeScript errors in new unit tests

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

* MM-69627: Use defineMessages for fill requirements titles

Keep i18n extraction able to find the header titles after moving them to the route.

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

* MM-69627: Address review feedback for task requirements

Update checklist items locally when saving requirement values instead of
refetching the run, add missing query/navigation tests, and apply small
callback/reduce cleanups from review.

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

* MM-69627: Fix task requirements review follow-ups

Propagate local checklist write failures, clear a stale task-requirements
flag when playbooks is unsupported, and cap fill values at the desktop/
server max length.

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

* MM-69627: Move requirement field into its own component

Keep one component per file on the fill-requirements screen.

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

---------

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

* MM-69122: Add file element support to interactive dialogs (#9898)

* Implement Interactive Dialog - file upload

* update demo plugin version for e2etests

* fix and add e2etests

* review fixes

* Fix version bump checkout when a tag shares the branch name (#10229)

* Fix Android crashes caused by animated custom emoji (#10030)

* Route GIF decoding through Glide

- Keep APNG4Android for APNG, animated WebP, and animated AVIF support.
- Prepend Glide's GIF decoders to avoid APNG4Android's native GIF overflow.

* Clear Expo image resources with Glide targets

- Detach drawables when Glide clears their custom target.
- Prevent recycled GIF frame resources from remaining attached to Expo image views.

* Preserve foreground image during GIF target recycling

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>

* detox - fix clobbering of feature flags that cause failures in other suites.  Also small fix of a incorrectly set test-id

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>
Co-authored-by: Nate Schlossberg <nate.schlossberg@mattermost.com>
2026-10-15 15:59:13 -07:00

485 lines
19 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, serverUrl);
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;