Biometric prompt, Jailbreak / Root detection and screenshot prevention (#8645)

* Handle biometric authentication

* jailbreak/root detection and biometric small fixes

* remove server from initializeSecurityManager and fix loginEntry

* Add screen capture prevention and other small fixes

* added unit tests to SecurityManager

* added shielded nativeID to protect views

* use MobilePreventScreenCapture instead of MobileAllowScreenshots in config type definition

* Apply Swizzle for screen capture on iOS

* Apply patch to bottom sheet to prevent screen captures

* fix ios sendReply

* Fix SDWebImage swizzle to use the correct session

* Fix potential crash on Android when using hardware keyboard

* rename patch for network library to remove warning

* add temp emm reference

* fix initializeSecurityManager tests

* fix translations

* use siteName for jailbreak detection when connecting to a new server

* fix i18n typo

* do not query the entire config from the db only the required fields

* migrate manage_apps to use defineMessages

* use TestHelper.wait in tests

* use defineMessages for security manager

* fix missing else statement for gm_to_channel

* created a TestHelper function to mockQuery and replace as jest.Mock with jest.mocked

* fix unit tests

* fix unit tests (again) and include setting the test environment to UTC

* Fix keyboard disappearing on iOS

* update react-native-emm
This commit is contained in:
Elias Nahum 2025-03-14 02:07:41 +08:00 committed by GitHub
parent d84a0afd3a
commit a5a1e53827
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 1923 additions and 253 deletions

View file

@ -0,0 +1,207 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createIntl} from 'react-intl';
import {Navigation} from 'react-native-navigation';
import {doPing} from '@actions/remote/general';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {Preferences, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getServer, getServerByIdentifier, queryAllActiveServers} from '@queries/app/servers';
import {getSecurityConfig} from '@queries/servers/system';
import TestHelper from '@test/test_helper';
import {logError} from '@utils/log';
import {canReceiveNotifications} from '@utils/push_proxy';
import {alertServerAlreadyConnected, alertServerError, loginToServer} from '@utils/server';
import * as Actions from './server';
import type {ServerDatabase} from '@typings/database/database';
import type ServersModel from '@typings/database/models/app/servers';
jest.mock('@queries/app/servers');
jest.mock('@queries/servers/system');
jest.mock('@database/manager', () => ({
getServerDatabaseAndOperator: jest.fn(),
setActiveServerDatabase: jest.fn(),
getActiveServerUrl: jest.fn(),
}));
jest.mock('@managers/security_manager');
jest.mock('@managers/websocket_manager');
jest.mock('@utils/log');
jest.mock('@utils/push_proxy');
jest.mock('@utils/server');
jest.mock('@actions/remote/general');
jest.mock('@actions/remote/systems');
jest.mock('react-native-navigation');
const translations = getTranslations(DEFAULT_LOCALE);
const intl = createIntl({locale: DEFAULT_LOCALE, messages: translations});
const theme = Preferences.THEMES.denim;
describe('initializeSecurityManager', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return when no servers are found', async () => {
const mockQuery = TestHelper.mockQuery<ServersModel>([]);
jest.mocked(queryAllActiveServers).mockReturnValueOnce(mockQuery);
await Actions.initializeSecurityManager();
expect(SecurityManager.init).not.toHaveBeenCalled();
});
it('should initialize SecurityManager with querying configurations', async () => {
const servers = [{url: 'server1'}, {url: 'server2'}] as ServersModel[];
const mockQuery = TestHelper.mockQuery<ServersModel>(servers);
jest.mocked(queryAllActiveServers).mockReturnValueOnce(mockQuery);
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation((serverUrl) => ({database: `db_${serverUrl}`} as unknown as ServerDatabase));
const config = {
SiteName: 'Site',
MobileEnableBiometrics: 'true',
MobilePreventScreenCapture: 'true',
MobileJailbreakProtection: 'true',
} as unknown as ClientConfig;
jest.mocked(getSecurityConfig).mockImplementation((db) => Promise.resolve({
...config,
key: `config_${db}`,
}));
await Actions.initializeSecurityManager();
expect(SecurityManager.init).toHaveBeenCalledWith({
server1: {...config, key: 'config_db_server1'},
server2: {...config, key: 'config_db_server2'},
}, undefined);
});
it('should log error when querying configuration fails', async () => {
const servers = [{url: 'server1'}] as ServersModel[];
const mockQuery = TestHelper.mockQuery<ServersModel>(servers);
jest.mocked(queryAllActiveServers).mockReturnValueOnce(mockQuery);
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => {
throw new Error('test error');
});
await Actions.initializeSecurityManager();
expect(logError).toHaveBeenCalledWith('initializeSecurityManager', expect.any(Error));
});
});
// Tests for switchToServer
describe('switchToServer', () => {
it('should log error when server is not found', async () => {
jest.mocked(getServer).mockResolvedValueOnce(undefined);
await Actions.switchToServer('serverUrl', theme, intl, jest.fn());
expect(logError).toHaveBeenCalledWith('Switch to Server with url serverUrl not found');
});
it('should switch to server when lastActiveAt is set', async () => {
const server = {url: 'serverUrl', lastActiveAt: 123} as ServersModel;
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(SecurityManager.isDeviceJailbroken).mockResolvedValueOnce(false);
jest.mocked(SecurityManager.authenticateWithBiometricsIfNeeded).mockResolvedValueOnce(true);
await Actions.switchToServer('serverUrl', theme, intl, jest.fn());
expect(Navigation.updateProps).toHaveBeenCalledWith(Screens.HOME, {extra: undefined});
expect(DatabaseManager.setActiveServerDatabase).toHaveBeenCalledWith('serverUrl');
expect(SecurityManager.setActiveServer).toHaveBeenCalledWith('serverUrl');
expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('serverUrl', 'Server Switch');
});
it('should not proceed if device is jailbroken', async () => {
const server = {url: 'serverUrl', lastActiveAt: 123} as ServersModel;
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(SecurityManager.isDeviceJailbroken).mockResolvedValueOnce(true);
jest.mocked(SecurityManager.authenticateWithBiometricsIfNeeded).mockResolvedValueOnce(true);
await Actions.switchToServer('serverUrl', theme, intl, jest.fn());
expect(Navigation.updateProps).not.toHaveBeenCalled();
expect(DatabaseManager.setActiveServerDatabase).not.toHaveBeenCalled();
expect(SecurityManager.setActiveServer).not.toHaveBeenCalled();
expect(WebsocketManager.initializeClient).not.toHaveBeenCalled();
expect(SecurityManager.isDeviceJailbroken).toHaveBeenCalledWith('serverUrl');
});
});
// Tests for switchToServerAndLogin
describe('switchToServerAndLogin', () => {
it('should log error when server is not found', async () => {
jest.mocked(getServer).mockResolvedValueOnce(undefined);
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
expect(logError).toHaveBeenCalledWith('Switch to Server with url serverUrl not found');
});
it('should alert server error when ping fails', async () => {
const server = {url: 'serverUrl'} as ServersModel;
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(doPing).mockResolvedValueOnce({error: 'ping error'});
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
expect(alertServerError).toHaveBeenCalledWith(intl, 'ping error');
});
it('should alert server error when fetching config and license fails', async () => {
const server = {url: 'serverUrl'} as ServersModel;
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(doPing).mockResolvedValueOnce({});
jest.mocked(fetchConfigAndLicense).mockResolvedValueOnce({error: 'config error'});
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
expect(alertServerError).toHaveBeenCalledWith(intl, 'config error');
});
it('should alert server already connected when server is already connected', async () => {
const server = {url: 'serverUrl'} as ServersModel;
const config = {DiagnosticId: 'diagId', MobileEnableBiometrics: 'true', SiteName: 'Site'} as ClientConfig;
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(doPing).mockResolvedValueOnce({});
jest.mocked(fetchConfigAndLicense).mockResolvedValueOnce({config});
jest.mocked(getServerByIdentifier).mockResolvedValueOnce({lastActiveAt: 123} as ServersModel);
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
expect(alertServerAlreadyConnected).toHaveBeenCalledWith(intl);
});
it('should authenticate with biometrics and login to server', async () => {
const server = {url: 'serverUrl', displayName: 'Server'} as ServersModel;
const config = {DiagnosticId: 'diagId', MobileEnableBiometrics: 'true', SiteName: 'Site'} as ClientConfig;
const license = {} as ClientLicense;
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(doPing).mockResolvedValueOnce({});
jest.mocked(fetchConfigAndLicense).mockResolvedValueOnce({config, license});
jest.mocked(getServerByIdentifier).mockResolvedValueOnce(undefined);
jest.mocked(SecurityManager.authenticateWithBiometrics).mockResolvedValueOnce(true);
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
expect(canReceiveNotifications).toHaveBeenCalledWith('serverUrl', undefined, intl);
expect(loginToServer).toHaveBeenCalledWith(theme, 'serverUrl', 'Server', config, license);
});
it('should not proceed if device is jailbroken', async () => {
const server = {url: 'serverUrl'} as ServersModel;
const config = {DiagnosticId: 'diagId', MobileJailbreakProtection: 'true'} as ClientConfig;
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(doPing).mockResolvedValueOnce({});
jest.mocked(fetchConfigAndLicense).mockResolvedValueOnce({config});
jest.mocked(getServerByIdentifier).mockResolvedValueOnce(undefined);
jest.mocked(SecurityManager.isDeviceJailbroken).mockResolvedValueOnce(true);
const callback = jest.fn();
await Actions.switchToServerAndLogin('serverUrl', theme, intl, callback);
expect(SecurityManager.isDeviceJailbroken).toHaveBeenCalledWith('serverUrl');
expect(callback).toHaveBeenCalled();
});
});

125
app/actions/app/server.ts Normal file
View file

@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Navigation} from 'react-native-navigation';
import {doPing} from '@actions/remote/general';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getServer, getServerByIdentifier, queryAllActiveServers} from '@queries/app/servers';
import {getSecurityConfig} from '@queries/servers/system';
import {logError} from '@utils/log';
import {canReceiveNotifications} from '@utils/push_proxy';
import {alertServerAlreadyConnected, alertServerError, loginToServer} from '@utils/server';
import type {IntlShape} from 'react-intl';
export async function initializeSecurityManager() {
const servers = await queryAllActiveServers()?.fetch();
if (!servers?.length) {
return;
}
const promises: Array<Promise<[string, SecurityClientConfig | null]>> = [];
const results: Record<string, SecurityClientConfig> = {};
for (const server of servers) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(server.url);
const promise = getSecurityConfig(database).then((config) => [server.url, config] as [string, SecurityClientConfig | null]);
promises.push(promise);
} catch (error) {
logError('initializeSecurityManager', error);
continue;
}
}
const resolvedConfigs = await Promise.allSettled(promises);
for (const result of resolvedConfigs) {
if (result.status === 'fulfilled') {
const [url, config] = result.value;
if (config && Object.keys(config).length > 0) { // Ensure the object is not empty
results[url] = config;
}
}
}
const serverUrl = await DatabaseManager.getActiveServerUrl();
SecurityManager.init(results, serverUrl);
}
export async function switchToServer(serverUrl: string, theme: Theme, intl: IntlShape, callback?: () => void) {
const server = await getServer(serverUrl);
if (!server) {
logError(`Switch to Server with url ${serverUrl} not found`);
return;
}
if (server.lastActiveAt) {
const isJailbroken = await SecurityManager.isDeviceJailbroken(server.url);
if (isJailbroken) {
return;
}
const authenticated = await SecurityManager.authenticateWithBiometricsIfNeeded(server.url);
if (authenticated) {
Navigation.updateProps(Screens.HOME, {extra: undefined});
DatabaseManager.setActiveServerDatabase(server.url);
SecurityManager.setActiveServer(server.url);
WebsocketManager.initializeClient(server.url, 'Server Switch');
}
return;
}
switchToServerAndLogin(serverUrl, theme, intl, callback);
}
export async function switchToServerAndLogin(serverUrl: string, theme: Theme, intl: IntlShape, callback?: () => void) {
const server = await getServer(serverUrl);
if (!server) {
logError(`Switch to Server with url ${serverUrl} not found`);
return;
}
const result = await doPing(server.url, true);
if (result.error) {
alertServerError(intl, result.error);
callback?.();
return;
}
const data = await fetchConfigAndLicense(server.url, true);
if (data.error) {
alertServerError(intl, data.error);
callback?.();
return;
}
const existingServer = await getServerByIdentifier(data.config!.DiagnosticId);
if (existingServer && existingServer.lastActiveAt > 0) {
alertServerAlreadyConnected(intl);
callback?.();
return;
}
if (data.config?.MobileJailbreakProtection === 'true') {
const isJailbroken = await SecurityManager.isDeviceJailbroken(server.url);
if (isJailbroken) {
callback?.();
return;
}
}
let authenticated = true;
if (data.config?.MobileEnableBiometrics === 'true') {
authenticated = await SecurityManager.authenticateWithBiometrics(server.url, data.config?.SiteName);
}
if (authenticated) {
canReceiveNotifications(server.url, result.canReceiveNotifications as string, intl);
loginToServer(theme, server.url, server.displayName, data.config!, data.license!);
}
}

View file

