diff --git a/app/actions/app/server.test.ts b/app/actions/app/server.test.ts new file mode 100644 index 000000000..c130015a1 --- /dev/null +++ b/app/actions/app/server.test.ts @@ -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([]); + 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(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(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(); + }); +}); diff --git a/app/actions/app/server.ts b/app/actions/app/server.ts new file mode 100644 index 000000000..8a9d34440 --- /dev/null +++ b/app/actions/app/server.ts @@ -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> = []; + const results: Record = {}; + + 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!); + } +} diff --git a/app/actions/remote/entry/login.ts b/app/actions/remote/entry/login.ts index 8cce868b2..f70a30500 100644 --- a/app/actions/remote/entry/login.ts +++ b/app/actions/remote/entry/login.ts @@ -5,6 +5,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems'; import DatabaseManager from '@database/manager'; import {getServerCredentials} from '@init/credentials'; import PerformanceMetricsManager from '@managers/performance_metrics_manager'; +import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; type AfterLoginArgs = { @@ -33,8 +34,10 @@ export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?: const credentials = await getServerCredentials(serverUrl); if (credentials?.token) { + SecurityManager.addServer(serverUrl, clData.config, true); WebsocketManager.createClient(serverUrl, credentials.token); await WebsocketManager.initializeClient(serverUrl, 'Login'); + SecurityManager.setActiveServer(serverUrl); } return {}; diff --git a/app/components/settings/container.tsx b/app/components/settings/container.tsx index 297aa2a9d..6314a902b 100644 --- a/app/components/settings/container.tsx +++ b/app/components/settings/container.tsx @@ -6,8 +6,11 @@ import {ScrollView} from 'react-native'; import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; import {useTheme} from '@context/theme'; +import SecurityManager from '@managers/security_manager'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import type {AvailableScreens} from '@typings/screens/navigation'; + const edges: Edge[] = ['left', 'right', 'bottom']; const getStyleSheet = makeStyleSheetFromTheme((theme) => { @@ -36,6 +39,7 @@ const SettingContainer = ({children, testID}: SettingContainerProps) => { edges={edges} style={styles.container} testID={`${testID}.screen`} + nativeID={SecurityManager.getShieldScreenId(`${testID}.screen` as AvailableScreens)} > { }; export const removeServerCredentials = async (serverUrl: string) => { - return KeyChain.resetInternetCredentials(serverUrl); + return KeyChain.resetInternetCredentials({server: serverUrl}); }; export const removeActiveServerCredentials = async () => { diff --git a/app/init/managed_app.ts b/app/init/managed_app.ts index 33cdecbef..2758c99a2 100644 --- a/app/init/managed_app.ts +++ b/app/init/managed_app.ts @@ -4,15 +4,55 @@ import Emm from '@mattermost/react-native-emm'; import deepEqual from 'deep-equal'; import {isRootedExperimentalAsync} from 'expo-device'; +import {defineMessages} from 'react-intl'; 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 {isMainActivity} from '@utils/helpers'; import {getIOSAppGroupDetails} from '@utils/mattermost_managed'; 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 { backgroundSince = 0; enabled = false; @@ -94,11 +134,11 @@ class ManagedAppSingleton { const locale = DEFAULT_LOCALE; const translations = getTranslations(locale); Alert.alert( - translations[t('mobile.managed.blocked_by')].replace('{vendor}', this.vendor), - translations[t('mobile.managed.jailbreak')]. + translations[messages.blocked.id].replace('{vendor}', this.vendor), + translations[messages.jailbreak.id]. replace('{vendor}', this.vendor), [{ - text: translations[t('mobile.managed.exit')], + text: translations[messages.exit.id], style: 'destructive', onPress: () => { Emm.exitApp(); @@ -123,7 +163,7 @@ class ManagedAppSingleton { if (authExpired) { try { 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, supressEnterPassword: true, }); @@ -158,12 +198,12 @@ class ManagedAppSingleton { }; showNotSecuredAlert = (translations: Record) => { - return new Promise(async (resolve) => { /* eslint-disable-line no-async-promise-executor */ + return new Promise((resolve) => { const buttons: AlertButton[] = []; if (Platform.OS === 'android') { buttons.push({ - text: translations[t('mobile.managed.settings')], + text: translations[messages.androidSettings.id], onPress: () => { Emm.openSecuritySettings(); }, @@ -171,26 +211,22 @@ class ManagedAppSingleton { } buttons.push({ - text: translations[t('mobile.managed.exit')], + text: translations[messages.exit.id], onPress: resolve, style: 'cancel', }); let message; - if (Platform.OS === 'ios') { - const {face} = await Emm.deviceSecureWith(); - - if (face) { - message = translations[t('mobile.managed.not_secured.ios')]; - } else { - message = translations[t('mobile.managed.not_secured.ios.touchId')]; - } + if (this.vendor) { + const platform = Platform.select({ios: messages.notSecuredVendorIOS.id, default: messages.notSecuredVendorAndroid.id}); + message = translations[platform].replace('{vendor}', this.vendor); } 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( - translations[t('mobile.managed.blocked_by')].replace('{vendor}', this.vendor), + translations[messages.blocked.id].replace('{vendor}', this.vendor), message, buttons, {cancelable: false, onDismiss: () => resolve}, diff --git a/app/managers/security_manager/index.test.ts b/app/managers/security_manager/index.test.ts new file mode 100644 index 000000000..c90f3d69c --- /dev/null +++ b/app/managers/security_manager/index.test.ts @@ -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 = { + '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 = { + '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 = { + '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 = { + '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 = { + '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 = { + '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 = { + '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`); + }); + }); +}); diff --git a/app/managers/security_manager/index.ts b/app/managers/security_manager/index.ts new file mode 100644 index 000000000..7be5d8fb3 --- /dev/null +++ b/app/managers/security_manager/index.ts @@ -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; + +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, activeServer?: string) { + if (this.initialized) { + return; + } + + this.initialized = true; + const added = new Set(); + 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, 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) => { + 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) => { + 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) => { + 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; diff --git a/app/managers/session_manager.test.ts b/app/managers/session_manager.test.ts index f7e563011..8396dc587 100644 --- a/app/managers/session_manager.test.ts +++ b/app/managers/session_manager.test.ts @@ -11,6 +11,7 @@ import {getAllServerCredentials, removeServerCredentials} from '@init/credential import {relaunchApp} from '@init/launch'; import PushNotifications from '@init/push_notifications'; import NetworkManager from '@managers/network_manager'; +import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; import {deleteFileCache, deleteFileCacheByDir} from '@utils/file'; import {isMainActivity} from '@utils/helpers'; @@ -37,6 +38,7 @@ jest.mock('@init/credentials'); jest.mock('@init/launch'); jest.mock('@init/push_notifications'); jest.mock('@managers/network_manager'); +jest.mock('@managers/security_manager'); jest.mock('@managers/websocket_manager'); jest.mock('@queries/app/servers'); jest.mock('@queries/servers/user'); @@ -107,6 +109,7 @@ describe('SessionManager', () => { expect(PushNotifications.removeServerNotifications).toHaveBeenCalledWith(mockServerUrl); expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl); expect(WebsocketManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl); + expect(SecurityManager.removeServer).toHaveBeenCalledWith(mockServerUrl); }); it('should handle session expiration', async () => { diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts index 989a0b3b2..49ac6a9ba 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -14,6 +14,7 @@ import {getAllServerCredentials, removeServerCredentials} from '@init/credential import {relaunchApp} from '@init/launch'; import PushNotifications from '@init/push_notifications'; import NetworkManager from '@managers/network_manager'; +import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; import {getAllServers, getServerDisplayName} from '@queries/app/servers'; import {getCurrentUser} from '@queries/servers/user'; @@ -114,6 +115,7 @@ export class SessionManagerSingleton { cancelSessionNotification(serverUrl); await removeServerCredentials(serverUrl); PushNotifications.removeServerNotifications(serverUrl); + SecurityManager.removeServer(serverUrl); NetworkManager.invalidateClient(serverUrl); WebsocketManager.invalidateClient(serverUrl); diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 4b4a388db..d7bdd2316 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -57,6 +57,7 @@ import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet} from '@hooks/device'; +import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; import { allOrientations, @@ -736,7 +737,10 @@ const CallScreen = ({ ); return ( - + {!isLandscape && header} diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index ce13d8afc..1944fc1d0 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -137,12 +137,12 @@ export const getCommonSystemValues = async (serverDatabase: Database) => { }; }; -const fromModelToClientConfig = (list: ConfigModel[]) => { +const fromModelToClientConfig = (list: ConfigModel[]) => { const config: {[key: string]: any} = {}; list.forEach((v) => { config[v.id] = v.value; }); - return config as ClientConfig; + return config as T; }; export const getConfig = async (database: Database) => { @@ -150,6 +150,12 @@ export const getConfig = async (database: Database) => { return fromModelToClientConfig(configList); }; +export const getSecurityConfig = async (database: Database) => { + const configList = await database.get(CONFIG).query( + Q.where('id', Q.oneOf(['MobileEnableBiometrics', 'MobileJailbreakProtection', 'MobilePreventScreenCapture', 'SiteName']))).fetch(); + return fromModelToClientConfig(configList); +}; + export const queryConfigValue = (database: Database, key: keyof ClientConfig) => { return database.get(CONFIG).query(Q.where('id', Q.eq(key))); }; @@ -205,7 +211,7 @@ export const getIsDataRetentionEnabled = async (database: Database) => { export const observeConfig = (database: Database): Observable => { return database.get(CONFIG).query().observeWithColumns(['value']).pipe( - switchMap((result) => of$(fromModelToClientConfig(result))), + switchMap((result) => of$(fromModelToClientConfig(result))), ); }; diff --git a/app/screens/apps_form/apps_form_component.tsx b/app/screens/apps_form/apps_form_component.tsx index bf438c3c9..e88abc317 100644 --- a/app/screens/apps_form/apps_form_component.tsx +++ b/app/screens/apps_form/apps_form_component.tsx @@ -15,6 +15,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useDidUpdate from '@hooks/did_update'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {filterEmptyOptions} from '@utils/apps'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations'; @@ -385,6 +386,7 @@ function AppsFormComponent({ + {renderContainerContent()} {FooterComponent && ()} - + ); } diff --git a/app/screens/browse_channels/browse_channels.tsx b/app/screens/browse_channels/browse_channels.tsx index ec54f7f21..a7efbbf96 100644 --- a/app/screens/browse_channels/browse_channels.tsx +++ b/app/screens/browse_channels/browse_channels.tsx @@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {dismissModal, goToScreen, setButtons} from '@screens/navigation'; import {alertErrorWithFallback} from '@utils/draft'; import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme'; @@ -231,7 +232,10 @@ export default function BrowseChannels(props: Props) { } return ( - + {content} ); diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index f525511d3..9b1b2fc80 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -15,6 +15,7 @@ import {useChannelSwitch} from '@hooks/channel_switch'; import {useIsTablet} from '@hooks/device'; import {useDefaultHeaderHeight} from '@hooks/header'; import {useTeamSwitch} from '@hooks/team_switch'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; @@ -116,6 +117,7 @@ const Channel = ({ edges={edges} testID='channel.screen' onLayout={onLayout} + nativeID={componentId ? SecurityManager.getShieldScreenId(componentId) : undefined} > {type === 'link' && [channel], [channel]); return ( - - - + + + +
- -
- {loading && + {loading && - } - {!loading && - - } - + } + {!loading && + + } + + ); } diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx index 060ebbec5..5358f8374 100644 --- a/app/screens/channel_info/channel_info.tsx +++ b/app/screens/channel_info/channel_info.tsx @@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {dismissModal} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -96,73 +97,78 @@ const ChannelInfo = ({ const convertGMOptionAvailable = isConvertGMFeatureAvailable && type === General.GM_CHANNEL && !isGuestUser; return ( - - - - {isBookmarksEnabled && - <ChannelBookmarks + <ScrollView + bounces={true} + alwaysBounceVertical={false} + contentContainerStyle={styles.content} + testID='channel_info.scroll_view' + > + <Title channelId={channelId} - canAddBookmarks={canAddBookmarks} - showInInfo={true} + type={type} /> - } - <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}/> - </> - } - {canEnableDisableCalls && - <> - <ChannelInfoEnableCalls + {isBookmarksEnabled && + <ChannelBookmarks 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}/> </> - } - <ChannelInfoAppBindings - channelId={channelId} - serverUrl={serverUrl} - dismissChannelInfo={onPressed} - /> - <DestructiveOptions - channelId={channelId} - componentId={componentId} - type={type} - /> - </ScrollView> - </SafeAreaView> + } + {canEnableDisableCalls && + <> + <ChannelInfoEnableCalls + channelId={channelId} + enabled={isCallsEnabledInChannel} + /> + <View style={styles.separator}/> + </> + } + <ChannelInfoAppBindings + channelId={channelId} + serverUrl={serverUrl} + dismissChannelInfo={onPressed} + /> + <DestructiveOptions + channelId={channelId} + componentId={componentId} + type={type} + /> + </ScrollView> + </SafeAreaView> + </View> ); }; diff --git a/app/screens/code/index.tsx b/app/screens/code/index.tsx index 816d1b1e3..cc1312158 100644 --- a/app/screens/code/index.tsx +++ b/app/screens/code/index.tsx @@ -13,6 +13,7 @@ import {SNACK_BAR_TYPE} from '@constants/snack_bar'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen, setButtons} from '@screens/navigation'; import {showSnackBar} from '@utils/snack_bar'; @@ -64,6 +65,7 @@ const Code = ({code, componentId, language, textStyle}: Props) => { <SafeAreaView edges={edges} style={styles.flex} + nativeID={SecurityManager.getShieldScreenId(componentId)} > <SyntaxHiglight code={code} diff --git a/app/screens/component_library/index.tsx b/app/screens/component_library/index.tsx index c8bc88a0f..351355a84 100644 --- a/app/screens/component_library/index.tsx +++ b/app/screens/component_library/index.tsx @@ -8,6 +8,7 @@ import AutocompleteSelector from '@components/autocomplete_selector'; import {Preferences} from '@constants'; import {CustomThemeProvider} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen} from '@screens/navigation'; import ButtonComponentLibrary from './button.cl'; @@ -111,7 +112,10 @@ const ComponentLibrary = ({componentId}: Props) => { const SelectedComponent = componentMap[selectedComponent]; return ( - <ScrollView style={{margin: 10}}> + <ScrollView + style={{margin: 10}} + nativeID={SecurityManager.getShieldScreenId(componentId)} + > <AutocompleteSelector testID='selectedComponent' label='Component' diff --git a/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx b/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx index e1eb4f933..536837dbe 100644 --- a/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx +++ b/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx @@ -3,19 +3,24 @@ import React, {useEffect, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; +import {View} from 'react-native'; import {fetchChannelMemberships, fetchGroupMessageMembersCommonTeams} from '@actions/remote/channel'; import {PER_PAGE_DEFAULT} from '@client/rest/constants'; import Loading from '@components/loading'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import SecurityManager from '@managers/security_manager'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import ConvertGMToChannelForm from './convert_gm_to_channel_form'; +import type {AvailableScreens} from '@typings/screens/navigation'; + type Props = { channelId: string; + componentId: AvailableScreens; currentUserId?: string; } @@ -61,11 +66,13 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { flexDirection: 'column', gap: 24, }, + flex: {flex: 1}, }; }); const ConvertGMToChannel = ({ channelId, + componentId, currentUserId, }: Props) => { const theme = useTheme(); @@ -131,8 +138,9 @@ const ConvertGMToChannel = ({ const showLoader = !loadingAnimationTimeout || !commonTeamsFetched || !channelMembersFetched; + let component; if (showLoader) { - return ( + component = ( <Loading containerStyle={styles.loadingContainer} size='large' @@ -141,14 +149,23 @@ const ConvertGMToChannel = ({ footerTextStyles={styles.text} /> ); + } else { + component = ( + <ConvertGMToChannelForm + commonTeams={commonTeams} + profiles={profiles} + channelId={channelId} + /> + ); } return ( - <ConvertGMToChannelForm - commonTeams={commonTeams} - profiles={profiles} - channelId={channelId} - /> + <View + nativeID={SecurityManager.getShieldScreenId(componentId)} + style={styles.flex} + > + {component} + </View> ); }; diff --git a/app/screens/convert_gm_to_channel/team_selector_list/index.tsx b/app/screens/convert_gm_to_channel/team_selector_list/index.tsx index 65dfd7bb4..9f0427273 100644 --- a/app/screens/convert_gm_to_channel/team_selector_list/index.tsx +++ b/app/screens/convert_gm_to_channel/team_selector_list/index.tsx @@ -8,10 +8,13 @@ import {StyleSheet, View} from 'react-native'; import SearchBar from '@components/search'; import TeamList from '@components/team_list'; import {useTheme} from '@context/theme'; +import {usePreventDoubleTap} from '@hooks/utils'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen} from '@screens/navigation'; -import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme'; +import type {AvailableScreens} from '@typings/screens/navigation'; + const styles = StyleSheet.create({ container: { padding: 12, @@ -22,11 +25,12 @@ const styles = StyleSheet.create({ }); type Props = { + componentId: AvailableScreens; teams: Team[]; selectTeam: (teamId: string) => void; } -const TeamSelectorList = ({teams, selectTeam}: Props) => { +const TeamSelectorList = ({componentId, teams, selectTeam}: Props) => { const theme = useTheme(); const [filteredTeams, setFilteredTeam] = useState(teams); const color = useMemo(() => changeOpacity(theme.centerChannelColor, 0.72), [theme]); @@ -39,13 +43,16 @@ const TeamSelectorList = ({teams, selectTeam}: Props) => { } }, 200), [teams]); - const handleOnPress = useCallback(preventDoubleTap((teamId: string) => { + const handleOnPress = usePreventDoubleTap(useCallback((teamId: string) => { selectTeam(teamId); popTopScreen(); - }), []); + }, [])); return ( - <View style={styles.container}> + <View + nativeID={SecurityManager.getShieldScreenId(componentId)} + style={styles.container} + > <SearchBar autoCapitalize='none' autoFocus={true} diff --git a/app/screens/create_direct_message/create_direct_message.tsx b/app/screens/create_direct_message/create_direct_message.tsx index 799e24852..291c5b458 100644 --- a/app/screens/create_direct_message/create_direct_message.tsx +++ b/app/screens/create_direct_message/create_direct_message.tsx @@ -19,6 +19,7 @@ import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useKeyboardOverlap} from '@hooks/device'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {dismissModal, setButtons} from '@screens/navigation'; import {alertErrorWithFallback} from '@utils/draft'; import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; @@ -298,6 +299,7 @@ export default function CreateDirectMessage({ <SafeAreaView style={style.container} testID='create_direct_message.screen' + nativeID={SecurityManager.getShieldScreenId(componentId)} onLayout={onLayout} ref={mainView} > diff --git a/app/screens/create_or_edit_channel/create_or_edit_channel.tsx b/app/screens/create_or_edit_channel/create_or_edit_channel.tsx index 1872edd07..87cfccf71 100644 --- a/app/screens/create_or_edit_channel/create_or_edit_channel.tsx +++ b/app/screens/create_or_edit_channel/create_or_edit_channel.tsx @@ -3,7 +3,7 @@ import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; 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 CompassIcon from '@components/compass_icon'; @@ -13,6 +13,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {buildNavigationButton, dismissModal, popTopScreen, setButtons} from '@screens/navigation'; 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); }; +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + const CreateOrEditChannel = ({ componentId, channel, @@ -233,21 +240,26 @@ const CreateOrEditChannel = ({ useAndroidHardwareBackHandler(componentId, handleClose); return ( - <ChannelInfoForm - error={appState.error} - saving={appState.saving} - channelType={channel?.type} - editing={editing} - onTypeChange={setType} - type={type} - displayName={displayName} - onDisplayNameChange={setDisplayName} - header={header} - headerOnly={headerOnly} - onHeaderChange={setHeader} - purpose={purpose} - onPurposeChange={setPurpose} - /> + <View + nativeID={SecurityManager.getShieldScreenId(componentId)} + style={styles.container} + > + <ChannelInfoForm + error={appState.error} + saving={appState.saving} + channelType={channel?.type} + editing={editing} + onTypeChange={setType} + type={type} + displayName={displayName} + onDisplayNameChange={setDisplayName} + header={header} + headerOnly={headerOnly} + onHeaderChange={setHeader} + purpose={purpose} + onPurposeChange={setPurpose} + /> + </View> ); }; diff --git a/app/screens/custom_status/custom_status.tsx b/app/screens/custom_status/custom_status.tsx index aa6f5b4b6..b3b4fcd13 100644 --- a/app/screens/custom_status/custom_status.tsx +++ b/app/screens/custom_status/custom_status.tsx @@ -19,6 +19,7 @@ import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet} from '@hooks/device'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {dismissModal, goToScreen, openAsBottomSheet, showModal} from '@screens/navigation'; import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers'; import {logDebug} from '@utils/log'; @@ -75,6 +76,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), borderTopWidth: 1, }, + flex: {flex: 1}, }; }); @@ -345,7 +347,10 @@ const CustomStatus = ({ }, [isBtnEnabled]); return ( - <> + <View + style={style.flex} + nativeID={SecurityManager.getShieldScreenId(componentId)} + > {isTablet && <TabletTitle action={intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'})} @@ -411,7 +416,7 @@ const CustomStatus = ({ </ScrollView> </KeyboardAvoidingView> </SafeAreaView> - </> + </View> ); }; diff --git a/app/screens/custom_status_clear_after/index.tsx b/app/screens/custom_status_clear_after/index.tsx index 7e1c9be75..bbca8caf1 100644 --- a/app/screens/custom_status_clear_after/index.tsx +++ b/app/screens/custom_status_clear_after/index.tsx @@ -14,6 +14,7 @@ import { } from 'react-native-navigation'; import {CustomStatusDurationEnum} from '@constants/custom_status'; +import SecurityManager from '@managers/security_manager'; import {observeCurrentUser} from '@queries/servers/user'; import {dismissModal, popTopScreen} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; @@ -180,13 +181,14 @@ class ClearAfterModal extends NavigationComponent<Props, State> { }; render() { - const {currentUser, theme} = this.props; + const {componentId, currentUser, theme} = this.props; const style = getStyleSheet(theme); const {duration, expiresAt, showExpiryTime} = this.state; return ( <SafeAreaView style={style.container} testID='custom_status_clear_after.screen' + nativeID={SecurityManager.getShieldScreenId(componentId)} > <KeyboardAwareScrollView bounces={false}> <View style={style.scrollView}> diff --git a/app/screens/edit_post/edit_post.tsx b/app/screens/edit_post/edit_post.tsx index 15f2fa4ad..3c3b336b9 100644 --- a/app/screens/edit_post/edit_post.tsx +++ b/app/screens/edit_post/edit_post.tsx @@ -16,6 +16,7 @@ import {useKeyboardOverlap} from '@hooks/device'; import useDidUpdate from '@hooks/did_update'; import {useInputPropagation} from '@hooks/input'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import PostError from '@screens/edit_post/post_error'; import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; import {changeOpacity} from '@utils/theme'; @@ -207,7 +208,10 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach if (isUpdating) { return ( - <View style={styles.loader}> + <View + style={styles.loader} + nativeID={SecurityManager.getShieldScreenId(componentId)} + > <Loading color={theme.buttonBg}/> </View> ); @@ -219,6 +223,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach testID='edit_post.screen' style={styles.container} onLayout={onLayout} + nativeID={SecurityManager.getShieldScreenId(componentId)} > <View style={styles.body} diff --git a/app/screens/edit_profile/edit_profile.tsx b/app/screens/edit_profile/edit_profile.tsx index ec6d63e36..7481d1a39 100644 --- a/app/screens/edit_profile/edit_profile.tsx +++ b/app/screens/edit_profile/edit_profile.tsx @@ -16,6 +16,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {dismissModal, popTopScreen, setButtons} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; @@ -295,7 +296,10 @@ const EditProfile = ({ ) : null; return ( - <> + <View + style={styles.flex} + nativeID={SecurityManager.getShieldScreenId(componentId)} + > {isTablet && <TabletTitle action={buttonText} @@ -312,7 +316,7 @@ const EditProfile = ({ > {content} </SafeAreaView> - </> + </View> ); }; diff --git a/app/screens/edit_server/index.tsx b/app/screens/edit_server/index.tsx index 3e01a8629..e0dae340e 100644 --- a/app/screens/edit_server/index.tsx +++ b/app/screens/edit_server/index.tsx @@ -10,6 +10,7 @@ import {SafeAreaView} from 'react-native-safe-area-context'; import DatabaseManager from '@database/manager'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {getServerByDisplayName} from '@queries/app/servers'; import Background from '@screens/background'; import {dismissModal} from '@screens/navigation'; @@ -93,7 +94,10 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) => useAndroidHardwareBackHandler(componentId, close); return ( - <View style={styles.flex}> + <View + style={styles.flex} + nativeID={SecurityManager.getShieldScreenId(componentId)} + > <Background theme={theme}/> <SafeAreaView key={'server_content'} diff --git a/app/screens/emoji_picker/picker/picker.tsx b/app/screens/emoji_picker/picker/picker.tsx index ec04fdd1f..6ce61d9fe 100644 --- a/app/screens/emoji_picker/picker/picker.tsx +++ b/app/screens/emoji_picker/picker/picker.tsx @@ -8,6 +8,7 @@ import {searchCustomEmojis} from '@actions/remote/custom_emoji'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {debounce} from '@helpers/api/general'; +import SecurityManager from '@managers/security_manager'; import {getKeyboardAppearanceFromTheme} from '@utils/theme'; import EmojiFiltered from './filtered'; @@ -15,6 +16,7 @@ import PickerHeader from './header'; import EmojiSections from './sections'; import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type {AvailableScreens} from '@typings/screens/navigation'; export const SCROLLVIEW_NATIVE_ID = 'emojiSelector'; @@ -82,6 +84,7 @@ const Picker = ({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress <View style={styles.flex} testID={`${testID}.screen`} + nativeID={SecurityManager.getShieldScreenId(testID as AvailableScreens)} > <View style={styles.searchBar}> <PickerHeader diff --git a/app/screens/find_channels/index.tsx b/app/screens/find_channels/index.tsx index aae3f173a..d2adcba59 100644 --- a/app/screens/find_channels/index.tsx +++ b/app/screens/find_channels/index.tsx @@ -9,6 +9,7 @@ import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useKeyboardOverlap} from '@hooks/device'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {dismissModal} from '@screens/navigation'; import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -87,6 +88,7 @@ const FindChannels = ({closeButtonId, componentId}: Props) => { <View style={styles.container} testID='find_channels.screen' + nativeID={SecurityManager.getShieldScreenId(componentId)} > <SearchBar autoCapitalize='none' diff --git a/app/screens/forgot_password/index.tsx b/app/screens/forgot_password/index.tsx index 480275ec5..dc3700aea 100644 --- a/app/screens/forgot_password/index.tsx +++ b/app/screens/forgot_password/index.tsx @@ -16,6 +16,7 @@ import FormattedText from '@components/formatted_text'; import {Screens} from '@constants'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useAvoidKeyboard} from '@hooks/device'; +import SecurityManager from '@managers/security_manager'; import Background from '@screens/background'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {isEmail} from '@utils/helpers'; @@ -141,6 +142,7 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => { <View style={styles.successContainer} testID={'password_send.link.sent'} + nativeID={SecurityManager.getShieldScreenId(componentId, false, true)} > <Inbox/> <FormattedText @@ -184,6 +186,7 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => { ref={keyboardAwareRef} scrollToOverflowEnabled={true} style={styles.flex} + nativeID={SecurityManager.getShieldScreenId(componentId, false, true)} > <View style={styles.centered} diff --git a/app/screens/gallery/index.tsx b/app/screens/gallery/index.tsx index e3a9015fa..430444678 100644 --- a/app/screens/gallery/index.tsx +++ b/app/screens/gallery/index.tsx @@ -3,13 +3,14 @@ import RNUtils from '@mattermost/rnutils'; 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 {hasCaptions} from '@calls/utils'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet, useWindowDimensions} from '@hooks/device'; import {useGalleryControls} from '@hooks/gallery'; +import SecurityManager from '@managers/security_manager'; import {dismissOverlay, setScreensOrientation} from '@screens/navigation'; import {freezeOtherScreens} from '@utils/gallery'; @@ -28,6 +29,10 @@ type Props = { items: GalleryItemType[]; } +const styles = StyleSheet.create({ + flex: {flex: 1}, +}); + const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialIndex, items}: Props) => { const dim = useWindowDimensions(); const isTablet = useIsTablet(); @@ -84,30 +89,35 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde return ( <CaptionsEnabledContext.Provider value={captionsEnabled}> - <Header - index={localIndex} - onClose={onClose} - style={headerStyles} - total={items.length} - /> - <Gallery - galleryIdentifier={galleryIdentifier} - initialIndex={initialIndex} - items={items} - onHide={close} - onIndexChange={onIndexChange} - onShouldHideControls={setControlsHidden} - ref={galleryRef} - targetDimensions={dimensions} - /> - <Footer - hideActions={hideActions} - item={items[localIndex]} - style={footerStyles} - hasCaptions={captionsAvailable[localIndex]} - captionEnabled={captionsEnabled[localIndex]} - onCaptionsPress={onCaptionsPress} - /> + <View + style={styles.flex} + nativeID={SecurityManager.getShieldScreenId(componentId)} + > + <Header + index={localIndex} + onClose={onClose} + style={headerStyles} + total={items.length} + /> + <Gallery + galleryIdentifier={galleryIdentifier} + initialIndex={initialIndex} + items={items} + onHide={close} + onIndexChange={onIndexChange} + onShouldHideControls={setControlsHidden} + ref={galleryRef} + targetDimensions={dimensions} + /> + <Footer + hideActions={hideActions} + item={items[localIndex]} + style={footerStyles} + hasCaptions={captionsAvailable[localIndex]} + captionEnabled={captionsEnabled[localIndex]} + onCaptionsPress={onCaptionsPress} + /> + </View> </CaptionsEnabledContext.Provider> ); }; diff --git a/app/screens/global_drafts/index.tsx b/app/screens/global_drafts/index.tsx index 0ca96c489..d83caf1fa 100644 --- a/app/screens/global_drafts/index.tsx +++ b/app/screens/global_drafts/index.tsx @@ -13,6 +13,7 @@ import {Screens} from '@constants'; import {useIsTablet} from '@hooks/device'; import {useDefaultHeaderHeight} from '@hooks/header'; import {useTeamSwitch} from '@hooks/team_switch'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen} from '../navigation'; @@ -69,6 +70,7 @@ const GlobalDrafts = ({componentId}: Props) => { mode='margin' style={styles.flex} testID='global_drafts.screen' + nativeID={SecurityManager.getShieldScreenId(componentId || Screens.GLOBAL_DRAFTS)} > <NavigationHeader showBackButton={!isTablet} diff --git a/app/screens/global_threads/global_threads.tsx b/app/screens/global_threads/global_threads.tsx index 3102a6065..8eaa4596b 100644 --- a/app/screens/global_threads/global_threads.tsx +++ b/app/screens/global_threads/global_threads.tsx @@ -16,6 +16,7 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet} from '@hooks/device'; import {useDefaultHeaderHeight} from '@hooks/header'; import {useTeamSwitch} from '@hooks/team_switch'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen} from '@screens/navigation'; import ThreadsList from './threads_list'; @@ -84,6 +85,7 @@ const GlobalThreads = ({componentId, globalThreadsTab}: Props) => { mode='margin' style={styles.flex} testID='global_threads.screen' + nativeID={SecurityManager.getShieldScreenId(componentId || Screens.GLOBAL_THREADS)} > <NavigationHeader showBackButton={!isTablet} diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx index 3929e473c..215643791 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx +++ b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx @@ -9,9 +9,8 @@ import Swipeable from 'react-native-gesture-handler/Swipeable'; import {Navigation} from 'react-native-navigation'; 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 {fetchConfigAndLicense} from '@actions/remote/systems'; import CompassIcon from '@components/compass_icon'; import Loading from '@components/loading'; import ServerIcon from '@components/server_icon'; @@ -20,14 +19,10 @@ import TutorialSwipeLeft from '@components/tutorial_highlight/swipe_left'; import {Events, Screens} from '@constants'; import {PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy'; import {useTheme} from '@context/theme'; -import DatabaseManager from '@database/manager'; import {subscribeServerUnreadAndMentions, type UnreadObserverArgs} from '@database/subscription/unreads'; import {useIsTablet} from '@hooks/device'; -import WebsocketManager from '@managers/websocket_manager'; -import {getServerByIdentifier} from '@queries/app/servers'; import {dismissBottomSheet} from '@screens/navigation'; -import {canReceiveNotifications} from '@utils/push_proxy'; -import {alertServerAlreadyConnected, alertServerError, alertServerLogout, alertServerRemove, editServer, loginToServer} from '@utils/server'; +import {alertServerLogout, alertServerRemove, editServer} from '@utils/server'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import {removeProtocol, stripTrailingSlashes} from '@utils/url'; @@ -253,29 +248,8 @@ const ServerItem = ({ const handleLogin = useCallback(async () => { swipeable.current?.close(); setSwitching(true); - const result = await doPing(server.url, true); - if (result.error) { - 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]); + await switchToServerAndLogin(server.url, theme, intl, () => setSwitching(false)); + }, [server.url, intl, theme]); const handleDismissTutorial = useCallback(() => { swipeable.current?.close(); @@ -309,14 +283,9 @@ const ServerItem = ({ if (server.lastActiveAt) { setSwitching(true); await dismissBottomSheet(); - Navigation.updateProps(Screens.HOME, {extra: undefined}); - DatabaseManager.setActiveServerDatabase(server.url); - WebsocketManager.initializeClient(server.url, 'Server Switch'); - return; } - - handleLogin(); - }, [isActive, server.lastActiveAt, server.url, handleLogin]); + await switchToServer(server.url, theme, intl, () => setSwitching(false)); + }, [isActive, server.lastActiveAt, server.url, theme, intl]); const onSwipeableWillOpen = useCallback(() => { DeviceEventEmitter.emit(Events.SWIPEABLE, server.url); diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index 821b5fb19..9aa6e6678 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -6,14 +6,16 @@ import {createBottomTabNavigator, type BottomTabBarProps} from '@react-navigatio import {NavigationContainer, DefaultTheme} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo} from 'react'; 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 {initializeSecurityManager} from '@actions/app/server'; import {autoUpdateTimezone} from '@actions/remote/user'; import ServerVersion from '@components/server_version'; import {Events, Launch, Screens} from '@constants'; import {useTheme} from '@context/theme'; import {useAppState} from '@hooks/device'; +import SecurityManager from '@managers/security_manager'; import {getAllServers} from '@queries/app/servers'; import {findChannels, popToRoot} from '@screens/navigation'; 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) { const theme = useTheme(); const intl = useIntl(); const appState = useAppState(); + useEffect(() => { + initializeSecurityManager(); + }, []); + const handleFindChannels = useCallback(() => { if (!NavigationStore.getScreensInStack().includes(Screens.FIND_CHANNELS)) { findChannels( @@ -136,7 +146,10 @@ export function HomeScreen(props: HomeProps) { }, []); return ( - <> + <View + style={styles.flex} + nativeID={SecurityManager.getShieldScreenId(Screens.HOME, true)} + > <NavigationContainer theme={{ ...DefaultTheme, @@ -190,7 +203,7 @@ export function HomeScreen(props: HomeProps) { </Tab.Navigator> </NavigationContainer> <ServerVersion/> - </> + </View> ); } diff --git a/app/screens/in_app_notification/index.tsx b/app/screens/in_app_notification/index.tsx index 54bb67d65..e14a4cbb8 100644 --- a/app/screens/in_app_notification/index.tsx +++ b/app/screens/in_app_notification/index.tsx @@ -12,6 +12,7 @@ import {openNotification} from '@actions/remote/notifications'; import {Navigation as NavigationTypes} from '@constants'; import DatabaseManager from '@database/manager'; import {useIsTablet} from '@hooks/device'; +import SecurityManager from '@managers/security_manager'; import {dismissOverlay} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity} from '@utils/theme'; @@ -157,6 +158,7 @@ const InAppNotification = ({componentId, serverName, serverUrl, notification}: I <Animated.View style={[styles.container, isTablet ? styles.tablet : undefined, animatedStyle]} testID='in_app_notification.screen' + nativeID={SecurityManager.getShieldScreenId(componentId)} > <View style={styles.flex}> <TouchableOpacity diff --git a/app/screens/integration_selector/integration_selector.tsx b/app/screens/integration_selector/integration_selector.tsx index 8cc324f2c..729705a47 100644 --- a/app/screens/integration_selector/integration_selector.tsx +++ b/app/screens/integration_selector/integration_selector.tsx @@ -18,6 +18,7 @@ import {debounce} from '@helpers/api/general'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import {t} from '@i18n'; +import SecurityManager from '@managers/security_manager'; import { buildNavigationButton, popTopScreen, setButtons, @@ -587,7 +588,10 @@ function IntegrationSelector( const selectedOptionsComponent = renderSelectedOptions(); return ( - <SafeAreaView style={style.container}> + <SafeAreaView + nativeID={SecurityManager.getShieldScreenId(componentId)} + style={style.container} + > <View testID='integration_selector.screen' style={style.searchBar} diff --git a/app/screens/interactive_dialog/index.tsx b/app/screens/interactive_dialog/index.tsx index 97daff23f..17546413d 100644 --- a/app/screens/interactive_dialog/index.tsx +++ b/app/screens/interactive_dialog/index.tsx @@ -14,6 +14,7 @@ import ErrorText from '@components/error_text'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import SecurityManager from '@managers/security_manager'; import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -234,6 +235,7 @@ function InteractiveDialog({ <SafeAreaView testID='interactive_dialog.screen' style={style.container} + nativeID={SecurityManager.getShieldScreenId(componentId)} > <KeyboardAwareScrollView ref={scrollView} diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index de6f1ea0a..0a92d87d6 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -15,6 +15,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useKeyboardOverlap} from '@hooks/device'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {dismissModal, setButtons} from '@screens/navigation'; import {isEmail} from '@utils/helpers'; import {mergeNavigationOptions} from '@utils/navigation'; @@ -416,6 +417,7 @@ export default function Invite({ onLayout={onLayoutWrapper} ref={mainView} testID='invite.screen' + nativeID={SecurityManager.getShieldScreenId(componentId)} > {renderContent()} </SafeAreaView> diff --git a/app/screens/join_team/join_team.tsx b/app/screens/join_team/join_team.tsx index deb4b92af..0b5327d56 100644 --- a/app/screens/join_team/join_team.tsx +++ b/app/screens/join_team/join_team.tsx @@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {dismissModal} from '@screens/navigation'; import {logDebug} from '@utils/log'; import {alertTeamAddError} from '@utils/navigation'; @@ -154,7 +155,10 @@ export default function JoinTeam({ } return ( - <View style={styles.container}> + <View + nativeID={SecurityManager.getShieldScreenId(componentId)} + style={styles.container} + > {body} </View> ); diff --git a/app/screens/latex/index.tsx b/app/screens/latex/index.tsx index 43dd0fdc1..b4e8fe730 100644 --- a/app/screens/latex/index.tsx +++ b/app/screens/latex/index.tsx @@ -8,6 +8,7 @@ import {SafeAreaView, type Edge} from 'react-native-safe-area-context'; import MathView from '@components/math_view'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen} from '@screens/navigation'; import {splitLatexCodeInLines} from '@utils/markdown/latex'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -81,6 +82,7 @@ const Latex = ({componentId, content}: Props) => { <SafeAreaView edges={edges} style={style.scrollContainer} + nativeID={SecurityManager.getShieldScreenId(componentId)} > <ScrollView style={style.scrollContainer} diff --git a/app/screens/login/index.tsx b/app/screens/login/index.tsx index f56f43644..5bde5e49c 100644 --- a/app/screens/login/index.tsx +++ b/app/screens/login/index.tsx @@ -15,6 +15,7 @@ import {useIsTablet} from '@hooks/device'; import {useDefaultHeaderHeight} from '@hooks/header'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import NetworkManager from '@managers/network_manager'; +import SecurityManager from '@managers/security_manager'; import Background from '@screens/background'; import {dismissModal, goToScreen, loginAnimationOptions, popTopScreen} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; @@ -213,6 +214,7 @@ const LoginOptions = ({ <View style={styles.flex} testID='login.screen' + nativeID={SecurityManager.getShieldScreenId(componentId, false, true)} > <Background theme={theme}/> <AnimatedSafeArea style={[styles.container, transform]}> diff --git a/app/screens/manage_channel_members/manage_channel_members.tsx b/app/screens/manage_channel_members/manage_channel_members.tsx index 396c45408..58f2c99d4 100644 --- a/app/screens/manage_channel_members/manage_channel_members.tsx +++ b/app/screens/manage_channel_members/manage_channel_members.tsx @@ -16,6 +16,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {openAsBottomSheet, popTopScreen, setButtons} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; import {showRemoveChannelUserSnackbar} from '@utils/snack_bar'; @@ -291,6 +292,7 @@ export default function ManageChannelMembers({ <SafeAreaView style={styles.container} testID='manage_members.screen' + nativeID={SecurityManager.getShieldScreenId(componentId)} > <View style={styles.searchBar}> <Search diff --git a/app/screens/mfa/index.tsx b/app/screens/mfa/index.tsx index be2a4896b..b59dc772e 100644 --- a/app/screens/mfa/index.tsx +++ b/app/screens/mfa/index.tsx @@ -17,6 +17,7 @@ import Loading from '@components/loading'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useAvoidKeyboard} from '@hooks/device'; import {t} from '@i18n'; +import SecurityManager from '@managers/security_manager'; import Background from '@screens/background'; import {popTopScreen} from '@screens/navigation'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; @@ -168,7 +169,10 @@ const MFA = ({componentId, config, goToHome, license, loginId, password, serverD useAndroidHardwareBackHandler(componentId, close); return ( - <View style={styles.flex}> + <View + nativeID={SecurityManager.getShieldScreenId(componentId, false, true)} + style={styles.flex} + > <Background theme={theme}/> <AnimatedSafeArea testID='mfa.screen' diff --git a/app/screens/onboarding/index.tsx b/app/screens/onboarding/index.tsx index 24b634a82..08da233bd 100644 --- a/app/screens/onboarding/index.tsx +++ b/app/screens/onboarding/index.tsx @@ -18,6 +18,7 @@ import Animated, {ReduceMotion, useAnimatedStyle, useDerivedValue, useSharedValu import {storeOnboardingViewedValue} from '@actions/app/global'; import {Screens} from '@constants'; +import SecurityManager from '@managers/security_manager'; import Background from '@screens/background'; import {goToScreen, loginAnimationOptions} from '@screens/navigation'; @@ -27,8 +28,10 @@ import SlideItem from './slide'; import useSlidesData from './slides_data'; import type {LaunchProps} from '@typings/launch'; +import type {AvailableScreens} from '@typings/screens/navigation'; interface OnboardingProps extends LaunchProps { + componentId: AvailableScreens; theme: Theme; } @@ -49,6 +52,7 @@ const styles = StyleSheet.create({ const AnimatedSafeArea = Animated.createAnimatedComponent(SafeAreaView); const Onboarding = ({ + componentId, theme, ...props }: OnboardingProps) => { @@ -133,6 +137,7 @@ const Onboarding = ({ <View style={styles.onBoardingContainer} testID='onboarding.screen' + nativeID={SecurityManager.getShieldScreenId(componentId, false, true)} > <Background theme={theme}/> <AnimatedSafeArea diff --git a/app/screens/overlay/index.tsx b/app/screens/overlay/index.tsx index 2d36dbcaa..e1c3757f0 100644 --- a/app/screens/overlay/index.tsx +++ b/app/screens/overlay/index.tsx @@ -1,14 +1,20 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // 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 = { + componentId: AvailableScreens; children: ReactNode; } -const Overlay = ({children}: Props) => { - return children; +const Overlay = ({children, componentId}: Props) => { + return (<View nativeID={SecurityManager.getShieldScreenId(componentId)}>{children}</View>); }; export default Overlay; diff --git a/app/screens/permalink/permalink.tsx b/app/screens/permalink/permalink.tsx index 22255dad6..8f0815e37 100644 --- a/app/screens/permalink/permalink.tsx +++ b/app/screens/permalink/permalink.tsx @@ -21,6 +21,7 @@ import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet} from '@hooks/device'; +import SecurityManager from '@managers/security_manager'; import {getChannelById, getMyChannel} from '@queries/servers/channel'; import {dismissModal} from '@screens/navigation'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; @@ -355,6 +356,7 @@ function Permalink({ <SafeAreaView style={containerStyle} testID='permalink.screen' + nativeID={SecurityManager.getShieldScreenId(Screens.PERMALINK)} edges={edges} > <Animated.View style={style.wrapper}> diff --git a/app/screens/pinned_messages/pinned_messages.tsx b/app/screens/pinned_messages/pinned_messages.tsx index 36f142cb4..b59775860 100644 --- a/app/screens/pinned_messages/pinned_messages.tsx +++ b/app/screens/pinned_messages/pinned_messages.tsx @@ -14,6 +14,7 @@ import {ExtraKeyboardProvider} from '@context/extra_keyboard'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen} from '@screens/navigation'; import {getDateForDateLine, selectOrderedPosts} from '@utils/post_list'; @@ -152,6 +153,7 @@ function SavedMessages({ edges={edges} style={styles.flex} testID='pinned_messages.screen' + nativeID={SecurityManager.getShieldScreenId(componentId)} > <ExtraKeyboardProvider> <FlatList diff --git a/app/screens/review_app/index.tsx b/app/screens/review_app/index.tsx index 6cdae7296..35b030d55 100644 --- a/app/screens/review_app/index.tsx +++ b/app/screens/review_app/index.tsx @@ -16,6 +16,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useBackNavigation from '@hooks/navigate_back'; +import SecurityManager from '@managers/security_manager'; import {dismissOverlay, showShareFeedbackOverlay} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -168,7 +169,10 @@ const ReviewApp = ({ }), []); return ( - <View style={styles.root}> + <View + nativeID={SecurityManager.getShieldScreenId(componentId)} + style={styles.root} + > <View style={styles.container} testID='rate_app.screen' diff --git a/app/screens/select_team/select_team.tsx b/app/screens/select_team/select_team.tsx index 2031bebf0..cb1a543c5 100644 --- a/app/screens/select_team/select_team.tsx +++ b/app/screens/select_team/select_team.tsx @@ -11,6 +11,7 @@ import {addCurrentUserToTeam, fetchTeamsForComponent, handleTeamChange} from '@a import Loading from '@components/loading'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import SecurityManager from '@managers/security_manager'; import {logDebug} from '@utils/log'; import {alertTeamAddError} from '@utils/navigation'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -21,6 +22,8 @@ import Header from './header'; import NoTeams from './no_teams'; import TeamList from './team_list'; +import type {AvailableScreens} from '@typings/screens/navigation'; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { flex: 1, @@ -34,6 +37,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ })); type Props = { + componentId: AvailableScreens; nTeams: number; firstTeamId?: string; } @@ -42,6 +46,7 @@ const safeAreaEdges = ['left' as const, 'right' as const]; const safeAreaStyle = {flex: 1}; const SelectTeam = ({ + componentId, nTeams, firstTeamId, }: Props) => { @@ -140,6 +145,7 @@ const SelectTeam = ({ mode='margin' edges={safeAreaEdges} style={safeAreaStyle} + nativeID={SecurityManager.getShieldScreenId(componentId)} > <Animated.View style={top}/> <View style={styles.container}> diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 41ec13d35..25d2fbebb 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -20,6 +20,7 @@ import {t} from '@i18n'; import {getServerCredentials} from '@init/credentials'; import PushNotifications from '@init/push_notifications'; import NetworkManager from '@managers/network_manager'; +import SecurityManager from '@managers/security_manager'; import {getServerByDisplayName, getServerByIdentifier} from '@queries/app/servers'; import Background from '@screens/background'; import {dismissModal, goToScreen, loginAnimationOptions, popTopScreen} from '@screens/navigation'; @@ -340,6 +341,22 @@ const Server = ({ 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 credentials = await getServerCredentials(ping.url); setConnecting(false); @@ -367,6 +384,7 @@ const Server = ({ <View style={styles.flex} testID='server.screen' + nativeID={SecurityManager.getShieldScreenId(componentId, false, true)} > <Background theme={theme}/> <AnimatedSafeArea diff --git a/app/screens/share_feedback/index.tsx b/app/screens/share_feedback/index.tsx index 90651e3a9..51692c4bb 100644 --- a/app/screens/share_feedback/index.tsx +++ b/app/screens/share_feedback/index.tsx @@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useBackNavigation from '@hooks/navigate_back'; +import SecurityManager from '@managers/security_manager'; import {dismissOverlay} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -134,7 +135,10 @@ const ShareFeedback = ({ }), []); return ( - <View style={styles.root}> + <View + nativeID={SecurityManager.getShieldScreenId(componentId)} + style={styles.root} + > <View style={styles.container} testID='rate_app.screen' diff --git a/app/screens/snack_bar/index.tsx b/app/screens/snack_bar/index.tsx index c4c47ae6d..09ff832c0 100644 --- a/app/screens/snack_bar/index.tsx +++ b/app/screens/snack_bar/index.tsx @@ -22,6 +22,7 @@ import {MESSAGE_TYPE, SNACK_BAR_CONFIG} from '@constants/snack_bar'; import {TABLET_SIDEBAR_WIDTH} from '@constants/view'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; +import SecurityManager from '@managers/security_manager'; import {dismissOverlay} from '@screens/navigation'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -261,6 +262,7 @@ const SnackBar = ({ <GestureDetector gesture={gesture}> <Animated.View style={animatedMotion} + nativeID={SecurityManager.getShieldScreenId(componentId)} > <Animated.View entering={FadeIn.duration(300)} diff --git a/app/screens/sso/index.tsx b/app/screens/sso/index.tsx index da5035297..eefb54bde 100644 --- a/app/screens/sso/index.tsx +++ b/app/screens/sso/index.tsx @@ -12,6 +12,7 @@ import {Screens, Sso} from '@constants'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import NetworkManager from '@managers/network_manager'; +import SecurityManager from '@managers/security_manager'; import Background from '@screens/background'; import {dismissModal, popTopScreen, resetToHome} from '@screens/navigation'; import {getFullErrorMessage, isErrorWithUrl} from '@utils/errors'; @@ -172,7 +173,10 @@ const SSO = ({ } return ( - <View style={styles.flex}> + <View + nativeID={SecurityManager.getShieldScreenId(componentId, false, true)} + style={styles.flex} + > <Background theme={theme}/> <AnimatedSafeArea style={[styles.flex, transform]}> {authentication} diff --git a/app/screens/table/index.tsx b/app/screens/table/index.tsx index 839e2c548..e7127868c 100644 --- a/app/screens/table/index.tsx +++ b/app/screens/table/index.tsx @@ -2,10 +2,11 @@ // See LICENSE.txt for license information. 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 useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen} from '@screens/navigation'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -48,15 +49,20 @@ const Table = ({componentId, renderAsFlex, renderRows, width}: Props) => { if (Platform.OS === 'android') { return ( - <ScrollView testID='table.screen'> - <ScrollView - contentContainerStyle={viewStyle} - horizontal={true} - testID='table.scroll_view' - > - {content} + <View + style={styles.container} + nativeID={SecurityManager.getShieldScreenId(componentId)} + > + <ScrollView testID='table.screen'> + <ScrollView + contentContainerStyle={viewStyle} + horizontal={true} + testID='table.scroll_view' + > + {content} + </ScrollView> </ScrollView> - </ScrollView> + </View> ); } @@ -64,6 +70,7 @@ const Table = ({componentId, renderAsFlex, renderRows, width}: Props) => { <SafeAreaView style={styles.container} testID='table.screen' + nativeID={SecurityManager.getShieldScreenId(componentId)} > <ScrollView style={styles.fullHeight} diff --git a/app/screens/terms_of_service/terms_of_service.tsx b/app/screens/terms_of_service/terms_of_service.tsx index 5d6818d0e..f28743103 100644 --- a/app/screens/terms_of_service/terms_of_service.tsx +++ b/app/screens/terms_of_service/terms_of_service.tsx @@ -20,6 +20,7 @@ import {Screens} from '@constants/index'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import SecurityManager from '@managers/security_manager'; import {dismissOverlay} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown'; @@ -291,7 +292,10 @@ const TermsOfService = ({ }, [styles, insets]); return ( - <View style={styles.root}> + <View + style={styles.root} + nativeID={SecurityManager.getShieldScreenId(componentId)} + > <View style={containerStyle}> <View style={styles.wrapper}> <Text style={styles.title}>{intl.formatMessage({id: 'terms_of_service.title', defaultMessage: 'Terms of Service'})}</Text> diff --git a/app/screens/thread/thread.tsx b/app/screens/thread/thread.tsx index f66a3d919..c141022f1 100644 --- a/app/screens/thread/thread.tsx +++ b/app/screens/thread/thread.tsx @@ -15,6 +15,7 @@ import {Screens} from '@constants'; import {ExtraKeyboardProvider} from '@context/extra_keyboard'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useDidUpdate from '@hooks/did_update'; +import SecurityManager from '@managers/security_manager'; import {popTopScreen, setButtons} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; @@ -114,6 +115,7 @@ const Thread = ({ edges={edges} testID='thread.screen' onLayout={onLayout} + nativeID={SecurityManager.getShieldScreenId(componentId)} > <RoundedHeaderContext/> {Boolean(rootPost) && diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 55d82ecc7..921e0d649 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -702,14 +702,20 @@ "mobile.manage_members.remove_member": "Remove from Channel", "mobile.manage_members.section_title_admins": "CHANNEL ADMINS", "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.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.ios": "This device must be secured with a passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.", - "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.android.vendor": "This device must be secured with a screen lock to use {vendor}.", + "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.settings": "Go to settings", + "mobile.managed.switch_server": "Switch server", "mobile.markdown.code.copy_code": "Copy Code", "mobile.markdown.code.plusMoreLines": "+{count, number} more {count, plural, one {line} other {lines}}", "mobile.markdown.copy_header": "Copy header text", diff --git a/ios/GekidouWrapper.swift b/ios/GekidouWrapper.swift index 32bedf951..e7882a011 100644 --- a/ios/GekidouWrapper.swift +++ b/ios/GekidouWrapper.swift @@ -8,9 +8,14 @@ import Foundation import Gekidou +import react_native_emm @objc class GekidouWrapper: NSObject { @objc public static let `default` = GekidouWrapper() + + override init() { + ScreenCaptureManager.startTrackingScreens() + } @objc func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) { PushNotification.default.postNotificationReceipt(userInfo) diff --git a/ios/Mattermost/Extensions/RNNotificationEventHandler+HandleReplyAction.m b/ios/Mattermost/Extensions/RNNotificationEventHandler+HandleReplyAction.m index cd1a8893f..4efb08b9c 100644 --- a/ios/Mattermost/Extensions/RNNotificationEventHandler+HandleReplyAction.m +++ b/ios/Mattermost/Extensions/RNNotificationEventHandler+HandleReplyAction.m @@ -59,6 +59,7 @@ static SendReplyCompletionHandlerIMP originalSendReplyCompletionHandlerImplement if (serverUrl == nil) { [self handleReplyFailure:@"" completionHandler:notificationCompletionHandler]; + return; } NSString *sessionToken = [[Keychain default] getTokenObjcFor:serverUrl]; diff --git a/ios/Mattermost/Extensions/SDWebImageDownloaderOperation+Swizzle.m b/ios/Mattermost/Extensions/SDWebImageDownloaderOperation+Swizzle.m index 9ee2251ec..d46186356 100644 --- a/ios/Mattermost/Extensions/SDWebImageDownloaderOperation+Swizzle.m +++ b/ios/Mattermost/Extensions/SDWebImageDownloaderOperation+Swizzle.m @@ -84,7 +84,7 @@ static URLSessionTaskDidReceiveChallengeIMP originalURLSessionTaskDidReceiveChal delegateQueue:session.delegateQueue]; 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); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index fb7e4cfda..d8da41af6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -7,7 +7,7 @@ PODS: - DoubleConversion (1.1.6) - EXApplication (6.0.1): - ExpoModulesCore - - EXConstants (17.0.5): + - EXConstants (17.0.3): - ExpoModulesCore - Expo (52.0.19): - ExpoModulesCore @@ -15,7 +15,7 @@ PODS: - ExpoModulesCore - ExpoDevice (7.0.1): - ExpoModulesCore - - ExpoFileSystem (18.0.7): + - ExpoFileSystem (18.0.6): - ExpoModulesCore - ExpoImage (2.0.4): - ExpoModulesCore @@ -1410,7 +1410,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-emm (1.5.1): + - react-native-emm (1.6.0): - DoubleConversion - glog - hermes-engine @@ -2496,11 +2496,11 @@ SPEC CHECKSUMS: CocoaLumberjack: 6a459bc897d6d80bd1b8c78482ec7ad05dffc3f0 DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 EXApplication: 88ebf1a95f85faa5d2df160016d61f2d060e9438 - EXConstants: e25c3f3ef5d1cf7a6ef911ebf7a3ab94d8d06d82 + EXConstants: 277129d9a42ba2cf1fad375e7eaa9939005c60be Expo: 848501c300b37b3fa7330c58a5353e07e03d5c3d ExpoCrypto: 483fc758365923f89ddaab4327c21cae9534841d ExpoDevice: 449822f2c45660c1ce83283dd51c67fe5404996c - ExpoFileSystem: 818e82dbb71175414d1ca310e926c48ff0d07348 + ExpoFileSystem: a38e1bb58d77f41717293a7b73ebd4014d8cb8dd ExpoImage: 18ec1a3e5cd96ee6162d988be3085e18113e143f ExpoLinearGradient: 18148bd38f98fa0229c1f9df23393b32ab6d2d32 ExpoModulesCore: c9cb309323aee3c048099bb40ad0226e3fcf0c56 @@ -2550,7 +2550,7 @@ SPEC CHECKSUMS: react-native-cameraroll: 5f180ef5e9b52b6c3c3a2645fa33a921d1e37a6c react-native-cookies: d648ab7025833b977c0b19e142503034f5f29411 react-native-document-picker: 530879d9e89b490f0954bcc4ab697c5b5e35d659 - react-native-emm: f6003bebdf4fef4feef7c61f96a5c174f43c6b5f + react-native-emm: 710fa6ce569cf04084224fa61a038e6143ea7fe0 react-native-image-picker: 130fad649d07e4eec8faaed361d3bba570e1e5ff react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-network-client: fdbd7f5f4c2818d6b90b4dcf9613d0a54ab41303 diff --git a/jest.config.js b/jest.config.js index 022dfb171..9ce77697b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +// eslint-disable-next-line no-process-env +process.env.TZ = 'UTC'; + module.exports = { preset: 'jest-expo', verbose: true, diff --git a/libraries/@mattermost/hardware-keyboard/android/src/main/java/com/mattermost/hardware/keyboard/MattermostHardwareKeyboardImpl.kt b/libraries/@mattermost/hardware-keyboard/android/src/main/java/com/mattermost/hardware/keyboard/MattermostHardwareKeyboardImpl.kt index 82b566aa8..207f8a4e0 100644 --- a/libraries/@mattermost/hardware-keyboard/android/src/main/java/com/mattermost/hardware/keyboard/MattermostHardwareKeyboardImpl.kt +++ b/libraries/@mattermost/hardware-keyboard/android/src/main/java/com/mattermost/hardware/keyboard/MattermostHardwareKeyboardImpl.kt @@ -32,6 +32,10 @@ class MattermostHardwareKeyboardImpl(reactApplicationContext: ReactApplicationCo } private fun sendEvent(action: String) { + if (!this::context.isInitialized) { + return + } + if (context.hasActiveReactInstance()) { val result: WritableMap = WritableNativeMap() result.putString("action", action) diff --git a/package-lock.json b/package-lock.json index 76dd66a95..0183fa8c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f", "@mattermost/compass-icons": "0.1.48", "@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-paste-input": "0.8.1", "@mattermost/react-native-turbo-log": "0.6.0", @@ -183,6 +183,11 @@ "version": "0.0.0", "license": "Apache 2.0" }, + "libraries/@mattermost/rnshield": { + "version": "0.0.0", + "extraneous": true, + "license": "Apache 2.0" + }, "libraries/@mattermost/rnutils": { "version": "0.0.0", "license": "Apache 2.0" @@ -4633,9 +4638,9 @@ "link": true }, "node_modules/@mattermost/react-native-emm": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@mattermost/react-native-emm/-/react-native-emm-1.5.1.tgz", - "integrity": "sha512-gkuj+UyPWqfxdG62lcqDT5301tPL5dgRpFm63HHegVs45bJX4LmNDHgF7MJuravOJjKX3huMpJDG/UnzHncbkQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@mattermost/react-native-emm/-/react-native-emm-1.6.0.tgz", + "integrity": "sha512-e8Ka0Zd6LZsbVWIiGeASiydV0WTQqc+2LNl8t/Zm22i0M7zhVgf4QIciXE/XjVJk1tfxJ5fhRM4LCp8q87HgTw==", "license": "MIT", "peerDependencies": { "react": "*", diff --git a/package.json b/package.json index b56528b50..b868cbeda 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f", "@mattermost/compass-icons": "0.1.48", "@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-paste-input": "0.8.1", "@mattermost/react-native-turbo-log": "0.6.0", diff --git a/patches/@gorhom+bottom-sheet+5.0.6.patch b/patches/@gorhom+bottom-sheet+5.0.6.patch index 1a276350d..a2dcdc958 100644 --- a/patches/@gorhom+bottom-sheet+5.0.6.patch +++ b/patches/@gorhom+bottom-sheet+5.0.6.patch @@ -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 -index 72fe2cb..abac348 100644 +index 0716ecd..081d723 100644 --- a/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({ diff --git a/patches/@mattermost+react-native-network-client+1.8.1.patch b/patches/@mattermost+react-native-network-client+1.8.2.patch similarity index 100% rename from patches/@mattermost+react-native-network-client+1.8.1.patch rename to patches/@mattermost+react-native-network-client+1.8.2.patch diff --git a/test/setup.ts b/test/setup.ts index 35f04700d..a523b7111 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -73,6 +73,7 @@ jest.mock('@nozbe/watermelondb/utils/common/randomId/randomId', () => ({})); jest.mock('@database/manager'); jest.doMock('react-native', () => { const { + AppState: RNAppState, Platform, StyleSheet, requireNativeComponent, @@ -87,6 +88,13 @@ jest.doMock('react-native', () => { alert: jest.fn(), }; + const AppState = { + ...RNAppState, + addEventListener: jest.fn(() => ({ + remove: jest.fn(), + })), + }; + const InteractionManager = { ...RNInteractionManager, runAfterInteractions: jest.fn((cb) => cb()), @@ -214,10 +222,12 @@ jest.doMock('react-native', () => { minor: 64, }, }, + select: jest.fn((dict) => dict.ios || dict.default), }, StyleSheet, requireNativeComponent, Alert, + AppState, InteractionManager, NativeModules, Linking, diff --git a/test/test_helper.ts b/test/test_helper.ts index 876ed3682..065cfafc4 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import {random} from 'lodash'; import nock from 'nock'; +import {of as of$} from 'rxjs'; import Config from '@assets/config.json'; import {Client} from '@client/rest'; @@ -18,6 +19,7 @@ import DatabaseManager from '@database/manager'; import {prepareCommonSystemValues} from '@queries/servers/system'; import type {APIClientInterface} from '@mattermost/react-native-network-client'; +import type {Model, Query} from '@nozbe/watermelondb'; const DEFAULT_LOCALE = 'en'; @@ -731,6 +733,13 @@ class TestHelperSingleton { wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time)); 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(); diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 39445816b..9d8416040 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -149,6 +149,9 @@ interface ClientConfig { MaxNotificationsPerChannel: string; MaxPostSize: string; MinimumHashtagLength: string; + MobileEnableBiometrics: string; + MobileJailbreakProtection: string; + MobilePreventScreenCapture: string; MobileExternalBrowser: string; OpenIdButtonColor: string; OpenIdButtonText: string; @@ -198,3 +201,5 @@ interface ClientConfig { WebsocketSecurePort: string; WebsocketURL: string; } + +type SecurityClientConfig = Pick<ClientConfig, 'MobileEnableBiometrics' | 'MobileJailbreakProtection' | 'MobilePreventScreenCapture' | 'SiteName'>