nexo/apps/mattermost/app/managers/offline_persistence_manager/index.ts

415 lines
16 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppState, type AppStateStatus, type NativeEventSubscription} from 'react-native';
import {BehaviorSubject, combineLatest, type Subscription} from 'rxjs';
import {distinctUntilChanged, skip} from 'rxjs/operators';
import {wipeServerDatabaseWithRetry, wipeServerFiles} from '@actions/local/ephemeral_mode/wipe';
import {clearEphemeralModeState, setDisconnectedSince, setLastSeenTime, setOfflineSince} from '@actions/local/systems';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import PushNotifications from '@init/push_notifications';
import WebsocketManager from '@managers/websocket_manager';
import {getServer, getServerDisplayName} from '@queries/app/servers';
import {getDisconnectedSince, getLastSeenTime, getOfflineSince, observeConfigValue} from '@queries/servers/system';
import {navigateToScreen} from '@screens/navigation';
import {logError} from '@utils/log';
class OfflinePersistenceManagerSingleton {
private offlineSubjects: {[serverUrl: string]: BehaviorSubject<boolean>} = {};
private disconnectionTimers: Record<string, NodeJS.Timeout> = {};
private purgeTimers: Record<string, NodeJS.Timeout> = {};
private wsSubscriptions: Record<string, Subscription> = {};
private configSubscriptions: Record<string, Subscription> = {};
private appStateSubscription?: NativeEventSubscription;
private activeServers = new Set<string>();
private thresholdMs: Record<string, number> = {};
private purgeThresholdMs: Record<string, number> = {};
private wipeInProgress = new Set<string>();
private evalQueue: Record<string, Promise<unknown>> = {};
// Serialise evaluations per server so concurrent emissions (config, WS state,
// AppState) cannot interleave their read-modify-write of persisted timer state.
private enqueueEval = (serverUrl: string, fn: () => Promise<void>): Promise<void> => {
const previous = this.evalQueue[serverUrl] ?? Promise.resolve();
const next = previous.then(fn).catch((error) => {
logError('OfflinePersistenceManager.enqueueEval', error);
});
this.evalQueue[serverUrl] = next;
return next;
};
public init = async (serverCredentials: ServerCredential[]) => {
for (const url of Object.keys(this.configSubscriptions)) {
this.removeServer(url);
}
await Promise.all(serverCredentials.map(async ({serverUrl}) => {
try {
const server = await getServer(serverUrl);
if (server && server.persistenceFlag === 'wiped') {
// Recover from a wipe interrupted by app termination before
// the DB + file artifacts were both deleted.
await this.wipeServerArtifacts(serverUrl);
}
this.addServer(serverUrl);
} catch (error) {
logError('OfflinePersistenceManager.init', error);
}
}));
};
public cleanup = () => {
for (const url of Object.keys(this.configSubscriptions)) {
this.removeServer(url);
}
this.wipeInProgress.clear();
};
public removeServer = (serverUrl: string) => {
this.pauseSubscriptions(serverUrl);
delete this.evalQueue[serverUrl];
};
// Tears down everything removeServer does except evalQueue, so callers that
// hold the eval lock (runWipe) can pause subs without orphaning the in-flight
// promise that serializes work for this server.
private pauseSubscriptions = (serverUrl: string) => {
if (this.activeServers.has(serverUrl)) {
this.deactivate(serverUrl, {silent: true});
}
this.configSubscriptions[serverUrl]?.unsubscribe();
delete this.configSubscriptions[serverUrl];
delete this.thresholdMs[serverUrl];
delete this.purgeThresholdMs[serverUrl];
delete this.offlineSubjects[serverUrl];
};
public isOffline = (serverUrl: string): boolean => {
return this.offlineSubjects[serverUrl]?.getValue() ?? false;
};
private addServer = (serverUrl: string) => {
if (this.configSubscriptions[serverUrl]) {
return;
}
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
this.configSubscriptions[serverUrl] = combineLatest([
observeConfigValue(database, 'MobileEphemeralModeEnabled'),
observeConfigValue(database, 'MobileEphemeralModeDisconnectionTimeoutSeconds'),
observeConfigValue(database, 'MobileEphemeralModeOfflinePersistenceTimerHours'),
]).pipe(
distinctUntilChanged(
([prevEnabled, prevTimeout, prevPurgeHours], [nextEnabled, nextTimeout, nextPurgeHours]) =>
prevEnabled === nextEnabled && prevTimeout === nextTimeout && prevPurgeHours === nextPurgeHours,
),
).subscribe(([enabledStr, timeoutStr, purgeHoursStr]) => {
this.onEphemeralModeConfigChange(serverUrl, enabledStr, timeoutStr, purgeHoursStr);
});
};
private onEphemeralModeConfigChange = async (
serverUrl: string,
enabledStr: string | undefined,
timeoutStr: string | undefined,
purgeHoursStr: string | undefined,
) => {
const nextEnabled = enabledStr === 'true';
const nextThresholdMs = Math.max(0, Number(timeoutStr ?? '0')) * 1000;
const nextPurgeThresholdMs = Math.max(0, Number(purgeHoursStr ?? '0')) * 3600 * 1000;
const wasActive = this.activeServers.has(serverUrl);
this.thresholdMs[serverUrl] = nextThresholdMs;
this.purgeThresholdMs[serverUrl] = nextPurgeThresholdMs;
if (nextEnabled && !wasActive) {
this.activate(serverUrl);
return;
}
if (!nextEnabled && wasActive) {
this.deactivate(serverUrl);
return;
}
if (nextEnabled && wasActive) {
this.enqueueEval(serverUrl, async () => {
await this.evaluateServer(serverUrl);
if (this.isOffline(serverUrl)) {
await this.evaluatePurge(serverUrl);
}
});
return;
}
// Disabled + inactive: clear any stale row left over from a prior enabled session.
await setDisconnectedSince(serverUrl, null);
};
private activate = (serverUrl: string) => {
this.activeServers.add(serverUrl);
this.ensureAppStateListener();
// skip(1) drops the BehaviorSubject's replay of the current WS state so it
// isn't mistaken for a fresh transition; persisted state drives the resume.
this.wsSubscriptions[serverUrl] = WebsocketManager.observeWebsocketState(serverUrl).pipe(
skip(1),
).subscribe(() => {
this.enqueueEval(serverUrl, () => this.evaluateServer(serverUrl));
});
this.enqueueEval(serverUrl, () => this.evaluateServer(serverUrl, true));
};
private deactivate = async (serverUrl: string, {silent = false}: {silent?: boolean} = {}) => {
this.activeServers.delete(serverUrl);
this.wsSubscriptions[serverUrl]?.unsubscribe();
delete this.wsSubscriptions[serverUrl];
this.clearDisconnectionTimer(serverUrl);
this.clearPurgeTimer(serverUrl);
if (!silent) {
// Server is still alive (feature disabled at runtime); flush offline state.
await this.flagOffline(serverUrl, false);
await setDisconnectedSince(serverUrl, null);
}
this.maybeRemoveAppStateListener();
};
private onAppStateChange = (appState: AppStateStatus) => {
if (appState !== 'active') {
return;
}
for (const url of this.activeServers) {
this.enqueueEval(url, async () => {
await this.evaluateServer(url);
if (this.isOffline(url)) {
await this.evaluatePurge(url);
}
});
}
};
private ensureAppStateListener = () => {
if (!this.appStateSubscription) {
this.appStateSubscription = AppState.addEventListener('change', this.onAppStateChange);
}
};
private maybeRemoveAppStateListener = () => {
if (this.activeServers.size === 0 && this.appStateSubscription) {
this.appStateSubscription.remove();
this.appStateSubscription = undefined;
}
};
private evaluateServer = async (serverUrl: string, isResume = false) => {
if (!this.activeServers.has(serverUrl)) {
return;
}
if (WebsocketManager.isConnected(serverUrl)) {
this.clearDisconnectionTimer(serverUrl);
await this.flagOffline(serverUrl, false);
await setDisconnectedSince(serverUrl, null);
return;
}
let database;
try {
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
} catch (error) {
logError('OfflinePersistenceManager.evaluateServer', error);
return;
}
const persisted = await getDisconnectedSince(database);
if (persisted === undefined) {
// Fresh timers only start on foreground transitions, not on init-resume or while backgrounded.
if (isResume || AppState.currentState !== 'active') {
return;
}
const now = Date.now();
await setDisconnectedSince(serverUrl, now);
this.startDisconnectionTimer(serverUrl, this.thresholdMs[serverUrl]);
return;
}
const elapsed = Date.now() - persisted;
const threshold = this.thresholdMs[serverUrl];
if (elapsed >= threshold) {
this.clearDisconnectionTimer(serverUrl);
await this.flagOffline(serverUrl, true);
return;
}
await this.flagOffline(serverUrl, false);
this.startDisconnectionTimer(serverUrl, threshold - elapsed);
};
private startDisconnectionTimer = (serverUrl: string, remainingMs: number) => {
this.clearDisconnectionTimer(serverUrl);
if (remainingMs <= 0) {
this.enqueueEval(serverUrl, () => this.flagOffline(serverUrl, true));
return;
}
this.disconnectionTimers[serverUrl] = setTimeout(() => {
delete this.disconnectionTimers[serverUrl];
this.enqueueEval(serverUrl, () => this.flagOffline(serverUrl, true));
}, remainingMs);
};
private clearDisconnectionTimer = (serverUrl: string) => {
const handle = this.disconnectionTimers[serverUrl];
if (handle) {
clearTimeout(handle);
delete this.disconnectionTimers[serverUrl];
}
};
private clearPurgeTimer = (serverUrl: string) => {
const handle = this.purgeTimers[serverUrl];
if (handle) {
clearTimeout(handle);
delete this.purgeTimers[serverUrl];
}
};
private flagOffline = async (serverUrl: string, value: boolean) => {
const subject = this.getOfflineSubject(serverUrl);
const prev = subject.getValue();
subject.next(value);
if (prev === value) {
return;
}
if (value) {
await this.onTransitionToOffline(serverUrl);
} else {
await this.onTransitionToOnline(serverUrl);
}
};
private onTransitionToOffline = async (serverUrl: string) => {
let database;
try {
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
} catch (error) {
logError('OfflinePersistenceManager.onTransitionToOffline', error);
return;
}
const existing = await getOfflineSince(database);
if (existing === undefined) {
// Derive offline_since from disconnected_since so a resume-past-threshold
// doesn't reset the start of the purge window to "now".
const disconnectedSince = await getDisconnectedSince(database);
const offlineSince = disconnectedSince === undefined ? Date.now() : disconnectedSince + this.thresholdMs[serverUrl];
await setOfflineSince(serverUrl, offlineSince);
await setLastSeenTime(serverUrl, Date.now());
}
await this.evaluatePurge(serverUrl);
};
private onTransitionToOnline = async (serverUrl: string) => {
this.clearPurgeTimer(serverUrl);
await clearEphemeralModeState(serverUrl);
};
private evaluatePurge = async (serverUrl: string) => {
if (!this.isOffline(serverUrl)) {
return;
}
const server = await getServer(serverUrl);
if (server && server.persistenceFlag === 'wiped') {
this.clearPurgeTimer(serverUrl);
return;
}
let database;
try {
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
} catch (error) {
logError('OfflinePersistenceManager.evaluatePurge', error);
return;
}
const lastSeen = await getLastSeenTime(database);
let offlineSince = await getOfflineSince(database);
if (offlineSince === undefined) {
// Defensive: flag says offline but no anchor persisted — fire immediately
// rather than risk missing the purge entirely.
await this.runWipe(serverUrl);
return;
}
const now = Date.now();
if (lastSeen !== undefined && now < lastSeen) {
const offset = lastSeen - now;
offlineSince -= offset;
await setOfflineSince(serverUrl, offlineSince);
}
await setLastSeenTime(serverUrl, now);
const purgeThresholdMs = this.purgeThresholdMs[serverUrl];
const remainingMs = (offlineSince + purgeThresholdMs) - now;
this.clearPurgeTimer(serverUrl);
if (remainingMs <= 0) {
await this.runWipe(serverUrl);
return;
}
this.purgeTimers[serverUrl] = setTimeout(() => {
delete this.purgeTimers[serverUrl];
this.enqueueEval(serverUrl, () => this.runWipe(serverUrl));
}, remainingMs);
};
private wipeServerArtifacts = (serverUrl: string) => {
PushNotifications.removeServerNotifications(serverUrl);
return Promise.all([
wipeServerDatabaseWithRetry(serverUrl),
wipeServerFiles(serverUrl),
]);
};
private runWipe = async (serverUrl: string) => {
if (this.wipeInProgress.has(serverUrl)) {
return;
}
this.wipeInProgress.add(serverUrl);
try {
const activeUrl = await DatabaseManager.getActiveServerUrl();
const displayName = (await getServerDisplayName(serverUrl)) || serverUrl;
if (serverUrl === activeUrl) {
navigateToScreen(Screens.DATA_ERASED, {serverUrl, displayName}, true);
}
await DatabaseManager.updatePersistenceFlag(serverUrl, 'wiped');
this.pauseSubscriptions(serverUrl);
const [{success}] = await this.wipeServerArtifacts(serverUrl);
if (!success) {
logError('OfflinePersistenceManager.runWipe: wipe failed after retries, server re-added with stale data', serverUrl);
}
this.addServer(serverUrl);
} catch (error) {
logError('OfflinePersistenceManager.runWipe', error);
} finally {
this.wipeInProgress.delete(serverUrl);
}
};
private getOfflineSubject = (serverUrl: string) => {
if (!this.offlineSubjects[serverUrl]) {
this.offlineSubjects[serverUrl] = new BehaviorSubject<boolean>(false);
}
return this.offlineSubjects[serverUrl];
};
}
const OfflinePersistenceManager = new OfflinePersistenceManagerSingleton();
export default OfflinePersistenceManager;