@ -5,6 +5,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import {getServerCredentials} from '@init/credentials'; import {getServerCredentials} from '@init/credentials';
import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager'; import WebsocketManager from '@managers/websocket_manager';
type AfterLoginArgs = { type AfterLoginArgs = {
@ -33,8 +34,10 @@ export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?:
const credentials = await getServerCredentials(serverUrl); const credentials = await getServerCredentials(serverUrl);
if (credentials?.token) { if (credentials?.token) {
SecurityManager.addServer(serverUrl, clData.config, true);
WebsocketManager.createClient(serverUrl, credentials.token); WebsocketManager.createClient(serverUrl, credentials.token);
await WebsocketManager.initializeClient(serverUrl, 'Login'); await WebsocketManager.initializeClient(serverUrl, 'Login');
SecurityManager.setActiveServer(serverUrl);
} }
return {}; return {};

View file

@ -6,8 +6,11 @@ import {ScrollView} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import SecurityManager from '@managers/security_manager';
import {makeStyleSheetFromTheme} from '@utils/theme'; import {makeStyleSheetFromTheme} from '@utils/theme';
import type {AvailableScreens} from '@typings/screens/navigation';
const edges: Edge[] = ['left', 'right', 'bottom']; const edges: Edge[] = ['left', 'right', 'bottom'];
const getStyleSheet = makeStyleSheetFromTheme((theme) => { const getStyleSheet = makeStyleSheetFromTheme((theme) => {
@ -36,6 +39,7 @@ const SettingContainer = ({children, testID}: SettingContainerProps) => {
edges={edges} edges={edges}
style={styles.container} style={styles.container}
testID={`${testID}.screen`} testID={`${testID}.screen`}
nativeID={SecurityManager.getShieldScreenId(`${testID}.screen` as AvailableScreens)}
> >
<ScrollView <ScrollView
contentContainerStyle={styles.contentContainerStyle} contentContainerStyle={styles.contentContainerStyle}

View file

@ -67,7 +67,7 @@ export const setServerCredentials = (serverUrl: string, token: string) => {
}; };
export const removeServerCredentials = async (serverUrl: string) => { export const removeServerCredentials = async (serverUrl: string) => {
return KeyChain.resetInternetCredentials(serverUrl); return KeyChain.resetInternetCredentials({server: serverUrl});
}; };
export const removeActiveServerCredentials = async () => { export const removeActiveServerCredentials = async () => {

View file

@ -4,15 +4,55 @@
import Emm from '@mattermost/react-native-emm'; import Emm from '@mattermost/react-native-emm';
import deepEqual from 'deep-equal'; import deepEqual from 'deep-equal';
import {isRootedExperimentalAsync} from 'expo-device'; import {isRootedExperimentalAsync} from 'expo-device';
import {defineMessages} from 'react-intl';
import {Alert, type AlertButton, AppState, type AppStateStatus, Platform} from 'react-native'; import {Alert, type AlertButton, AppState, type AppStateStatus, Platform} from 'react-native';
import {DEFAULT_LOCALE, getTranslations, t} from '@i18n'; import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import {toMilliseconds} from '@utils/datetime'; import {toMilliseconds} from '@utils/datetime';
import {isMainActivity} from '@utils/helpers'; import {isMainActivity} from '@utils/helpers';
import {getIOSAppGroupDetails} from '@utils/mattermost_managed'; import {getIOSAppGroupDetails} from '@utils/mattermost_managed';
const PROMPT_IN_APP_PIN_CODE_AFTER = toMilliseconds({minutes: 5}); const PROMPT_IN_APP_PIN_CODE_AFTER = toMilliseconds({minutes: 5});
const messages = defineMessages({
blocked: {
id: 'mobile.managed.blocked_by',
defaultMessage: 'Blocked by {vendor}',
},
jailbreak: {
id: 'mobile.managed.jailbreak.emm',
defaultMessage: 'Jailbroken or rooted devices are not trusted by {vendor}.\n\nThe app will now close.',
},
exit: {
id: 'mobile.managed.exit',
defaultMessage: 'Exit',
},
securedBy: {
id: 'mobile.managed.secured_by',
defaultMessage: 'Secured by {vendor}',
},
androidSettings: {
id: 'mobile.managed.settings',
defaultMessage: 'Go to settings',
},
notSecuredVendorIOS: {
id: 'mobile.managed.not_secured.ios.vendor',
defaultMessage: 'This device must be secured with biometrics or passcode to use {vendor}.\n\nGo to Settings > Face ID & Passcode.',
},
notSecuredVendorAndroid: {
id: 'mobile.managed.not_secured.android.vendor',
defaultMessage: 'This device must be secured with a screen lock to use {vendor}.',
},
notSecuredIOS: {
id: 'mobile.managed.not_secured.ios',
defaultMessage: 'This device must be secured with biometrics or passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.',
},
notSecuredAndroid: {
id: 'mobile.managed.not_secured.android',
defaultMessage: 'This device must be secured with a screen lock to use Mattermost.',
},
});
class ManagedAppSingleton { class ManagedAppSingleton {
backgroundSince = 0; backgroundSince = 0;
enabled = false; enabled = false;
@ -94,11 +134,11 @@ class ManagedAppSingleton {
const locale = DEFAULT_LOCALE; const locale = DEFAULT_LOCALE;
const translations = getTranslations(locale); const translations = getTranslations(locale);
Alert.alert( Alert.alert(
translations[t('mobile.managed.blocked_by')].replace('{vendor}', this.vendor), translations[messages.blocked.id].replace('{vendor}', this.vendor),
translations[t('mobile.managed.jailbreak')]. translations[messages.jailbreak.id].
replace('{vendor}', this.vendor), replace('{vendor}', this.vendor),
[{ [{
text: translations[t('mobile.managed.exit')], text: translations[messages.exit.id],
style: 'destructive', style: 'destructive',
onPress: () => { onPress: () => {
Emm.exitApp(); Emm.exitApp();
@ -123,7 +163,7 @@ class ManagedAppSingleton {
if (authExpired) { if (authExpired) {
try { try {
const auth = await Emm.authenticate({ const auth = await Emm.authenticate({
reason: translations[t('mobile.managed.secured_by')].replace('{vendor}', this.vendor), reason: translations[messages.securedBy.id].replace('{vendor}', this.vendor),
fallback: true, fallback: true,
supressEnterPassword: true, supressEnterPassword: true,
}); });
@ -158,12 +198,12 @@ class ManagedAppSingleton {
}; };
showNotSecuredAlert = (translations: Record<string, string>) => { showNotSecuredAlert = (translations: Record<string, string>) => {
return new Promise(async (resolve) => { /* eslint-disable-line no-async-promise-executor */ return new Promise((resolve) => {
const buttons: AlertButton[] = []; const buttons: AlertButton[] = [];
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
buttons.push({ buttons.push({
text: translations[t('mobile.managed.settings')], text: translations[messages.androidSettings.id],
onPress: () => { onPress: () => {
Emm.openSecuritySettings(); Emm.openSecuritySettings();
}, },
@ -171,26 +211,22 @@ class ManagedAppSingleton {
} }
buttons.push({ buttons.push({
text: translations[t('mobile.managed.exit')], text: translations[messages.exit.id],
onPress: resolve, onPress: resolve,
style: 'cancel', style: 'cancel',
}); });
let message; let message;
if (Platform.OS === 'ios') { if (this.vendor) {
const {face} = await Emm.deviceSecureWith(); const platform = Platform.select({ios: messages.notSecuredVendorIOS.id, default: messages.notSecuredVendorAndroid.id});
message = translations[platform].replace('{vendor}', this.vendor);
if (face) {
message = translations[t('mobile.managed.not_secured.ios')];
} else {
message = translations[t('mobile.managed.not_secured.ios.touchId')];
}
} else { } else {
message = translations[t('mobile.managed.not_secured.android')]; const platform = Platform.select({ios: messages.notSecuredIOS.id, default: messages.notSecuredAndroid.id});
message = translations[platform];
} }
Alert.alert( Alert.alert(
translations[t('mobile.managed.blocked_by')].replace('{vendor}', this.vendor), translations[messages.blocked.id].replace('{vendor}', this.vendor),
message, message,
buttons, buttons,
{cancelable: false, onDismiss: () => resolve}, {cancelable: false, onDismiss: () => resolve},

View file

@ -0,0 +1,518 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm from '@mattermost/react-native-emm';
import {isRootedExperimentalAsync} from 'expo-device';
import {Alert, type AppStateStatus} from 'react-native';
import {switchToServer} from '@actions/app/server';
import {logout} from '@actions/remote/session';
import {Screens} from '@constants';
import {DEFAULT_LOCALE, getTranslations, t} from '@i18n';
import {getServerCredentials} from '@init/credentials';
import TestHelper from '@test/test_helper';
import {toMilliseconds} from '@utils/datetime';
import {logError} from '@utils/log';
import SecurityManager from '.';
jest.mock('@mattermost/react-native-emm', () => ({
isDeviceSecured: jest.fn(),
authenticate: jest.fn(),
openSecuritySettings: jest.fn(),
exitApp: jest.fn(),
enableBlurScreen: jest.fn(),
}));
jest.mock('expo-device', () => ({
isRootedExperimentalAsync: jest.fn(),
}));
jest.mock('@actions/app/server', () => ({switchToServer: jest.fn()}));
jest.mock('@actions/remote/session', () => ({logout: jest.fn()}));
jest.mock('@utils/datetime', () => ({toMilliseconds: jest.fn(() => 25000)}));
jest.mock('@utils/helpers', () => ({
isMainActivity: jest.fn(() => true),
isTablet: jest.fn(() => false),
}));
jest.mock('@utils/log', () => ({logError: jest.fn()}));
jest.mock('@init/managed_app', () => ({enabled: true, inAppPinCode: false}));
jest.mock('@init/credentials', () => ({getServerCredentials: jest.fn().mockResolvedValue({token: 'token'})}));
describe('SecurityManager', () => {
beforeEach(() => {
jest.clearAllMocks();
SecurityManager.initialized = false;
SecurityManager.serverConfig = {};
SecurityManager.activeServer = undefined;
});
describe('init', () => {
test('should initialize with servers', () => {
const servers: Record<string, any> = {
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
};
SecurityManager.init(servers, 'server-1');
expect(SecurityManager.serverConfig['server-1']).toBeDefined();
expect(SecurityManager.activeServer).toBe('server-1');
});
test('should initialize with servers and set active server', async () => {
const servers: Record<string, any> = {
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
'server-2': {SiteName: 'Server Two', MobileEnableBiometrics: 'false'},
};
await SecurityManager.init(servers, 'server-1');
expect(SecurityManager.serverConfig['server-1']).toBeDefined();
expect(SecurityManager.serverConfig['server-2']).toBeDefined();
expect(SecurityManager.activeServer).toBe('server-1');
});
test('should not reinitialize if already initialized', async () => {
const servers: Record<string, any> = {
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
};
SecurityManager.initialized = true;
await SecurityManager.init(servers, 'server-1');
expect(SecurityManager.serverConfig['server-1']).toBeUndefined();
});
test('should set active server and authenticate if not jailbroken', async () => {
const servers: Record<string, any> = {
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
};
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false);
const isDeviceJailbrokenSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken');
const authenticateWithBiometricsIfNeededSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded');
await SecurityManager.init(servers, 'server-1');
expect(SecurityManager.activeServer).toBe('server-1');
expect(isDeviceJailbrokenSpy).toHaveBeenCalledWith('server-1');
expect(authenticateWithBiometricsIfNeededSpy).toHaveBeenCalledWith('server-1');
});
test('should not authenticate if device is jailbroken', async () => {
const servers: Record<string, any> = {
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true', MobileJailbreakProtection: 'true'},
};
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
await SecurityManager.init(servers, 'server-1');
expect(SecurityManager.activeServer).toBe('server-1');
expect(SecurityManager.isDeviceJailbroken).toHaveBeenCalledWith('server-1');
expect(SecurityManager.authenticateWithBiometricsIfNeeded).not.toHaveBeenCalled();
});
test('should not set active server if not provided', async () => {
const servers: Record<string, any> = {
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
};
await SecurityManager.init(servers);
expect(SecurityManager.activeServer).toBeUndefined();
});
});
describe('addServer', () => {
test('should add server config with biometrics enabled', () => {
SecurityManager.addServer('server-1', {SiteName: 'Server One', MobileEnableBiometrics: 'true'} as ClientConfig);
expect(SecurityManager.serverConfig['server-1']).toEqual({
siteName: 'Server One',
Biometrics: true,
JailbreakProtection: false,
PreventScreenCapture: false,
authenticated: false,
});
});
test('should add server config with jailbreak protection enabled', () => {
SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobileJailbreakProtection: 'true'} as ClientConfig);
expect(SecurityManager.serverConfig['server-2']).toEqual({
siteName: 'Server Two',
Biometrics: false,
JailbreakProtection: true,
PreventScreenCapture: false,
authenticated: false,
});
});
test('should add server config with screen capture prevention enabled', () => {
SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as ClientConfig);
expect(SecurityManager.serverConfig['server-3']).toEqual({
siteName: 'Server Three',
Biometrics: false,
JailbreakProtection: false,
PreventScreenCapture: true,
authenticated: false,
});
});
test('should add server config with all features enabled', () => {
SecurityManager.addServer('server-4', {
SiteName: 'Server Four',
MobileEnableBiometrics: 'true',
MobileJailbreakProtection: 'true',
MobilePreventScreenCapture: 'true',
} as ClientConfig);
expect(SecurityManager.serverConfig['server-4']).toEqual({
siteName: 'Server Four',
Biometrics: true,
JailbreakProtection: true,
PreventScreenCapture: true,
authenticated: false,
});
});
test('should add server config with authenticated set to true', () => {
SecurityManager.addServer('server-5', {SiteName: 'Server Five'} as ClientConfig, true);
expect(SecurityManager.serverConfig['server-5']).toEqual({
siteName: 'Server Five',
Biometrics: false,
JailbreakProtection: false,
PreventScreenCapture: false,
authenticated: true,
});
});
test('should add server config without config', () => {
SecurityManager.addServer('server-6');
expect(SecurityManager.serverConfig['server-6']).toEqual({
siteName: undefined,
Biometrics: false,
JailbreakProtection: false,
PreventScreenCapture: false,
authenticated: false,
});
});
test('should update a server config previously added', () => {
SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as ClientConfig, true);
expect(SecurityManager.serverConfig['server-3']).toEqual({
siteName: 'Server Three',
Biometrics: false,
JailbreakProtection: false,
PreventScreenCapture: true,
authenticated: true,
});
});
});
describe('removeServer', () => {
test('should remove server config and active server', async () => {
const servers: Record<string, any> = {
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
'server-2': {SiteName: 'Server Two', MobileEnableBiometrics: 'false'},
};
await SecurityManager.init(servers, 'server-1');
SecurityManager.removeServer('server-1');
expect(SecurityManager.serverConfig['server-1']).toBeUndefined();
expect(SecurityManager.activeServer).toBeUndefined();
expect(SecurityManager.initialized).toBe(false);
});
test('should remove server config', () => {
SecurityManager.addServer('server-3');
SecurityManager.removeServer('server-3');
expect(SecurityManager.serverConfig['server-3']).toBeUndefined();
});
});
describe('getServerConfig', () => {
test('should return server config', () => {
SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as ClientConfig);
expect(SecurityManager.getServerConfig('server-4')?.siteName).toBe('Server Four');
});
test('should return undefined if server config does not exist', () => {
expect(SecurityManager.getServerConfig('server-5')).toBeUndefined();
});
});
describe('setActiveServer', () => {
test('should set active server and update lastAccessed', () => {
SecurityManager.addServer('server-5');
const before = Date.now();
SecurityManager.setActiveServer('server-5');
const after = Date.now();
expect(SecurityManager.activeServer).toBe('server-5');
expect(SecurityManager.serverConfig['server-5'].lastAccessed).toBeGreaterThanOrEqual(before);
expect(SecurityManager.serverConfig['server-5'].lastAccessed).toBeLessThanOrEqual(after);
});
test('should not set active server if server does not exist', () => {
SecurityManager.setActiveServer('server-6');
expect(SecurityManager.activeServer).toBeUndefined();
});
test('should update active server and lastAccessed if a different server is set', () => {
SecurityManager.addServer('server-7');
SecurityManager.addServer('server-8');
SecurityManager.setActiveServer('server-7');
const before = Date.now();
SecurityManager.setActiveServer('server-8');
const after = Date.now();
expect(SecurityManager.activeServer).toBe('server-8');
expect(SecurityManager.serverConfig['server-8'].lastAccessed).toBeGreaterThanOrEqual(before);
expect(SecurityManager.serverConfig['server-8'].lastAccessed).toBeLessThanOrEqual(after);
});
test('should not change active server or lastAccessed if the same server is set', async () => {
SecurityManager.addServer('server-9');
SecurityManager.setActiveServer('server-9');
const lastAccessed = SecurityManager.serverConfig['server-9'].lastAccessed;
SecurityManager.setActiveServer('server-9');
expect(SecurityManager.activeServer).toBe('server-9');
expect(SecurityManager.serverConfig['server-9'].lastAccessed).toBe(lastAccessed);
});
});
describe('isScreenCapturePrevented', () => {
test('should return true if screen capture prevention is enabled', () => {
SecurityManager.addServer('server-1', {SiteName: 'Server One', MobilePreventScreenCapture: 'true'} as ClientConfig);
expect(SecurityManager.isScreenCapturePrevented('server-1')).toBe(true);
});
test('should return false if screen capture prevention is disabled', () => {
SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobilePreventScreenCapture: 'false'} as ClientConfig);
expect(SecurityManager.isScreenCapturePrevented('server-2')).toBe(false);
});
test('should return false if server configuration does not exist', () => {
expect(SecurityManager.isScreenCapturePrevented('server-3')).toBe(false);
});
test('should return false if PreventScreenCapture property is not set', () => {
SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as ClientConfig);
expect(SecurityManager.isScreenCapturePrevented('server-4')).toBe(false);
});
});
describe('authenticateWithBiometricsIfNeeded', () => {
test('should handle biometric authentication if biometrics enabled and device secured', async () => {
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
jest.mocked(Emm.authenticate).mockResolvedValue(true);
SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as ClientConfig);
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-6')).resolves.toBe(true);
expect(Emm.isDeviceSecured).toHaveBeenCalled();
expect(Emm.authenticate).toHaveBeenCalled();
});
test('should not prompt for biometric authentication if biometrics enabled but device is not secured', async () => {
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(false);
const showNotSecuredAlertSpy = jest.spyOn(SecurityManager, 'showNotSecuredAlert');
SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as ClientConfig);
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-6')).resolves.toBe(false);
expect(showNotSecuredAlertSpy).toHaveBeenCalled();
expect(Emm.isDeviceSecured).toHaveBeenCalled();
expect(Emm.authenticate).not.toHaveBeenCalled();
});
test('should not attempt biometric authentication if biometrics not enabled', async () => {
SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'false'} as ClientConfig);
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-8')).resolves.toBe(true);
expect(Emm.isDeviceSecured).not.toHaveBeenCalled();
expect(Emm.authenticate).not.toHaveBeenCalled();
});
test('should resolve with true if biometric authentication succeeds', async () => {
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
jest.mocked(Emm.authenticate).mockResolvedValue(true);
SecurityManager.addServer('server-9', {MobileEnableBiometrics: 'true'} as ClientConfig);
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-9')).resolves.toBe(true);
expect(Emm.isDeviceSecured).toHaveBeenCalled();
expect(Emm.authenticate).toHaveBeenCalled();
});
test('should log error and resolve with false if biometric authentication fails', async () => {
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
jest.mocked(Emm.authenticate).mockResolvedValue(false);
SecurityManager.addServer('server-10', {MobileEnableBiometrics: 'true'} as ClientConfig);
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-10')).resolves.toBe(false);
expect(Emm.isDeviceSecured).toHaveBeenCalled();
expect(Emm.authenticate).toHaveBeenCalled();
expect(SecurityManager.getServerConfig('server-10')?.authenticated).toBe(false);
expect(logError).toHaveBeenCalled();
});
test('should log error and resolve with false if biometric authentication throws an error', async () => {
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
jest.mocked(Emm.authenticate).mockRejectedValue(new Error('Authorization cancelled'));
SecurityManager.addServer('server-11', {MobileEnableBiometrics: 'true'} as ClientConfig);
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-11')).resolves.toBe(false);
expect(Emm.isDeviceSecured).toHaveBeenCalled();
expect(Emm.authenticate).toHaveBeenCalled();
expect(logError).toHaveBeenCalled();
});
test('should not attempt biometric authentication if server was previously authenticated within 5 mins', async () => {
SecurityManager.addServer('server-12', {MobileEnableBiometrics: 'true'} as ClientConfig, true);
SecurityManager.serverConfig['server-12'].lastAccessed = Date.now() - toMilliseconds({minutes: 1});
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-12')).resolves.toBe(true);
expect(Emm.isDeviceSecured).not.toHaveBeenCalled();
expect(Emm.authenticate).not.toHaveBeenCalled();
});
test('should not attempt biometric authentication if server was previously failed authentication even though lastAccess is less than 5 mins', async () => {
SecurityManager.addServer('server-13', {MobileEnableBiometrics: 'true'} as ClientConfig);
SecurityManager.serverConfig['server-13'].authenticated = false;
SecurityManager.serverConfig['server-13'].lastAccessed = Date.now() - toMilliseconds({minutes: 1});
await SecurityManager.authenticateWithBiometricsIfNeeded('server-13');
expect(SecurityManager.serverConfig['server-13'].authenticated).toBe(false);
await expect(Emm.authenticate).toHaveBeenCalled();
});
});
describe('onAppStateChange', () => {
test('should handle app state changes', async () => {
SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'true'} as ClientConfig);
SecurityManager.setActiveServer('server-8');
await SecurityManager.onAppStateChange('background' as AppStateStatus);
expect(SecurityManager.backgroundSince).toBeGreaterThan(0);
await SecurityManager.onAppStateChange('active' as AppStateStatus);
expect(SecurityManager.backgroundSince).toBe(0);
});
test('should call biometric authentication app state changes', async () => {
const authenticateWithBiometrics = jest.spyOn(SecurityManager, 'authenticateWithBiometrics');
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false);
SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'true'} as ClientConfig);
SecurityManager.setActiveServer('server-8');
SecurityManager.onAppStateChange('background' as AppStateStatus);
SecurityManager.backgroundSince = Date.now() - toMilliseconds({minutes: 5, seconds: 1});
SecurityManager.onAppStateChange('active' as AppStateStatus);
await TestHelper.wait(300);
expect(authenticateWithBiometrics).toHaveBeenCalledWith('server-8');
});
});
describe('showNotSecuredAlert', () => {
test('should show not secured alert', async () => {
SecurityManager.addServer('server-9');
SecurityManager.showNotSecuredAlert('server-9', 'Test Site', getTranslations(DEFAULT_LOCALE));
await TestHelper.wait(300);
expect(Alert.alert).toHaveBeenCalled();
});
});
describe('goToPreviousServer', () => {
test('should switch to previous server', async () => {
SecurityManager.activeServer = undefined;
SecurityManager.addServer('server-10', {MobileEnableBiometrics: 'true'} as ClientConfig);
SecurityManager.setActiveServer('server-10');
SecurityManager.addServer('server-11', {MobileEnableBiometrics: 'true'} as ClientConfig);
SecurityManager.setActiveServer('server-11');
await SecurityManager.goToPreviousServer(['server-10', 'server-11']);
expect(switchToServer).toHaveBeenCalledWith('server-10', expect.anything(), expect.anything());
});
});
describe('buildAlertOptions', () => {
test('should build alert options with logout', async () => {
jest.mocked(getServerCredentials).mockResolvedValue({token: 'some-token', serverUrl: 'server-12', userId: 'me'});
SecurityManager.addServer('server-12', {MobileEnableBiometrics: 'true'} as ClientConfig);
SecurityManager.setActiveServer('server-12');
const translations = getTranslations(DEFAULT_LOCALE);
const buttons = await SecurityManager.buildAlertOptions('server-12', translations);
expect(buttons.length).toBeGreaterThan(0);
const logoutButton = buttons.find((button) => button.text === translations[t('mobile.managed.logout')]);
expect(logoutButton).toBeDefined();
logoutButton?.onPress?.();
expect(logout).toHaveBeenCalledWith('server-12', undefined);
});
});
describe('showDeviceNotTrustedAlert', () => {
test('should show device not trusted alert', async () => {
const server = 'server-13';
const siteName = 'Site Name';
const translations = getTranslations(DEFAULT_LOCALE);
await SecurityManager.showDeviceNotTrustedAlert(server, siteName, translations);
expect(Alert.alert).toHaveBeenCalledWith(
translations[t('mobile.managed.blocked_by')].replace('{vendor}', siteName),
translations[t('mobile.managed.jailbreak')].replace('{vendor}', siteName),
expect.any(Array),
{cancelable: false},
);
});
});
describe('showBiometricFailureAlert', () => {
test('should show biometric failure alert', async () => {
const server = 'server-14';
const siteName = 'Site Name';
const translations = getTranslations(DEFAULT_LOCALE);
await SecurityManager.showBiometricFailureAlert(server, false, siteName, translations);
expect(Alert.alert).toHaveBeenCalledWith(
translations[t('mobile.managed.blocked_by')].replace('{vendor}', siteName),
translations[t('mobile.managed.biometric_failed')],
expect.any(Array),
{cancelable: false},
);
});
});
describe('isDeviceJailbroken', () => {
test('should check if device is jailbroken', async () => {
const server = 'server-15';
const siteName = 'Site Name';
SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as ClientConfig);
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
const translations = getTranslations(DEFAULT_LOCALE);
const result = await SecurityManager.isDeviceJailbroken(server, siteName);
expect(result).toBe(true);
await TestHelper.wait(300);
expect(Alert.alert).toHaveBeenCalledWith(
translations[t('mobile.managed.blocked_by')].replace('{vendor}', siteName),
translations[t('mobile.managed.jailbreak')].replace('{vendor}', siteName),
expect.any(Array),
{cancelable: false},
);
});
test('should return false if device is not jailbroken', async () => {
const server = 'server-16';
const siteName = 'Site Name';
SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as ClientConfig);
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false);
const result = await SecurityManager.isDeviceJailbroken(server, siteName);
expect(result).toBe(false);
expect(Alert.alert).not.toHaveBeenCalled();
});
});
describe('getShieldScreenId', () => {
test('should return the name of the screen shielded if prevent screen capture is enabled', () => {
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as ClientConfig);
SecurityManager.activeServer = 'server-2';
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen.shielded`);
});
test('should return the name of the screen without shielded if active server is different', () => {
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as ClientConfig);
SecurityManager.activeServer = 'server-1';
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen`);
});
test('should return the name of the screen shielded if prevent screen capture is disabled', () => {
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'false'} as ClientConfig);
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen`);
});
test('should return the name of the screen shielded if prevent screen capture is disabled but forced', () => {
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'false'} as ClientConfig);
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL, true)).toBe(`${Screens.CHANNEL}.screen.shielded`);
});
test('should return the name of the screen as shielded but skip', () => {
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as ClientConfig);
SecurityManager.activeServer = 'server-2';
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL, false, true)).toBe(`${Screens.CHANNEL}.screen.skip.shielded`);
});
});
});

View file

@ -0,0 +1,515 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm from '@mattermost/react-native-emm';
import {isRootedExperimentalAsync} from 'expo-device';
import {createIntl, defineMessages} from 'react-intl';
import {Alert, type AlertButton, AppState, type AppStateStatus, Platform} from 'react-native';
import {switchToServer} from '@actions/app/server';
import {logout} from '@actions/remote/session';
import {Preferences} from '@constants';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import {getServerCredentials} from '@init/credentials';
import ManagedApp from '@init/managed_app';
import {toMilliseconds} from '@utils/datetime';
import {isMainActivity} from '@utils/helpers';
import {logError} from '@utils/log';
import type {AvailableScreens} from '@typings/screens/navigation';
type SecurityManagerServerConfig = {
Biometrics?: boolean;
JailbreakProtection?: boolean;
PreventScreenCapture?: boolean;
authenticated?: boolean;
lastAccessed?: number;
siteName?: string;
};
type SecurityManagerServersCollection = Record<string, SecurityManagerServerConfig>;
const messages = defineMessages({
not_secured_vendor_ios: {
id: 'mobile.managed.not_secured.ios.vendor',
defaultMessage: 'This device must be secured with biometrics or passcode to use {vendor}.\n\nGo to Settings > Face ID & Passcode.',
},
not_secured_vendor_android: {
id: 'mobile.managed.not_secured.android.vendor',
defaultMessage: 'This device must be secured with a screen lock to use {vendor}.',
},
not_secured_ios: {
id: 'mobile.managed.not_secured.ios',
defaultMessage: 'This device must be secured with biometrics or passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.',
},
not_secured_android: {
id: 'mobile.managed.not_secured.android',
defaultMessage: 'This device must be secured with a screen lock to use Mattermost.',
},
blocked_by: {
id: 'mobile.managed.blocked_by',
defaultMessage: 'Blocked by {vendor}',
},
androidSettings: {
id: 'mobile.managed.settings',
defaultMessage: 'Go to settings',
},
securedBy: {
id: 'mobile.managed.secured_by',
defaultMessage: 'Secured by {vendor}',
},
logout: {
id: 'mobile.managed.logout',
defaultMessage: 'Logout',
},
ok: {
id: 'mobile.managed.OK',
defaultMessage: 'OK',
},
switchServer: {
id: 'mobile.managed.switch_server',
defaultMessage: 'Switch server',
},
exit: {
id: 'mobile.managed.exit',
defaultMessage: 'Exit',
},
jailbreak: {
id: 'mobile.managed.jailbreak',
defaultMessage: 'Jailbroken or rooted devices are not trusted by {vendor}.',
},
biometric_failed: {
id: 'mobile.managed.biometric_failed',
defaultMessage: 'Biometric or Passcode authentication failed.',
},
});
class SecurityManagerSingleton {
activeServer?: string;
serverConfig: SecurityManagerServersCollection = {};
backgroundSince = 0;
previousAppState?: AppStateStatus;
initialized = false;
constructor() {
AppState.addEventListener('change', this.onAppStateChange);
}
/**
* Initializes the class with existing servers on app launch.
* Should be called when the app starts.
*/
async init(servers: Record<string, SecurityClientConfig>, activeServer?: string) {
if (this.initialized) {
return;
}
this.initialized = true;
const added = new Set<string>();
for (const [server, config] of Object.entries(servers)) {
if (!this.serverConfig[server]) {
this.addServer(server, config);
added.add(server);
}
}
if (activeServer && (!added.has(activeServer) || !this.activeServer)) {
this.activeServer = activeServer;
this.setScreenCapturePolicy(activeServer);
const isJailbroken = await this.isDeviceJailbroken(activeServer);
if (!isJailbroken) {
this.authenticateWithBiometricsIfNeeded(activeServer);
}
}
}
/**
* Handles app state changes to prompt authentication when resuming from background.
*/
onAppStateChange = async (appState: AppStateStatus) => {
if (this.isAuthenticationHandledByEmm()) {
return;
}
const isActive = appState === 'active';
const isBackground = appState === 'background';
if (isActive && this.previousAppState === 'background') {
if (this.activeServer) {
const config = this.getServerConfig(this.activeServer);
if (config && config.Biometrics && isMainActivity()) {
const authExpired = this.backgroundSince > 0 && (Date.now() - this.backgroundSince) >= toMilliseconds({minutes: 5});
if (authExpired) {
const isJailbroken = await this.isDeviceJailbroken(this.activeServer);
if (!isJailbroken) {
await this.authenticateWithBiometrics(this.activeServer);
}
}
this.backgroundSince = 0;
}
}
} else if (isBackground) {
this.backgroundSince = Date.now();
}
this.previousAppState = appState;
};
/**
* Checks if EMM is already enabled and setup
* to handle biometric / passcode authentication.
*/
isAuthenticationHandledByEmm = () => {
return ManagedApp.enabled && ManagedApp.inAppPinCode;
};
/**
* Checks if EMM is already enabled and setup
* to handle jailbreak protection.
*/
isJalbreakProtectionHandledByEmm = () => {
return ManagedApp.enabled && ManagedApp.cacheConfig?.jailbreakProtection === 'true';
};
/**
* Checks if EMM is already enabled and setup
* to handle screenshot protection.
*/
isScreenshotProtectionHandledByEmm = () => {
return ManagedApp.enabled && ManagedApp.cacheConfig?.blurApplicationScreen === 'true';
};
/**
* Get the configuration of a server to prevent screenshots.
*/
isScreenCapturePrevented = (server: string) => {
const config = this.getServerConfig(server);
if (!config) {
return false;
}
return config.PreventScreenCapture == null ? false : config.PreventScreenCapture;
};
/**
* Checks if the device is Jailbroken or Rooted.
*/
isDeviceJailbroken = async (server: string, siteName?: string) => {
if (this.isJalbreakProtectionHandledByEmm()) {
return false;
}
const config = this.getServerConfig(server);
if (!config && !siteName) {
return false;
}
const locale = DEFAULT_LOCALE;
const translations = getTranslations(locale);
if (config?.JailbreakProtection || siteName) {
const isRooted = await isRootedExperimentalAsync();
if (isRooted) {
this.showDeviceNotTrustedAlert(server, siteName, translations);
return true;
}
}
return false;
};
/**
* Add the config for a server.
*/
addServer = (server: string, config?: SecurityClientConfig, authenticated = false) => {
const mobileConfig: SecurityManagerServerConfig = {
siteName: config?.SiteName,
Biometrics: config?.MobileEnableBiometrics === 'true',
JailbreakProtection: config?.MobileJailbreakProtection === 'true',
PreventScreenCapture: config?.MobilePreventScreenCapture === 'true',
authenticated,
};
this.serverConfig[server] = mobileConfig;
};
/**
* Removes a configured server, to be called on logout.
*/
removeServer = async (server: string) => {
delete this.serverConfig[server];
if (server === this.activeServer) {
this.initialized = false;
this.activeServer = undefined;
}
};
/**
* Get the configuration of a server.
*/
getServerConfig = (server: string): SecurityManagerServerConfig| undefined => {
return this.serverConfig[server];
};
/**
* Switches the active server.
*/
setActiveServer = (server: string) => {
if (this.activeServer === server) {
// active server is not changing, so no need to do anything here
return;
}
if (this.activeServer && this.serverConfig[this.activeServer]) {
this.serverConfig[this.activeServer].lastAccessed = Date.now();
}
if (this.serverConfig[server]) {
this.activeServer = server;
this.serverConfig[server].lastAccessed = Date.now();
this.setScreenCapturePolicy(server);
}
};
/**
* Gets the last accessed server.
*/
getLastAccessedServer = (otherServers: string[]) => {
const lastAccessed = otherServers.map((s) => this.serverConfig[s].lastAccessed).sort().reverse()[0];
return Object.keys(this.serverConfig).find((s) => this.serverConfig[s].lastAccessed === lastAccessed);
};
/**
* Switches to the previous server.
*/
goToPreviousServer = async (otherServers: string[]) => {
// Switch to last accessed server
const lastAccessedServer = this.getLastAccessedServer(otherServers);
if (lastAccessedServer) {
const theme = Preferences.THEMES.denim;
const locale = DEFAULT_LOCALE;
const intl = createIntl({
locale,
defaultLocale: DEFAULT_LOCALE,
messages: getTranslations(locale),
});
switchToServer(lastAccessedServer, theme, intl);
}
};
/**
* Determines if biometric authentication should be prompted.
*/
authenticateWithBiometricsIfNeeded = async (server: string) => {
if (this.isAuthenticationHandledByEmm()) {
return true;
}
const config = this.getServerConfig(server);
if (!config) {
return true;
}
if (config?.Biometrics) {
const lastAccessed = config?.lastAccessed ?? 0;
const timeSinceLastAccessed = Date.now() - lastAccessed;
if (timeSinceLastAccessed > toMilliseconds({minutes: 5}) || config.authenticated === false) {
return this.authenticateWithBiometrics(server);
}
}
return true;
};
/**
* Handles biometric authentication.
*/
authenticateWithBiometrics = async (server: string, siteName?: string) => {
if (this.isAuthenticationHandledByEmm()) {
return true;
}
const config = this.getServerConfig(server);
if (!config && !siteName) {
return true;
}
const locale = DEFAULT_LOCALE;
const translations = getTranslations(locale);
const isSecured = await Emm.isDeviceSecured();
if (!isSecured) {
await this.showNotSecuredAlert(server, siteName, translations);
return false;
}
const shouldBlurOnAuthenticate = server === this.activeServer && this.isScreenCapturePrevented(server);
try {
const auth = await Emm.authenticate({
reason: translations[messages.securedBy.id].replace('{vendor}', siteName || config?.siteName || 'Mattermost'),
fallback: true,
supressEnterPassword: true,
blurOnAuthenticate: shouldBlurOnAuthenticate,
});
if (config) {
config.authenticated = auth;
}
if (!auth) {
throw new Error('Authorization cancelled');
}
} catch (err) {
logError('Failed to authenticate with biometrics', err);
this.showBiometricFailureAlert(server, shouldBlurOnAuthenticate, siteName, translations);
return false;
}
return true;
};
/**
* Sets the screen capture policy for the given server.
*/
setScreenCapturePolicy = (server: string) => {
if (this.isScreenshotProtectionHandledByEmm()) {
return;
}
Emm.enableBlurScreen(this.isScreenCapturePrevented(server));
};
/**
* Gets the shielded screen ID for the screen.
*/
getShieldScreenId = (screen: AvailableScreens, force = false, skip = false) => {
if ((this.activeServer && this.isScreenCapturePrevented(this.activeServer)) || force) {
const name = `${screen}.screen`;
return skip ? `${name}.skip.shielded` : `${name}.shielded`;
}
return `${screen}.screen`;
};
/**
* Builds the alert options for the alert.
*/
buildAlertOptions = async (server: string, translations: Record<string, string>, callback?: (value: boolean) => void) => {
const buttons: AlertButton[] = [];
const hasSessionToServer = await getServerCredentials(server);
if (server && hasSessionToServer) {
buttons.push({
text: translations[messages.logout.id],
style: 'destructive',
onPress: async () => {
await logout(server, undefined);
callback?.(true);
},
});
}
const otherServers = Object.keys(this.serverConfig).filter((s) => s !== server);
if (otherServers.length > 0) {
if (otherServers.length === 1 && otherServers[0] === this.activeServer) {
buttons.push({
text: translations[messages.ok.id],
style: 'cancel',
onPress: () => {
callback?.(true);
},
});
} else {
buttons.push({
text: translations[messages.switchServer.id],
style: 'cancel',
onPress: () => {
this.goToPreviousServer(otherServers);
callback?.(true);
},
});
}
}
if (buttons.length === 0) {
buttons.push({
text: translations[messages.exit.id],
style: 'destructive',
onPress: () => {
Emm.exitApp();
},
});
}
return buttons;
};
showDeviceNotTrustedAlert = async (server: string, siteName: string | undefined, translations: Record<string, string>) => {
const buttons = await this.buildAlertOptions(server, translations);
const securedBy = siteName || this.getServerConfig(server)?.siteName || 'Mattermost';
Alert.alert(
translations[messages.blocked_by.id].replace('{vendor}', securedBy),
translations[messages.jailbreak.id].
replace('{vendor}', securedBy),
buttons,
{cancelable: false},
);
};
/**
* Shows an alert when the device does not have biometrics or passcode set.
*/
showNotSecuredAlert = async (server: string, siteName: string | undefined, translations: Record<string, string>) => {
const buttons: AlertButton[] = [];
const config = this.serverConfig[server];
const securedBy = siteName || config?.siteName || 'Mattermost';
if (Platform.OS === 'android') {
buttons.push({
text: translations[messages.androidSettings.id],
onPress: () => {
Emm.openSecuritySettings();
},
});
}
const alertButtons = await this.buildAlertOptions(server, translations);
buttons.push(...alertButtons);
let message;
if (config?.siteName || siteName) {
const key = Platform.select({ios: messages.not_secured_vendor_ios.id, default: messages.not_secured_vendor_android.id});
message = translations[key].replace('{vendor}', securedBy);
} else {
const key = Platform.select({ios: messages.not_secured_ios.id, default: messages.not_secured_android.id});
message = translations[key];
}
Alert.alert(
translations[messages.blocked_by.id].replace('{vendor}', securedBy),
message,
buttons,
{cancelable: false},
);
};
/**
* Shows an alert when biometric authentication fails.
*/
showBiometricFailureAlert = async (server: string, blurOnAuthenticate: boolean, siteName: string | undefined, translations: Record<string, string>) => {
const buttons = await this.buildAlertOptions(server, translations, () => {
if (blurOnAuthenticate) {
Emm.removeBlurEffect();
}
});
const securedBy = siteName || this.getServerConfig(server)?.siteName || 'Mattermost';
Alert.alert(
translations[messages.blocked_by.id].replace('{vendor}', securedBy),
translations[messages.biometric_failed.id],
buttons,
{cancelable: false},
);
};
}
const SecurityManager = new SecurityManagerSingleton();
export default SecurityManager;

View file

@ -11,6 +11,7 @@ import {getAllServerCredentials, removeServerCredentials} from '@init/credential
import {relaunchApp} from '@init/launch'; import {relaunchApp} from '@init/launch';
import PushNotifications from '@init/push_notifications'; import PushNotifications from '@init/push_notifications';
import NetworkManager from '@managers/network_manager'; import NetworkManager from '@managers/network_manager';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager'; import WebsocketManager from '@managers/websocket_manager';
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file'; import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
import {isMainActivity} from '@utils/helpers'; import {isMainActivity} from '@utils/helpers';
@ -37,6 +38,7 @@ jest.mock('@init/credentials');
jest.mock('@init/launch'); jest.mock('@init/launch');
jest.mock('@init/push_notifications'); jest.mock('@init/push_notifications');
jest.mock('@managers/network_manager'); jest.mock('@managers/network_manager');
jest.mock('@managers/security_manager');
jest.mock('@managers/websocket_manager'); jest.mock('@managers/websocket_manager');
jest.mock('@queries/app/servers'); jest.mock('@queries/app/servers');
jest.mock('@queries/servers/user'); jest.mock('@queries/servers/user');
@ -107,6 +109,7 @@ describe('SessionManager', () => {
expect(PushNotifications.removeServerNotifications).toHaveBeenCalledWith(mockServerUrl); expect(PushNotifications.removeServerNotifications).toHaveBeenCalledWith(mockServerUrl);
expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl); expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl);
expect(WebsocketManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl); expect(WebsocketManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl);
expect(SecurityManager.removeServer).toHaveBeenCalledWith(mockServerUrl);
}); });
it('should handle session expiration', async () => { it('should handle session expiration', async () => {

View file

@ -14,6 +14,7 @@ import {getAllServerCredentials, removeServerCredentials} from '@init/credential
import {relaunchApp} from '@init/launch'; import {relaunchApp} from '@init/launch';
import PushNotifications from '@init/push_notifications'; import PushNotifications from '@init/push_notifications';
import NetworkManager from '@managers/network_manager'; import NetworkManager from '@managers/network_manager';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager'; import WebsocketManager from '@managers/websocket_manager';
import {getAllServers, getServerDisplayName} from '@queries/app/servers'; import {getAllServers, getServerDisplayName} from '@queries/app/servers';
import {getCurrentUser} from '@queries/servers/user'; import {getCurrentUser} from '@queries/servers/user';
@ -114,6 +115,7 @@ export class SessionManagerSingleton {
cancelSessionNotification(serverUrl); cancelSessionNotification(serverUrl);
await removeServerCredentials(serverUrl); await removeServerCredentials(serverUrl);
PushNotifications.removeServerNotifications(serverUrl); PushNotifications.removeServerNotifications(serverUrl);
SecurityManager.removeServer(serverUrl);
NetworkManager.invalidateClient(serverUrl); NetworkManager.invalidateClient(serverUrl);
WebsocketManager.invalidateClient(serverUrl); WebsocketManager.invalidateClient(serverUrl);

View file

@ -57,6 +57,7 @@ import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager'; import WebsocketManager from '@managers/websocket_manager';
import { import {
allOrientations, allOrientations,
@ -736,7 +737,10 @@ const CallScreen = ({
); );
return ( return (
<SafeAreaView style={style.wrapper}> <SafeAreaView
style={style.wrapper}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<StatusBar barStyle={'light-content'}/> <StatusBar barStyle={'light-content'}/>
<View style={style.container}> <View style={style.container}>
{!isLandscape && header} {!isLandscape && header}

View file

@ -137,12 +137,12 @@ export const getCommonSystemValues = async (serverDatabase: Database) => {
}; };
}; };
const fromModelToClientConfig = (list: ConfigModel[]) => { const fromModelToClientConfig = <T = ClientConfig>(list: ConfigModel[]) => {
const config: {[key: string]: any} = {}; const config: {[key: string]: any} = {};
list.forEach((v) => { list.forEach((v) => {
config[v.id] = v.value; config[v.id] = v.value;
}); });
return config as ClientConfig; return config as T;
}; };
export const getConfig = async (database: Database) => { export const getConfig = async (database: Database) => {
@ -150,6 +150,12 @@ export const getConfig = async (database: Database) => {
return fromModelToClientConfig(configList); return fromModelToClientConfig(configList);
}; };
export const getSecurityConfig = async (database: Database) => {
const configList = await database.get<ConfigModel>(CONFIG).query(
Q.where('id', Q.oneOf(['MobileEnableBiometrics', 'MobileJailbreakProtection', 'MobilePreventScreenCapture', 'SiteName']))).fetch();
return fromModelToClientConfig<SecurityClientConfig>(configList);
};
export const queryConfigValue = (database: Database, key: keyof ClientConfig) => { export const queryConfigValue = (database: Database, key: keyof ClientConfig) => {
return database.get<ConfigModel>(CONFIG).query(Q.where('id', Q.eq(key))); return database.get<ConfigModel>(CONFIG).query(Q.where('id', Q.eq(key)));
}; };
@ -205,7 +211,7 @@ export const getIsDataRetentionEnabled = async (database: Database) => {
export const observeConfig = (database: Database): Observable<ClientConfig | undefined> => { export const observeConfig = (database: Database): Observable<ClientConfig | undefined> => {
return database.get<ConfigModel>(CONFIG).query().observeWithColumns(['value']).pipe( return database.get<ConfigModel>(CONFIG).query().observeWithColumns(['value']).pipe(
switchMap((result) => of$(fromModelToClientConfig(result))), switchMap((result) => of$(fromModelToClientConfig<ClientConfig>(result))),
); );
}; };

View file

@ -15,6 +15,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useDidUpdate from '@hooks/did_update'; import useDidUpdate from '@hooks/did_update';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {filterEmptyOptions} from '@utils/apps'; import {filterEmptyOptions} from '@utils/apps';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations'; import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations';
@ -385,6 +386,7 @@ function AppsFormComponent({
<SafeAreaView <SafeAreaView
testID='interactive_dialog.screen' testID='interactive_dialog.screen'
style={style.container} style={style.container}
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<ScrollView <ScrollView
ref={scrollView} ref={scrollView}

View file

@ -12,6 +12,7 @@ import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal} from '@screens/navigation'; import {dismissModal} from '@screens/navigation';
import {hapticFeedback} from '@utils/general'; import {hapticFeedback} from '@utils/general';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -198,11 +199,14 @@ const BottomSheet = ({
if (isTablet) { if (isTablet) {
const FooterComponent = footerComponent; const FooterComponent = footerComponent;
return ( return (
<> <View
style={styles.view}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<View style={styles.separator}/> <View style={styles.separator}/>
{renderContainerContent()} {renderContainerContent()}
{FooterComponent && (<FooterComponent/>)} {FooterComponent && (<FooterComponent/>)}
</> </View>
); );
} }

View file

@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal, goToScreen, setButtons} from '@screens/navigation'; import {dismissModal, goToScreen, setButtons} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft'; import {alertErrorWithFallback} from '@utils/draft';
import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme'; import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
@ -231,7 +232,10 @@ export default function BrowseChannels(props: Props) {
} }
return ( return (
<SafeAreaView style={style.container}> <SafeAreaView
style={style.container}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
{content} {content}
</SafeAreaView> </SafeAreaView>
); );

View file

@ -15,6 +15,7 @@ import {useChannelSwitch} from '@hooks/channel_switch';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header'; import {useDefaultHeaderHeight} from '@hooks/header';
import {useTeamSwitch} from '@hooks/team_switch'; import {useTeamSwitch} from '@hooks/team_switch';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store'; import EphemeralStore from '@store/ephemeral_store';
@ -116,6 +117,7 @@ const Channel = ({
edges={edges} edges={edges}
testID='channel.screen' testID='channel.screen'
onLayout={onLayout} onLayout={onLayout}
nativeID={componentId ? SecurityManager.getShieldScreenId(componentId) : undefined}
> >
<ChannelHeader <ChannelHeader
channelId={channelId} channelId={channelId}

View file

@ -20,6 +20,7 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useKeyboardOverlap} from '@hooks/device'; import {useKeyboardOverlap} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {t} from '@i18n'; import {t} from '@i18n';
import SecurityManager from '@managers/security_manager';
import {dismissModal} from '@screens/navigation'; import {dismissModal} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft'; import {alertErrorWithFallback} from '@utils/draft';
import {mergeNavigationOptions} from '@utils/navigation'; import {mergeNavigationOptions} from '@utils/navigation';
@ -255,6 +256,7 @@ export default function ChannelAddMembers({
onLayout={onLayout} onLayout={onLayout}
ref={mainView} ref={mainView}
edges={['top', 'left', 'right']} edges={['top', 'left', 'right']}
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<View style={style.searchBar}> <View style={style.searchBar}>
<Search <Search

View file

@ -13,6 +13,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation';
import {getFullErrorMessage} from '@utils/errors'; import {getFullErrorMessage} from '@utils/errors';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -269,6 +270,7 @@ const ChannelBookmarkAddOrEdit = ({
edges={edges} edges={edges}
style={styles.content} style={styles.content}
testID='channel_bookmark.screen' testID='channel_bookmark.screen'
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
{type === 'link' && {type === 'link' &&
<BookmarkLink <BookmarkLink

View file

@ -14,6 +14,7 @@ import {General} from '@constants';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import {type FileFilter, FileFilters, filterFileExtensions} from '@utils/file'; import {type FileFilter, FileFilters, filterFileExtensions} from '@utils/file';
import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme'; import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
@ -135,48 +136,53 @@ function ChannelFiles({
const fileChannels = useMemo(() => [channel], [channel]); const fileChannels = useMemo(() => [channel], [channel]);
return ( return (
<SafeAreaView <View
edges={edges}
style={styles.flex} style={styles.flex}
testID={`${TEST_ID}.screen`} nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<View style={styles.searchBar}> <SafeAreaView
<Search edges={edges}
testID={`${TEST_ID}.search_bar`} style={styles.flex}
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})} testID={`${TEST_ID}.screen`}
cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})} >
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)} <View style={styles.searchBar}>
onChangeText={onTextChange} <Search
onCancel={clearSearch} testID={`${TEST_ID}.search_bar`}
autoCapitalize='none' placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)} cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
value={term} placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onTextChange}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
<Header
onFilterChanged={handleFilterChange}
selectedFilter={filter}
/> />
</View> {loading &&
<Header
onFilterChanged={handleFilterChange}
selectedFilter={filter}
/>
{loading &&
<Loading <Loading
color={theme.buttonBg} color={theme.buttonBg}
size='large' size='large'
containerStyle={styles.loading} containerStyle={styles.loading}
/> />
} }
{!loading && {!loading &&
<FileResults <FileResults
canDownloadFiles={canDownloadFiles} canDownloadFiles={canDownloadFiles}
fileChannels={fileChannels} fileChannels={fileChannels}
fileInfos={fileInfos} fileInfos={fileInfos}
paddingTop={styles.noPaddingTop} paddingTop={styles.noPaddingTop}
publicLinkEnabled={publicLinkEnabled} publicLinkEnabled={publicLinkEnabled}
searchValue={term} searchValue={term}
isChannelFiles={true} isChannelFiles={true}
isFilterEnabled={filter !== FileFilters.ALL} isFilterEnabled={filter !== FileFilters.ALL}
/> />
} }
</SafeAreaView> </SafeAreaView>
</View>
); );
} }

View file

@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal} from '@screens/navigation'; import {dismissModal} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -96,73 +97,78 @@ const ChannelInfo = ({
const convertGMOptionAvailable = isConvertGMFeatureAvailable && type === General.GM_CHANNEL && !isGuestUser; const convertGMOptionAvailable = isConvertGMFeatureAvailable && type === General.GM_CHANNEL && !isGuestUser;
return ( return (
<SafeAreaView <View
edges={edges}
style={styles.flex} style={styles.flex}
testID='channel_info.screen' nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<ScrollView <SafeAreaView
bounces={true} edges={edges}
alwaysBounceVertical={false} style={styles.flex}
contentContainerStyle={styles.content} testID='channel_info.screen'
testID='channel_info.scroll_view'
> >
<Title <ScrollView
channelId={channelId} bounces={true}
type={type} alwaysBounceVertical={false}
/> contentContainerStyle={styles.content}
{isBookmarksEnabled && testID='channel_info.scroll_view'
<ChannelBookmarks >
<Title
channelId={channelId} channelId={channelId}
canAddBookmarks={canAddBookmarks} type={type}
showInInfo={true}
/> />
} {isBookmarksEnabled &&
<ChannelActions <ChannelBookmarks
channelId={channelId}
inModal={true}
dismissChannelInfo={onPressed}
callsEnabled={callsAvailable}
testID='channel_info.channel_actions'
/>
<Extra channelId={channelId}/>
<View style={styles.separator}/>
<Options
channelId={channelId}
type={type}
callsEnabled={callsAvailable}
canManageMembers={canManageMembers}
isCRTEnabled={isCRTEnabled}
canManageSettings={canManageSettings}
/>
<View style={styles.separator}/>
{convertGMOptionAvailable &&
<>
<ConvertToChannelLabel channelId={channelId}/>
<View style={styles.separator}/>
</>
}
{canEnableDisableCalls &&
<>
<ChannelInfoEnableCalls
channelId={channelId} channelId={channelId}
enabled={isCallsEnabledInChannel} canAddBookmarks={canAddBookmarks}
showInInfo={true}
/> />
}
<ChannelActions
channelId={channelId}
inModal={true}
dismissChannelInfo={onPressed}
callsEnabled={callsAvailable}
testID='channel_info.channel_actions'
/>
<Extra channelId={channelId}/>
<View style={styles.separator}/>
<Options
channelId={channelId}
type={type}
callsEnabled={callsAvailable}
canManageMembers={canManageMembers}
isCRTEnabled={isCRTEnabled}
canManageSettings={canManageSettings}
/>
<View style={styles.separator}/>
{convertGMOptionAvailable &&
<>
<ConvertToChannelLabel channelId={channelId}/>
<View style={styles.separator}/> <View style={styles.separator}/>
</> </>
} }
<ChannelInfoAppBindings {canEnableDisableCalls &&
channelId={channelId} <>
serverUrl={serverUrl} <ChannelInfoEnableCalls
dismissChannelInfo={onPressed} channelId={channelId}
/> enabled={isCallsEnabledInChannel}
<DestructiveOptions />
channelId={channelId} <View style={styles.separator}/>
componentId={componentId} </>
type={type} }
/> <ChannelInfoAppBindings
</ScrollView> channelId={channelId}
</SafeAreaView> serverUrl={serverUrl}
dismissChannelInfo={onPressed}
/>
<DestructiveOptions
channelId={channelId}
componentId={componentId}
type={type}
/>
</ScrollView>
</SafeAreaView>
</View>
); );
}; };

View file

@ -13,6 +13,7 @@ import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {popTopScreen, setButtons} from '@screens/navigation'; import {popTopScreen, setButtons} from '@screens/navigation';
import {showSnackBar} from '@utils/snack_bar'; import {showSnackBar} from '@utils/snack_bar';
@ -64,6 +65,7 @@ const Code = ({code, componentId, language, textStyle}: Props) => {
<SafeAreaView <SafeAreaView
edges={edges} edges={edges}
style={styles.flex} style={styles.flex}
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<SyntaxHiglight <SyntaxHiglight
code={code} code={code}

View file

@ -8,6 +8,7 @@ import AutocompleteSelector from '@components/autocomplete_selector';
import {Preferences} from '@constants'; import {Preferences} from '@constants';
import {CustomThemeProvider} from '@context/theme'; import {CustomThemeProvider} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import ButtonComponentLibrary from './button.cl'; import ButtonComponentLibrary from './button.cl';
@ -111,7 +112,10 @@ const ComponentLibrary = ({componentId}: Props) => {
const SelectedComponent = componentMap[selectedComponent]; const SelectedComponent = componentMap[selectedComponent];
return ( return (
<ScrollView style={{margin: 10}}> <ScrollView
style={{margin: 10}}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<AutocompleteSelector <AutocompleteSelector
testID='selectedComponent' testID='selectedComponent'
label='Component' label='Component'

View file

@ -3,19 +3,24 @@
import React, {useEffect, useRef, useState} from 'react'; import React, {useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {fetchChannelMemberships, fetchGroupMessageMembersCommonTeams} from '@actions/remote/channel'; import {fetchChannelMemberships, fetchGroupMessageMembersCommonTeams} from '@actions/remote/channel';
import {PER_PAGE_DEFAULT} from '@client/rest/constants'; import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import Loading from '@components/loading'; import Loading from '@components/loading';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import SecurityManager from '@managers/security_manager';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
import ConvertGMToChannelForm from './convert_gm_to_channel_form'; import ConvertGMToChannelForm from './convert_gm_to_channel_form';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = { type Props = {
channelId: string; channelId: string;
componentId: AvailableScreens;
currentUserId?: string; currentUserId?: string;
} }
@ -61,11 +66,13 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
flexDirection: 'column', flexDirection: 'column',
gap: 24, gap: 24,
}, },
flex: {flex: 1},
}; };
}); });
const ConvertGMToChannel = ({ const ConvertGMToChannel = ({
channelId, channelId,
componentId,
currentUserId, currentUserId,
}: Props) => { }: Props) => {
const theme = useTheme(); const theme = useTheme();
@ -131,8 +138,9 @@ const ConvertGMToChannel = ({
const showLoader = !loadingAnimationTimeout || !commonTeamsFetched || !channelMembersFetched; const showLoader = !loadingAnimationTimeout || !commonTeamsFetched || !channelMembersFetched;
let component;
if (showLoader) { if (showLoader) {
return ( component = (
<Loading <Loading
containerStyle={styles.loadingContainer} containerStyle={styles.loadingContainer}
size='large' size='large'
@ -141,14 +149,23 @@ const ConvertGMToChannel = ({
footerTextStyles={styles.text} footerTextStyles={styles.text}
/> />
); );
} else {
component = (
<ConvertGMToChannelForm
commonTeams={commonTeams}
profiles={profiles}
channelId={channelId}
/>
);
} }
return ( return (
<ConvertGMToChannelForm <View
commonTeams={commonTeams} nativeID={SecurityManager.getShieldScreenId(componentId)}
profiles={profiles} style={styles.flex}
channelId={channelId} >
/> {component}
</View>
); );
}; };

View file

@ -8,10 +8,13 @@ import {StyleSheet, View} from 'react-native';
import SearchBar from '@components/search'; import SearchBar from '@components/search';
import TeamList from '@components/team_list'; import TeamList from '@components/team_list';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {usePreventDoubleTap} from '@hooks/utils';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme'; import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
import type {AvailableScreens} from '@typings/screens/navigation';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
padding: 12, padding: 12,
@ -22,11 +25,12 @@ const styles = StyleSheet.create({
}); });
type Props = { type Props = {
componentId: AvailableScreens;
teams: Team[]; teams: Team[];
selectTeam: (teamId: string) => void; selectTeam: (teamId: string) => void;
} }
const TeamSelectorList = ({teams, selectTeam}: Props) => { const TeamSelectorList = ({componentId, teams, selectTeam}: Props) => {
const theme = useTheme(); const theme = useTheme();
const [filteredTeams, setFilteredTeam] = useState(teams); const [filteredTeams, setFilteredTeam] = useState(teams);
const color = useMemo(() => changeOpacity(theme.centerChannelColor, 0.72), [theme]); const color = useMemo(() => changeOpacity(theme.centerChannelColor, 0.72), [theme]);
@ -39,13 +43,16 @@ const TeamSelectorList = ({teams, selectTeam}: Props) => {
} }
}, 200), [teams]); }, 200), [teams]);
const handleOnPress = useCallback(preventDoubleTap((teamId: string) => { const handleOnPress = usePreventDoubleTap(useCallback((teamId: string) => {
selectTeam(teamId); selectTeam(teamId);
popTopScreen(); popTopScreen();
}), []); }, []));
return ( return (
<View style={styles.container}> <View
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.container}
>
<SearchBar <SearchBar
autoCapitalize='none' autoCapitalize='none'
autoFocus={true} autoFocus={true}

View file

@ -19,6 +19,7 @@ import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useKeyboardOverlap} from '@hooks/device'; import {useKeyboardOverlap} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal, setButtons} from '@screens/navigation'; import {dismissModal, setButtons} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft'; import {alertErrorWithFallback} from '@utils/draft';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
@ -298,6 +299,7 @@ export default function CreateDirectMessage({
<SafeAreaView <SafeAreaView
style={style.container} style={style.container}
testID='create_direct_message.screen' testID='create_direct_message.screen'
nativeID={SecurityManager.getShieldScreenId(componentId)}
onLayout={onLayout} onLayout={onLayout}
ref={mainView} ref={mainView}
> >

View file

@ -3,7 +3,7 @@
import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
import {Keyboard} from 'react-native'; import {Keyboard, StyleSheet, View} from 'react-native';
import {createChannel, patchChannel as handlePatchChannel, switchToChannelById} from '@actions/remote/channel'; import {createChannel, patchChannel as handlePatchChannel, switchToChannelById} from '@actions/remote/channel';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
@ -13,6 +13,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {buildNavigationButton, dismissModal, popTopScreen, setButtons} from '@screens/navigation'; import {buildNavigationButton, dismissModal, popTopScreen, setButtons} from '@screens/navigation';
import {validateDisplayName} from '@utils/channel'; import {validateDisplayName} from '@utils/channel';
@ -68,6 +69,12 @@ const makeCloseButton = (icon: ImageResource) => {
return buildNavigationButton(CLOSE_BUTTON_ID, 'close.create_or_edit_channel.button', icon); return buildNavigationButton(CLOSE_BUTTON_ID, 'close.create_or_edit_channel.button', icon);
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
const CreateOrEditChannel = ({ const CreateOrEditChannel = ({
componentId, componentId,
channel, channel,
@ -233,21 +240,26 @@ const CreateOrEditChannel = ({
useAndroidHardwareBackHandler(componentId, handleClose); useAndroidHardwareBackHandler(componentId, handleClose);
return ( return (
<ChannelInfoForm <View
error={appState.error} nativeID={SecurityManager.getShieldScreenId(componentId)}
saving={appState.saving} style={styles.container}
channelType={channel?.type} >
editing={editing} <ChannelInfoForm
onTypeChange={setType} error={appState.error}
type={type} saving={appState.saving}
displayName={displayName} channelType={channel?.type}
onDisplayNameChange={setDisplayName} editing={editing}
header={header} onTypeChange={setType}
headerOnly={headerOnly} type={type}
onHeaderChange={setHeader} displayName={displayName}
purpose={purpose} onDisplayNameChange={setDisplayName}
onPurposeChange={setPurpose} header={header}
/> headerOnly={headerOnly}
onHeaderChange={setHeader}
purpose={purpose}
onPurposeChange={setPurpose}
/>
</View>
); );
}; };

View file

@ -19,6 +19,7 @@ import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal, goToScreen, openAsBottomSheet, showModal} from '@screens/navigation'; import {dismissModal, goToScreen, openAsBottomSheet, showModal} from '@screens/navigation';
import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers'; import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers';
import {logDebug} from '@utils/log'; import {logDebug} from '@utils/log';
@ -75,6 +76,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
borderTopWidth: 1, borderTopWidth: 1,
}, },
flex: {flex: 1},
}; };
}); });
@ -345,7 +347,10 @@ const CustomStatus = ({
}, [isBtnEnabled]); }, [isBtnEnabled]);
return ( return (
<> <View
style={style.flex}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
{isTablet && {isTablet &&
<TabletTitle <TabletTitle
action={intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'})} action={intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'})}
@ -411,7 +416,7 @@ const CustomStatus = ({
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
</> </View>
); );
}; };

View file

@ -14,6 +14,7 @@ import {
} from 'react-native-navigation'; } from 'react-native-navigation';
import {CustomStatusDurationEnum} from '@constants/custom_status'; import {CustomStatusDurationEnum} from '@constants/custom_status';
import SecurityManager from '@managers/security_manager';
import {observeCurrentUser} from '@queries/servers/user'; import {observeCurrentUser} from '@queries/servers/user';
import {dismissModal, popTopScreen} from '@screens/navigation'; import {dismissModal, popTopScreen} from '@screens/navigation';
import NavigationStore from '@store/navigation_store'; import NavigationStore from '@store/navigation_store';
@ -180,13 +181,14 @@ class ClearAfterModal extends NavigationComponent<Props, State> {
}; };
render() { render() {
const {currentUser, theme} = this.props; const {componentId, currentUser, theme} = this.props;
const style = getStyleSheet(theme); const style = getStyleSheet(theme);
const {duration, expiresAt, showExpiryTime} = this.state; const {duration, expiresAt, showExpiryTime} = this.state;
return ( return (
<SafeAreaView <SafeAreaView
style={style.container} style={style.container}
testID='custom_status_clear_after.screen' testID='custom_status_clear_after.screen'
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<KeyboardAwareScrollView bounces={false}> <KeyboardAwareScrollView bounces={false}>
<View style={style.scrollView}> <View style={style.scrollView}>

View file

@ -16,6 +16,7 @@ import {useKeyboardOverlap} from '@hooks/device';
import useDidUpdate from '@hooks/did_update'; import useDidUpdate from '@hooks/did_update';
import {useInputPropagation} from '@hooks/input'; import {useInputPropagation} from '@hooks/input';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import PostError from '@screens/edit_post/post_error'; import PostError from '@screens/edit_post/post_error';
import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation';
import {changeOpacity} from '@utils/theme'; import {changeOpacity} from '@utils/theme';
@ -207,7 +208,10 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
if (isUpdating) { if (isUpdating) {
return ( return (
<View style={styles.loader}> <View
style={styles.loader}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<Loading color={theme.buttonBg}/> <Loading color={theme.buttonBg}/>
</View> </View>
); );
@ -219,6 +223,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
testID='edit_post.screen' testID='edit_post.screen'
style={styles.container} style={styles.container}
onLayout={onLayout} onLayout={onLayout}
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<View <View
style={styles.body} style={styles.body}

View file

@ -16,6 +16,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal, popTopScreen, setButtons} from '@screens/navigation'; import {dismissModal, popTopScreen, setButtons} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap'; import {preventDoubleTap} from '@utils/tap';
@ -295,7 +296,10 @@ const EditProfile = ({
) : null; ) : null;
return ( return (
<> <View
style={styles.flex}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
{isTablet && {isTablet &&
<TabletTitle <TabletTitle
action={buttonText} action={buttonText}
@ -312,7 +316,7 @@ const EditProfile = ({
> >
{content} {content}
</SafeAreaView> </SafeAreaView>
</> </View>
); );
}; };

View file

@ -10,6 +10,7 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {getServerByDisplayName} from '@queries/app/servers'; import {getServerByDisplayName} from '@queries/app/servers';
import Background from '@screens/background'; import Background from '@screens/background';
import {dismissModal} from '@screens/navigation'; import {dismissModal} from '@screens/navigation';
@ -93,7 +94,10 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) =>
useAndroidHardwareBackHandler(componentId, close); useAndroidHardwareBackHandler(componentId, close);
return ( return (
<View style={styles.flex}> <View
style={styles.flex}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<Background theme={theme}/> <Background theme={theme}/>
<SafeAreaView <SafeAreaView
key={'server_content'} key={'server_content'}

View file

@ -8,6 +8,7 @@ import {searchCustomEmojis} from '@actions/remote/custom_emoji';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general'; import {debounce} from '@helpers/api/general';
import SecurityManager from '@managers/security_manager';
import {getKeyboardAppearanceFromTheme} from '@utils/theme'; import {getKeyboardAppearanceFromTheme} from '@utils/theme';
import EmojiFiltered from './filtered'; import EmojiFiltered from './filtered';
@ -15,6 +16,7 @@ import PickerHeader from './header';
import EmojiSections from './sections'; import EmojiSections from './sections';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type {AvailableScreens} from '@typings/screens/navigation';
export const SCROLLVIEW_NATIVE_ID = 'emojiSelector'; export const SCROLLVIEW_NATIVE_ID = 'emojiSelector';
@ -82,6 +84,7 @@ const Picker = ({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress
<View <View
style={styles.flex} style={styles.flex}
testID={`${testID}.screen`} testID={`${testID}.screen`}
nativeID={SecurityManager.getShieldScreenId(testID as AvailableScreens)}
> >
<View style={styles.searchBar}> <View style={styles.searchBar}>
<PickerHeader <PickerHeader

View file

@ -9,6 +9,7 @@ import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useKeyboardOverlap} from '@hooks/device'; import {useKeyboardOverlap} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal} from '@screens/navigation'; import {dismissModal} from '@screens/navigation';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
@ -87,6 +88,7 @@ const FindChannels = ({closeButtonId, componentId}: Props) => {
<View <View
style={styles.container} style={styles.container}
testID='find_channels.screen' testID='find_channels.screen'
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<SearchBar <SearchBar
autoCapitalize='none' autoCapitalize='none'

View file

@ -16,6 +16,7 @@ import FormattedText from '@components/formatted_text';
import {Screens} from '@constants'; import {Screens} from '@constants';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useAvoidKeyboard} from '@hooks/device'; import {useAvoidKeyboard} from '@hooks/device';
import SecurityManager from '@managers/security_manager';
import Background from '@screens/background'; import Background from '@screens/background';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {isEmail} from '@utils/helpers'; import {isEmail} from '@utils/helpers';
@ -141,6 +142,7 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => {
<View <View
style={styles.successContainer} style={styles.successContainer}
testID={'password_send.link.sent'} testID={'password_send.link.sent'}
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
> >
<Inbox/> <Inbox/>
<FormattedText <FormattedText
@ -184,6 +186,7 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => {
ref={keyboardAwareRef} ref={keyboardAwareRef}
scrollToOverflowEnabled={true} scrollToOverflowEnabled={true}
style={styles.flex} style={styles.flex}
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
> >
<View <View
style={styles.centered} style={styles.centered}

View file

@ -3,13 +3,14 @@
import RNUtils from '@mattermost/rnutils'; import RNUtils from '@mattermost/rnutils';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Platform} from 'react-native'; import {Platform, StyleSheet, View} from 'react-native';
import {CaptionsEnabledContext} from '@calls/context'; import {CaptionsEnabledContext} from '@calls/context';
import {hasCaptions} from '@calls/utils'; import {hasCaptions} from '@calls/utils';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet, useWindowDimensions} from '@hooks/device'; import {useIsTablet, useWindowDimensions} from '@hooks/device';
import {useGalleryControls} from '@hooks/gallery'; import {useGalleryControls} from '@hooks/gallery';
import SecurityManager from '@managers/security_manager';
import {dismissOverlay, setScreensOrientation} from '@screens/navigation'; import {dismissOverlay, setScreensOrientation} from '@screens/navigation';
import {freezeOtherScreens} from '@utils/gallery'; import {freezeOtherScreens} from '@utils/gallery';
@ -28,6 +29,10 @@ type Props = {
items: GalleryItemType[]; items: GalleryItemType[];
} }
const styles = StyleSheet.create({
flex: {flex: 1},
});
const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialIndex, items}: Props) => { const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialIndex, items}: Props) => {
const dim = useWindowDimensions(); const dim = useWindowDimensions();
const isTablet = useIsTablet(); const isTablet = useIsTablet();
@ -84,30 +89,35 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
return ( return (
<CaptionsEnabledContext.Provider value={captionsEnabled}> <CaptionsEnabledContext.Provider value={captionsEnabled}>
<Header <View
index={localIndex} style={styles.flex}
onClose={onClose} nativeID={SecurityManager.getShieldScreenId(componentId)}
style={headerStyles} >
total={items.length} <Header
/> index={localIndex}
<Gallery onClose={onClose}
galleryIdentifier={galleryIdentifier} style={headerStyles}
initialIndex={initialIndex} total={items.length}
items={items} />
onHide={close} <Gallery
onIndexChange={onIndexChange} galleryIdentifier={galleryIdentifier}
onShouldHideControls={setControlsHidden} initialIndex={initialIndex}
ref={galleryRef} items={items}
targetDimensions={dimensions} onHide={close}
/> onIndexChange={onIndexChange}
<Footer onShouldHideControls={setControlsHidden}
hideActions={hideActions} ref={galleryRef}
item={items[localIndex]} targetDimensions={dimensions}
style={footerStyles} />
hasCaptions={captionsAvailable[localIndex]} <Footer
captionEnabled={captionsEnabled[localIndex]} hideActions={hideActions}
onCaptionsPress={onCaptionsPress} item={items[localIndex]}
/> style={footerStyles}
hasCaptions={captionsAvailable[localIndex]}
captionEnabled={captionsEnabled[localIndex]}
onCaptionsPress={onCaptionsPress}
/>
</View>
</CaptionsEnabledContext.Provider> </CaptionsEnabledContext.Provider>
); );
}; };

View file

@ -13,6 +13,7 @@ import {Screens} from '@constants';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header'; import {useDefaultHeaderHeight} from '@hooks/header';
import {useTeamSwitch} from '@hooks/team_switch'; import {useTeamSwitch} from '@hooks/team_switch';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '../navigation'; import {popTopScreen} from '../navigation';
@ -69,6 +70,7 @@ const GlobalDrafts = ({componentId}: Props) => {
mode='margin' mode='margin'
style={styles.flex} style={styles.flex}
testID='global_drafts.screen' testID='global_drafts.screen'
nativeID={SecurityManager.getShieldScreenId(componentId || Screens.GLOBAL_DRAFTS)}
> >
<NavigationHeader <NavigationHeader
showBackButton={!isTablet} showBackButton={!isTablet}

View file

@ -16,6 +16,7 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header'; import {useDefaultHeaderHeight} from '@hooks/header';
import {useTeamSwitch} from '@hooks/team_switch'; import {useTeamSwitch} from '@hooks/team_switch';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import ThreadsList from './threads_list'; import ThreadsList from './threads_list';
@ -84,6 +85,7 @@ const GlobalThreads = ({componentId, globalThreadsTab}: Props) => {
mode='margin' mode='margin'
style={styles.flex} style={styles.flex}
testID='global_threads.screen' testID='global_threads.screen'
nativeID={SecurityManager.getShieldScreenId(componentId || Screens.GLOBAL_THREADS)}
> >
<NavigationHeader <NavigationHeader
showBackButton={!isTablet} showBackButton={!isTablet}

View file

@ -9,9 +9,8 @@ import Swipeable from 'react-native-gesture-handler/Swipeable';
import {Navigation} from 'react-native-navigation'; import {Navigation} from 'react-native-navigation';
import {storeMultiServerTutorial} from '@actions/app/global'; import {storeMultiServerTutorial} from '@actions/app/global';
import {doPing} from '@actions/remote/general'; import {switchToServer, switchToServerAndLogin} from '@actions/app/server';
import {logout} from '@actions/remote/session'; import {logout} from '@actions/remote/session';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading'; import Loading from '@components/loading';
import ServerIcon from '@components/server_icon'; import ServerIcon from '@components/server_icon';
@ -20,14 +19,10 @@ import TutorialSwipeLeft from '@components/tutorial_highlight/swipe_left';
import {Events, Screens} from '@constants'; import {Events, Screens} from '@constants';
import {PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy'; import {PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
import {subscribeServerUnreadAndMentions, type UnreadObserverArgs} from '@database/subscription/unreads'; import {subscribeServerUnreadAndMentions, type UnreadObserverArgs} from '@database/subscription/unreads';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import WebsocketManager from '@managers/websocket_manager';
import {getServerByIdentifier} from '@queries/app/servers';
import {dismissBottomSheet} from '@screens/navigation'; import {dismissBottomSheet} from '@screens/navigation';
import {canReceiveNotifications} from '@utils/push_proxy'; import {alertServerLogout, alertServerRemove, editServer} from '@utils/server';
import {alertServerAlreadyConnected, alertServerError, alertServerLogout, alertServerRemove, editServer, loginToServer} from '@utils/server';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
import {removeProtocol, stripTrailingSlashes} from '@utils/url'; import {removeProtocol, stripTrailingSlashes} from '@utils/url';
@ -253,29 +248,8 @@ const ServerItem = ({
const handleLogin = useCallback(async () => { const handleLogin = useCallback(async () => {
swipeable.current?.close(); swipeable.current?.close();
setSwitching(true); setSwitching(true);
const result = await doPing(server.url, true); await switchToServerAndLogin(server.url, theme, intl, () => setSwitching(false));
if (result.error) { }, [server.url, intl, theme]);
alertServerError(intl, result.error);
setSwitching(false);
return;
}
const data = await fetchConfigAndLicense(server.url, true);
if (data.error) {
alertServerError(intl, data.error);
setSwitching(false);
return;
}
const existingServer = await getServerByIdentifier(data.config!.DiagnosticId);
if (existingServer && existingServer.lastActiveAt > 0) {
alertServerAlreadyConnected(intl);
setSwitching(false);
return;
}
canReceiveNotifications(server.url, result.canReceiveNotifications as string, intl);
loginToServer(theme, server.url, displayName, data.config!, data.license!);
}, [server.url, intl, theme, displayName]);
const handleDismissTutorial = useCallback(() => { const handleDismissTutorial = useCallback(() => {
swipeable.current?.close(); swipeable.current?.close();
@ -309,14 +283,9 @@ const ServerItem = ({
if (server.lastActiveAt) { if (server.lastActiveAt) {
setSwitching(true); setSwitching(true);
await dismissBottomSheet(); await dismissBottomSheet();
Navigation.updateProps(Screens.HOME, {extra: undefined});
DatabaseManager.setActiveServerDatabase(server.url);
WebsocketManager.initializeClient(server.url, 'Server Switch');
return;
} }
await switchToServer(server.url, theme, intl, () => setSwitching(false));
handleLogin(); }, [isActive, server.lastActiveAt, server.url, theme, intl]);
}, [isActive, server.lastActiveAt, server.url, handleLogin]);
const onSwipeableWillOpen = useCallback(() => { const onSwipeableWillOpen = useCallback(() => {
DeviceEventEmitter.emit(Events.SWIPEABLE, server.url); DeviceEventEmitter.emit(Events.SWIPEABLE, server.url);

View file

@ -6,14 +6,16 @@ import {createBottomTabNavigator, type BottomTabBarProps} from '@react-navigatio
import {NavigationContainer, DefaultTheme} from '@react-navigation/native'; import {NavigationContainer, DefaultTheme} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo} from 'react'; import React, {useCallback, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
import {DeviceEventEmitter, Platform} from 'react-native'; import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native';
import {enableFreeze, enableScreens} from 'react-native-screens'; import {enableFreeze, enableScreens} from 'react-native-screens';
import {initializeSecurityManager} from '@actions/app/server';
import {autoUpdateTimezone} from '@actions/remote/user'; import {autoUpdateTimezone} from '@actions/remote/user';
import ServerVersion from '@components/server_version'; import ServerVersion from '@components/server_version';
import {Events, Launch, Screens} from '@constants'; import {Events, Launch, Screens} from '@constants';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {useAppState} from '@hooks/device'; import {useAppState} from '@hooks/device';
import SecurityManager from '@managers/security_manager';
import {getAllServers} from '@queries/app/servers'; import {getAllServers} from '@queries/app/servers';
import {findChannels, popToRoot} from '@screens/navigation'; import {findChannels, popToRoot} from '@screens/navigation';
import NavigationStore from '@store/navigation_store'; import NavigationStore from '@store/navigation_store';
@ -57,11 +59,19 @@ const updateTimezoneIfNeeded = async () => {
} }
}; };
const styles = StyleSheet.create({
flex: {flex: 1},
});
export function HomeScreen(props: HomeProps) { export function HomeScreen(props: HomeProps) {
const theme = useTheme(); const theme = useTheme();
const intl = useIntl(); const intl = useIntl();
const appState = useAppState(); const appState = useAppState();
useEffect(() => {
initializeSecurityManager();
}, []);
const handleFindChannels = useCallback(() => { const handleFindChannels = useCallback(() => {
if (!NavigationStore.getScreensInStack().includes(Screens.FIND_CHANNELS)) { if (!NavigationStore.getScreensInStack().includes(Screens.FIND_CHANNELS)) {
findChannels( findChannels(
@ -136,7 +146,10 @@ export function HomeScreen(props: HomeProps) {
}, []); }, []);
return ( return (
<> <View
style={styles.flex}
nativeID={SecurityManager.getShieldScreenId(Screens.HOME, true)}
>
<NavigationContainer <NavigationContainer
theme={{ theme={{
...DefaultTheme, ...DefaultTheme,
@ -190,7 +203,7 @@ export function HomeScreen(props: HomeProps) {
</Tab.Navigator> </Tab.Navigator>
</NavigationContainer> </NavigationContainer>
<ServerVersion/> <ServerVersion/>
</> </View>
); );
} }

View file

@ -12,6 +12,7 @@ import {openNotification} from '@actions/remote/notifications';
import {Navigation as NavigationTypes} from '@constants'; import {Navigation as NavigationTypes} from '@constants';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import SecurityManager from '@managers/security_manager';
import {dismissOverlay} from '@screens/navigation'; import {dismissOverlay} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap'; import {preventDoubleTap} from '@utils/tap';
import {changeOpacity} from '@utils/theme'; import {changeOpacity} from '@utils/theme';
@ -157,6 +158,7 @@ const InAppNotification = ({componentId, serverName, serverUrl, notification}: I
<Animated.View <Animated.View
style={[styles.container, isTablet ? styles.tablet : undefined, animatedStyle]} style={[styles.container, isTablet ? styles.tablet : undefined, animatedStyle]}
testID='in_app_notification.screen' testID='in_app_notification.screen'
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<View style={styles.flex}> <View style={styles.flex}>
<TouchableOpacity <TouchableOpacity

View file

@ -18,6 +18,7 @@ import {debounce} from '@helpers/api/general';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {t} from '@i18n'; import {t} from '@i18n';
import SecurityManager from '@managers/security_manager';
import { import {
buildNavigationButton, buildNavigationButton,
popTopScreen, setButtons, popTopScreen, setButtons,
@ -587,7 +588,10 @@ function IntegrationSelector(
const selectedOptionsComponent = renderSelectedOptions(); const selectedOptionsComponent = renderSelectedOptions();
return ( return (
<SafeAreaView style={style.container}> <SafeAreaView
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={style.container}
>
<View <View
testID='integration_selector.screen' testID='integration_selector.screen'
style={style.searchBar} style={style.searchBar}

View file

@ -14,6 +14,7 @@ import ErrorText from '@components/error_text';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import SecurityManager from '@managers/security_manager';
import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation';
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations'; import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -234,6 +235,7 @@ function InteractiveDialog({
<SafeAreaView <SafeAreaView
testID='interactive_dialog.screen' testID='interactive_dialog.screen'
style={style.container} style={style.container}
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<KeyboardAwareScrollView <KeyboardAwareScrollView
ref={scrollView} ref={scrollView}

View file

@ -15,6 +15,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {useKeyboardOverlap} from '@hooks/device'; import {useKeyboardOverlap} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal, setButtons} from '@screens/navigation'; import {dismissModal, setButtons} from '@screens/navigation';
import {isEmail} from '@utils/helpers'; import {isEmail} from '@utils/helpers';
import {mergeNavigationOptions} from '@utils/navigation'; import {mergeNavigationOptions} from '@utils/navigation';
@ -416,6 +417,7 @@ export default function Invite({
onLayout={onLayoutWrapper} onLayout={onLayoutWrapper}
ref={mainView} ref={mainView}
testID='invite.screen' testID='invite.screen'
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
{renderContent()} {renderContent()}
</SafeAreaView> </SafeAreaView>

View file

@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal} from '@screens/navigation'; import {dismissModal} from '@screens/navigation';
import {logDebug} from '@utils/log'; import {logDebug} from '@utils/log';
import {alertTeamAddError} from '@utils/navigation'; import {alertTeamAddError} from '@utils/navigation';
@ -154,7 +155,10 @@ export default function JoinTeam({
} }
return ( return (
<View style={styles.container}> <View
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.container}
>
{body} {body}
</View> </View>
); );

View file

@ -8,6 +8,7 @@ import {SafeAreaView, type Edge} from 'react-native-safe-area-context';
import MathView from '@components/math_view'; import MathView from '@components/math_view';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import {splitLatexCodeInLines} from '@utils/markdown/latex'; import {splitLatexCodeInLines} from '@utils/markdown/latex';
import {makeStyleSheetFromTheme} from '@utils/theme'; import {makeStyleSheetFromTheme} from '@utils/theme';
@ -81,6 +82,7 @@ const Latex = ({componentId, content}: Props) => {
<SafeAreaView <SafeAreaView
edges={edges} edges={edges}
style={style.scrollContainer} style={style.scrollContainer}
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<ScrollView <ScrollView
style={style.scrollContainer} style={style.scrollContainer}

View file

@ -15,6 +15,7 @@ import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header'; import {useDefaultHeaderHeight} from '@hooks/header';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import NetworkManager from '@managers/network_manager'; import NetworkManager from '@managers/network_manager';
import SecurityManager from '@managers/security_manager';
import Background from '@screens/background'; import Background from '@screens/background';
import {dismissModal, goToScreen, loginAnimationOptions, popTopScreen} from '@screens/navigation'; import {dismissModal, goToScreen, loginAnimationOptions, popTopScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap'; import {preventDoubleTap} from '@utils/tap';
@ -213,6 +214,7 @@ const LoginOptions = ({
<View <View
style={styles.flex} style={styles.flex}
testID='login.screen' testID='login.screen'
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
> >
<Background theme={theme}/> <Background theme={theme}/>
<AnimatedSafeArea style={[styles.container, transform]}> <AnimatedSafeArea style={[styles.container, transform]}>

View file

@ -16,6 +16,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {openAsBottomSheet, popTopScreen, setButtons} from '@screens/navigation'; import {openAsBottomSheet, popTopScreen, setButtons} from '@screens/navigation';
import NavigationStore from '@store/navigation_store'; import NavigationStore from '@store/navigation_store';
import {showRemoveChannelUserSnackbar} from '@utils/snack_bar'; import {showRemoveChannelUserSnackbar} from '@utils/snack_bar';
@ -291,6 +292,7 @@ export default function ManageChannelMembers({
<SafeAreaView <SafeAreaView
style={styles.container} style={styles.container}
testID='manage_members.screen' testID='manage_members.screen'
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<View style={styles.searchBar}> <View style={styles.searchBar}>
<Search <Search

View file

@ -17,6 +17,7 @@ import Loading from '@components/loading';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useAvoidKeyboard} from '@hooks/device'; import {useAvoidKeyboard} from '@hooks/device';
import {t} from '@i18n'; import {t} from '@i18n';
import SecurityManager from '@managers/security_manager';
import Background from '@screens/background'; import Background from '@screens/background';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
@ -168,7 +169,10 @@ const MFA = ({componentId, config, goToHome, license, loginId, password, serverD
useAndroidHardwareBackHandler(componentId, close); useAndroidHardwareBackHandler(componentId, close);
return ( return (
<View style={styles.flex}> <View
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
style={styles.flex}
>
<Background theme={theme}/> <Background theme={theme}/>
<AnimatedSafeArea <AnimatedSafeArea
testID='mfa.screen' testID='mfa.screen'

View file

@ -18,6 +18,7 @@ import Animated, {ReduceMotion, useAnimatedStyle, useDerivedValue, useSharedValu
import {storeOnboardingViewedValue} from '@actions/app/global'; import {storeOnboardingViewedValue} from '@actions/app/global';
import {Screens} from '@constants'; import {Screens} from '@constants';
import SecurityManager from '@managers/security_manager';
import Background from '@screens/background'; import Background from '@screens/background';
import {goToScreen, loginAnimationOptions} from '@screens/navigation'; import {goToScreen, loginAnimationOptions} from '@screens/navigation';
@ -27,8 +28,10 @@ import SlideItem from './slide';
import useSlidesData from './slides_data'; import useSlidesData from './slides_data';
import type {LaunchProps} from '@typings/launch'; import type {LaunchProps} from '@typings/launch';
import type {AvailableScreens} from '@typings/screens/navigation';
interface OnboardingProps extends LaunchProps { interface OnboardingProps extends LaunchProps {
componentId: AvailableScreens;
theme: Theme; theme: Theme;
} }
@ -49,6 +52,7 @@ const styles = StyleSheet.create({
const AnimatedSafeArea = Animated.createAnimatedComponent(SafeAreaView); const AnimatedSafeArea = Animated.createAnimatedComponent(SafeAreaView);
const Onboarding = ({ const Onboarding = ({
componentId,
theme, theme,
...props ...props
}: OnboardingProps) => { }: OnboardingProps) => {
@ -133,6 +137,7 @@ const Onboarding = ({
<View <View
style={styles.onBoardingContainer} style={styles.onBoardingContainer}
testID='onboarding.screen' testID='onboarding.screen'
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
> >
<Background theme={theme}/> <Background theme={theme}/>
<AnimatedSafeArea <AnimatedSafeArea

View file

@ -1,14 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {type ReactNode} from 'react'; import React, {type ReactNode} from 'react';
import {View} from 'react-native';
import SecurityManager from '@managers/security_manager';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = { type Props = {
componentId: AvailableScreens;
children: ReactNode; children: ReactNode;
} }
const Overlay = ({children}: Props) => { const Overlay = ({children, componentId}: Props) => {
return children; return (<View nativeID={SecurityManager.getShieldScreenId(componentId)}>{children}</View>);
}; };
export default Overlay; export default Overlay;

View file

@ -21,6 +21,7 @@ import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import SecurityManager from '@managers/security_manager';
import {getChannelById, getMyChannel} from '@queries/servers/channel'; import {getChannelById, getMyChannel} from '@queries/servers/channel';
import {dismissModal} from '@screens/navigation'; import {dismissModal} from '@screens/navigation';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
@ -355,6 +356,7 @@ function Permalink({
<SafeAreaView <SafeAreaView
style={containerStyle} style={containerStyle}
testID='permalink.screen' testID='permalink.screen'
nativeID={SecurityManager.getShieldScreenId(Screens.PERMALINK)}
edges={edges} edges={edges}
> >
<Animated.View style={style.wrapper}> <Animated.View style={style.wrapper}>

View file

@ -14,6 +14,7 @@ import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import {getDateForDateLine, selectOrderedPosts} from '@utils/post_list'; import {getDateForDateLine, selectOrderedPosts} from '@utils/post_list';
@ -152,6 +153,7 @@ function SavedMessages({
edges={edges} edges={edges}
style={styles.flex} style={styles.flex}
testID='pinned_messages.screen' testID='pinned_messages.screen'
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<ExtraKeyboardProvider> <ExtraKeyboardProvider>
<FlatList <FlatList

View file

@ -16,6 +16,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useBackNavigation from '@hooks/navigate_back'; import useBackNavigation from '@hooks/navigate_back';
import SecurityManager from '@managers/security_manager';
import {dismissOverlay, showShareFeedbackOverlay} from '@screens/navigation'; import {dismissOverlay, showShareFeedbackOverlay} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
@ -168,7 +169,10 @@ const ReviewApp = ({
}), []); }), []);
return ( return (
<View style={styles.root}> <View
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.root}
>
<View <View
style={styles.container} style={styles.container}
testID='rate_app.screen' testID='rate_app.screen'

View file

@ -11,6 +11,7 @@ import {addCurrentUserToTeam, fetchTeamsForComponent, handleTeamChange} from '@a
import Loading from '@components/loading'; import Loading from '@components/loading';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import SecurityManager from '@managers/security_manager';
import {logDebug} from '@utils/log'; import {logDebug} from '@utils/log';
import {alertTeamAddError} from '@utils/navigation'; import {alertTeamAddError} from '@utils/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme'; import {makeStyleSheetFromTheme} from '@utils/theme';
@ -21,6 +22,8 @@ import Header from './header';
import NoTeams from './no_teams'; import NoTeams from './no_teams';
import TeamList from './team_list'; import TeamList from './team_list';
import type {AvailableScreens} from '@typings/screens/navigation';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: { container: {
flex: 1, flex: 1,
@ -34,6 +37,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
})); }));
type Props = { type Props = {
componentId: AvailableScreens;
nTeams: number; nTeams: number;
firstTeamId?: string; firstTeamId?: string;
} }
@ -42,6 +46,7 @@ const safeAreaEdges = ['left' as const, 'right' as const];
const safeAreaStyle = {flex: 1}; const safeAreaStyle = {flex: 1};
const SelectTeam = ({ const SelectTeam = ({
componentId,
nTeams, nTeams,
firstTeamId, firstTeamId,
}: Props) => { }: Props) => {
@ -140,6 +145,7 @@ const SelectTeam = ({
mode='margin' mode='margin'
edges={safeAreaEdges} edges={safeAreaEdges}
style={safeAreaStyle} style={safeAreaStyle}
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<Animated.View style={top}/> <Animated.View style={top}/>
<View style={styles.container}> <View style={styles.container}>

View file

@ -20,6 +20,7 @@ import {t} from '@i18n';
import {getServerCredentials} from '@init/credentials'; import {getServerCredentials} from '@init/credentials';
import PushNotifications from '@init/push_notifications'; import PushNotifications from '@init/push_notifications';
import NetworkManager from '@managers/network_manager'; import NetworkManager from '@managers/network_manager';
import SecurityManager from '@managers/security_manager';
import {getServerByDisplayName, getServerByIdentifier} from '@queries/app/servers'; import {getServerByDisplayName, getServerByIdentifier} from '@queries/app/servers';
import Background from '@screens/background'; import Background from '@screens/background';
import {dismissModal, goToScreen, loginAnimationOptions, popTopScreen} from '@screens/navigation'; import {dismissModal, goToScreen, loginAnimationOptions, popTopScreen} from '@screens/navigation';
@ -340,6 +341,22 @@ const Server = ({
return; return;
} }
if (data.config.MobileJailbreakProtection === 'true') {
const isJailbroken = await SecurityManager.isDeviceJailbroken(ping.url, data.config.SiteName);
if (isJailbroken) {
setConnecting(false);
return;
}
}
if (data.config.MobileEnableBiometrics === 'true') {
const biometricsResult = await SecurityManager.authenticateWithBiometrics(ping.url, data.config.SiteName);
if (!biometricsResult) {
setConnecting(false);
return;
}
}
const server = await getServerByIdentifier(data.config.DiagnosticId); const server = await getServerByIdentifier(data.config.DiagnosticId);
const credentials = await getServerCredentials(ping.url); const credentials = await getServerCredentials(ping.url);
setConnecting(false); setConnecting(false);
@ -367,6 +384,7 @@ const Server = ({
<View <View
style={styles.flex} style={styles.flex}
testID='server.screen' testID='server.screen'
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
> >
<Background theme={theme}/> <Background theme={theme}/>
<AnimatedSafeArea <AnimatedSafeArea

View file

@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useBackNavigation from '@hooks/navigate_back'; import useBackNavigation from '@hooks/navigate_back';
import SecurityManager from '@managers/security_manager';
import {dismissOverlay} from '@screens/navigation'; import {dismissOverlay} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
@ -134,7 +135,10 @@ const ShareFeedback = ({
}), []); }), []);
return ( return (
<View style={styles.root}> <View
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.root}
>
<View <View
style={styles.container} style={styles.container}
testID='rate_app.screen' testID='rate_app.screen'

View file

@ -22,6 +22,7 @@ import {MESSAGE_TYPE, SNACK_BAR_CONFIG} from '@constants/snack_bar';
import {TABLET_SIDEBAR_WIDTH} from '@constants/view'; import {TABLET_SIDEBAR_WIDTH} from '@constants/view';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
import SecurityManager from '@managers/security_manager';
import {dismissOverlay} from '@screens/navigation'; import {dismissOverlay} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme'; import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
@ -261,6 +262,7 @@ const SnackBar = ({
<GestureDetector gesture={gesture}> <GestureDetector gesture={gesture}>
<Animated.View <Animated.View
style={animatedMotion} style={animatedMotion}
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<Animated.View <Animated.View
entering={FadeIn.duration(300)} entering={FadeIn.duration(300)}

View file

@ -12,6 +12,7 @@ import {Screens, Sso} from '@constants';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed'; import useNavButtonPressed from '@hooks/navigation_button_pressed';
import NetworkManager from '@managers/network_manager'; import NetworkManager from '@managers/network_manager';
import SecurityManager from '@managers/security_manager';
import Background from '@screens/background'; import Background from '@screens/background';
import {dismissModal, popTopScreen, resetToHome} from '@screens/navigation'; import {dismissModal, popTopScreen, resetToHome} from '@screens/navigation';
import {getFullErrorMessage, isErrorWithUrl} from '@utils/errors'; import {getFullErrorMessage, isErrorWithUrl} from '@utils/errors';
@ -172,7 +173,10 @@ const SSO = ({
} }
return ( return (
<View style={styles.flex}> <View
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
style={styles.flex}
>
<Background theme={theme}/> <Background theme={theme}/>
<AnimatedSafeArea style={[styles.flex, transform]}> <AnimatedSafeArea style={[styles.flex, transform]}>
{authentication} {authentication}

View file

@ -2,10 +2,11 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useCallback} from 'react'; import React, {useCallback} from 'react';
import {Platform, ScrollView, StyleSheet} from 'react-native'; import {Platform, ScrollView, StyleSheet, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context'; import {SafeAreaView} from 'react-native-safe-area-context';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import type {AvailableScreens} from '@typings/screens/navigation'; import type {AvailableScreens} from '@typings/screens/navigation';
@ -48,15 +49,20 @@ const Table = ({componentId, renderAsFlex, renderRows, width}: Props) => {
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
return ( return (
<ScrollView testID='table.screen'> <View
<ScrollView style={styles.container}
contentContainerStyle={viewStyle} nativeID={SecurityManager.getShieldScreenId(componentId)}
horizontal={true} >
testID='table.scroll_view' <ScrollView testID='table.screen'>
> <ScrollView
{content} contentContainerStyle={viewStyle}
horizontal={true}
testID='table.scroll_view'
>
{content}
</ScrollView>
</ScrollView> </ScrollView>
</ScrollView> </View>
); );
} }
@ -64,6 +70,7 @@ const Table = ({componentId, renderAsFlex, renderRows, width}: Props) => {
<SafeAreaView <SafeAreaView
style={styles.container} style={styles.container}
testID='table.screen' testID='table.screen'
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<ScrollView <ScrollView
style={styles.fullHeight} style={styles.fullHeight}

View file

@ -20,6 +20,7 @@ import {Screens} from '@constants/index';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import SecurityManager from '@managers/security_manager';
import {dismissOverlay} from '@screens/navigation'; import {dismissOverlay} from '@screens/navigation';
import NavigationStore from '@store/navigation_store'; import NavigationStore from '@store/navigation_store';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown'; import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown';
@ -291,7 +292,10 @@ const TermsOfService = ({
}, [styles, insets]); }, [styles, insets]);
return ( return (
<View style={styles.root}> <View
style={styles.root}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<View style={containerStyle}> <View style={containerStyle}>
<View style={styles.wrapper}> <View style={styles.wrapper}>
<Text style={styles.title}>{intl.formatMessage({id: 'terms_of_service.title', defaultMessage: 'Terms of Service'})}</Text> <Text style={styles.title}>{intl.formatMessage({id: 'terms_of_service.title', defaultMessage: 'Terms of Service'})}</Text>

View file

@ -15,6 +15,7 @@ import {Screens} from '@constants';
import {ExtraKeyboardProvider} from '@context/extra_keyboard'; import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useDidUpdate from '@hooks/did_update'; import useDidUpdate from '@hooks/did_update';
import SecurityManager from '@managers/security_manager';
import {popTopScreen, setButtons} from '@screens/navigation'; import {popTopScreen, setButtons} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store'; import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store'; import NavigationStore from '@store/navigation_store';
@ -114,6 +115,7 @@ const Thread = ({
edges={edges} edges={edges}
testID='thread.screen' testID='thread.screen'
onLayout={onLayout} onLayout={onLayout}
nativeID={SecurityManager.getShieldScreenId(componentId)}
> >
<RoundedHeaderContext/> <RoundedHeaderContext/>
{Boolean(rootPost) && {Boolean(rootPost) &&

View file

@ -702,14 +702,20 @@
"mobile.manage_members.remove_member": "Remove from Channel", "mobile.manage_members.remove_member": "Remove from Channel",
"mobile.manage_members.section_title_admins": "CHANNEL ADMINS", "mobile.manage_members.section_title_admins": "CHANNEL ADMINS",
"mobile.manage_members.section_title_members": "MEMBERS", "mobile.manage_members.section_title_members": "MEMBERS",
"mobile.managed.biometric_failed": "Biometric or Passcode authentication failed.",
"mobile.managed.blocked_by": "Blocked by {vendor}", "mobile.managed.blocked_by": "Blocked by {vendor}",
"mobile.managed.exit": "Exit", "mobile.managed.exit": "Exit",
"mobile.managed.jailbreak": "Jailbroken or rooted devices are not trusted by {vendor}.\n\nThe app will now close.", "mobile.managed.jailbreak": "Jailbroken or rooted devices are not trusted by {vendor}.",
"mobile.managed.jailbreak.emm": "Jailbroken or rooted devices are not trusted by {vendor}.\n\nThe app will now close.",
"mobile.managed.logout": "Logout",
"mobile.managed.not_secured.android": "This device must be secured with a screen lock to use Mattermost.", "mobile.managed.not_secured.android": "This device must be secured with a screen lock to use Mattermost.",
"mobile.managed.not_secured.ios": "This device must be secured with a passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.", "mobile.managed.not_secured.android.vendor": "This device must be secured with a screen lock to use {vendor}.",
"mobile.managed.not_secured.ios.touchId": "This device must be secured with a passcode to use Mattermost.\n\nGo to Settings > Touch ID & Passcode.", "mobile.managed.not_secured.ios": "This device must be secured with biometrics or passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.",
"mobile.managed.not_secured.ios.vendor": "This device must be secured with biometrics or passcode to use {vendor}.\n\nGo to Settings > Face ID & Passcode.",
"mobile.managed.OK": "OK",
"mobile.managed.secured_by": "Secured by {vendor}", "mobile.managed.secured_by": "Secured by {vendor}",
"mobile.managed.settings": "Go to settings", "mobile.managed.settings": "Go to settings",
"mobile.managed.switch_server": "Switch server",
"mobile.markdown.code.copy_code": "Copy Code", "mobile.markdown.code.copy_code": "Copy Code",
"mobile.markdown.code.plusMoreLines": "+{count, number} more {count, plural, one {line} other {lines}}", "mobile.markdown.code.plusMoreLines": "+{count, number} more {count, plural, one {line} other {lines}}",
"mobile.markdown.copy_header": "Copy header text", "mobile.markdown.copy_header": "Copy header text",

View file

@ -8,9 +8,14 @@
import Foundation import Foundation
import Gekidou import Gekidou
import react_native_emm
@objc class GekidouWrapper: NSObject { @objc class GekidouWrapper: NSObject {
@objc public static let `default` = GekidouWrapper() @objc public static let `default` = GekidouWrapper()
override init() {
ScreenCaptureManager.startTrackingScreens()
}
@objc func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) { @objc func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) {
PushNotification.default.postNotificationReceipt(userInfo) PushNotification.default.postNotificationReceipt(userInfo)

View file

@ -59,6 +59,7 @@ static SendReplyCompletionHandlerIMP originalSendReplyCompletionHandlerImplement
if (serverUrl == nil) { if (serverUrl == nil) {
[self handleReplyFailure:@"" completionHandler:notificationCompletionHandler]; [self handleReplyFailure:@"" completionHandler:notificationCompletionHandler];
return;
} }
NSString *sessionToken = [[Keychain default] getTokenObjcFor:serverUrl]; NSString *sessionToken = [[Keychain default] getTokenObjcFor:serverUrl];

View file

@ -84,7 +84,7 @@ static URLSessionTaskDidReceiveChallengeIMP originalURLSessionTaskDidReceiveChal
delegateQueue:session.delegateQueue]; delegateQueue:session.delegateQueue];
NSURLRequest *authorizedRequest = [BearerAuthenticationAdapter addAuthorizationBearerTokenTo:request withSessionBaseUrlString:sessionBaseUrl.absoluteString]; NSURLRequest *authorizedRequest = [BearerAuthenticationAdapter addAuthorizationBearerTokenTo:request withSessionBaseUrlString:sessionBaseUrl.absoluteString];
return originalInitWithRequestInSessionOptionsContextImplementation(self, @selector(initWithRequest:inSession:options:context:), authorizedRequest, session, &options, context); return originalInitWithRequestInSessionOptionsContextImplementation(self, @selector(initWithRequest:inSession:options:context:), authorizedRequest, newSession, &options, context);
} }
return originalInitWithRequestInSessionOptionsContextImplementation(self, @selector(initWithRequest:inSession:options:context:), request, session, &options, context); return originalInitWithRequestInSessionOptionsContextImplementation(self, @selector(initWithRequest:inSession:options:context:), request, session, &options, context);

View file

@ -7,7 +7,7 @@ PODS:
- DoubleConversion (1.1.6) - DoubleConversion (1.1.6)
- EXApplication (6.0.1): - EXApplication (6.0.1):
- ExpoModulesCore - ExpoModulesCore
- EXConstants (17.0.5): - EXConstants (17.0.3):
- ExpoModulesCore - ExpoModulesCore
- Expo (52.0.19): - Expo (52.0.19):
- ExpoModulesCore - ExpoModulesCore
@ -15,7 +15,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoDevice (7.0.1): - ExpoDevice (7.0.1):
- ExpoModulesCore - ExpoModulesCore
- ExpoFileSystem (18.0.7): - ExpoFileSystem (18.0.6):
- ExpoModulesCore - ExpoModulesCore
- ExpoImage (2.0.4): - ExpoImage (2.0.4):
- ExpoModulesCore - ExpoModulesCore
@ -1410,7 +1410,7 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- react-native-emm (1.5.1): - react-native-emm (1.6.0):
- DoubleConversion - DoubleConversion
- glog - glog
- hermes-engine - hermes-engine
@ -2496,11 +2496,11 @@ SPEC CHECKSUMS:
CocoaLumberjack: 6a459bc897d6d80bd1b8c78482ec7ad05dffc3f0 CocoaLumberjack: 6a459bc897d6d80bd1b8c78482ec7ad05dffc3f0
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
EXApplication: 88ebf1a95f85faa5d2df160016d61f2d060e9438 EXApplication: 88ebf1a95f85faa5d2df160016d61f2d060e9438
EXConstants: e25c3f3ef5d1cf7a6ef911ebf7a3ab94d8d06d82 EXConstants: 277129d9a42ba2cf1fad375e7eaa9939005c60be
Expo: 848501c300b37b3fa7330c58a5353e07e03d5c3d Expo: 848501c300b37b3fa7330c58a5353e07e03d5c3d
ExpoCrypto: 483fc758365923f89ddaab4327c21cae9534841d ExpoCrypto: 483fc758365923f89ddaab4327c21cae9534841d
ExpoDevice: 449822f2c45660c1ce83283dd51c67fe5404996c ExpoDevice: 449822f2c45660c1ce83283dd51c67fe5404996c
ExpoFileSystem: 818e82dbb71175414d1ca310e926c48ff0d07348 ExpoFileSystem: a38e1bb58d77f41717293a7b73ebd4014d8cb8dd
ExpoImage: 18ec1a3e5cd96ee6162d988be3085e18113e143f ExpoImage: 18ec1a3e5cd96ee6162d988be3085e18113e143f
ExpoLinearGradient: 18148bd38f98fa0229c1f9df23393b32ab6d2d32 ExpoLinearGradient: 18148bd38f98fa0229c1f9df23393b32ab6d2d32
ExpoModulesCore: c9cb309323aee3c048099bb40ad0226e3fcf0c56 ExpoModulesCore: c9cb309323aee3c048099bb40ad0226e3fcf0c56
@ -2550,7 +2550,7 @@ SPEC CHECKSUMS:
react-native-cameraroll: 5f180ef5e9b52b6c3c3a2645fa33a921d1e37a6c react-native-cameraroll: 5f180ef5e9b52b6c3c3a2645fa33a921d1e37a6c
react-native-cookies: d648ab7025833b977c0b19e142503034f5f29411 react-native-cookies: d648ab7025833b977c0b19e142503034f5f29411
react-native-document-picker: 530879d9e89b490f0954bcc4ab697c5b5e35d659 react-native-document-picker: 530879d9e89b490f0954bcc4ab697c5b5e35d659
react-native-emm: f6003bebdf4fef4feef7c61f96a5c174f43c6b5f react-native-emm: 710fa6ce569cf04084224fa61a038e6143ea7fe0
react-native-image-picker: 130fad649d07e4eec8faaed361d3bba570e1e5ff react-native-image-picker: 130fad649d07e4eec8faaed361d3bba570e1e5ff
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-network-client: fdbd7f5f4c2818d6b90b4dcf9613d0a54ab41303 react-native-network-client: fdbd7f5f4c2818d6b90b4dcf9613d0a54ab41303

View file

@ -1,6 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// eslint-disable-next-line no-process-env
process.env.TZ = 'UTC';
module.exports = { module.exports = {
preset: 'jest-expo', preset: 'jest-expo',
verbose: true, verbose: true,

View file

@ -32,6 +32,10 @@ class MattermostHardwareKeyboardImpl(reactApplicationContext: ReactApplicationCo
} }
private fun sendEvent(action: String) { private fun sendEvent(action: String) {
if (!this::context.isInitialized) {
return
}
if (context.hasActiveReactInstance()) { if (context.hasActiveReactInstance()) {
val result: WritableMap = WritableNativeMap() val result: WritableMap = WritableNativeMap()
result.putString("action", action) result.putString("action", action)

13
package-lock.json generated
View file

@ -20,7 +20,7 @@
"@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f", "@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f",
"@mattermost/compass-icons": "0.1.48", "@mattermost/compass-icons": "0.1.48",
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
"@mattermost/react-native-emm": "1.5.1", "@mattermost/react-native-emm": "1.6.0",
"@mattermost/react-native-network-client": "1.8.2", "@mattermost/react-native-network-client": "1.8.2",
"@mattermost/react-native-paste-input": "0.8.1", "@mattermost/react-native-paste-input": "0.8.1",
"@mattermost/react-native-turbo-log": "0.6.0", "@mattermost/react-native-turbo-log": "0.6.0",
@ -183,6 +183,11 @@
"version": "0.0.0", "version": "0.0.0",
"license": "Apache 2.0" "license": "Apache 2.0"
}, },
"libraries/@mattermost/rnshield": {
"version": "0.0.0",
"extraneous": true,
"license": "Apache 2.0"
},
"libraries/@mattermost/rnutils": { "libraries/@mattermost/rnutils": {
"version": "0.0.0", "version": "0.0.0",
"license": "Apache 2.0" "license": "Apache 2.0"
@ -4633,9 +4638,9 @@
"link": true "link": true
}, },
"node_modules/@mattermost/react-native-emm": { "node_modules/@mattermost/react-native-emm": {
"version": "1.5.1", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-emm/-/react-native-emm-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@mattermost/react-native-emm/-/react-native-emm-1.6.0.tgz",
"integrity": "sha512-gkuj+UyPWqfxdG62lcqDT5301tPL5dgRpFm63HHegVs45bJX4LmNDHgF7MJuravOJjKX3huMpJDG/UnzHncbkQ==", "integrity": "sha512-e8Ka0Zd6LZsbVWIiGeASiydV0WTQqc+2LNl8t/Zm22i0M7zhVgf4QIciXE/XjVJk1tfxJ5fhRM4LCp8q87HgTw==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",

View file

@ -21,7 +21,7 @@
"@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f", "@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f",
"@mattermost/compass-icons": "0.1.48", "@mattermost/compass-icons": "0.1.48",
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
"@mattermost/react-native-emm": "1.5.1", "@mattermost/react-native-emm": "1.6.0",
"@mattermost/react-native-network-client": "1.8.2", "@mattermost/react-native-network-client": "1.8.2",
"@mattermost/react-native-paste-input": "0.8.1", "@mattermost/react-native-paste-input": "0.8.1",
"@mattermost/react-native-turbo-log": "0.6.0", "@mattermost/react-native-turbo-log": "0.6.0",

View file

@ -1,5 +1,18 @@
diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx
index c79181a..31fc208 100644
--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx
+++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx
@@ -1892,7 +1892,7 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
detached={detached}
style={_providedContainerStyle}
>
- <Animated.View style={containerStyle}>
+ <Animated.View style={containerStyle} nativeID={'BottomSheetComponent'}>
<BottomSheetBackgroundContainer
key="BottomSheetBackgroundContainer"
animatedIndex={animatedIndex}
diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetContainer/BottomSheetContainer.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetContainer/BottomSheetContainer.tsx diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetContainer/BottomSheetContainer.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetContainer/BottomSheetContainer.tsx
index 72fe2cb..abac348 100644 index 0716ecd..081d723 100644
--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetContainer/BottomSheetContainer.tsx --- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetContainer/BottomSheetContainer.tsx
+++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetContainer/BottomSheetContainer.tsx +++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetContainer/BottomSheetContainer.tsx
@@ -30,10 +30,10 @@ function BottomSheetContainerComponent({ @@ -30,10 +30,10 @@ function BottomSheetContainerComponent({

View file

@ -73,6 +73,7 @@ jest.mock('@nozbe/watermelondb/utils/common/randomId/randomId', () => ({}));
jest.mock('@database/manager'); jest.mock('@database/manager');
jest.doMock('react-native', () => { jest.doMock('react-native', () => {
const { const {
AppState: RNAppState,
Platform, Platform,
StyleSheet, StyleSheet,
requireNativeComponent, requireNativeComponent,
@ -87,6 +88,13 @@ jest.doMock('react-native', () => {
alert: jest.fn(), alert: jest.fn(),
}; };
const AppState = {
...RNAppState,
addEventListener: jest.fn(() => ({
remove: jest.fn(),
})),
};
const InteractionManager = { const InteractionManager = {
...RNInteractionManager, ...RNInteractionManager,
runAfterInteractions: jest.fn((cb) => cb()), runAfterInteractions: jest.fn((cb) => cb()),
@ -214,10 +222,12 @@ jest.doMock('react-native', () => {
minor: 64, minor: 64,
}, },
}, },
select: jest.fn((dict) => dict.ios || dict.default),
}, },
StyleSheet, StyleSheet,
requireNativeComponent, requireNativeComponent,
Alert, Alert,
AppState,
InteractionManager, InteractionManager,
NativeModules, NativeModules,
Linking, Linking,

View file

@ -7,6 +7,7 @@ import assert from 'assert';
import {random} from 'lodash'; import {random} from 'lodash';
import nock from 'nock'; import nock from 'nock';
import {of as of$} from 'rxjs';
import Config from '@assets/config.json'; import Config from '@assets/config.json';
import {Client} from '@client/rest'; import {Client} from '@client/rest';
@ -18,6 +19,7 @@ import DatabaseManager from '@database/manager';
import {prepareCommonSystemValues} from '@queries/servers/system'; import {prepareCommonSystemValues} from '@queries/servers/system';
import type {APIClientInterface} from '@mattermost/react-native-network-client'; import type {APIClientInterface} from '@mattermost/react-native-network-client';
import type {Model, Query} from '@nozbe/watermelondb';
const DEFAULT_LOCALE = 'en'; const DEFAULT_LOCALE = 'en';
@ -731,6 +733,13 @@ class TestHelperSingleton {
wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time)); wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time));
tick = () => new Promise((r) => setImmediate(r)); tick = () => new Promise((r) => setImmediate(r));
mockQuery = <T extends Model>(returnValue: T | T[]) => {
return {
fetch: async () => returnValue,
observe: of$(returnValue),
} as unknown as Query<T>;
};
} }
const TestHelper = new TestHelperSingleton(); const TestHelper = new TestHelperSingleton();

View file

@ -149,6 +149,9 @@ interface ClientConfig {
MaxNotificationsPerChannel: string; MaxNotificationsPerChannel: string;
MaxPostSize: string; MaxPostSize: string;
MinimumHashtagLength: string; MinimumHashtagLength: string;
MobileEnableBiometrics: string;
MobileJailbreakProtection: string;
MobilePreventScreenCapture: string;
MobileExternalBrowser: string; MobileExternalBrowser: string;
OpenIdButtonColor: string; OpenIdButtonColor: string;
OpenIdButtonText: string; OpenIdButtonText: string;
@ -198,3 +201,5 @@ interface ClientConfig {
WebsocketSecurePort: string; WebsocketSecurePort: string;
WebsocketURL: string; WebsocketURL: string;
} }
type SecurityClientConfig = Pick<ClientConfig, 'MobileEnableBiometrics' | 'MobileJailbreakProtection' | 'MobilePreventScreenCapture' | 'SiteName'>