feat(iOS): Add Microsoft Intune MAM integration with multi-server support (#9312)
* refactor: implement custom ExpoImage wrapper for cache control Add ExpoImage component with automatic cacheKey/cachePath management and replace all expo-image imports across the app * refactor(ios): convert Gekidou to CocoaPods Migrate from Swift Package Manager to CocoaPods, add Keychain write operations, refactor notification handler to remove react-native-notifications headers, and upgrade Swift to 5.0 * npm audit * update fastlane * feat(ci): integrate Intune MAM for enterprise builds with strict OSS protection Add Intune submodule, CI actions, Fastlane configuration, developer scripts, pre-commit hooks, and validation workflows to enable internal MAM builds while protecting OSS repository * fix tests by mocking @mattermost/intune * feat: implement Intune MAM integration with comprehensive security enforcement Add IntuneManager, refactor SecurityManager/SessionManager for MAM policies, implement native OIDC auth flow, add biometric enforcement, conditional launch blocking, and file protection controls * fix alerts when no server database is present * Unify cache strategy * fix emit config changed after it was stored in the db * Handle Mid-Session Enrollment Detection * fix ADALLogOverrideDisabled missing in Fastfile * fix flow for initial enrollment * fix and add unit tests * enable Intune configuration for PR and beta builds, CLIENT_ID should be changed before actual release * Update intune submodule with addressed feedback * fix validate-intune-clean workflow * feat(intune): add comprehensive error handling and SAML+Entra support Add production-ready error handling for native Entra authentication with user-friendly i18n messages, comprehensive test coverage, and support for Entra login when server requires SAML. * update i18n * update intune submodule * update build-pr token * fix race condition between server auth and intune enrollment * fix CI workflow to build with intune * use deploy key for intune submodule * set the config directly in the submodule .git * debug injection * try setting GIT_SSH_COMMAND * remove action debug * fix server url input * match pod cache with intune hash * Fastfile and envs * have workflows check for intune/.git * have ci cache intune frameworks as well * update Fastlane to set no-cache to artifacts uploaded * fix s3 upload * fix pblist template * Attempt to remove the cache control for PR uploads to s3 * use hash from commit for S3 path * Implement crash-resilient selective wipe with automatic retry and add removeInternetPassword to Gekidou Keychain * Fix surface errors from intune login * fix postinstall scripts * use cacheKey for draft md images * remove unnecessary double await during test * Have isMinimumLicenseTier accept valid license sku tier as target * Add missing Auth error messages * remove the last period for intune errors in i18n * do not call unenroll with wipe on manual logout * Fix tests and Intune error messages * do not filter any SSO type regardless of which is used for Intune * fix 412 to not retry * fix tests, app logs sharing and share_extension avatar cache * apply setScreenCapturePolicy on license change Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com> * re-apply screen capture on enrollment Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com> * use userData from intunr login and prevent getMe Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com> * Check for Biometrics and Jailbreak as we used to --------- Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>
This commit is contained in:
parent
6fdfd57c11
commit
44eb76bed7
165 changed files with 9015 additions and 1904 deletions
51
.github/actions/prepare-ios-build/action.yaml
vendored
51
.github/actions/prepare-ios-build/action.yaml
vendored
|
|
@ -1,29 +1,62 @@
|
||||||
name: prepare-ios-build
|
name: prepare-ios-build
|
||||||
description: Action to prepare environment for ios build
|
description: Action to prepare environment for ios build
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
intune-enabled:
|
||||||
|
description: 'Enable Intune MAM features (requires Xcode 26.1+)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
intune-ssh-private-key:
|
||||||
|
description: 'SSH private key for Intune submodule repository access (required if intune-enabled is true)'
|
||||||
|
required: false
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: ci/setup-xcode
|
- name: ci/setup-xcode
|
||||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
|
||||||
with:
|
with:
|
||||||
xcode-version: latest-stable
|
xcode-version: '26.1'
|
||||||
|
|
||||||
- name: ci/prepare-mobile-build
|
- name: ci/prepare-mobile-build
|
||||||
uses: ./.github/actions/prepare-mobile-build
|
uses: ./.github/actions/prepare-mobile-build
|
||||||
|
|
||||||
- name: ci/install-pods-dependencies
|
- name: ci/setup-intune
|
||||||
|
if: inputs.intune-enabled == 'true'
|
||||||
|
uses: ./.github/actions/setup-intune
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ inputs.intune-ssh-private-key }}
|
||||||
|
|
||||||
|
- name: Get Intune submodule commit hash
|
||||||
|
if: inputs.intune-enabled == 'true'
|
||||||
|
id: intune-hash
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "::group::install-pods-dependencies"
|
if [ -e "libraries/@mattermost/intune/.git" ]; then
|
||||||
npm run ios-gems
|
INTUNE_HASH=$(cd libraries/@mattermost/intune && git rev-parse --short HEAD)
|
||||||
npm run pod-install
|
echo "hash=${INTUNE_HASH}" >> $GITHUB_OUTPUT
|
||||||
echo "::endgroup::"
|
else
|
||||||
|
echo "hash=none" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Cache Pods
|
- name: Cache Pods
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
with:
|
with:
|
||||||
path: Pods
|
path: |
|
||||||
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
|
ios/Pods
|
||||||
|
libraries/@mattermost/intune/ios/Frameworks
|
||||||
|
key: ${{ runner.os }}-pods-v3-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pods-
|
${{ runner.os }}-pods-v3-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-
|
||||||
|
${{ runner.os }}-pods-v3-intune-${{ inputs.intune-enabled }}-
|
||||||
|
|
||||||
|
- name: ci/install-pods-dependencies
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
INTUNE_ENABLED: ${{ inputs.intune-enabled == 'true' && '1' || '0' }}
|
||||||
|
run: |
|
||||||
|
echo "::group::install-pods-dependencies"
|
||||||
|
echo "INTUNE_ENABLED=$INTUNE_ENABLED"
|
||||||
|
npm run ios-gems
|
||||||
|
npm run pod-install
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
|
||||||
27
.github/actions/setup-intune-ssh-key/action.yaml
vendored
Normal file
27
.github/actions/setup-intune-ssh-key/action.yaml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
# See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
name: Setup Intune SSH Key
|
||||||
|
description: Configure SSH private key for accessing Intune submodule repository
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
ssh-private-key:
|
||||||
|
description: 'SSH private key for Intune submodule repository access'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Configure Intune submodule SSH key
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
|
||||||
|
# Configure Intune submodule SSH key (separate from main repo key)
|
||||||
|
INTUNE_KEY_PATH="${HOME}/.ssh/intune_submodule_ed25519"
|
||||||
|
echo -e '${{ inputs.ssh-private-key }}' > ${INTUNE_KEY_PATH}
|
||||||
|
chmod 0600 ${INTUNE_KEY_PATH}
|
||||||
|
ssh-keygen -y -f ${INTUNE_KEY_PATH} > ${INTUNE_KEY_PATH}.pub
|
||||||
|
|
||||||
|
echo "✅ Intune submodule SSH key configured"
|
||||||
60
.github/actions/setup-intune/action.yaml
vendored
Normal file
60
.github/actions/setup-intune/action.yaml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
# See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
name: Setup Intune
|
||||||
|
description: Initialize Intune submodule and link module for internal builds with MAM features
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
ssh-private-key:
|
||||||
|
description: 'SSH private key for Intune submodule repository access'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Configure SSH for Intune submodule
|
||||||
|
uses: ./.github/actions/setup-intune-ssh-key
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ inputs.ssh-private-key }}
|
||||||
|
|
||||||
|
- name: Initialize Intune submodule
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GIT_SSH_COMMAND: ssh -i $HOME/.ssh/intune_submodule_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
|
||||||
|
run: |
|
||||||
|
echo "::group::Initialize Intune submodule"
|
||||||
|
# Check if submodule is already initialized
|
||||||
|
if [ -e "libraries/@mattermost/intune/.git" ]; then
|
||||||
|
echo "ℹ️ Intune submodule already initialized, updating..."
|
||||||
|
git submodule update --remote libraries/@mattermost/intune
|
||||||
|
else
|
||||||
|
echo "📥 Initializing Intune submodule..."
|
||||||
|
git submodule update --init --recursive libraries/@mattermost/intune
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display submodule info
|
||||||
|
cd libraries/@mattermost/intune
|
||||||
|
echo "📦 Intune submodule initialized:"
|
||||||
|
echo " Branch: $(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
echo " Commit: $(git rev-parse --short HEAD)"
|
||||||
|
echo " Remote: $(git remote get-url origin)"
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
echo "✅ Intune submodule ready"
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
- name: Link Intune module to node_modules
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::group::Link Intune module"
|
||||||
|
# Install the module without saving to package.json
|
||||||
|
npm install --install-links --no-save ./libraries/@mattermost/intune
|
||||||
|
|
||||||
|
# Verify symlink was created
|
||||||
|
if [ -L "node_modules/@mattermost/intune" ] || [ -d "node_modules/@mattermost/intune" ]; then
|
||||||
|
echo "✅ Intune module linked to node_modules/@mattermost/intune"
|
||||||
|
else
|
||||||
|
echo "❌ Failed to link Intune module"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::endgroup::"
|
||||||
23
.github/actions/setup-ssh-key/action.yaml
vendored
Normal file
23
.github/actions/setup-ssh-key/action.yaml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
# See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
name: Setup SSH Key
|
||||||
|
description: Configure SSH private key for accessing private repositories
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
ssh-private-key:
|
||||||
|
description: 'SSH private key for repository access'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Configure SSH private key
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
SSH_KEY_PATH=~/.ssh/id_ed25519
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo -e '${{ inputs.ssh-private-key }}' > ${SSH_KEY_PATH}
|
||||||
|
chmod 0600 ${SSH_KEY_PATH}
|
||||||
|
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
|
||||||
|
echo "✅ SSH key configured"
|
||||||
23
.github/workflows/build-ios-beta.yml
vendored
23
.github/workflows/build-ios-beta.yml
vendored
|
|
@ -11,6 +11,7 @@ on:
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 22.14.0
|
NODE_VERSION: 22.14.0
|
||||||
TERM: xterm
|
TERM: xterm
|
||||||
|
INTUNE_ENABLED: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -22,7 +23,7 @@ jobs:
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
|
|
||||||
build-ios-simulator:
|
build-ios-simulator:
|
||||||
runs-on: macos-14-large
|
runs-on: macos-15-large
|
||||||
if: ${{ !contains(github.ref_name, 'beta-ios') }}
|
if: ${{ !contains(github.ref_name, 'beta-ios') }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
|
@ -32,6 +33,9 @@ jobs:
|
||||||
|
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
|
with:
|
||||||
|
intune-enabled: 'true'
|
||||||
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/build-ios-simulator
|
- name: ci/build-ios-simulator
|
||||||
env:
|
env:
|
||||||
|
|
@ -50,7 +54,7 @@ jobs:
|
||||||
path: Mattermost-simulator-x86_64.app.zip
|
path: Mattermost-simulator-x86_64.app.zip
|
||||||
|
|
||||||
build-and-deploy-ios-beta:
|
build-and-deploy-ios-beta:
|
||||||
runs-on: macos-14-large
|
runs-on: macos-15-large
|
||||||
if: ${{ !contains(github.ref_name, 'beta-sim') }}
|
if: ${{ !contains(github.ref_name, 'beta-sim') }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
|
@ -58,17 +62,16 @@ jobs:
|
||||||
- name: ci/checkout-repo
|
- name: ci/checkout-repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: ci/output-ssh-private-key
|
- name: ci/setup-ssh-key
|
||||||
shell: bash
|
uses: ./.github/actions/setup-ssh-key
|
||||||
run: |
|
with:
|
||||||
SSH_KEY_PATH=~/.ssh/id_ed25519
|
ssh-private-key: ${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH}
|
|
||||||
chmod 0600 ${SSH_KEY_PATH}
|
|
||||||
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
|
|
||||||
|
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
|
with:
|
||||||
|
intune-enabled: 'true'
|
||||||
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/build-and-deploy-ios-beta
|
- name: ci/build-and-deploy-ios-beta
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
25
.github/workflows/build-ios-release.yml
vendored
25
.github/workflows/build-ios-release.yml
vendored
|
|
@ -11,6 +11,7 @@ on:
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 22.14.0
|
NODE_VERSION: 22.14.0
|
||||||
TERM: xterm
|
TERM: xterm
|
||||||
|
INTUNE_ENABLED: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -22,7 +23,7 @@ jobs:
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
|
|
||||||
build-and-deploy-ios-release:
|
build-and-deploy-ios-release:
|
||||||
runs-on: macos-14-large
|
runs-on: macos-15-large
|
||||||
if: ${{ !contains(github.ref_name, 'release-sim') }}
|
if: ${{ !contains(github.ref_name, 'release-sim') }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
|
@ -30,17 +31,16 @@ jobs:
|
||||||
- name: ci/checkout-repo
|
- name: ci/checkout-repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: ci/setup-ssh-key
|
||||||
|
uses: ./.github/actions/setup-ssh-key
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
|
with:
|
||||||
- name: ci/output-ssh-private-key
|
intune-enabled: 'true'
|
||||||
shell: bash
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
run: |
|
|
||||||
SSH_KEY_PATH=~/.ssh/id_ed25519
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH}
|
|
||||||
chmod 0600 ${SSH_KEY_PATH}
|
|
||||||
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
|
|
||||||
|
|
||||||
- name: ci/build-and-deploy-ios-release
|
- name: ci/build-and-deploy-ios-release
|
||||||
env:
|
env:
|
||||||
|
|
@ -71,7 +71,7 @@ jobs:
|
||||||
path: "*.ipa"
|
path: "*.ipa"
|
||||||
|
|
||||||
build-ios-simulator:
|
build-ios-simulator:
|
||||||
runs-on: macos-14-large
|
runs-on: macos-15-large
|
||||||
if: ${{ !contains(github.ref_name , 'release-ios') }}
|
if: ${{ !contains(github.ref_name , 'release-ios') }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
|
@ -81,6 +81,9 @@ jobs:
|
||||||
|
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
|
with:
|
||||||
|
intune-enabled: 'true'
|
||||||
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/build-ios-simulator
|
- name: ci/build-ios-simulator
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
20
.github/workflows/build-pr.yml
vendored
20
.github/workflows/build-pr.yml
vendored
|
|
@ -8,6 +8,7 @@ on:
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 22.14.0
|
NODE_VERSION: 22.14.0
|
||||||
TERM: xterm
|
TERM: xterm
|
||||||
|
INTUNE_ENABLED: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -22,7 +23,7 @@ jobs:
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
|
|
||||||
build-ios-pr:
|
build-ios-pr:
|
||||||
runs-on: macos-14-large
|
runs-on: macos-15-large
|
||||||
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' }}
|
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
|
@ -32,17 +33,16 @@ jobs:
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- name: ci/setup-ssh-key
|
||||||
|
uses: ./.github/actions/setup-ssh-key
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
|
with:
|
||||||
- name: ci/output-ssh-private-key
|
intune-enabled: 'true'
|
||||||
shell: bash
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
run: |
|
|
||||||
SSH_KEY_PATH=~/.ssh/id_ed25519
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH}
|
|
||||||
chmod 0600 ${SSH_KEY_PATH}
|
|
||||||
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
|
|
||||||
|
|
||||||
- name: ci/build-ios-pr
|
- name: ci/build-ios-pr
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
8
.github/workflows/e2e-detox-pr.yml
vendored
8
.github/workflows/e2e-detox-pr.yml
vendored
|
|
@ -14,6 +14,9 @@ concurrency:
|
||||||
group: "${{ github.workflow }}-${{ github.event.pull_request.number }}-${{ github.event.label.name }}"
|
group: "${{ github.workflow }}-${{ github.event.pull_request.number }}-${{ github.event.label.name }}"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
INTUNE_ENABLED: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-initial-status-ios:
|
update-initial-status-ios:
|
||||||
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
||||||
|
|
@ -46,7 +49,7 @@ jobs:
|
||||||
|
|
||||||
build-ios-simulator:
|
build-ios-simulator:
|
||||||
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
if: contains(github.event.label.name, 'E2E iOS tests for PR')
|
||||||
runs-on: macos-14
|
runs-on: macos-15
|
||||||
needs:
|
needs:
|
||||||
- update-initial-status-ios
|
- update-initial-status-ios
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -55,6 +58,9 @@ jobs:
|
||||||
|
|
||||||
- name: Prepare iOS Build
|
- name: Prepare iOS Build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
|
with:
|
||||||
|
intune-enabled: 'true'
|
||||||
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: Build iOS Simulator
|
- name: Build iOS Simulator
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
8
.github/workflows/e2e-detox-release.yml
vendored
8
.github/workflows/e2e-detox-release.yml
vendored
|
|
@ -5,6 +5,9 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- release-*
|
- release-*
|
||||||
|
|
||||||
|
env:
|
||||||
|
INTUNE_ENABLED: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-initial-status-ios:
|
update-initial-status-ios:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
@ -33,7 +36,7 @@ jobs:
|
||||||
status: pending
|
status: pending
|
||||||
|
|
||||||
build-ios-simulator:
|
build-ios-simulator:
|
||||||
runs-on: macos-14
|
runs-on: macos-15
|
||||||
needs:
|
needs:
|
||||||
- update-initial-status-ios
|
- update-initial-status-ios
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -42,6 +45,9 @@ jobs:
|
||||||
|
|
||||||
- name: Prepare iOS Build
|
- name: Prepare iOS Build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
|
with:
|
||||||
|
intune-enabled: 'true'
|
||||||
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: Build iOS Simulator
|
- name: Build iOS Simulator
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
8
.github/workflows/e2e-detox-scheduled.yml
vendored
8
.github/workflows/e2e-detox-scheduled.yml
vendored
|
|
@ -4,6 +4,9 @@ on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 0 * * 4,5" # Wednesday and Thursday midnight
|
- cron: "0 0 * * 4,5" # Wednesday and Thursday midnight
|
||||||
|
|
||||||
|
env:
|
||||||
|
INTUNE_ENABLED: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-initial-status-ios:
|
update-initial-status-ios:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
@ -32,7 +35,7 @@ jobs:
|
||||||
status: pending
|
status: pending
|
||||||
|
|
||||||
build-ios-simulator:
|
build-ios-simulator:
|
||||||
runs-on: macos-14
|
runs-on: macos-15
|
||||||
needs:
|
needs:
|
||||||
- update-initial-status-ios
|
- update-initial-status-ios
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -41,6 +44,9 @@ jobs:
|
||||||
|
|
||||||
- name: Prepare iOS Build
|
- name: Prepare iOS Build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
|
with:
|
||||||
|
intune-enabled: 'true'
|
||||||
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: Build iOS Simulator
|
- name: Build iOS Simulator
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
2
.github/workflows/e2e-ios-template.yml
vendored
2
.github/workflows/e2e-ios-template.yml
vendored
|
|
@ -122,7 +122,7 @@ jobs:
|
||||||
|
|
||||||
e2e-ios:
|
e2e-ios:
|
||||||
name: ios-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
|
name: ios-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
|
||||||
runs-on: macos-14
|
runs-on: macos-15
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }}
|
timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }}
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
20
.github/workflows/github-release.yml
vendored
20
.github/workflows/github-release.yml
vendored
|
|
@ -15,6 +15,7 @@ env:
|
||||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||||
SNYK_VERSION: "1.1297.2"
|
SNYK_VERSION: "1.1297.2"
|
||||||
CYCLONEDX_VERSION: "v0.27.2"
|
CYCLONEDX_VERSION: "v0.27.2"
|
||||||
|
INTUNE_ENABLED: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -26,24 +27,23 @@ jobs:
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
|
|
||||||
build-ios-unsigned:
|
build-ios-unsigned:
|
||||||
runs-on: macos-14-large
|
runs-on: macos-15-large
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
steps:
|
steps:
|
||||||
- name: ci/checkout-repo
|
- name: ci/checkout-repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: ci/setup-ssh-key
|
||||||
|
uses: ./.github/actions/setup-ssh-key
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}
|
||||||
|
|
||||||
- name: ci/prepare-ios-build
|
- name: ci/prepare-ios-build
|
||||||
uses: ./.github/actions/prepare-ios-build
|
uses: ./.github/actions/prepare-ios-build
|
||||||
|
with:
|
||||||
- name: ci/output-ssh-private-key
|
intune-enabled: 'true'
|
||||||
shell: bash
|
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
|
||||||
run: |
|
|
||||||
SSH_KEY_PATH=~/.ssh/id_ed25519
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH}
|
|
||||||
chmod 0600 ${SSH_KEY_PATH}
|
|
||||||
ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub
|
|
||||||
|
|
||||||
- name: ci/build-ios-unsigned
|
- name: ci/build-ios-unsigned
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
145
.github/workflows/validate-intune-clean.yml
vendored
Normal file
145
.github/workflows/validate-intune-clean.yml
vendored
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
---
|
||||||
|
name: Validate Intune Clean
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'ios/Podfile'
|
||||||
|
- 'ios/Podfile.lock'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'ios/Mattermost/Info.plist'
|
||||||
|
- 'ios/Mattermost/Mattermost.entitlements'
|
||||||
|
- 'ios/Mattermost.xcodeproj/project.pbxproj'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-podfile-lock:
|
||||||
|
name: Validate Podfile.lock is OSS-only
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: ci/checkout-repo
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Check Podfile.lock for Intune references
|
||||||
|
run: |
|
||||||
|
if grep -q "mattermost-intune\|IntuneMAMSwift" ios/Podfile.lock; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ERROR: Podfile.lock contains Intune dependencies"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo "The committed Podfile.lock should only contain OSS dependencies."
|
||||||
|
echo "Please restore the OSS version:"
|
||||||
|
echo ""
|
||||||
|
echo " git checkout main -- ios/Podfile.lock"
|
||||||
|
echo ""
|
||||||
|
echo "Or run: npm run intune:disable"
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Podfile.lock is clean (OSS-only)"
|
||||||
|
|
||||||
|
- name: Check for package.json Intune reference
|
||||||
|
run: |
|
||||||
|
# Check only dependencies and devDependencies sections (not scripts)
|
||||||
|
if jq -e '.dependencies."@mattermost/intune" // .devDependencies."@mattermost/intune"' package.json > /dev/null 2>&1; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ERROR: package.json contains @mattermost/intune dependency"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo "The Intune library should NOT be in package.json dependencies."
|
||||||
|
echo "It is installed via --no-save flag during internal builds only."
|
||||||
|
echo ""
|
||||||
|
echo "The 'intune:link' script is OK - only dependencies are checked."
|
||||||
|
echo ""
|
||||||
|
echo "Please remove the @mattermost/intune entry from dependencies."
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ package.json is clean (no Intune in dependencies)"
|
||||||
|
|
||||||
|
- name: Check for package-lock.json Intune reference
|
||||||
|
run: |
|
||||||
|
if grep -q "@mattermost/intune" package-lock.json 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ERROR: package-lock.json contains @mattermost/intune"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo "The Intune library should NOT be in package-lock.json."
|
||||||
|
echo ""
|
||||||
|
echo "Please run: npm install (without INTUNE_ENABLED set)"
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ package-lock.json is clean (no Intune reference)"
|
||||||
|
|
||||||
|
- name: Check Info.plist for Intune configuration
|
||||||
|
run: |
|
||||||
|
if grep -q "IntuneMAMSettings\|msauth\.com\.microsoft\.intunemam\|mattermost-intunemam\|mmauthbeta-intunemam\|intunemam-mtd\|msauthv2\|msauthv3" ios/Mattermost/Info.plist; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ERROR: Info.plist contains Intune configuration"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo "The Info.plist should NOT contain Intune-specific settings."
|
||||||
|
echo "Intune configuration is applied by Fastlane during internal builds only."
|
||||||
|
echo ""
|
||||||
|
echo "Please restore the OSS version:"
|
||||||
|
echo ""
|
||||||
|
echo " git checkout main -- ios/Mattermost/Info.plist"
|
||||||
|
echo ""
|
||||||
|
echo "Or run: npm run intune:disable"
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Info.plist is clean (no Intune configuration)"
|
||||||
|
|
||||||
|
- name: Check entitlements for Intune keychain groups
|
||||||
|
run: |
|
||||||
|
if grep -q "com\.microsoft\.adalcache\|com\.microsoft\.intune\.mam\|\.intunemam" ios/Mattermost/Mattermost.entitlements 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ERROR: Mattermost.entitlements contains Intune keychain groups"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo "The entitlements should NOT contain Intune-specific keychain groups."
|
||||||
|
echo "Intune keychain groups are added by Fastlane during internal builds only."
|
||||||
|
echo ""
|
||||||
|
echo "Please restore the OSS version:"
|
||||||
|
echo ""
|
||||||
|
echo " git checkout main -- ios/Mattermost/Mattermost.entitlements"
|
||||||
|
echo ""
|
||||||
|
echo "Or run: npm run intune:disable"
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Mattermost.entitlements is clean (no Intune keychain groups)"
|
||||||
|
|
||||||
|
- name: Check project.pbxproj for Intune frameworks
|
||||||
|
run: |
|
||||||
|
if grep -q "MSAL\|IntuneMAM\|IntuneMAMSwift" ios/Mattermost.xcodeproj/project.pbxproj 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ERROR: project.pbxproj contains Intune framework references"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo "The project.pbxproj should NOT contain Intune framework references."
|
||||||
|
echo "Intune frameworks (MSAL, IntuneMAM, IntuneMAMSwift) are added by Fastlane during internal builds only."
|
||||||
|
echo ""
|
||||||
|
echo "Please restore the OSS version:"
|
||||||
|
echo ""
|
||||||
|
echo " git checkout main -- ios/Mattermost.xcodeproj/project.pbxproj"
|
||||||
|
echo ""
|
||||||
|
echo "Or run: npm run intune:disable"
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ project.pbxproj is clean (no Intune framework references)"
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -124,5 +124,12 @@ libraries/**/**/.build
|
||||||
android/app/src/main/res/raw/*
|
android/app/src/main/res/raw/*
|
||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
|
# Intune submodule (private repository - internal builds only)
|
||||||
|
# Track submodule reference but ignore content
|
||||||
|
libraries/@mattermost/intune/*
|
||||||
|
!libraries/@mattermost/intune/.gitkeep
|
||||||
|
|
||||||
|
# Prevent committing Intune module in node_modules
|
||||||
|
node_modules/@mattermost/intune
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
|
||||||
11
.gitmodules
vendored
Normal file
11
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Intune MAM library submodule (private repository - internal builds only)
|
||||||
|
# To pin to a specific tag/commit:
|
||||||
|
# cd libraries/@mattermost/intune
|
||||||
|
# git checkout <tag-or-commit>
|
||||||
|
# cd ../..
|
||||||
|
# git add libraries/@mattermost/intune
|
||||||
|
# git commit -m "Update Intune submodule to <version>"
|
||||||
|
|
||||||
|
[submodule "libraries/@mattermost/intune"]
|
||||||
|
path = libraries/@mattermost/intune
|
||||||
|
url = git@github.com:mattermost/mattermost-mobile-intune.git
|
||||||
|
|
@ -2,17 +2,17 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {createIntl} from 'react-intl';
|
import {createIntl} from 'react-intl';
|
||||||
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
import {Navigation} from 'react-native-navigation';
|
import {Navigation} from 'react-native-navigation';
|
||||||
|
|
||||||
import {doPing} from '@actions/remote/general';
|
import {doPing} from '@actions/remote/general';
|
||||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||||
import {Preferences, Screens} from '@constants';
|
import {Events, Preferences, Screens} from '@constants';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
|
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
|
||||||
import SecurityManager from '@managers/security_manager';
|
import SecurityManager from '@managers/security_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
import {getServer, getServerByIdentifier, queryAllActiveServers} from '@queries/app/servers';
|
import {getServer, getServerByIdentifier} from '@queries/app/servers';
|
||||||
import {getSecurityConfig} from '@queries/servers/system';
|
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
import {canReceiveNotifications} from '@utils/push_proxy';
|
import {canReceiveNotifications} from '@utils/push_proxy';
|
||||||
|
|
@ -20,17 +20,13 @@ import {alertServerAlreadyConnected, alertServerError, loginToServer} from '@uti
|
||||||
|
|
||||||
import * as Actions from './server';
|
import * as Actions from './server';
|
||||||
|
|
||||||
import type {ServerDatabase} from '@typings/database/database';
|
|
||||||
import type ServersModel from '@typings/database/models/app/servers';
|
import type ServersModel from '@typings/database/models/app/servers';
|
||||||
|
|
||||||
jest.mock('@queries/app/servers');
|
jest.mock('@queries/app/servers');
|
||||||
jest.mock('@queries/servers/system');
|
jest.mock('@queries/servers/system');
|
||||||
jest.mock('@database/manager', () => ({
|
jest.mock('@database/manager');
|
||||||
getServerDatabaseAndOperator: jest.fn(),
|
|
||||||
setActiveServerDatabase: jest.fn(),
|
|
||||||
getActiveServerUrl: jest.fn(),
|
|
||||||
}));
|
|
||||||
jest.mock('@managers/security_manager');
|
jest.mock('@managers/security_manager');
|
||||||
|
|
||||||
jest.mock('@managers/websocket_manager');
|
jest.mock('@managers/websocket_manager');
|
||||||
jest.mock('@utils/log');
|
jest.mock('@utils/log');
|
||||||
jest.mock('@utils/push_proxy');
|
jest.mock('@utils/push_proxy');
|
||||||
|
|
@ -43,58 +39,29 @@ const translations = getTranslations(DEFAULT_LOCALE);
|
||||||
const intl = createIntl({locale: DEFAULT_LOCALE, messages: translations});
|
const intl = createIntl({locale: DEFAULT_LOCALE, messages: translations});
|
||||||
const theme = Preferences.THEMES.denim;
|
const theme = Preferences.THEMES.denim;
|
||||||
|
|
||||||
describe('initializeSecurityManager', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return when no servers are found', async () => {
|
|
||||||
const mockQuery = TestHelper.mockQuery<ServersModel>([]);
|
|
||||||
jest.mocked(queryAllActiveServers).mockReturnValueOnce(mockQuery);
|
|
||||||
await Actions.initializeSecurityManager();
|
|
||||||
expect(SecurityManager.init).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize SecurityManager with querying configurations', async () => {
|
|
||||||
const servers = [{url: 'server1'}, {url: 'server2'}] as ServersModel[];
|
|
||||||
const mockQuery = TestHelper.mockQuery<ServersModel>(servers);
|
|
||||||
jest.mocked(queryAllActiveServers).mockReturnValueOnce(mockQuery);
|
|
||||||
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation((serverUrl) => ({database: `db_${serverUrl}`} as unknown as ServerDatabase));
|
|
||||||
const config = {
|
|
||||||
SiteName: 'Site',
|
|
||||||
MobileEnableBiometrics: 'true',
|
|
||||||
MobilePreventScreenCapture: 'true',
|
|
||||||
MobileJailbreakProtection: 'true',
|
|
||||||
} as unknown as ClientConfig;
|
|
||||||
jest.mocked(getSecurityConfig).mockImplementation((db) => Promise.resolve({
|
|
||||||
...config,
|
|
||||||
key: `config_${db}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await Actions.initializeSecurityManager();
|
|
||||||
|
|
||||||
expect(SecurityManager.init).toHaveBeenCalledWith({
|
|
||||||
server1: {...config, key: 'config_db_server1'},
|
|
||||||
server2: {...config, key: 'config_db_server2'},
|
|
||||||
}, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log error when querying configuration fails', async () => {
|
|
||||||
const servers = [{url: 'server1'}] as ServersModel[];
|
|
||||||
const mockQuery = TestHelper.mockQuery<ServersModel>(servers);
|
|
||||||
jest.mocked(queryAllActiveServers).mockReturnValueOnce(mockQuery);
|
|
||||||
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => {
|
|
||||||
throw new Error('test error');
|
|
||||||
});
|
|
||||||
|
|
||||||
await Actions.initializeSecurityManager();
|
|
||||||
|
|
||||||
expect(logError).toHaveBeenCalledWith('initializeSecurityManager', expect.any(Error));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tests for switchToServer
|
// Tests for switchToServer
|
||||||
describe('switchToServer', () => {
|
describe('switchToServer', () => {
|
||||||
|
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Register the SecurityManager listener manually since the mocked SecurityManager doesn't run its constructor
|
||||||
|
DeviceEventEmitter.addListener(Events.ACTIVE_SERVER_CHANGED, jest.mocked(SecurityManager).setActiveServer);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Initialize the database for each test
|
||||||
|
await DatabaseManager.init(['serverUrl']);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up
|
||||||
|
await DatabaseManager.destroyServerDatabase('serverUrl');
|
||||||
|
emitSpy.mockClear();
|
||||||
|
jest.mocked(SecurityManager).setActiveServer.mockClear();
|
||||||
|
jest.mocked(SecurityManager).isDeviceJailbroken.mockClear();
|
||||||
|
jest.mocked(SecurityManager).authenticateWithBiometricsIfNeeded.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
it('should log error when server is not found', async () => {
|
it('should log error when server is not found', async () => {
|
||||||
jest.mocked(getServer).mockResolvedValueOnce(undefined);
|
jest.mocked(getServer).mockResolvedValueOnce(undefined);
|
||||||
await Actions.switchToServer('serverUrl', theme, intl, jest.fn());
|
await Actions.switchToServer('serverUrl', theme, intl, jest.fn());
|
||||||
|
|
@ -103,15 +70,26 @@ describe('switchToServer', () => {
|
||||||
|
|
||||||
it('should switch to server when lastActiveAt is set', async () => {
|
it('should switch to server when lastActiveAt is set', async () => {
|
||||||
const server = {url: 'serverUrl', lastActiveAt: 123} as ServersModel;
|
const server = {url: 'serverUrl', lastActiveAt: 123} as ServersModel;
|
||||||
|
const setActiveSpy = jest.spyOn(DatabaseManager, 'setActiveServerDatabase');
|
||||||
jest.mocked(getServer).mockResolvedValueOnce(server);
|
jest.mocked(getServer).mockResolvedValueOnce(server);
|
||||||
jest.mocked(SecurityManager.isDeviceJailbroken).mockResolvedValueOnce(false);
|
jest.mocked(SecurityManager).isDeviceJailbroken.mockResolvedValueOnce(false);
|
||||||
jest.mocked(SecurityManager.authenticateWithBiometricsIfNeeded).mockResolvedValueOnce(true);
|
jest.mocked(SecurityManager).authenticateWithBiometricsIfNeeded.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
await Actions.switchToServer('serverUrl', theme, intl, jest.fn());
|
await Actions.switchToServer('serverUrl', theme, intl, jest.fn());
|
||||||
|
|
||||||
|
// Wait for the async database operation to complete (setActiveServerDatabase is called without await)
|
||||||
|
await TestHelper.wait(10);
|
||||||
|
const options = {
|
||||||
|
skipJailbreakCheck: true,
|
||||||
|
skipBiometricCheck: true,
|
||||||
|
skipMAMEnrollmentCheck: false,
|
||||||
|
forceSwitch: false,
|
||||||
|
};
|
||||||
|
|
||||||
expect(Navigation.updateProps).toHaveBeenCalledWith(Screens.HOME, {extra: undefined});
|
expect(Navigation.updateProps).toHaveBeenCalledWith(Screens.HOME, {extra: undefined});
|
||||||
expect(DatabaseManager.setActiveServerDatabase).toHaveBeenCalledWith('serverUrl');
|
expect(setActiveSpy).toHaveBeenCalledWith('serverUrl', options);
|
||||||
expect(SecurityManager.setActiveServer).toHaveBeenCalledWith('serverUrl');
|
expect(emitSpy).toHaveBeenCalledWith(Events.ACTIVE_SERVER_CHANGED, {serverUrl: 'serverUrl', options});
|
||||||
|
expect(SecurityManager.setActiveServer).toHaveBeenCalledWith({serverUrl: 'serverUrl', options});
|
||||||
expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('serverUrl', 'Server Switch');
|
expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('serverUrl', 'Server Switch');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,49 +9,13 @@ import {Screens} from '@constants';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import SecurityManager from '@managers/security_manager';
|
import SecurityManager from '@managers/security_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
import {getServer, getServerByIdentifier, queryAllActiveServers} from '@queries/app/servers';
|
import {getServer, getServerByIdentifier} from '@queries/app/servers';
|
||||||
import {getSecurityConfig} from '@queries/servers/system';
|
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
import {canReceiveNotifications} from '@utils/push_proxy';
|
import {canReceiveNotifications} from '@utils/push_proxy';
|
||||||
import {alertServerAlreadyConnected, alertServerError, loginToServer} from '@utils/server';
|
import {alertServerAlreadyConnected, alertServerError, loginToServer} from '@utils/server';
|
||||||
|
|
||||||
import type {IntlShape} from 'react-intl';
|
import type {IntlShape} from 'react-intl';
|
||||||
|
|
||||||
export async function initializeSecurityManager() {
|
|
||||||
const servers = await queryAllActiveServers()?.fetch();
|
|
||||||
if (!servers?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promises: Array<Promise<[string, SecurityClientConfig | null]>> = [];
|
|
||||||
const results: Record<string, SecurityClientConfig> = {};
|
|
||||||
|
|
||||||
for (const server of servers) {
|
|
||||||
try {
|
|
||||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(server.url);
|
|
||||||
const promise = getSecurityConfig(database).then((config) => [server.url, config] as [string, SecurityClientConfig | null]);
|
|
||||||
promises.push(promise);
|
|
||||||
} catch (error) {
|
|
||||||
logError('initializeSecurityManager', error);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedConfigs = await Promise.allSettled(promises);
|
|
||||||
|
|
||||||
for (const result of resolvedConfigs) {
|
|
||||||
if (result.status === 'fulfilled') {
|
|
||||||
const [url, config] = result.value;
|
|
||||||
if (config && Object.keys(config).length > 0) { // Ensure the object is not empty
|
|
||||||
results[url] = config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverUrl = await DatabaseManager.getActiveServerUrl();
|
|
||||||
SecurityManager.init(results, serverUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function switchToServer(serverUrl: string, theme: Theme, intl: IntlShape, callback?: () => void) {
|
export async function switchToServer(serverUrl: string, theme: Theme, intl: IntlShape, callback?: () => void) {
|
||||||
const server = await getServer(serverUrl);
|
const server = await getServer(serverUrl);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
|
|
@ -67,10 +31,15 @@ export async function switchToServer(serverUrl: string, theme: Theme, intl: Intl
|
||||||
const authenticated = await SecurityManager.authenticateWithBiometricsIfNeeded(server.url);
|
const authenticated = await SecurityManager.authenticateWithBiometricsIfNeeded(server.url);
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
Navigation.updateProps(Screens.HOME, {extra: undefined});
|
Navigation.updateProps(Screens.HOME, {extra: undefined});
|
||||||
DatabaseManager.setActiveServerDatabase(server.url);
|
DatabaseManager.setActiveServerDatabase(server.url, {
|
||||||
SecurityManager.setActiveServer(server.url);
|
skipJailbreakCheck: true,
|
||||||
|
skipBiometricCheck: true,
|
||||||
|
skipMAMEnrollmentCheck: false,
|
||||||
|
forceSwitch: false,
|
||||||
|
});
|
||||||
WebsocketManager.initializeClient(server.url, 'Server Switch');
|
WebsocketManager.initializeClient(server.url, 'Server Switch');
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {DeviceEventEmitter, Image} from 'react-native';
|
import {Image} from 'expo-image';
|
||||||
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import {Navigation, Screens} from '@constants';
|
import {Navigation, Screens} from '@constants';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
|
|
@ -10,8 +11,11 @@ import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from
|
||||||
import {addChannelToTeamHistory} from '@queries/servers/team';
|
import {addChannelToTeamHistory} from '@queries/servers/team';
|
||||||
import {goToScreen, popTo} from '@screens/navigation';
|
import {goToScreen, popTo} from '@screens/navigation';
|
||||||
import NavigationStore from '@store/navigation_store';
|
import NavigationStore from '@store/navigation_store';
|
||||||
|
import {getExtensionFromMime} from '@utils/file';
|
||||||
import {isTablet} from '@utils/helpers';
|
import {isTablet} from '@utils/helpers';
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
|
import {removeImageProxyForKey} from '@utils/markdown';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {isParsableUrl} from '@utils/url';
|
import {isParsableUrl} from '@utils/url';
|
||||||
|
|
||||||
import type {DraftScreenTab} from '@constants/draft';
|
import type {DraftScreenTab} from '@constants/draft';
|
||||||
|
|
@ -296,24 +300,15 @@ export async function updateDraftMarkdownImageMetadata({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getImageMetadata(url: string) {
|
async function getImageMetadata(serverUrl: string, url: string) {
|
||||||
let height = 0;
|
|
||||||
let width = 0;
|
|
||||||
let format;
|
let format;
|
||||||
await new Promise((resolve) => {
|
const sourceKey = removeImageProxyForKey(url);
|
||||||
Image.getSize(
|
const cacheKey = `uid-${urlSafeBase64Encode(sourceKey)}`;
|
||||||
url,
|
const image = await Image.loadAsync({uri: url, cacheKey, cachePath: urlSafeBase64Encode(serverUrl)});
|
||||||
(imageWidth, imageHeight) => {
|
|
||||||
width = imageWidth;
|
|
||||||
height = imageHeight;
|
|
||||||
resolve(null);
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
logError('Failed getImageMetadata to get image size', error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if (image.mediaType) {
|
||||||
|
format = getExtensionFromMime(image.mediaType);
|
||||||
|
} else {
|
||||||
/**
|
/**
|
||||||
* Regex Explanation:
|
* Regex Explanation:
|
||||||
* \. - Matches a literal period (e.g., before "jpg").
|
* \. - Matches a literal period (e.g., before "jpg").
|
||||||
|
|
@ -329,16 +324,18 @@ async function getImageMetadata(url: string) {
|
||||||
if (match) {
|
if (match) {
|
||||||
format = match[1];
|
format = match[1];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
height,
|
height: image.height,
|
||||||
width,
|
width: image.width,
|
||||||
format,
|
format,
|
||||||
frame_count: 1,
|
frame_count: 1,
|
||||||
url,
|
url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseMarkdownImages(markdown: string, imageMetadata: Dictionary<PostImage | undefined>) {
|
export async function parseMarkdownImages(serverUrl: string, markdown: string, imageMetadata: Dictionary<PostImage | undefined>) {
|
||||||
// Regex break down
|
// Regex break down
|
||||||
// ([a-zA-Z][a-zA-Z\d+\-.]*):\/\/ - Matches any valid scheme (protocol), such as http, https, ftp, mailto, file, etc.
|
// ([a-zA-Z][a-zA-Z\d+\-.]*):\/\/ - Matches any valid scheme (protocol), such as http, https, ftp, mailto, file, etc.
|
||||||
// [^\s()<>]+ - Matches the main part of the URL, excluding spaces, parentheses, and angle brackets.
|
// [^\s()<>]+ - Matches the main part of the URL, excluding spaces, parentheses, and angle brackets.
|
||||||
|
|
@ -350,7 +347,7 @@ export async function parseMarkdownImages(markdown: string, imageMetadata: Dicti
|
||||||
const promises = matches.reduce<Array<Promise<PostImage & {url: string}>>>((result, match) => {
|
const promises = matches.reduce<Array<Promise<PostImage & {url: string}>>>((result, match) => {
|
||||||
const imageUrl = match[1];
|
const imageUrl = match[1];
|
||||||
if (isParsableUrl(imageUrl)) {
|
if (isParsableUrl(imageUrl)) {
|
||||||
result.push(getImageMetadata(imageUrl));
|
result.push(getImageMetadata(serverUrl, imageUrl));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
346
app/actions/local/session/index.test.ts
Normal file
346
app/actions/local/session/index.test.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import NetInfo, {type NetInfoState} from '@react-native-community/netinfo';
|
||||||
|
import {Platform} from 'react-native';
|
||||||
|
|
||||||
|
import {removePushDisabledInServerAcknowledged} from '@actions/app/global';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {resetMomentLocale} from '@i18n';
|
||||||
|
import {getAllServerCredentials, removeServerCredentials} from '@init/credentials';
|
||||||
|
import PushNotifications from '@init/push_notifications';
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
|
import {getDeviceToken} from '@queries/app/global';
|
||||||
|
import {getExpiredSession} from '@queries/servers/system';
|
||||||
|
import {getCurrentUser} from '@queries/servers/user';
|
||||||
|
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
|
||||||
|
import {clearCookiesForServer, getCSRFFromCookie, urlSafeBase64Encode} from '@utils/security';
|
||||||
|
|
||||||
|
import {cancelAllSessionNotifications, cancelSessionNotification, findSession, terminateSession} from './index';
|
||||||
|
|
||||||
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
import type {Database} from '@nozbe/watermelondb';
|
||||||
|
import type {ServerDatabase, ServerDatabases} from '@typings/database/database';
|
||||||
|
import type UserModel from '@typings/database/models/servers/user';
|
||||||
|
|
||||||
|
// Mock all dependencies
|
||||||
|
jest.mock('@react-native-community/netinfo');
|
||||||
|
jest.mock('expo-image', () => ({
|
||||||
|
Image: {
|
||||||
|
clearDiskCache: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock('@actions/app/global');
|
||||||
|
jest.mock('@database/manager', () => ({
|
||||||
|
getServerDatabaseAndOperator: jest.fn(),
|
||||||
|
getActiveServerDatabase: jest.fn(),
|
||||||
|
destroyServerDatabase: jest.fn(),
|
||||||
|
deleteServerDatabase: jest.fn(),
|
||||||
|
serverDatabases: {},
|
||||||
|
}));
|
||||||
|
jest.mock('@i18n', () => ({
|
||||||
|
resetMomentLocale: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@init/credentials');
|
||||||
|
jest.mock('@init/push_notifications', () => ({
|
||||||
|
removeServerNotifications: jest.fn(),
|
||||||
|
cancelScheduleNotification: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@managers/network_manager', () => ({
|
||||||
|
invalidateClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@managers/websocket_manager', () => ({
|
||||||
|
invalidateClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@queries/app/global');
|
||||||
|
jest.mock('@queries/servers/system');
|
||||||
|
jest.mock('@queries/servers/user');
|
||||||
|
jest.mock('@utils/file');
|
||||||
|
jest.mock('@utils/security');
|
||||||
|
|
||||||
|
describe('session actions', () => {
|
||||||
|
const mockServerUrl = 'https://example.com';
|
||||||
|
const mockDatabase = {database: 'mockDb'};
|
||||||
|
const mockOperator = {
|
||||||
|
handleSystem: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findSession', () => {
|
||||||
|
const mockSessions: Session[] = [
|
||||||
|
{id: 'session1', device_id: 'device123', props: {csrf: 'csrf123', os: 'ios'}} as Session,
|
||||||
|
{id: 'session2', device_id: 'device456', props: {csrf: 'csrf456', os: 'android'}} as Session,
|
||||||
|
{id: 'session3', device_id: 'device789', props: {csrf: 'csrf789', os: 'ios'}} as Session,
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({
|
||||||
|
database: mockDatabase as unknown as Database,
|
||||||
|
operator: mockOperator as unknown as ServerDataOperator,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find session by expired session ID', async () => {
|
||||||
|
const expiredSession = {id: 'session2', notificationId: '123'};
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue(expiredSession as SessionExpiration);
|
||||||
|
jest.mocked(getDeviceToken).mockResolvedValue('device999');
|
||||||
|
jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf999');
|
||||||
|
|
||||||
|
const result = await findSession(mockServerUrl, mockSessions);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSessions[1]);
|
||||||
|
expect(getExpiredSession).toHaveBeenCalledWith(mockDatabase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find session by device token', async () => {
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue(undefined);
|
||||||
|
jest.mocked(getDeviceToken).mockResolvedValue('device456');
|
||||||
|
jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf999');
|
||||||
|
|
||||||
|
const result = await findSession(mockServerUrl, mockSessions);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSessions[1]);
|
||||||
|
expect(getDeviceToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find session by CSRF token', async () => {
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue(undefined);
|
||||||
|
jest.mocked(getDeviceToken).mockResolvedValue('device999');
|
||||||
|
jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf789');
|
||||||
|
|
||||||
|
const result = await findSession(mockServerUrl, mockSessions);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSessions[2]);
|
||||||
|
expect(getCSRFFromCookie).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find session by platform OS', async () => {
|
||||||
|
Platform.OS = 'android';
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue(undefined);
|
||||||
|
jest.mocked(getDeviceToken).mockResolvedValue('device999');
|
||||||
|
jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf999');
|
||||||
|
|
||||||
|
const result = await findSession(mockServerUrl, mockSessions);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSessions[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when no session matches', async () => {
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue(undefined);
|
||||||
|
jest.mocked(getDeviceToken).mockResolvedValue('device999');
|
||||||
|
jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf999');
|
||||||
|
Platform.OS = 'web';
|
||||||
|
|
||||||
|
const result = await findSession(mockServerUrl, mockSessions);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors gracefully and return undefined', async () => {
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => {
|
||||||
|
throw new Error('Database error');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await findSession(mockServerUrl, mockSessions);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelAllSessionNotifications', () => {
|
||||||
|
it('should cancel notifications for all servers with credentials', async () => {
|
||||||
|
const mockCredentials = [
|
||||||
|
{serverUrl: 'https://server1.com', userId: 'user1', token: 'token1'},
|
||||||
|
{serverUrl: 'https://server2.com', userId: 'user2', token: 'token2'},
|
||||||
|
];
|
||||||
|
jest.mocked(getAllServerCredentials).mockResolvedValue(mockCredentials);
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({
|
||||||
|
database: mockDatabase as unknown as Database,
|
||||||
|
operator: mockOperator as unknown as ServerDataOperator,
|
||||||
|
});
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue({
|
||||||
|
id: 'session1',
|
||||||
|
notificationId: '123',
|
||||||
|
} as SessionExpiration);
|
||||||
|
jest.mocked(NetInfo.fetch).mockResolvedValue({
|
||||||
|
isInternetReachable: true,
|
||||||
|
} as NetInfoState);
|
||||||
|
|
||||||
|
await cancelAllSessionNotifications();
|
||||||
|
|
||||||
|
expect(getAllServerCredentials).toHaveBeenCalled();
|
||||||
|
expect(DatabaseManager.getServerDatabaseAndOperator).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty credentials list', async () => {
|
||||||
|
jest.mocked(getAllServerCredentials).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await cancelAllSessionNotifications();
|
||||||
|
|
||||||
|
expect(getAllServerCredentials).toHaveBeenCalled();
|
||||||
|
expect(DatabaseManager.getServerDatabaseAndOperator).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelSessionNotification', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({
|
||||||
|
database: mockDatabase as unknown as Database,
|
||||||
|
operator: mockOperator as unknown as ServerDataOperator,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel notification when expired session has notification ID and internet is reachable', async () => {
|
||||||
|
const expiredSession = {id: 'session1', notificationId: '123'};
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue(expiredSession as SessionExpiration);
|
||||||
|
jest.mocked(NetInfo.fetch).mockResolvedValue({
|
||||||
|
isInternetReachable: true,
|
||||||
|
} as NetInfoState);
|
||||||
|
|
||||||
|
const result = await cancelSessionNotification(mockServerUrl);
|
||||||
|
|
||||||
|
expect(PushNotifications.cancelScheduleNotification).toHaveBeenCalledWith(123);
|
||||||
|
expect(mockOperator.handleSystem).toHaveBeenCalledWith({
|
||||||
|
systems: [{
|
||||||
|
id: 'sessionExpiration',
|
||||||
|
value: '',
|
||||||
|
}],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cancel notification when no notification ID', async () => {
|
||||||
|
const expiredSession = {id: 'session1', notificationId: ''};
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue(expiredSession as SessionExpiration);
|
||||||
|
jest.mocked(NetInfo.fetch).mockResolvedValue({
|
||||||
|
isInternetReachable: true,
|
||||||
|
} as NetInfoState);
|
||||||
|
|
||||||
|
const result = await cancelSessionNotification(mockServerUrl);
|
||||||
|
|
||||||
|
expect(PushNotifications.cancelScheduleNotification).not.toHaveBeenCalled();
|
||||||
|
expect(mockOperator.handleSystem).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cancel notification when internet not reachable', async () => {
|
||||||
|
const expiredSession = {id: 'session1', notificationId: '123'};
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue(expiredSession as SessionExpiration);
|
||||||
|
jest.mocked(NetInfo.fetch).mockResolvedValue({
|
||||||
|
isInternetReachable: false,
|
||||||
|
} as NetInfoState);
|
||||||
|
|
||||||
|
const result = await cancelSessionNotification(mockServerUrl);
|
||||||
|
|
||||||
|
expect(PushNotifications.cancelScheduleNotification).not.toHaveBeenCalled();
|
||||||
|
expect(mockOperator.handleSystem).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors gracefully and return error object', async () => {
|
||||||
|
const error = new Error('Database error');
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cancelSessionNotification(mockServerUrl);
|
||||||
|
|
||||||
|
expect(result).toEqual({error});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('terminateSession', () => {
|
||||||
|
const encodedServerUrl = 'aHR0cHM6Ly9leGFtcGxlLmNvbQ==';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({
|
||||||
|
database: mockDatabase as unknown as Database,
|
||||||
|
operator: mockOperator as unknown as ServerDataOperator,
|
||||||
|
});
|
||||||
|
jest.mocked(getExpiredSession).mockResolvedValue(undefined);
|
||||||
|
jest.mocked(NetInfo.fetch).mockResolvedValue({
|
||||||
|
isInternetReachable: false,
|
||||||
|
} as NetInfoState);
|
||||||
|
jest.mocked(urlSafeBase64Encode).mockReturnValue(encodedServerUrl);
|
||||||
|
jest.mocked(getCurrentUser).mockResolvedValue(undefined);
|
||||||
|
(DatabaseManager.serverDatabases as ServerDatabases) = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call all cleanup functions in correct order for removeServer=true', async () => {
|
||||||
|
await terminateSession(mockServerUrl, true);
|
||||||
|
|
||||||
|
// Verify all cleanup functions called
|
||||||
|
expect(removeServerCredentials).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(PushNotifications.removeServerNotifications).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(WebsocketManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(removePushDisabledInServerAcknowledged).toHaveBeenCalledWith(encodedServerUrl);
|
||||||
|
expect(DatabaseManager.destroyServerDatabase).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(resetMomentLocale).toHaveBeenCalled();
|
||||||
|
expect(clearCookiesForServer).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(deleteFileCache).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(deleteFileCacheByDir).toHaveBeenCalledWith('mmPasteInput');
|
||||||
|
expect(deleteFileCacheByDir).toHaveBeenCalledWith('thumbnails');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call deleteServerDatabase when removeServer=false', async () => {
|
||||||
|
await terminateSession(mockServerUrl, false);
|
||||||
|
|
||||||
|
expect(DatabaseManager.deleteServerDatabase).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(DatabaseManager.destroyServerDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(removePushDisabledInServerAcknowledged).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear cookies for server', async () => {
|
||||||
|
await terminateSession(mockServerUrl, true);
|
||||||
|
|
||||||
|
expect(clearCookiesForServer).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear image cache with URL-safe encoded server URL', async () => {
|
||||||
|
await terminateSession(mockServerUrl, true);
|
||||||
|
|
||||||
|
expect(urlSafeBase64Encode).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete file caches for server and common directories', async () => {
|
||||||
|
await terminateSession(mockServerUrl, true);
|
||||||
|
|
||||||
|
expect(deleteFileCache).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(deleteFileCacheByDir).toHaveBeenCalledWith('mmPasteInput');
|
||||||
|
expect(deleteFileCacheByDir).toHaveBeenCalledWith('thumbnails');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset locale with user locale when active server database exists', async () => {
|
||||||
|
const mockUser = {locale: 'es'};
|
||||||
|
const mockServerDatabase = {database: 'serverDb'} as unknown as ServerDatabase;
|
||||||
|
|
||||||
|
(DatabaseManager.serverDatabases as ServerDatabases) = {[mockServerUrl]: mockServerDatabase};
|
||||||
|
jest.mocked(DatabaseManager.getActiveServerDatabase).mockResolvedValue(mockServerDatabase as unknown as Database);
|
||||||
|
jest.mocked(getCurrentUser).mockResolvedValue(mockUser as unknown as UserModel);
|
||||||
|
|
||||||
|
await terminateSession(mockServerUrl, true);
|
||||||
|
|
||||||
|
// Wait for the async resetLocale to complete (not awaited in implementation)
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(resetMomentLocale).toHaveBeenCalledWith('es');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset locale to default when no active server database', async () => {
|
||||||
|
(DatabaseManager.serverDatabases as ServerDatabases) = {};
|
||||||
|
|
||||||
|
await terminateSession(mockServerUrl, true);
|
||||||
|
|
||||||
|
// Wait for the async resetLocale to complete (not awaited in implementation)
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(resetMomentLocale).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
190
app/actions/local/session/index.ts
Normal file
190
app/actions/local/session/index.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import {Platform} from 'react-native';
|
||||||
|
|
||||||
|
import {removePushDisabledInServerAcknowledged} from '@actions/app/global';
|
||||||
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {resetMomentLocale} from '@i18n';
|
||||||
|
import {getAllServerCredentials, removeServerCredentials} from '@init/credentials';
|
||||||
|
import PushNotifications from '@init/push_notifications';
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
|
import {getDeviceToken} from '@queries/app/global';
|
||||||
|
import {getExpiredSession} from '@queries/servers/system';
|
||||||
|
import {getCurrentUser} from '@queries/servers/user';
|
||||||
|
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
|
||||||
|
import {logError, logWarning} from '@utils/log';
|
||||||
|
import {clearCookiesForServer, getCSRFFromCookie, urlSafeBase64Encode} from '@utils/security';
|
||||||
|
|
||||||
|
const resetLocale = async () => {
|
||||||
|
if (Object.keys(DatabaseManager.serverDatabases).length) {
|
||||||
|
const serverDatabase = await DatabaseManager.getActiveServerDatabase();
|
||||||
|
const user = await getCurrentUser(serverDatabase!);
|
||||||
|
resetMomentLocale(user?.locale);
|
||||||
|
} else {
|
||||||
|
resetMomentLocale();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function findSession(serverUrl: string, sessions: Session[]) {
|
||||||
|
try {
|
||||||
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
const expiredSession = await getExpiredSession(database);
|
||||||
|
const deviceToken = await getDeviceToken();
|
||||||
|
|
||||||
|
// First try and find the session by the given identifier
|
||||||
|
let session = sessions.find((s) => s.id === expiredSession?.id);
|
||||||
|
if (session) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next try and find the session by deviceId
|
||||||
|
if (deviceToken) {
|
||||||
|
session = sessions.find((s) => s.device_id === deviceToken);
|
||||||
|
if (session) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next try and find the session by the CSRF token
|
||||||
|
const csrfToken = await getCSRFFromCookie(serverUrl);
|
||||||
|
if (csrfToken) {
|
||||||
|
session = sessions.find((s) => s.props?.csrf === csrfToken);
|
||||||
|
if (session) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next try and find the session based on the OS
|
||||||
|
// if multiple sessions exists with the same os type this can be inaccurate
|
||||||
|
session = sessions.find((s) => s.props?.os.toLowerCase() === Platform.OS);
|
||||||
|
if (session) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError('findSession', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point we did not find the session
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cancelAllSessionNotifications = async () => {
|
||||||
|
const serverCredentials = await getAllServerCredentials();
|
||||||
|
for (const {serverUrl} of serverCredentials) {
|
||||||
|
cancelSessionNotification(serverUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cancelSessionNotification = async (serverUrl: string) => {
|
||||||
|
try {
|
||||||
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
const expiredSession = await getExpiredSession(database);
|
||||||
|
const rechable = (await NetInfo.fetch()).isInternetReachable;
|
||||||
|
|
||||||
|
if (expiredSession?.notificationId && rechable) {
|
||||||
|
PushNotifications.cancelScheduleNotification(parseInt(expiredSession.notificationId, 10));
|
||||||
|
operator.handleSystem({
|
||||||
|
systems: [{
|
||||||
|
id: SYSTEM_IDENTIFIERS.SESSION_EXPIRATION,
|
||||||
|
value: '',
|
||||||
|
}],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
} catch (e) {
|
||||||
|
logError('cancelSessionNotification', e);
|
||||||
|
return {error: e};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const terminateSession = async (serverUrl: string, removeServer: boolean) => {
|
||||||
|
const errors: Array<{operation: string; error: unknown}> = [];
|
||||||
|
|
||||||
|
// Helper to safely execute operations and optionally track errors
|
||||||
|
const safeExecute = async (operation: string, fn: () => Promise<unknown>, critical = true) => {
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
|
||||||
|
// Check if function returned {error}
|
||||||
|
if (result && typeof result === 'object' && 'error' in result && critical) {
|
||||||
|
errors.push({operation, error: result.error});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (critical) {
|
||||||
|
errors.push({operation, error});
|
||||||
|
} else {
|
||||||
|
// Log but don't track as failure
|
||||||
|
logWarning(`terminateSession: ${operation} failed (non-critical)`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel session notification (critical)
|
||||||
|
await safeExecute('cancelSessionNotification', async () => {
|
||||||
|
await cancelSessionNotification(serverUrl);
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
// Remove server credentials (critical)
|
||||||
|
await safeExecute('removeServerCredentials', async () => {
|
||||||
|
await removeServerCredentials(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove push notifications (synchronous, no error handling needed)
|
||||||
|
PushNotifications.removeServerNotifications(serverUrl);
|
||||||
|
|
||||||
|
// Invalidate clients (synchronous, no error handling needed)
|
||||||
|
NetworkManager.invalidateClient(serverUrl);
|
||||||
|
WebsocketManager.invalidateClient(serverUrl);
|
||||||
|
|
||||||
|
// Remove push disabled acknowledgment (non-critical)
|
||||||
|
if (removeServer) {
|
||||||
|
await safeExecute('removePushDisabledInServerAcknowledged', async () => {
|
||||||
|
const result = await removePushDisabledInServerAcknowledged(urlSafeBase64Encode(serverUrl));
|
||||||
|
if (result && typeof result === 'object' && 'error' in result) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database operations (critical)
|
||||||
|
await safeExecute('databaseOperation', async () => {
|
||||||
|
if (removeServer) {
|
||||||
|
await DatabaseManager.destroyServerDatabase(serverUrl);
|
||||||
|
} else {
|
||||||
|
await DatabaseManager.deleteServerDatabase(serverUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset locale (non-critical)
|
||||||
|
await safeExecute('resetLocale', async () => {
|
||||||
|
await resetLocale();
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
// Clear cookies (synchronous)
|
||||||
|
clearCookiesForServer(serverUrl);
|
||||||
|
|
||||||
|
// Delete file caches (critical - we need to wipe local data)
|
||||||
|
await safeExecute('deleteFileCache', async () => {
|
||||||
|
await deleteFileCache(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
await safeExecute('deleteFileCacheMmPasteInput', async () => {
|
||||||
|
await deleteFileCacheByDir('mmPasteInput');
|
||||||
|
});
|
||||||
|
|
||||||
|
await safeExecute('deleteFileCacheThumbnails', async () => {
|
||||||
|
await deleteFileCacheByDir('thumbnails');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return {error: errors};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
|
|
||||||
import {Q} from '@nozbe/watermelondb';
|
import {Q} from '@nozbe/watermelondb';
|
||||||
import deepEqual from 'deep-equal';
|
import deepEqual from 'deep-equal';
|
||||||
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
|
import {Events} from '@constants';
|
||||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {getServerCredentials} from '@init/credentials';
|
import {getServerCredentials} from '@init/credentials';
|
||||||
|
|
@ -36,6 +38,7 @@ export async function storeConfigAndLicense(serverUrl: string, config: ClientCon
|
||||||
|
|
||||||
if (systems.length) {
|
if (systems.length) {
|
||||||
await operator.handleSystem({systems, prepareRecordsOnly: false});
|
await operator.handleSystem({systems, prepareRecordsOnly: false});
|
||||||
|
DeviceEventEmitter.emit(Events.LICENSE_CHANGED, {serverUrl, license});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await storeConfig(serverUrl, config);
|
return await storeConfig(serverUrl, config);
|
||||||
|
|
@ -76,7 +79,9 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configsToDelete.length || configsToUpdate.length) {
|
if (configsToDelete.length || configsToUpdate.length) {
|
||||||
return operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
|
const results = await operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
|
||||||
|
DeviceEventEmitter.emit(Events.CONFIG_CHANGED, {serverUrl, config});
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('storeConfig', error);
|
logError('storeConfig', error);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
updateRecentCustomStatuses,
|
updateRecentCustomStatuses,
|
||||||
updateLocalUser,
|
updateLocalUser,
|
||||||
storeProfile,
|
storeProfile,
|
||||||
|
getCurrentUserLocale,
|
||||||
} from './user';
|
} from './user';
|
||||||
|
|
||||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
|
@ -127,3 +128,39 @@ describe('storeProfile', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getCurrentUserLocale', () => {
|
||||||
|
it('should return user locale when user exists', async () => {
|
||||||
|
const userWithLocale = TestHelper.fakeUser({
|
||||||
|
id: 'userid',
|
||||||
|
locale: 'es',
|
||||||
|
});
|
||||||
|
await operator.handleUsers({users: [userWithLocale], prepareRecordsOnly: false});
|
||||||
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: userWithLocale.id}], prepareRecordsOnly: false});
|
||||||
|
|
||||||
|
const locale = await getCurrentUserLocale(serverUrl);
|
||||||
|
expect(locale).toBe('es');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return DEFAULT_LOCALE when user has no locale', async () => {
|
||||||
|
const userWithoutLocale = TestHelper.fakeUser({
|
||||||
|
id: 'userid',
|
||||||
|
locale: '',
|
||||||
|
});
|
||||||
|
await operator.handleUsers({users: [userWithoutLocale], prepareRecordsOnly: false});
|
||||||
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: userWithoutLocale.id}], prepareRecordsOnly: false});
|
||||||
|
|
||||||
|
const locale = await getCurrentUserLocale(serverUrl);
|
||||||
|
expect(locale).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return DEFAULT_LOCALE when no user exists', async () => {
|
||||||
|
const locale = await getCurrentUserLocale(serverUrl);
|
||||||
|
expect(locale).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return DEFAULT_LOCALE when database not found', async () => {
|
||||||
|
const locale = await getCurrentUserLocale('foo');
|
||||||
|
expect(locale).toBe('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {DEFAULT_LOCALE} from '@i18n';
|
||||||
import {getRecentCustomStatuses} from '@queries/servers/system';
|
import {getRecentCustomStatuses} from '@queries/servers/system';
|
||||||
import {getCurrentUser, getUserById} from '@queries/servers/user';
|
import {getCurrentUser, getUserById} from '@queries/servers/user';
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
|
|
@ -160,3 +161,13 @@ export const storeProfile = async (serverUrl: string, profile: UserProfile) => {
|
||||||
return {error};
|
return {error};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCurrentUserLocale = async (serverUrl: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
const user = await getCurrentUser(database);
|
||||||
|
return user?.locale || DEFAULT_LOCALE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ beforeEach(async () => {
|
||||||
describe('fetchCustomEmojis', () => {
|
describe('fetchCustomEmojis', () => {
|
||||||
it('should fetch custom emojis successfully', async () => {
|
it('should fetch custom emojis successfully', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
apiClient: {
|
||||||
|
baseUrl: serverUrl,
|
||||||
|
},
|
||||||
getCustomEmojis: jest.fn().mockResolvedValue(mockEmojis),
|
getCustomEmojis: jest.fn().mockResolvedValue(mockEmojis),
|
||||||
getCustomEmojiImageUrl: jest.fn(),
|
getCustomEmojiImageUrl: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
@ -45,6 +48,9 @@ describe('fetchCustomEmojis', () => {
|
||||||
|
|
||||||
it('should handle error during fetch custom emojis', async () => {
|
it('should handle error during fetch custom emojis', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
apiClient: {
|
||||||
|
baseUrl: serverUrl,
|
||||||
|
},
|
||||||
getCustomEmojis: jest.fn().mockRejectedValue(error),
|
getCustomEmojis: jest.fn().mockRejectedValue(error),
|
||||||
getCustomEmojiImageUrl: jest.fn(),
|
getCustomEmojiImageUrl: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
@ -65,6 +71,9 @@ describe('searchCustomEmojis', () => {
|
||||||
it('should search custom emojis successfully', async () => {
|
it('should search custom emojis successfully', async () => {
|
||||||
const term = 'emoji';
|
const term = 'emoji';
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
apiClient: {
|
||||||
|
baseUrl: serverUrl,
|
||||||
|
},
|
||||||
searchCustomEmoji: jest.fn().mockResolvedValue(mockEmojis),
|
searchCustomEmoji: jest.fn().mockResolvedValue(mockEmojis),
|
||||||
getCustomEmojiImageUrl: jest.fn(),
|
getCustomEmojiImageUrl: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
@ -80,6 +89,9 @@ describe('searchCustomEmojis', () => {
|
||||||
it('should handle error during search custom emojis', async () => {
|
it('should handle error during search custom emojis', async () => {
|
||||||
const term = 'emoji';
|
const term = 'emoji';
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
apiClient: {
|
||||||
|
baseUrl: serverUrl,
|
||||||
|
},
|
||||||
searchCustomEmoji: jest.fn().mockRejectedValue(error),
|
searchCustomEmoji: jest.fn().mockRejectedValue(error),
|
||||||
getCustomEmojiImageUrl: jest.fn(),
|
getCustomEmojiImageUrl: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
@ -99,6 +111,9 @@ describe('searchCustomEmojis', () => {
|
||||||
describe('fetchEmojisByName', () => {
|
describe('fetchEmojisByName', () => {
|
||||||
it('should fetch emojis by name successfully', async () => {
|
it('should fetch emojis by name successfully', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
apiClient: {
|
||||||
|
baseUrl: serverUrl,
|
||||||
|
},
|
||||||
getCustomEmojiByName: jest.fn().mockResolvedValue(emoji),
|
getCustomEmojiByName: jest.fn().mockResolvedValue(emoji),
|
||||||
getCustomEmojiImageUrl: jest.fn(),
|
getCustomEmojiImageUrl: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
@ -112,6 +127,9 @@ describe('fetchEmojisByName', () => {
|
||||||
|
|
||||||
it('should handle no emojis', async () => {
|
it('should handle no emojis', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
apiClient: {
|
||||||
|
baseUrl: serverUrl,
|
||||||
|
},
|
||||||
getCustomEmojiByName: jest.fn().mockRejectedValue('error message'),
|
getCustomEmojiByName: jest.fn().mockRejectedValue('error message'),
|
||||||
getCustomEmojiImageUrl: jest.fn(),
|
getCustomEmojiImageUrl: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {getServerCredentials} from '@init/credentials';
|
import {getServerCredentials} from '@init/credentials';
|
||||||
|
import IntuneManager from '@managers/intune_manager';
|
||||||
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
import SecurityManager from '@managers/security_manager';
|
import SecurityManager from '@managers/security_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
|
|
@ -34,10 +35,10 @@ export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?:
|
||||||
|
|
||||||
const credentials = await getServerCredentials(serverUrl);
|
const credentials = await getServerCredentials(serverUrl);
|
||||||
if (credentials?.token) {
|
if (credentials?.token) {
|
||||||
SecurityManager.addServer(serverUrl, clData.config, true);
|
const intunePolicy = await IntuneManager.getPolicy(serverUrl);
|
||||||
|
SecurityManager.addServer(serverUrl, clData.config, false, intunePolicy);
|
||||||
WebsocketManager.createClient(serverUrl, credentials.token, credentials.preauthSecret);
|
WebsocketManager.createClient(serverUrl, credentials.token, credentials.preauthSecret);
|
||||||
await WebsocketManager.initializeClient(serverUrl, 'Login');
|
await WebsocketManager.initializeClient(serverUrl, 'Login');
|
||||||
SecurityManager.setActiveServer(serverUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
import {createIntl} from 'react-intl';
|
import {createIntl} from 'react-intl';
|
||||||
import {Alert, DeviceEventEmitter, Platform} from 'react-native';
|
import {Alert, DeviceEventEmitter, Platform} from 'react-native';
|
||||||
|
|
||||||
|
import {cancelSessionNotification, findSession} from '@actions/local/session';
|
||||||
import {Events} from '@constants';
|
import {Events} from '@constants';
|
||||||
import {GLOBAL_IDENTIFIERS, SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {GLOBAL_IDENTIFIERS, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
import {logWarning} from '@utils/log';
|
import {logWarning} from '@utils/log';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,11 +19,10 @@ import {
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
cancelSessionNotification,
|
nativeEntraLogin,
|
||||||
scheduleSessionNotification,
|
scheduleSessionNotification,
|
||||||
sendPasswordResetEmail,
|
sendPasswordResetEmail,
|
||||||
ssoLogin,
|
ssoLogin,
|
||||||
findSession,
|
|
||||||
} from './session';
|
} from './session';
|
||||||
|
|
||||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
|
@ -35,7 +36,7 @@ const intl = createIntl({
|
||||||
const serverUrl = 'baseHandler.test.com';
|
const serverUrl = 'baseHandler.test.com';
|
||||||
let operator: ServerDataOperator;
|
let operator: ServerDataOperator;
|
||||||
|
|
||||||
const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile;
|
const user1 = TestHelper.fakeUser({id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''});
|
||||||
|
|
||||||
const session1 = {id: 'sessionid1', user_id: user1.id, device_id: 'deviceid', props: {csrf: 'csrfid'}} as Session;
|
const session1 = {id: 'sessionid1', user_id: user1.id, device_id: 'deviceid', props: {csrf: 'csrfid'}} as Session;
|
||||||
|
|
||||||
|
|
@ -45,6 +46,7 @@ const throwFunc = () => {
|
||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
login: jest.fn(() => user1),
|
login: jest.fn(() => user1),
|
||||||
|
loginByIntune: jest.fn().mockResolvedValue(user1),
|
||||||
setCSRFToken: jest.fn(),
|
setCSRFToken: jest.fn(),
|
||||||
setClientCredentials: jest.fn(),
|
setClientCredentials: jest.fn(),
|
||||||
getClientConfigOld: jest.fn(() => ({})),
|
getClientConfigOld: jest.fn(() => ({})),
|
||||||
|
|
@ -491,3 +493,167 @@ describe('logout', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('nativeEntraLogin', () => {
|
||||||
|
const serverDisplayName = 'Test Server';
|
||||||
|
const serverIdentifier = 'test-server-id';
|
||||||
|
const intuneScope = 'api://test-scope/.default';
|
||||||
|
|
||||||
|
const mockTokens = {
|
||||||
|
accessToken: 'mock-access-token',
|
||||||
|
idToken: 'mock-id-token',
|
||||||
|
identity: {
|
||||||
|
upn: 'test@example.com',
|
||||||
|
tid: 'tenant-id',
|
||||||
|
oid: 'object-id',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let IntuneManager: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Get the mocked IntuneManager
|
||||||
|
IntuneManager = require('@managers/intune_manager').default;
|
||||||
|
|
||||||
|
// Reset all mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Set up default implementations
|
||||||
|
IntuneManager.login.mockResolvedValue(mockTokens);
|
||||||
|
IntuneManager.enrollServer.mockResolvedValue(undefined);
|
||||||
|
IntuneManager.isManagedServer.mockResolvedValue(false);
|
||||||
|
|
||||||
|
mockClient.loginByIntune.mockReset().mockImplementation(() => Promise.resolve(user1));
|
||||||
|
mockGetCSRFFromCookie.mockImplementation(() => Promise.resolve('csrfid'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should successfully login on first try without enrollment', async () => {
|
||||||
|
IntuneManager.isManagedServer.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(false);
|
||||||
|
expect(IntuneManager.login).toHaveBeenCalledWith(serverUrl, [intuneScope]);
|
||||||
|
expect(mockClient.loginByIntune).toHaveBeenCalledWith(mockTokens.accessToken, expect.any(String));
|
||||||
|
expect(mockClient.setCSRFToken).toHaveBeenCalledWith('csrfid');
|
||||||
|
expect(IntuneManager.enrollServer).toHaveBeenCalledWith(serverUrl, mockTokens.identity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 401 token expiration and retry with refreshed token', async () => {
|
||||||
|
const refreshedTokens = {
|
||||||
|
...mockTokens,
|
||||||
|
accessToken: 'refreshed-access-token',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockClient.loginByIntune.mockRejectedValueOnce({status_code: 401} as never).mockResolvedValueOnce(user1);
|
||||||
|
IntuneManager.login.mockResolvedValueOnce(mockTokens).mockResolvedValueOnce(refreshedTokens);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(false);
|
||||||
|
expect(IntuneManager.login).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockClient.loginByIntune).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockClient.loginByIntune).toHaveBeenNthCalledWith(2, refreshedTokens.accessToken, expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 412 MAM enrollment required', async () => {
|
||||||
|
const error = {status_code: 412};
|
||||||
|
mockClient.loginByIntune.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(true);
|
||||||
|
expect(result.error).toEqual(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 400 LDAP user missing error', async () => {
|
||||||
|
const error = {
|
||||||
|
status_code: 400,
|
||||||
|
server_error_id: 'ent.intune.login.ldap_user_missing.app_error',
|
||||||
|
};
|
||||||
|
mockClient.loginByIntune.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(true);
|
||||||
|
expect(result.error).toEqual(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 409 user deactivated error', async () => {
|
||||||
|
const error = {status_code: 409};
|
||||||
|
mockClient.loginByIntune.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(true);
|
||||||
|
expect(result.error).toEqual(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 428 account creation blocked error', async () => {
|
||||||
|
const error = {
|
||||||
|
status_code: 428,
|
||||||
|
server_error_id: 'ent.intune.login.account_creation_blocked.app_error',
|
||||||
|
};
|
||||||
|
mockClient.loginByIntune.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(true);
|
||||||
|
expect(result.error).toEqual(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle MSAL login failure', async () => {
|
||||||
|
const msalError = {
|
||||||
|
domain: 'MSALErrorDomain',
|
||||||
|
code: -50005,
|
||||||
|
message: 'User canceled authentication',
|
||||||
|
};
|
||||||
|
IntuneManager.login.mockRejectedValueOnce(msalError);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(true);
|
||||||
|
expect(result.error).toEqual(msalError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enroll in MAM after successful login if not already managed', async () => {
|
||||||
|
IntuneManager.isManagedServer.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(false);
|
||||||
|
expect(IntuneManager.isManagedServer).toHaveBeenCalledWith(serverUrl);
|
||||||
|
expect(IntuneManager.enrollServer).toHaveBeenCalledWith(serverUrl, mockTokens.identity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip MAM enrollment if already managed', async () => {
|
||||||
|
IntuneManager.isManagedServer.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(false);
|
||||||
|
expect(IntuneManager.isManagedServer).toHaveBeenCalledWith(serverUrl);
|
||||||
|
expect(IntuneManager.enrollServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if post-login MAM enrollment fails', async () => {
|
||||||
|
const enrollmentError = new Error('Enrollment failed');
|
||||||
|
IntuneManager.isManagedServer.mockResolvedValue(false);
|
||||||
|
IntuneManager.enrollServer.mockRejectedValueOnce(enrollmentError);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(true);
|
||||||
|
expect(result.error).toEqual(enrollmentError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle generic server errors', async () => {
|
||||||
|
const error = {status_code: 500, message: 'Internal server error'};
|
||||||
|
mockClient.loginByIntune.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(true);
|
||||||
|
expect(result.error).toEqual(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import NetInfo from '@react-native-community/netinfo';
|
|
||||||
import {defineMessages, type IntlShape} from 'react-intl';
|
import {defineMessages, type IntlShape} from 'react-intl';
|
||||||
import {Alert, DeviceEventEmitter, Platform, type AlertButton} from 'react-native';
|
import {Alert, DeviceEventEmitter, type AlertButton} from 'react-native';
|
||||||
|
|
||||||
|
import {cancelSessionNotification, findSession} from '@actions/local/session';
|
||||||
import {Database, Events} from '@constants';
|
import {Database, Events} from '@constants';
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import PushNotifications from '@init/push_notifications';
|
import IntuneManager from '@managers/intune_manager';
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
import {getDeviceToken} from '@queries/app/global';
|
import {getDeviceToken} from '@queries/app/global';
|
||||||
import {getServerDisplayName} from '@queries/app/servers';
|
import {getServerDisplayName} from '@queries/app/servers';
|
||||||
import {getCurrentUserId, getExpiredSession} from '@queries/servers/system';
|
import {getCurrentUserId} from '@queries/servers/system';
|
||||||
import {getCurrentUser} from '@queries/servers/user';
|
import {getCurrentUser} from '@queries/servers/user';
|
||||||
import {resetToHome} from '@screens/navigation';
|
import {resetToHome} from '@screens/navigation';
|
||||||
import EphemeralStore from '@store/ephemeral_store';
|
import EphemeralStore from '@store/ephemeral_store';
|
||||||
|
|
@ -26,6 +26,7 @@ import {getServerUrlAfterRedirect} from '@utils/url';
|
||||||
|
|
||||||
import {loginEntry} from './entry';
|
import {loginEntry} from './entry';
|
||||||
|
|
||||||
|
import type {Client} from '@client/rest';
|
||||||
import type {LoginArgs} from '@typings/database/database';
|
import type {LoginArgs} from '@typings/database/database';
|
||||||
|
|
||||||
const HTTP_UNAUTHORIZED = 401;
|
const HTTP_UNAUTHORIZED = 401;
|
||||||
|
|
@ -172,6 +173,7 @@ type LogoutOptions = {
|
||||||
removeServer?: boolean;
|
removeServer?: boolean;
|
||||||
skipEvents?: boolean;
|
skipEvents?: boolean;
|
||||||
logoutOnAlert?: boolean;
|
logoutOnAlert?: boolean;
|
||||||
|
skipAlert?: boolean; // Skip showing alert dialog (for automated wipes)
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logout = async (
|
export const logout = async (
|
||||||
|
|
@ -182,6 +184,7 @@ export const logout = async (
|
||||||
removeServer = false,
|
removeServer = false,
|
||||||
skipEvents = false,
|
skipEvents = false,
|
||||||
logoutOnAlert = false,
|
logoutOnAlert = false,
|
||||||
|
skipAlert = false,
|
||||||
}: LogoutOptions = {}) => {
|
}: LogoutOptions = {}) => {
|
||||||
if (!skipServerLogout) {
|
if (!skipServerLogout) {
|
||||||
let loggedOut = false;
|
let loggedOut = false;
|
||||||
|
|
@ -196,7 +199,7 @@ export const logout = async (
|
||||||
logWarning('An error occurred logging out from the server', serverUrl, getFullErrorMessage(error));
|
logWarning('An error occurred logging out from the server', serverUrl, getFullErrorMessage(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!loggedOut) {
|
if (!loggedOut && !skipAlert) {
|
||||||
const title = intl?.formatMessage(logoutMessages.title) || logoutMessages.title.defaultMessage;
|
const title = intl?.formatMessage(logoutMessages.title) || logoutMessages.title.defaultMessage;
|
||||||
|
|
||||||
const bodyMessage = logoutOnAlert ? logoutMessages.bodyForced : logoutMessages.body;
|
const bodyMessage = logoutOnAlert ? logoutMessages.bodyForced : logoutMessages.body;
|
||||||
|
|
@ -232,30 +235,6 @@ export const logout = async (
|
||||||
return {data: true};
|
return {data: true};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelSessionNotification = async (serverUrl: string) => {
|
|
||||||
try {
|
|
||||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
||||||
const expiredSession = await getExpiredSession(database);
|
|
||||||
const rechable = (await NetInfo.fetch()).isInternetReachable;
|
|
||||||
|
|
||||||
if (expiredSession?.notificationId && rechable) {
|
|
||||||
PushNotifications.cancelScheduleNotification(parseInt(expiredSession.notificationId, 10));
|
|
||||||
operator.handleSystem({
|
|
||||||
systems: [{
|
|
||||||
id: SYSTEM_IDENTIFIERS.SESSION_EXPIRATION,
|
|
||||||
value: '',
|
|
||||||
}],
|
|
||||||
prepareRecordsOnly: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
} catch (e) {
|
|
||||||
logError('cancelSessionNotification', e);
|
|
||||||
return {error: e};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const scheduleSessionNotification = async (serverUrl: string) => {
|
export const scheduleSessionNotification = async (serverUrl: string) => {
|
||||||
try {
|
try {
|
||||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
|
@ -303,18 +282,13 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) =
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string, preauthSecret?: string): Promise<LoginActionResponse> => {
|
const completeSSOLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, client: Client, userData?: UserProfile, skipChecks = false): Promise<LoginActionResponse> => {
|
||||||
const database = DatabaseManager.appDatabase?.database;
|
const database = DatabaseManager.appDatabase?.database;
|
||||||
if (!database) {
|
if (!database) {
|
||||||
return {error: 'App database not found', failed: true};
|
return {error: 'App database not found', failed: true};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = NetworkManager.getClient(serverUrl);
|
|
||||||
|
|
||||||
client.setClientCredentials(bearerToken, preauthSecret);
|
|
||||||
client.setCSRFToken(csrfToken);
|
|
||||||
|
|
||||||
// Setting up active database for this SSO login flow
|
// Setting up active database for this SSO login flow
|
||||||
const server = await DatabaseManager.createServerDatabase({
|
const server = await DatabaseManager.createServerDatabase({
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -324,7 +298,9 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
|
||||||
displayName: serverDisplayName,
|
displayName: serverDisplayName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const user = await client.getMe();
|
|
||||||
|
const user = userData || await client.getMe();
|
||||||
|
|
||||||
await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false});
|
await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false});
|
||||||
await server?.operator.handleSystem({
|
await server?.operator.handleSystem({
|
||||||
systems: [{
|
systems: [{
|
||||||
|
|
@ -341,100 +317,100 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
|
||||||
try {
|
try {
|
||||||
await addPushProxyVerificationStateFromLogin(serverUrl);
|
await addPushProxyVerificationStateFromLogin(serverUrl);
|
||||||
const {error} = await loginEntry({serverUrl});
|
const {error} = await loginEntry({serverUrl});
|
||||||
await DatabaseManager.setActiveServerDatabase(serverUrl);
|
await DatabaseManager.setActiveServerDatabase(serverUrl, {
|
||||||
|
skipMAMEnrollmentCheck: skipChecks,
|
||||||
|
skipJailbreakCheck: skipChecks,
|
||||||
|
skipBiometricCheck: skipChecks,
|
||||||
|
});
|
||||||
return {error, failed: false};
|
return {error, failed: false};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {error, failed: false};
|
return {error, failed: false};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ssoLoginWithCodeExchange = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, loginCode: string, samlChallenge: Pick<SAMLChallenge, 'codeVerifier' | 'state'>, preauthSecret?: string): Promise<LoginActionResponse> => {
|
export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string, preauthSecret?: string): Promise<LoginActionResponse> => {
|
||||||
const database = DatabaseManager.appDatabase?.database;
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
if (!database) {
|
|
||||||
return {error: 'App database not found', failed: true};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
client.setClientCredentials(bearerToken, preauthSecret);
|
||||||
|
client.setCSRFToken(csrfToken);
|
||||||
|
|
||||||
|
const result = await completeSSOLogin(serverUrl, serverDisplayName, serverIdentifier, client);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ssoLoginWithCodeExchange = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, loginCode: string, samlChallenge: Pick<SAMLChallenge, 'codeVerifier' | 'state'>, preauthSecret?: string): Promise<LoginActionResponse> => {
|
||||||
const client = NetworkManager.getClient(serverUrl);
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
const {token, csrf} = await client.exchangeSsoLoginCode(loginCode, samlChallenge.codeVerifier, samlChallenge.state);
|
const {token, csrf} = await client.exchangeSsoLoginCode(loginCode, samlChallenge.codeVerifier, samlChallenge.state);
|
||||||
|
|
||||||
client.setClientCredentials(token, preauthSecret);
|
client.setClientCredentials(token, preauthSecret);
|
||||||
client.setCSRFToken(csrf);
|
client.setCSRFToken(csrf);
|
||||||
|
|
||||||
const server = await DatabaseManager.createServerDatabase({
|
const result = await completeSSOLogin(serverUrl, serverDisplayName, serverIdentifier, client);
|
||||||
config: {
|
return result;
|
||||||
dbName: serverUrl,
|
|
||||||
serverUrl,
|
|
||||||
identifier: serverIdentifier,
|
|
||||||
displayName: serverDisplayName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const user = await client.getMe();
|
|
||||||
await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false});
|
|
||||||
await server?.operator.handleSystem({
|
|
||||||
systems: [{
|
|
||||||
id: Database.SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
|
|
||||||
value: user.id,
|
|
||||||
}],
|
|
||||||
prepareRecordsOnly: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logDebug('error on ssoLoginWithCodeExchange', getFullErrorMessage(error));
|
|
||||||
return {error, failed: true};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addPushProxyVerificationStateFromLogin(serverUrl);
|
|
||||||
const {error} = await loginEntry({serverUrl});
|
|
||||||
await DatabaseManager.setActiveServerDatabase(serverUrl);
|
|
||||||
return {error, failed: false};
|
|
||||||
} catch (error) {
|
|
||||||
return {error, failed: false};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function findSession(serverUrl: string, sessions: Session[]) {
|
export const nativeEntraLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, intuneScope: string): Promise<LoginActionResponse> => {
|
||||||
try {
|
try {
|
||||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
// Step 1: Acquire MSAL tokens with IntuneScope
|
||||||
const expiredSession = await getExpiredSession(database);
|
const tokens = await IntuneManager.login(serverUrl, [intuneScope]);
|
||||||
|
const {accessToken, identity} = tokens;
|
||||||
|
|
||||||
|
// Step 2: POST accessToken to /oauth/intune to exchange for session token
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
const deviceToken = await getDeviceToken();
|
const deviceToken = await getDeviceToken();
|
||||||
|
let csrfToken: string;
|
||||||
|
let userData: UserProfile | undefined;
|
||||||
|
|
||||||
// First try and find the session by the given identifier hyqddef7jjdktqiyy36gxa8sqy
|
try {
|
||||||
let session = sessions.find((s) => s.id === expiredSession?.id);
|
userData = await client.loginByIntune(accessToken, deviceToken);
|
||||||
if (session) {
|
csrfToken = await getCSRFFromCookie(serverUrl);
|
||||||
return session;
|
} catch (error) {
|
||||||
|
if (isErrorWithStatusCode(error)) {
|
||||||
|
switch (error.status_code) {
|
||||||
|
case 401: {
|
||||||
|
// Token expired/invalid - try refreshing
|
||||||
|
logDebug('nativeEntraLogin: Token expired, retrying');
|
||||||
|
const refreshedTokens = await IntuneManager.login(serverUrl, [intuneScope]);
|
||||||
|
userData = await client.loginByIntune(refreshedTokens.accessToken, deviceToken);
|
||||||
|
csrfToken = await getCSRFFromCookie(serverUrl);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
// Next try and find the session by deviceId
|
// 400: LDAP user missing
|
||||||
if (deviceToken) {
|
// 409: User locked/disabled
|
||||||
session = sessions.find((s) => s.device_id === deviceToken);
|
// 428: Account creation blocked
|
||||||
if (session) {
|
// All other errors - throw for i18n handling
|
||||||
return session;
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next try and find the session by the CSRF token
|
client.setCSRFToken(csrfToken);
|
||||||
const csrfToken = await getCSRFFromCookie(serverUrl);
|
|
||||||
if (csrfToken) {
|
// Step 3: Complete SSO login flow (sets up database, etc.)
|
||||||
session = sessions.find((s) => s.props?.csrf === csrfToken);
|
const result = await completeSSOLogin(serverUrl, serverDisplayName, serverIdentifier, client, userData, true);
|
||||||
if (session) {
|
|
||||||
return session;
|
// Step 4: Enroll in MAM if not already enrolled (if 412 was not triggered)
|
||||||
|
if (result && !result.failed) {
|
||||||
|
try {
|
||||||
|
const isManaged = await IntuneManager.isManagedServer(serverUrl);
|
||||||
|
if (!isManaged) {
|
||||||
|
await IntuneManager.enrollServer(serverUrl, identity);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logWarning('Intune MAM enrollment failed, MAM protection may not be configured properly', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next try and find the session based on the OS
|
return result;
|
||||||
// if multiple sessions exists with the same os type this can be inaccurate
|
} catch (error) {
|
||||||
session = sessions.find((s) => s.props?.os.toLowerCase() === Platform.OS);
|
logError('nativeEntraLogin failed', error);
|
||||||
if (session) {
|
return {error, failed: true};
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
};
|
||||||
logError('findSession', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// At this point we did not find the session
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getUserLoginType = async (serverUrl: string, loginId: string) => {
|
export const getUserLoginType = async (serverUrl: string, loginId: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,37 @@ describe('ClientUsers', () => {
|
||||||
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, defaultExpectedOptions, false);
|
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, defaultExpectedOptions, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('loginByIntune', async () => {
|
||||||
|
const accessToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...';
|
||||||
|
const deviceId = 'deviceId';
|
||||||
|
const expectedUrl = '/oauth/intune';
|
||||||
|
const expectedOptions = {
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
device_id: deviceId,
|
||||||
|
access_token: accessToken,
|
||||||
|
},
|
||||||
|
headers: {'Cache-Control': 'no-store'},
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.loginByIntune(accessToken, deviceId);
|
||||||
|
|
||||||
|
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions, false);
|
||||||
|
|
||||||
|
// Test with default deviceId
|
||||||
|
const defaultExpectedOptions = {
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
device_id: '',
|
||||||
|
access_token: accessToken,
|
||||||
|
},
|
||||||
|
headers: {'Cache-Control': 'no-store'},
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.loginByIntune(accessToken);
|
||||||
|
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, defaultExpectedOptions, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('logout', async () => {
|
test('logout', async () => {
|
||||||
const expectedUrl = `${client.getUsersRoute()}/logout`;
|
const expectedUrl = `${client.getUsersRoute()}/logout`;
|
||||||
const expectedOptions = {method: 'post'};
|
const expectedOptions = {method: 'post'};
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface ClientUsersMix {
|
||||||
login: (loginId: string, password: string, token?: string, deviceId?: string, ldapOnly?: boolean) => Promise<UserProfile>;
|
login: (loginId: string, password: string, token?: string, deviceId?: string, ldapOnly?: boolean) => Promise<UserProfile>;
|
||||||
loginById: (id: string, password: string, token?: string, deviceId?: string) => Promise<UserProfile>;
|
loginById: (id: string, password: string, token?: string, deviceId?: string) => Promise<UserProfile>;
|
||||||
loginByMagicLinkLogin: (token: string, deviceId?: string) => Promise<UserProfile>;
|
loginByMagicLinkLogin: (token: string, deviceId?: string) => Promise<UserProfile>;
|
||||||
|
loginByIntune: (accessToken: string, deviceId?: string) => Promise<UserProfile>;
|
||||||
logout: () => Promise<any>;
|
logout: () => Promise<any>;
|
||||||
getProfiles: (page?: number, perPage?: number, options?: Record<string, any>) => Promise<UserProfile[]>;
|
getProfiles: (page?: number, perPage?: number, options?: Record<string, any>) => Promise<UserProfile[]>;
|
||||||
getProfilesByIds: (userIds: string[], options?: Record<string, any>, groupLabel?: RequestGroupLabel) => Promise<UserProfile[]>;
|
getProfilesByIds: (userIds: string[], options?: Record<string, any>, groupLabel?: RequestGroupLabel) => Promise<UserProfile[]>;
|
||||||
|
|
@ -144,7 +145,7 @@ const ClientUsers = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
|
||||||
};
|
};
|
||||||
|
|
||||||
loginById = async (id: string, password: string, token = '', deviceId = '') => {
|
loginById = async (id: string, password: string, token = '', deviceId = '') => {
|
||||||
const body: any = {
|
const body = {
|
||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
id,
|
id,
|
||||||
password,
|
password,
|
||||||
|
|
@ -164,6 +165,25 @@ const ClientUsers = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
|
||||||
return resp?.data;
|
return resp?.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
loginByIntune = async (accessToken: string, deviceId = '') => {
|
||||||
|
const body = {
|
||||||
|
device_id: deviceId,
|
||||||
|
access_token: accessToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await this.doFetch(
|
||||||
|
'/oauth/intune',
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
body,
|
||||||
|
headers: {'Cache-Control': 'no-store'},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return resp?.data;
|
||||||
|
};
|
||||||
|
|
||||||
loginByMagicLinkLogin = async (token: string, deviceId = '') => {
|
loginByMagicLinkLogin = async (token: string, deviceId = '') => {
|
||||||
const body = {
|
const body = {
|
||||||
magic_link_token: token,
|
magic_link_token: token,
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,17 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import base64 from 'base-64';
|
import base64 from 'base-64';
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React, {useCallback, useMemo} from 'react';
|
import React, {useCallback, useMemo} from 'react';
|
||||||
import {Text, View} from 'react-native';
|
import {Text, View} from 'react-native';
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
import {SvgXml} from 'react-native-svg';
|
import {SvgXml} from 'react-native-svg';
|
||||||
|
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||||
import {COMMAND_SUGGESTION_ERROR} from '@constants/apps';
|
import {COMMAND_SUGGESTION_ERROR} from '@constants/apps';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
const slashIcon = require('@assets/images/autocomplete/slash_command.png');
|
const slashIcon = require('@assets/images/autocomplete/slash_command.png');
|
||||||
|
|
@ -106,9 +107,10 @@ const SlashSuggestionItem = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
let image = (
|
let image = (
|
||||||
<Image
|
<ExpoImage
|
||||||
style={style.slashIcon}
|
style={style.slashIcon}
|
||||||
source={slashIcon}
|
source={slashIcon}
|
||||||
|
cachePolicy='memory'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (icon === COMMAND_SUGGESTION_ERROR) {
|
if (icon === COMMAND_SUGGESTION_ERROR) {
|
||||||
|
|
@ -120,7 +122,8 @@ const SlashSuggestionItem = ({
|
||||||
);
|
);
|
||||||
} else if (icon.startsWith('http')) {
|
} else if (icon.startsWith('http')) {
|
||||||
image = (
|
image = (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`slash-${urlSafeBase64Encode(iconAsSource.uri)}`}
|
||||||
source={iconAsSource}
|
source={iconAsSource}
|
||||||
style={style.uriIcon}
|
style={style.uriIcon}
|
||||||
/>
|
/>
|
||||||
|
|
@ -142,7 +145,8 @@ const SlashSuggestionItem = ({
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
image = (
|
image = (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`slash-${urlSafeBase64Encode(iconAsSource.uri)}`}
|
||||||
source={iconAsSource}
|
source={iconAsSource}
|
||||||
style={style.uriIcon}
|
style={style.uriIcon}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image, type ImageStyle} from 'expo-image';
|
import {type ImageStyle} from 'expo-image';
|
||||||
import React, {useState, useCallback} from 'react';
|
import React, {useState, useCallback} from 'react';
|
||||||
import {type StyleProp, type TextStyle} from 'react-native';
|
import {type StyleProp, type TextStyle} from 'react-native';
|
||||||
|
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
import Emoji from '@components/emoji';
|
import Emoji from '@components/emoji';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import FileIcon from '@components/files/file_icon';
|
import FileIcon from '@components/files/file_icon';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
|
|
@ -40,8 +42,9 @@ const BookmarkIcon = ({emoji, emojiSize, emojiStyle, file, genericStyle, iconSiz
|
||||||
);
|
);
|
||||||
} else if (imageUrl && !emoji && !hasImageError) {
|
} else if (imageUrl && !emoji && !hasImageError) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<ExpoImage
|
||||||
testID='bookmark-image'
|
testID='bookmark-image'
|
||||||
|
id={`bookmark-image-${urlSafeBase64Encode(imageUrl)}`}
|
||||||
source={{uri: imageUrl}}
|
source={{uri: imageUrl}}
|
||||||
style={imageStyle}
|
style={imageStyle}
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ const ChannelBookmarkOptions = ({
|
||||||
type,
|
type,
|
||||||
lastPictureUpdate: 0,
|
lastPictureUpdate: 0,
|
||||||
uri: '',
|
uri: '',
|
||||||
|
cacheKey: fileInfo.id!,
|
||||||
};
|
};
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ const ChannelBookmarks = ({
|
||||||
|
|
||||||
const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => {
|
const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => {
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
const items = files.map((f) => fileToGalleryItem(f, f.user_id));
|
const items = files.map((f) => fileToGalleryItem(f, f.user_id, undefined, 0, f.id));
|
||||||
openGalleryAtIndex(galleryIdentifier, idx, items);
|
openGalleryAtIndex(galleryIdentifier, idx, items);
|
||||||
}
|
}
|
||||||
}, [files, galleryIdentifier]));
|
}, [files, galleryIdentifier]));
|
||||||
|
|
@ -108,7 +108,9 @@ const ChannelBookmarks = ({
|
||||||
publicLinkEnabled,
|
publicLinkEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const renderItemSeparator = useCallback(() => (<View style={styles.emptyItemSeparator}/>), []);
|
const renderItemSeparator = useCallback(() => (
|
||||||
|
<View style={styles.emptyItemSeparator}/>
|
||||||
|
), [styles.emptyItemSeparator]);
|
||||||
|
|
||||||
const onScrolled = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
const onScrolled = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
setAllowEndFade(isCloseToBottom(e.nativeEvent));
|
setAllowEndFade(isCloseToBottom(e.nativeEvent));
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React, {useMemo} from 'react';
|
import React, {useMemo} from 'react';
|
||||||
import {StyleSheet, View} from 'react-native';
|
import {StyleSheet, View} from 'react-native';
|
||||||
|
|
||||||
import {buildAbsoluteUrl} from '@actions/remote/file';
|
import {buildAbsoluteUrl} from '@actions/remote/file';
|
||||||
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {changeOpacity} from '@utils/theme';
|
import {changeOpacity} from '@utils/theme';
|
||||||
|
import {getLastPictureUpdate} from '@utils/user';
|
||||||
|
|
||||||
import type UserModel from '@typings/database/models/servers/user';
|
import type UserModel from '@typings/database/models/servers/user';
|
||||||
|
|
||||||
|
|
@ -42,7 +43,8 @@ const ProfileAvatar = ({
|
||||||
let picture;
|
let picture;
|
||||||
if (uri) {
|
if (uri) {
|
||||||
picture = (
|
picture = (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`user-${author.id}-${getLastPictureUpdate(author)}`}
|
||||||
source={{uri: buildAbsoluteUrl(serverUrl, uri)}}
|
source={{uri: buildAbsoluteUrl(serverUrl, uri)}}
|
||||||
style={[styles.avatar, styles.avatarRadius]}
|
style={[styles.avatar, styles.avatarRadius]}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||||
import {Image as ExpoImage} from 'expo-image';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Image, Platform, StyleSheet, Text} from 'react-native';
|
import {StyleSheet, Text} from 'react-native';
|
||||||
import {of as of$} from 'rxjs';
|
import {of as of$} from 'rxjs';
|
||||||
import {switchMap} from 'rxjs/operators';
|
import {switchMap} from 'rxjs/operators';
|
||||||
|
|
||||||
import {fetchCustomEmojiInBatch} from '@actions/remote/custom_emoji';
|
import {fetchCustomEmojiInBatch} from '@actions/remote/custom_emoji';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
import {queryCustomEmojisByName} from '@queries/servers/custom_emoji';
|
import {queryCustomEmojisByName} from '@queries/servers/custom_emoji';
|
||||||
|
|
@ -102,35 +102,27 @@ const Emoji = (props: EmojiProps) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Platform.select({
|
return (
|
||||||
ios: (
|
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
|
id={`emoji-${emojiName}`}
|
||||||
source={image}
|
source={image}
|
||||||
style={[commonStyle, imageStyle, {width, height}]}
|
style={[commonStyle, imageStyle, {width, height}]}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
testID={testID}
|
testID={testID}
|
||||||
recyclingKey={key}
|
recyclingKey={key}
|
||||||
|
transition={0}
|
||||||
|
cachePolicy='memory'
|
||||||
/>
|
/>
|
||||||
),
|
);
|
||||||
android: (
|
|
||||||
<Image
|
|
||||||
key={key}
|
|
||||||
source={image}
|
|
||||||
style={[commonStyle, imageStyle, {width, height}]}
|
|
||||||
resizeMode='contain'
|
|
||||||
testID={testID}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Platform.select({
|
return (
|
||||||
ios: (
|
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
|
id={`emoji-${emojiName}`}
|
||||||
style={[commonStyle, imageStyle, {width, height}]}
|
style={[commonStyle, imageStyle, {width, height}]}
|
||||||
source={{uri: imageUrl}}
|
source={{uri: imageUrl}}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
|
|
@ -139,19 +131,9 @@ const Emoji = (props: EmojiProps) => {
|
||||||
cachePolicy='disk'
|
cachePolicy='disk'
|
||||||
placeholder={require('@assets/images/thumb.png')}
|
placeholder={require('@assets/images/thumb.png')}
|
||||||
placeholderContentFit='contain'
|
placeholderContentFit='contain'
|
||||||
|
transition={0}
|
||||||
/>
|
/>
|
||||||
),
|
);
|
||||||
android: (
|
|
||||||
<Image
|
|
||||||
source={{uri: imageUrl, cache: 'force-cache'}}
|
|
||||||
style={[commonStyle, imageStyle, {width, height}]}
|
|
||||||
resizeMode='contain'
|
|
||||||
testID={testID}
|
|
||||||
key={key}
|
|
||||||
defaultSource={require('@assets/images/thumb.png')}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => {
|
const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => {
|
||||||
|
|
|
||||||
130
app/components/expo_image/index.tsx
Normal file
130
app/components/expo_image/index.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {Image, ImageBackground, type ImageBackgroundProps, type ImageProps, type ImageSource} from 'expo-image';
|
||||||
|
import React, {forwardRef, useMemo} from 'react';
|
||||||
|
import Animated from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
|
|
||||||
|
type ExpoImagePropsWithId = ImageProps & {id: string};
|
||||||
|
type ExpoImagePropsMemoryOnly = ImageProps & {cachePolicy: 'memory'; id?: string};
|
||||||
|
type ExpoImageProps = ExpoImagePropsWithId | ExpoImagePropsMemoryOnly;
|
||||||
|
|
||||||
|
type ExpoImageBackgroundPropsWithId = ImageBackgroundProps & {id: string};
|
||||||
|
type ExpoImageBackgroundPropsMemoryOnly = ImageBackgroundProps & {cachePolicy: 'memory'; id?: string};
|
||||||
|
type ExpoImageBackgroundProps = ExpoImageBackgroundPropsWithId | ExpoImageBackgroundPropsMemoryOnly;
|
||||||
|
|
||||||
|
const ExpoImage = forwardRef<Image, ExpoImageProps>(({id, ...props}, ref) => {
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY NOTE: cachePath uses base64 encoding for URL safety, NOT encryption.
|
||||||
|
* Server URLs are not considered sensitive information, and this encoding is purely
|
||||||
|
* for filesystem path compatibility (avoiding special characters in directory names).
|
||||||
|
*/
|
||||||
|
const cachePath = useMemo(() => urlSafeBase64Encode(serverUrl), [serverUrl]);
|
||||||
|
const source: ImageSource = useMemo(() => {
|
||||||
|
if (typeof props.source === 'number') {
|
||||||
|
return props.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add cacheKey and cachePath if id is provided (i.e., not memory-only caching)
|
||||||
|
if (id) {
|
||||||
|
return {
|
||||||
|
...props.source,
|
||||||
|
cacheKey: id,
|
||||||
|
cachePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.source;
|
||||||
|
}, [id, props.source, cachePath]);
|
||||||
|
|
||||||
|
// Process placeholder to add cachePath and cacheKey if it has a uri
|
||||||
|
const placeholder: ImageSource | undefined = useMemo(() => {
|
||||||
|
if (!props.placeholder || typeof props.placeholder === 'number' || typeof props.placeholder === 'string') {
|
||||||
|
return props.placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If placeholder has a uri and id is provided, add cachePath and cacheKey
|
||||||
|
if (props.placeholder.uri && id) {
|
||||||
|
return {
|
||||||
|
...props.placeholder,
|
||||||
|
cacheKey: `${id}-thumb`,
|
||||||
|
cachePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.placeholder;
|
||||||
|
}, [props.placeholder, id, cachePath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
source={source}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ExpoImage.displayName = 'ExpoImage';
|
||||||
|
|
||||||
|
const ExpoImageBackground = ({id, ...props}: ExpoImageBackgroundProps) => {
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
const cachePath = useMemo(() => urlSafeBase64Encode(serverUrl), [serverUrl]);
|
||||||
|
const source: ImageSource = useMemo(() => {
|
||||||
|
if (typeof props.source === 'number') {
|
||||||
|
return props.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add cacheKey and cachePath if id is provided (i.e., not memory-only caching)
|
||||||
|
if (id) {
|
||||||
|
return {
|
||||||
|
...props.source,
|
||||||
|
cacheKey: id,
|
||||||
|
cachePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.source;
|
||||||
|
}, [id, props.source, cachePath]);
|
||||||
|
|
||||||
|
// Process placeholder to add cachePath and cacheKey if it has a uri
|
||||||
|
const placeholder: ImageSource | undefined = useMemo(() => {
|
||||||
|
if (!props.placeholder || typeof props.placeholder === 'number' || typeof props.placeholder === 'string') {
|
||||||
|
return props.placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If placeholder has a uri and id is provided, add cachePath and cacheKey
|
||||||
|
if (props.placeholder.uri && id) {
|
||||||
|
return {
|
||||||
|
...props.placeholder,
|
||||||
|
cacheKey: `${id}-thumb`,
|
||||||
|
cachePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.placeholder;
|
||||||
|
}, [props.placeholder, id, cachePath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImageBackground
|
||||||
|
{...props}
|
||||||
|
source={source}
|
||||||
|
placeholder={placeholder}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</ImageBackground>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExpoImageAnimated = Animated.createAnimatedComponent(ExpoImage);
|
||||||
|
|
||||||
|
export {
|
||||||
|
ExpoImageAnimated,
|
||||||
|
ExpoImageBackground,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpoImage;
|
||||||
|
|
@ -296,6 +296,7 @@ describe('Files', () => {
|
||||||
type: 'image',
|
type: 'image',
|
||||||
uri: file.uri || '',
|
uri: file.uri || '',
|
||||||
width: file.width,
|
width: file.width,
|
||||||
|
cacheKey: file.id || '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {getByTestId} = render(
|
const {getByTestId} = render(
|
||||||
|
|
@ -342,6 +343,7 @@ describe('Files', () => {
|
||||||
type: 'image',
|
type: 'image',
|
||||||
uri: file.uri || '',
|
uri: file.uri || '',
|
||||||
width: file.width,
|
width: file.width,
|
||||||
|
cacheKey: file.id || '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {getByTestId} = render(
|
const {getByTestId} = render(
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ const Files = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => {
|
const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => {
|
||||||
const items = filesForGallery.map((f) => fileToGalleryItem(f, f.user_id, postProps));
|
const items = filesForGallery.map((f) => fileToGalleryItem(f, f.user_id, postProps, 0, f.id));
|
||||||
openGalleryAtIndex(galleryIdentifier, idx, items);
|
openGalleryAtIndex(galleryIdentifier, idx, items);
|
||||||
}, [filesForGallery, galleryIdentifier, postProps]));
|
}, [filesForGallery, galleryIdentifier, postProps]));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {useTheme} from '@context/theme';
|
||||||
import {getServerCredentials} from '@init/credentials';
|
import {getServerCredentials} from '@init/credentials';
|
||||||
import {fileExists} from '@utils/file';
|
import {fileExists} from '@utils/file';
|
||||||
import {calculateDimensions} from '@utils/images';
|
import {calculateDimensions} from '@utils/images';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
import FileIcon from './file_icon';
|
import FileIcon from './file_icon';
|
||||||
|
|
@ -108,7 +109,8 @@ const VideoFile = ({
|
||||||
if (cred?.token) {
|
if (cred?.token) {
|
||||||
headers.Authorization = `Bearer ${cred.token}`;
|
headers.Authorization = `Bearer ${cred.token}`;
|
||||||
}
|
}
|
||||||
const {uri, height, width} = await getThumbnailAsync(data.localPath || videoUrl, {time: 1000, headers});
|
const cachePath = urlSafeBase64Encode(serverUrl);
|
||||||
|
const {uri, height, width} = await getThumbnailAsync(data.localPath || videoUrl, {time: 1000, headers, cachePath});
|
||||||
data.mini_preview = uri;
|
data.mini_preview = uri;
|
||||||
data.height = height;
|
data.height = height;
|
||||||
data.width = width;
|
data.width = width;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
...typography('Body', 200),
|
...typography('Body', 200),
|
||||||
|
lineHeight: undefined,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,10 @@ import {useGalleryItem} from '@hooks/gallery';
|
||||||
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
|
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
|
||||||
import {lookupMimeType} from '@utils/file';
|
import {lookupMimeType} from '@utils/file';
|
||||||
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
||||||
import {generateId} from '@utils/general';
|
|
||||||
import {bottomSheetSnapPoint} from '@utils/helpers';
|
import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
||||||
import {getMarkdownImageSize, removeImageProxyForKey} from '@utils/markdown';
|
import {getMarkdownImageSize, removeImageProxyForKey} from '@utils/markdown';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {secureGetFromRecord} from '@utils/types';
|
import {secureGetFromRecord} from '@utils/types';
|
||||||
import {normalizeProtocol, safeDecodeURIComponent, tryOpenURL} from '@utils/url';
|
import {normalizeProtocol, safeDecodeURIComponent, tryOpenURL} from '@utils/url';
|
||||||
|
|
@ -88,15 +88,16 @@ const MarkdownImage = ({
|
||||||
const isTablet = useIsTablet();
|
const isTablet = useIsTablet();
|
||||||
const style = getStyleSheet(theme);
|
const style = getStyleSheet(theme);
|
||||||
const managedConfig = useManagedConfig<ManagedConfig>();
|
const managedConfig = useManagedConfig<ManagedConfig>();
|
||||||
|
const sourceKey = removeImageProxyForKey(source);
|
||||||
|
|
||||||
// Pattern suggested in https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents
|
// Pattern suggested in https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents
|
||||||
const genericFileRef = useRef<string | null>(null);
|
const genericFileRef = useRef<string | null>(null);
|
||||||
if (genericFileRef.current === null) {
|
if (genericFileRef.current === null) {
|
||||||
genericFileRef.current = generateId('uid');
|
genericFileRef.current = `uid-${urlSafeBase64Encode(sourceKey)}`;
|
||||||
}
|
}
|
||||||
const genericFileId = genericFileRef.current;
|
const genericFileId = genericFileRef.current;
|
||||||
|
|
||||||
const metadata = secureGetFromRecord(imagesMetadata, removeImageProxyForKey(source)) || Object.values(imagesMetadata || {})[0];
|
const metadata = secureGetFromRecord(imagesMetadata, sourceKey) || Object.values(imagesMetadata || {})[0];
|
||||||
const [failed, setFailed] = useState(() => isGifTooLarge(metadata));
|
const [failed, setFailed] = useState(() => isGifTooLarge(metadata));
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
const galleryIdentifier = `${postId}-${genericFileId}-${location}`;
|
const galleryIdentifier = `${postId}-${genericFileId}-${location}`;
|
||||||
|
|
@ -129,7 +130,7 @@ const MarkdownImage = ({
|
||||||
|
|
||||||
const handlePreviewImage = useCallback(() => {
|
const handlePreviewImage = useCallback(() => {
|
||||||
const item: GalleryItemType = {
|
const item: GalleryItemType = {
|
||||||
...fileToGalleryItem(fileInfo),
|
...fileToGalleryItem(fileInfo, undefined, undefined, 0, fileInfo.id),
|
||||||
mime_type: lookupMimeType(fileInfo.name),
|
mime_type: lookupMimeType(fileInfo.name),
|
||||||
type: 'image',
|
type: 'image',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ import {useServerUrl} from '@context/server';
|
||||||
import {useGalleryItem} from '@hooks/gallery';
|
import {useGalleryItem} from '@hooks/gallery';
|
||||||
import {lookupMimeType} from '@utils/file';
|
import {lookupMimeType} from '@utils/file';
|
||||||
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
||||||
import {generateId} from '@utils/general';
|
|
||||||
import {calculateDimensions, isGifTooLarge} from '@utils/images';
|
import {calculateDimensions, isGifTooLarge} from '@utils/images';
|
||||||
import {removeImageProxyForKey} from '@utils/markdown';
|
import {removeImageProxyForKey} from '@utils/markdown';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {secureGetFromRecord} from '@utils/types';
|
import {secureGetFromRecord} from '@utils/types';
|
||||||
import {safeDecodeURIComponent} from '@utils/url';
|
import {safeDecodeURIComponent} from '@utils/url';
|
||||||
|
|
||||||
|
|
@ -48,10 +48,13 @@ const MarkTableImage = ({
|
||||||
}: MarkdownTableImageProps) => {
|
}: MarkdownTableImageProps) => {
|
||||||
const sourceKey = removeImageProxyForKey(source);
|
const sourceKey = removeImageProxyForKey(source);
|
||||||
const metadata = secureGetFromRecord(imagesMetadata, sourceKey);
|
const metadata = secureGetFromRecord(imagesMetadata, sourceKey);
|
||||||
const fileId = useRef(generateId('uid')).current;
|
const fileId = useRef<string | null>(null);
|
||||||
|
if (fileId.current === null) {
|
||||||
|
fileId.current = `uid-${urlSafeBase64Encode(sourceKey)}`;
|
||||||
|
}
|
||||||
const [failed, setFailed] = useState(isGifTooLarge(metadata));
|
const [failed, setFailed] = useState(isGifTooLarge(metadata));
|
||||||
const currentServerUrl = useServerUrl();
|
const currentServerUrl = useServerUrl();
|
||||||
const galleryIdentifier = `${postId}-${fileId}-${location}`;
|
const galleryIdentifier = `${postId}-${fileId.current}-${location}`;
|
||||||
|
|
||||||
const getImageSource = useCallback(() => {
|
const getImageSource = useCallback(() => {
|
||||||
let uri = source;
|
let uri = source;
|
||||||
|
|
@ -83,7 +86,7 @@ const MarkTableImage = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: fileId,
|
id: fileId.current || '',
|
||||||
name: filename,
|
name: filename,
|
||||||
extension,
|
extension,
|
||||||
has_preview_image: true,
|
has_preview_image: true,
|
||||||
|
|
@ -95,7 +98,7 @@ const MarkTableImage = ({
|
||||||
size: 0,
|
size: 0,
|
||||||
user_id: '',
|
user_id: '',
|
||||||
};
|
};
|
||||||
}, [fileId, getImageSource, metadata?.height, metadata?.width, postId]);
|
}, [getImageSource, metadata?.height, metadata?.width, postId]);
|
||||||
|
|
||||||
const handlePreviewImage = useCallback(() => {
|
const handlePreviewImage = useCallback(() => {
|
||||||
const file = getFileInfo();
|
const file = getFileInfo();
|
||||||
|
|
@ -103,7 +106,7 @@ const MarkTableImage = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item: GalleryItemType = {
|
const item: GalleryItemType = {
|
||||||
...fileToGalleryItem(file),
|
...fileToGalleryItem(file, undefined, undefined, 0, file.id),
|
||||||
type: 'image',
|
type: 'image',
|
||||||
};
|
};
|
||||||
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
||||||
|
|
@ -136,7 +139,7 @@ const MarkTableImage = ({
|
||||||
>
|
>
|
||||||
<Animated.View style={[styles, {width, height}]}>
|
<Animated.View style={[styles, {width, height}]}>
|
||||||
<ProgressiveImage
|
<ProgressiveImage
|
||||||
id={fileId}
|
id={fileId.current}
|
||||||
imageUri={source}
|
imageUri={source}
|
||||||
forwardRef={ref}
|
forwardRef={ref}
|
||||||
onError={onLoadFailed}
|
onError={onLoadFailed}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React, {useCallback, useMemo, useState} from 'react';
|
import React, {useCallback, useMemo, useState} from 'react';
|
||||||
|
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {isValidUrl} from '@utils/url';
|
import {isValidUrl} from '@utils/url';
|
||||||
|
|
||||||
|
|
@ -40,7 +41,8 @@ const OptionIcon = ({icon, iconColor, destructive}: OptionIconProps) => {
|
||||||
|
|
||||||
if (isValidUrl(icon) && !failedToLoadImage) {
|
if (isValidUrl(icon) && !failedToLoadImage) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`option-icon-${urlSafeBase64Encode(icon)}`}
|
||||||
source={iconAsSource}
|
source={iconAsSource}
|
||||||
style={styles.icon}
|
style={styles.icon}
|
||||||
onError={onErrorLoadingIcon}
|
onError={onErrorLoadingIcon}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {StyleSheet, TouchableOpacity, View} from 'react-native';
|
import {StyleSheet, TouchableOpacity, View} from 'react-native';
|
||||||
|
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
import {Preferences} from '@constants';
|
import {Preferences} from '@constants';
|
||||||
import {changeOpacity} from '@utils/theme';
|
import {changeOpacity} from '@utils/theme';
|
||||||
|
|
@ -58,9 +58,10 @@ const ScheduledPostTooltip = ({onClose}: Props) => {
|
||||||
testID='scheduled_post_tutorial_tooltip'
|
testID='scheduled_post_tutorial_tooltip'
|
||||||
>
|
>
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<Image
|
<ExpoImage
|
||||||
source={longPressGestureHandLogo}
|
source={longPressGestureHandLogo}
|
||||||
style={styles.image}
|
style={styles.image}
|
||||||
|
cachePolicy='memory'
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.close}
|
style={styles.close}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ function Uploads({
|
||||||
}, [containerHeight, hasFiles]);
|
}, [containerHeight, hasFiles]);
|
||||||
|
|
||||||
const openGallery = useCallback((file: FileInfo) => {
|
const openGallery = useCallback((file: FileInfo) => {
|
||||||
const items = filesForGallery.current.map((f) => fileToGalleryItem(f, currentUserId));
|
const items = filesForGallery.current.map((f) => fileToGalleryItem(f, currentUserId, undefined, 0, f.id || f.clientId));
|
||||||
const index = filesForGallery.current.findIndex((f) => f.clientId === file.clientId);
|
const index = filesForGallery.current.findIndex((f) => f.clientId === file.clientId);
|
||||||
openGalleryAtIndex(galleryIdentifier, index, items, true);
|
openGalleryAtIndex(galleryIdentifier, index, items, true);
|
||||||
}, [currentUserId, galleryIdentifier]);
|
}, [currentUserId, galleryIdentifier]);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React, {useCallback, type ReactNode} from 'react';
|
import React, {useCallback, type ReactNode} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {useIntl} from 'react-intl';
|
||||||
import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native';
|
import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native';
|
||||||
|
|
||||||
import {buildAbsoluteUrl} from '@actions/remote/file';
|
import {buildAbsoluteUrl} from '@actions/remote/file';
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import ProfilePicture from '@components/profile_picture';
|
import ProfilePicture from '@components/profile_picture';
|
||||||
import {View as ViewConstant} from '@constants';
|
import {View as ViewConstant} from '@constants';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
|
|
@ -55,7 +55,8 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}:
|
||||||
if (overrideIconUrl) {
|
if (overrideIconUrl) {
|
||||||
const source = {uri: overrideIconUrl};
|
const source = {uri: overrideIconUrl};
|
||||||
iconComponent = (
|
iconComponent = (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`user-override-icon-${post.id}`}
|
||||||
source={source}
|
source={source}
|
||||||
style={{
|
style={{
|
||||||
height: pictureSize,
|
height: pictureSize,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import useDidUpdate from '@hooks/did_update';
|
||||||
import {useGalleryItem} from '@hooks/gallery';
|
import {useGalleryItem} from '@hooks/gallery';
|
||||||
import {lookupMimeType} from '@utils/file';
|
import {lookupMimeType} from '@utils/file';
|
||||||
import {openGalleryAtIndex} from '@utils/gallery';
|
import {openGalleryAtIndex} from '@utils/gallery';
|
||||||
import {generateId} from '@utils/general';
|
|
||||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity} from '@utils/theme';
|
import {changeOpacity} from '@utils/theme';
|
||||||
import {secureGetFromRecord} from '@utils/types';
|
import {secureGetFromRecord} from '@utils/types';
|
||||||
import {extractFilenameFromUrl, isImageLink, isValidUrl} from '@utils/url';
|
import {extractFilenameFromUrl, isImageLink, isValidUrl} from '@utils/url';
|
||||||
|
|
@ -53,8 +53,11 @@ const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, m
|
||||||
const galleryIdentifier = `${postId}-ImagePreview-${location}`;
|
const galleryIdentifier = `${postId}-ImagePreview-${location}`;
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
const fileId = useRef(generateId('uid')).current;
|
const fileId = useRef<string | null>(null);
|
||||||
const [imageUrl, setImageUrl] = useState(expandedLink || link);
|
const [imageUrl, setImageUrl] = useState(expandedLink || link);
|
||||||
|
if (fileId.current === null) {
|
||||||
|
fileId.current = `uid-${urlSafeBase64Encode(imageUrl)}`;
|
||||||
|
}
|
||||||
const isTablet = useIsTablet();
|
const isTablet = useIsTablet();
|
||||||
const imageProps = secureGetFromRecord(metadata?.images, link);
|
const imageProps = secureGetFromRecord(metadata?.images, link);
|
||||||
const dimensions = calculateDimensions(imageProps?.height, imageProps?.width, layoutWidth || getViewPortWidth(isReplyPost, isTablet));
|
const dimensions = calculateDimensions(imageProps?.height, imageProps?.width, layoutWidth || getViewPortWidth(isReplyPost, isTablet));
|
||||||
|
|
@ -65,7 +68,7 @@ const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, m
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
const item: GalleryItemType = {
|
const item: GalleryItemType = {
|
||||||
id: fileId,
|
id: fileId.current || '',
|
||||||
postId,
|
postId,
|
||||||
uri: imageUrl,
|
uri: imageUrl,
|
||||||
width: imageProps?.width || 0,
|
width: imageProps?.width || 0,
|
||||||
|
|
@ -74,6 +77,7 @@ const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, m
|
||||||
mime_type: lookupMimeType(imageUrl) || 'image/png',
|
mime_type: lookupMimeType(imageUrl) || 'image/png',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
lastPictureUpdate: 0,
|
lastPictureUpdate: 0,
|
||||||
|
cacheKey: fileId.current || '',
|
||||||
};
|
};
|
||||||
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
||||||
};
|
};
|
||||||
|
|
@ -129,7 +133,7 @@ const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, m
|
||||||
<Animated.View testID={`ImagePreview-${fileId}`}>
|
<Animated.View testID={`ImagePreview-${fileId}`}>
|
||||||
<ProgressiveImage
|
<ProgressiveImage
|
||||||
forwardRef={ref}
|
forwardRef={ref}
|
||||||
id={fileId}
|
id={fileId.current}
|
||||||
imageUri={imageUrl}
|
imageUri={imageUrl}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ exports[`AttachmentAuthor it matches snapshot when both name and icon are provid
|
||||||
source={
|
source={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
"cacheKey": "attachment-author-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
|
||||||
|
"cachePath": "",
|
||||||
"uri": "https://images.com/image.png",
|
"uri": "https://images.com/image.png",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -91,6 +93,8 @@ exports[`AttachmentAuthor it matches snapshot when only icon is provided 1`] = `
|
||||||
source={
|
source={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
"cacheKey": "attachment-author-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
|
||||||
|
"cachePath": "",
|
||||||
"uri": "https://images.com/image.png",
|
"uri": "https://images.com/image.png",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ exports[`AttachmentFooter it matches snapshot when both footer and footer_icon a
|
||||||
source={
|
source={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
"cacheKey": "attachment-footer-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
|
||||||
|
"cachePath": "",
|
||||||
"uri": "https://images.com/image.png",
|
"uri": "https://images.com/image.png",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Text, View} from 'react-native';
|
import {Text, View} from 'react-native';
|
||||||
|
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {useExternalLinkHandler} from '@hooks/use_external_link_handler';
|
import {useExternalLinkHandler} from '@hooks/use_external_link_handler';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -41,7 +42,8 @@ const AttachmentAuthor = ({icon, link, name, theme}: Props) => {
|
||||||
return (
|
return (
|
||||||
<View style={style.container}>
|
<View style={style.container}>
|
||||||
{Boolean(icon) &&
|
{Boolean(icon) &&
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`attachment-author-icon-${urlSafeBase64Encode(icon!)}`}
|
||||||
source={{uri: icon}}
|
source={{uri: icon}}
|
||||||
key='author_icon'
|
key='author_icon'
|
||||||
style={style.icon}
|
style={style.icon}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Text, View, Platform} from 'react-native';
|
import {Text, View, Platform} from 'react-native';
|
||||||
|
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -39,7 +40,8 @@ const AttachmentFooter = ({icon, text, theme}: Props) => {
|
||||||
return (
|
return (
|
||||||
<View style={style.container}>
|
<View style={style.container}>
|
||||||
{Boolean(icon) &&
|
{Boolean(icon) &&
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`attachment-footer-icon-${urlSafeBase64Encode(icon!)}`}
|
||||||
source={{uri: icon}}
|
source={{uri: icon}}
|
||||||
key='footer_icon'
|
key='footer_icon'
|
||||||
style={style.icon}
|
style={style.icon}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import {useIsTablet} from '@hooks/device';
|
||||||
import {useGalleryItem} from '@hooks/gallery';
|
import {useGalleryItem} from '@hooks/gallery';
|
||||||
import {lookupMimeType} from '@utils/file';
|
import {lookupMimeType} from '@utils/file';
|
||||||
import {openGalleryAtIndex} from '@utils/gallery';
|
import {openGalleryAtIndex} from '@utils/gallery';
|
||||||
import {generateId} from '@utils/general';
|
|
||||||
import {isGifTooLarge, calculateDimensions, getViewPortWidth} from '@utils/images';
|
import {isGifTooLarge, calculateDimensions, getViewPortWidth} from '@utils/images';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
|
import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
|
||||||
|
|
||||||
|
|
@ -55,7 +55,10 @@ export type Props = {
|
||||||
const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId, theme}: Props) => {
|
const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId, theme}: Props) => {
|
||||||
const galleryIdentifier = `${postId}-AttachmentImage-${location}`;
|
const galleryIdentifier = `${postId}-AttachmentImage-${location}`;
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const fileId = useRef(generateId('uid')).current;
|
const fileId = useRef<string | null>(null);
|
||||||
|
if (fileId.current === null) {
|
||||||
|
fileId.current = `uid-${urlSafeBase64Encode(imageUrl)}`;
|
||||||
|
}
|
||||||
const isTablet = useIsTablet();
|
const isTablet = useIsTablet();
|
||||||
const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, layoutWidth || getViewPortWidth(false, isTablet, true));
|
const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, layoutWidth || getViewPortWidth(false, isTablet, true));
|
||||||
const style = getStyleSheet(theme);
|
const style = getStyleSheet(theme);
|
||||||
|
|
@ -67,7 +70,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
const item: GalleryItemType = {
|
const item: GalleryItemType = {
|
||||||
id: fileId,
|
id: fileId.current || '',
|
||||||
postId,
|
postId,
|
||||||
uri: imageUrl,
|
uri: imageUrl,
|
||||||
width: imageMetadata.width,
|
width: imageMetadata.width,
|
||||||
|
|
@ -76,6 +79,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId
|
||||||
mime_type: lookupMimeType(imageUrl) || 'image/png',
|
mime_type: lookupMimeType(imageUrl) || 'image/png',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
lastPictureUpdate: 0,
|
lastPictureUpdate: 0,
|
||||||
|
cacheKey: fileId.current || '',
|
||||||
};
|
};
|
||||||
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
||||||
};
|
};
|
||||||
|
|
@ -105,7 +109,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId
|
||||||
<Animated.View testID={`attachmentImage-${fileId}`}>
|
<Animated.View testID={`attachmentImage-${fileId}`}>
|
||||||
<ProgressiveImage
|
<ProgressiveImage
|
||||||
forwardRef={ref}
|
forwardRef={ref}
|
||||||
id={fileId}
|
id={fileId.current}
|
||||||
imageStyle={style.attachmentMargin}
|
imageStyle={style.attachmentMargin}
|
||||||
imageUri={imageUrl}
|
imageUri={imageUrl}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {StyleSheet, View} from 'react-native';
|
import {StyleSheet, View} from 'react-native';
|
||||||
|
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
uri: string;
|
uri: string;
|
||||||
}
|
}
|
||||||
|
|
@ -24,7 +26,8 @@ const style = StyleSheet.create({
|
||||||
const AttachmentThumbnail = ({uri}: Props) => {
|
const AttachmentThumbnail = ({uri}: Props) => {
|
||||||
return (
|
return (
|
||||||
<View style={style.container}>
|
<View style={style.container}>
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`attachment-thumbnail-${urlSafeBase64Encode(uri)}`}
|
||||||
source={{uri}}
|
source={{uri}}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
style={style.image}
|
style={style.image}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image, type ImageSource} from 'expo-image';
|
import {type ImageSource} from 'expo-image';
|
||||||
import React, {useMemo, useRef} from 'react';
|
import React, {useMemo, useRef} from 'react';
|
||||||
import {TouchableWithoutFeedback, useWindowDimensions} from 'react-native';
|
import {TouchableWithoutFeedback, useWindowDimensions} from 'react-native';
|
||||||
import Animated from 'react-native-reanimated';
|
import Animated from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {View as ViewConstants} from '@constants';
|
import {View as ViewConstants} from '@constants';
|
||||||
import {GalleryInit} from '@context/gallery';
|
import {GalleryInit} from '@context/gallery';
|
||||||
import {useGalleryItem} from '@hooks/gallery';
|
import {useGalleryItem} from '@hooks/gallery';
|
||||||
import {lookupMimeType} from '@utils/file';
|
import {lookupMimeType} from '@utils/file';
|
||||||
import {openGalleryAtIndex} from '@utils/gallery';
|
import {openGalleryAtIndex} from '@utils/gallery';
|
||||||
import {generateId} from '@utils/general';
|
|
||||||
import {isTablet} from '@utils/helpers';
|
import {isTablet} from '@utils/helpers';
|
||||||
import {calculateDimensions} from '@utils/images';
|
import {calculateDimensions} from '@utils/images';
|
||||||
import {type BestImage, getNearestPoint} from '@utils/opengraph';
|
import {type BestImage, getNearestPoint} from '@utils/opengraph';
|
||||||
|
|
@ -59,7 +59,10 @@ const getViewPostWidth = (isReplyPost: boolean, deviceHeight: number, deviceWidt
|
||||||
};
|
};
|
||||||
|
|
||||||
const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
|
const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
|
||||||
const fileId = useRef(generateId('uid')).current;
|
const fileId = useRef<string | null>(null);
|
||||||
|
if (fileId.current === null) {
|
||||||
|
fileId.current = `uid-opengraph-image-${postId}`;
|
||||||
|
}
|
||||||
const dimensions = useWindowDimensions();
|
const dimensions = useWindowDimensions();
|
||||||
const style = getStyleSheet(theme);
|
const style = getStyleSheet(theme);
|
||||||
const galleryIdentifier = `${postId}-OpenGraphImage-${location}`;
|
const galleryIdentifier = `${postId}-OpenGraphImage-${location}`;
|
||||||
|
|
@ -67,7 +70,7 @@ const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraph
|
||||||
const bestDimensions = useMemo(() => ({
|
const bestDimensions = useMemo(() => ({
|
||||||
height: MAX_IMAGE_HEIGHT,
|
height: MAX_IMAGE_HEIGHT,
|
||||||
width: layoutWidth || getViewPostWidth(isReplyPost, dimensions.height, dimensions.width),
|
width: layoutWidth || getViewPostWidth(isReplyPost, dimensions.height, dimensions.width),
|
||||||
}), [isReplyPost, dimensions]);
|
}), [layoutWidth, isReplyPost, dimensions.height, dimensions.width]);
|
||||||
const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height');
|
const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height');
|
||||||
const imageUrl = (bestImage.secure_url || bestImage.url)!;
|
const imageUrl = (bestImage.secure_url || bestImage.url)!;
|
||||||
const imagesMetadata = metadata?.images;
|
const imagesMetadata = metadata?.images;
|
||||||
|
|
@ -91,7 +94,7 @@ const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraph
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
const item: GalleryItemType = {
|
const item: GalleryItemType = {
|
||||||
id: fileId,
|
id: fileId.current!,
|
||||||
postId,
|
postId,
|
||||||
uri: imageUrl,
|
uri: imageUrl,
|
||||||
width: imageDimensions.width,
|
width: imageDimensions.width,
|
||||||
|
|
@ -100,6 +103,7 @@ const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraph
|
||||||
mime_type: lookupMimeType(imageUrl) || 'image/png',
|
mime_type: lookupMimeType(imageUrl) || 'image/png',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
lastPictureUpdate: 0,
|
lastPictureUpdate: 0,
|
||||||
|
cacheKey: fileId.current!,
|
||||||
};
|
};
|
||||||
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
||||||
};
|
};
|
||||||
|
|
@ -120,13 +124,14 @@ const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraph
|
||||||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||||
<Animated.View style={[styles, style.imageContainer, dimensionsStyle]}>
|
<Animated.View style={[styles, style.imageContainer, dimensionsStyle]}>
|
||||||
<TouchableWithoutFeedback onPress={onGestureEvent}>
|
<TouchableWithoutFeedback onPress={onGestureEvent}>
|
||||||
<Animated.View testID={`OpenGraphImage-${fileId}`}>
|
<Animated.View testID={`OpenGraphImage-${fileId.current}`}>
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={fileId.current}
|
||||||
style={[style.image, dimensionsStyle]}
|
style={[style.image, dimensionsStyle]}
|
||||||
source={source}
|
source={source}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
ref={ref}
|
ref={ref}
|
||||||
nativeID={`OpenGraphImage-${fileId}`}
|
nativeID={`OpenGraphImage-${fileId.current}`}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {ImageBackground} from 'expo-image';
|
|
||||||
import React, {useCallback} from 'react';
|
import React, {useCallback} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {useIntl} from 'react-intl';
|
||||||
import {StyleSheet, TouchableOpacity} from 'react-native';
|
import {StyleSheet, TouchableOpacity} from 'react-native';
|
||||||
|
|
||||||
|
import {ExpoImageBackground} from '@components/expo_image';
|
||||||
import {useIsTablet} from '@hooks/device';
|
import {useIsTablet} from '@hooks/device';
|
||||||
import {calculateDimensions, getViewPortWidth} from '@utils/images';
|
import {calculateDimensions, getViewPortWidth} from '@utils/images';
|
||||||
import {changeOpacity} from '@utils/theme';
|
import {changeOpacity} from '@utils/theme';
|
||||||
|
|
@ -88,13 +88,14 @@ const YouTube = ({isReplyPost, layoutWidth, metadata}: YouTubeProps) => {
|
||||||
style={[styles.imageContainer, {height: dimensions.height, width: dimensions.width}]}
|
style={[styles.imageContainer, {height: dimensions.height, width: dimensions.width}]}
|
||||||
onPress={playYouTubeVideo}
|
onPress={playYouTubeVideo}
|
||||||
>
|
>
|
||||||
<ImageBackground
|
<ExpoImageBackground
|
||||||
|
id={`youtube-${videoId}`}
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
style={[styles.image, dimensions]}
|
style={[styles.image, dimensions]}
|
||||||
source={{uri: imgUrl}}
|
source={{uri: imgUrl}}
|
||||||
>
|
>
|
||||||
<YouTubeLogo style={styles.shadow}/>
|
<YouTubeLogo style={styles.shadow}/>
|
||||||
</ImageBackground>
|
</ExpoImageBackground>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image as ExpoImage, type ImageSource} from 'expo-image';
|
import {type ImageSource} from 'expo-image';
|
||||||
import React, {useMemo} from 'react';
|
import React, {useMemo} from 'react';
|
||||||
import Animated from 'react-native-reanimated';
|
|
||||||
|
|
||||||
import {buildAbsoluteUrl} from '@actions/remote/file';
|
import {buildAbsoluteUrl} from '@actions/remote/file';
|
||||||
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import {ExpoImageAnimated} from '@components/expo_image';
|
||||||
import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile';
|
import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
|
|
@ -25,8 +25,6 @@ type Props = {
|
||||||
url?: string;
|
url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(ExpoImage);
|
|
||||||
|
|
||||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
return {
|
return {
|
||||||
icon: {
|
icon: {
|
||||||
|
|
@ -62,6 +60,13 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
|
||||||
// in the containing object (author).
|
// in the containing object (author).
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [author, serverUrl, source, lastPictureUpdateAt]);
|
}, [author, serverUrl, source, lastPictureUpdateAt]);
|
||||||
|
const id = useMemo(() => {
|
||||||
|
if (author) {
|
||||||
|
return `user-${author.id}-${lastPictureUpdateAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [author, lastPictureUpdateAt]);
|
||||||
|
|
||||||
if (typeof source === 'string') {
|
if (typeof source === 'string') {
|
||||||
return (
|
return (
|
||||||
|
|
@ -75,8 +80,9 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
|
||||||
|
|
||||||
if (imgSource?.uri?.startsWith('file://')) {
|
if (imgSource?.uri?.startsWith('file://')) {
|
||||||
return (
|
return (
|
||||||
<AnimatedImage
|
<ExpoImageAnimated
|
||||||
key={imgSource.uri}
|
id={id}
|
||||||
|
key={id}
|
||||||
ref={forwardRef}
|
ref={forwardRef}
|
||||||
style={fIStyle}
|
style={fIStyle}
|
||||||
source={{uri: imgSource.uri}}
|
source={{uri: imgSource.uri}}
|
||||||
|
|
@ -86,8 +92,9 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
|
||||||
|
|
||||||
if (imgSource) {
|
if (imgSource) {
|
||||||
return (
|
return (
|
||||||
<AnimatedImage
|
<ExpoImageAnimated
|
||||||
key={imgSource.uri}
|
id={id}
|
||||||
|
key={id}
|
||||||
ref={forwardRef}
|
ref={forwardRef}
|
||||||
style={fIStyle}
|
style={fIStyle}
|
||||||
source={imgSource}
|
source={imgSource}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image, ImageBackground, type ImageContentFit, type ImageStyle} from 'expo-image';
|
import {type ImageContentFit, type ImageStyle} from 'expo-image';
|
||||||
import React, {type ReactNode, useEffect, useState} from 'react';
|
import React, {type ReactNode, useEffect, useState} from 'react';
|
||||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native';
|
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native';
|
||||||
import Animated from 'react-native-reanimated';
|
import Animated from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import ExpoImage, {ExpoImageAnimated, ExpoImageBackground} from '@components/expo_image';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
|
||||||
|
|
||||||
type Props = ProgressiveImageProps & {
|
type Props = ProgressiveImageProps & {
|
||||||
children?: ReactNode | ReactNode[];
|
children?: ReactNode | ReactNode[];
|
||||||
forwardRef?: React.RefObject<any>;
|
forwardRef?: React.RefObject<any>;
|
||||||
|
|
@ -65,14 +64,14 @@ const ProgressiveImage = ({
|
||||||
if (isBackgroundImage && imageUri) {
|
if (isBackgroundImage && imageUri) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.defaultImageContainer, style]}>
|
<View style={[styles.defaultImageContainer, style]}>
|
||||||
<ImageBackground
|
<ExpoImageBackground
|
||||||
key={id}
|
id={id}
|
||||||
source={{uri: imageUri}}
|
source={{uri: imageUri}}
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
style={[StyleSheet.absoluteFill, imageStyle as StyleProp<ViewStyle>]}
|
style={[StyleSheet.absoluteFill, imageStyle as StyleProp<ViewStyle>]}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ImageBackground>
|
</ExpoImageBackground>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +79,8 @@ const ProgressiveImage = ({
|
||||||
if (defaultSource) {
|
if (defaultSource) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.defaultImageContainer, style]}>
|
<View style={[styles.defaultImageContainer, style]}>
|
||||||
<AnimatedImage
|
<ExpoImageAnimated
|
||||||
|
id={id}
|
||||||
ref={forwardRef}
|
ref={forwardRef}
|
||||||
source={defaultSource}
|
source={defaultSource}
|
||||||
style={[
|
style={[
|
||||||
|
|
@ -101,7 +101,8 @@ const ProgressiveImage = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.defaultImageContainer, style]}>
|
<Animated.View style={[styles.defaultImageContainer, style]}>
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={id}
|
||||||
ref={forwardRef}
|
ref={forwardRef}
|
||||||
placeholder={{uri: thumbnailUri}}
|
placeholder={{uri: thumbnailUri}}
|
||||||
placeholderContentFit='cover'
|
placeholderContentFit='cover'
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image, type ImageSource, type ImageStyle} from 'expo-image';
|
import {type ImageSource, type ImageStyle} from 'expo-image';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {type StyleProp, Text, type TextStyle, TouchableHighlight, View, type ViewStyle} from 'react-native';
|
import {type StyleProp, Text, type TextStyle, TouchableHighlight, View, type ViewStyle} from 'react-native';
|
||||||
|
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {usePreventDoubleTap} from '@hooks/utils';
|
import {usePreventDoubleTap} from '@hooks/utils';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {typography} from '@utils/typography';
|
import {typography} from '@utils/typography';
|
||||||
import {isValidUrl} from '@utils/url';
|
import {isValidUrl} from '@utils/url';
|
||||||
|
|
@ -122,7 +124,8 @@ const useImageAndStyle = (icon: string | ImageSource | undefined, imageStyles: S
|
||||||
const imageStyle: StyleProp<ImageStyle> = [imageStyles];
|
const imageStyle: StyleProp<ImageStyle> = [imageStyles];
|
||||||
imageStyle.push({width: 24, height: 24});
|
imageStyle.push({width: 24, height: 24});
|
||||||
image = (
|
image = (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`slide-up-panel-item-${urlSafeBase64Encode(icon.uri)}`}
|
||||||
source={icon}
|
source={icon}
|
||||||
style={imageStyle}
|
style={imageStyle}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {View, Text, type StyleProp, type TextStyle} from 'react-native';
|
import {View, Text, type StyleProp, type TextStyle} from 'react-native';
|
||||||
|
|
||||||
import {buildAbsoluteUrl} from '@actions/remote/file';
|
import {buildAbsoluteUrl} from '@actions/remote/file';
|
||||||
import {buildTeamIconUrl} from '@actions/remote/team';
|
import {buildTeamIconUrl} from '@actions/remote/team';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
|
@ -124,7 +124,8 @@ export default function TeamIcon({
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
teamIconContent = (
|
teamIconContent = (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`team-icon-${id}-${lastIconUpdate}`}
|
||||||
style={styles.image}
|
style={styles.image}
|
||||||
source={{uri: buildAbsoluteUrl(serverUrl, buildTeamIconUrl(serverUrl, id, lastIconUpdate))}}
|
source={{uri: buildAbsoluteUrl(serverUrl, buildTeamIconUrl(serverUrl, id, lastIconUpdate))}}
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React, {useMemo} from 'react';
|
import React, {useMemo} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {useIntl} from 'react-intl';
|
||||||
import {TouchableWithoutFeedback, View, Text, type ViewStyle} from 'react-native';
|
import {TouchableWithoutFeedback, View, Text, type ViewStyle} from 'react-native';
|
||||||
import Animated from 'react-native-reanimated';
|
import Animated from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import FileIcon from '@components/files/file_icon';
|
import FileIcon from '@components/files/file_icon';
|
||||||
import ImageFile from '@components/files/image_file';
|
import ImageFile from '@components/files/image_file';
|
||||||
import UploadRetry from '@components/post_draft/uploads/upload_item/upload_retry';
|
import UploadRetry from '@components/post_draft/uploads/upload_item/upload_retry';
|
||||||
|
|
@ -210,10 +210,11 @@ export default function UploadItemShared({
|
||||||
if (isShareExtension) {
|
if (isShareExtension) {
|
||||||
return (
|
return (
|
||||||
<View style={style.imageOnlyThumbnail}>
|
<View style={style.imageOnlyThumbnail}>
|
||||||
<Image
|
<ExpoImage
|
||||||
source={{uri: file.uri}}
|
source={{uri: file.uri}}
|
||||||
style={style.imageOnlyImage}
|
style={style.imageOnlyImage}
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
|
cachePolicy='memory'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -237,7 +238,7 @@ export default function UploadItemShared({
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, [file.uri, imageFileData, forwardRef, inViewPort, isImageFile, isShareExtension, style.imageOnlyThumbnail, style.imageOnlyImage, style.iconContainer, fileForCheck]);
|
}, [isImageFile, style.iconContainer, style.imageOnlyThumbnail, style.imageOnlyImage, fileForCheck, isShareExtension, imageFileData, forwardRef, inViewPort, file.uri]);
|
||||||
|
|
||||||
const fileExtension = file.extension?.toUpperCase() || file.name?.split('.').pop()?.toUpperCase() || '';
|
const fileExtension = file.extension?.toUpperCase() || file.name?.split('.').pop()?.toUpperCase() || '';
|
||||||
const formattedSize = getFormattedFileSize(file.size || 0);
|
const formattedSize = getFormattedFileSize(file.size || 0);
|
||||||
|
|
|
||||||
|
|
@ -807,6 +807,8 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
|
||||||
source={
|
source={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
"cacheKey": "user-1-123456",
|
||||||
|
"cachePath": "",
|
||||||
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
|
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -1148,6 +1150,8 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
|
||||||
source={
|
source={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
"cacheKey": "user-1-123456",
|
||||||
|
"cachePath": "",
|
||||||
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
|
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -1527,6 +1531,8 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
|
||||||
source={
|
source={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
"cacheKey": "user-1-123456",
|
||||||
|
"cachePath": "",
|
||||||
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
|
"uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -1767,6 +1773,8 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
|
||||||
source={
|
source={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
"cacheKey": "user-2-123456",
|
||||||
|
"cachePath": "",
|
||||||
"uri": "https://community.mattermost.com/api/v4/users/2/image?_=123456",
|
"uri": "https://community.mattermost.com/api/v4/users/2/image?_=123456",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React, {type ComponentProps} from 'react';
|
import React, {type ComponentProps} from 'react';
|
||||||
import {Image} from 'react-native';
|
|
||||||
|
|
||||||
import {Screens} from '@constants';
|
import {Screens} from '@constants';
|
||||||
import {Ringtone} from '@constants/calls';
|
import {Ringtone} from '@constants/calls';
|
||||||
|
|
@ -93,20 +92,11 @@ describe('components/channel_list_row', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const originalResolveAssetSource = Image.resolveAssetSource;
|
// const originalResolveAssetSource = Image.resolveAssetSource;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const server = await TestHelper.setupServerDatabase();
|
const server = await TestHelper.setupServerDatabase();
|
||||||
database = server.database;
|
database = server.database;
|
||||||
|
|
||||||
// This is needed to properly populate the URLs until
|
|
||||||
// https://github.com/facebook/react-native/pull/43497
|
|
||||||
// gets into React Native Jest code.
|
|
||||||
Image.resolveAssetSource = jest.fn().mockImplementation((source) => source);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Image.resolveAssetSource = originalResolveAssetSource;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function getBaseProps(): ComponentProps<typeof UserList> {
|
function getBaseProps(): ComponentProps<typeof UserList> {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ export const GLOBAL_IDENTIFIERS = {
|
||||||
LAST_VIEWED_CHANNEL: 'lastViewedChannel',
|
LAST_VIEWED_CHANNEL: 'lastViewedChannel',
|
||||||
LAST_VIEWED_THREAD: 'lastViewedThread',
|
LAST_VIEWED_THREAD: 'lastViewedThread',
|
||||||
PUSH_DISABLED_ACK: 'pushDisabledAck',
|
PUSH_DISABLED_ACK: 'pushDisabledAck',
|
||||||
|
CACHE_MIGRATION: 'cacheMigration',
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum OperationType {
|
export enum OperationType {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {cacheDirectory} from 'expo-file-system';
|
|
||||||
|
|
||||||
import {isTablet} from '@utils/helpers';
|
import {isTablet} from '@utils/helpers';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
DOCUMENTS_PATH: `${cacheDirectory}/Documents`,
|
|
||||||
IS_TABLET: isTablet(),
|
IS_TABLET: isTablet(),
|
||||||
PUSH_NOTIFY_ANDROID_REACT_NATIVE: 'android_rn',
|
PUSH_NOTIFY_ANDROID_REACT_NATIVE: 'android_rn',
|
||||||
PUSH_NOTIFY_APPLE_REACT_NATIVE: 'apple_rn',
|
PUSH_NOTIFY_APPLE_REACT_NATIVE: 'apple_rn',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export default keyMirror({
|
||||||
CLOSE_BOTTOM_SHEET: null,
|
CLOSE_BOTTOM_SHEET: null,
|
||||||
CLOSE_GALLERY: null,
|
CLOSE_GALLERY: null,
|
||||||
CONFIG_CHANGED: null,
|
CONFIG_CHANGED: null,
|
||||||
|
LICENSE_CHANGED: null,
|
||||||
FREEZE_SCREEN: null,
|
FREEZE_SCREEN: null,
|
||||||
GALLERY_ACTIONS: null,
|
GALLERY_ACTIONS: null,
|
||||||
LEAVE_CHANNEL: null,
|
LEAVE_CHANNEL: null,
|
||||||
|
|
@ -34,5 +35,6 @@ export default keyMirror({
|
||||||
JOIN_CALL_BAR_VISIBLE: null,
|
JOIN_CALL_BAR_VISIBLE: null,
|
||||||
DRAFT_SWIPEABLE: null,
|
DRAFT_SWIPEABLE: null,
|
||||||
ACTIVE_SCREEN: null,
|
ACTIVE_SCREEN: null,
|
||||||
|
ACTIVE_SERVER_CHANGED: null,
|
||||||
FILE_ADD_REMOVED: null,
|
FILE_ADD_REMOVED: null,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import logger from '@nozbe/watermelondb/utils/common/logger';
|
||||||
import {deleteAsync} from 'expo-file-system';
|
import {deleteAsync} from 'expo-file-system';
|
||||||
import {DeviceEventEmitter, Platform} from 'react-native';
|
import {DeviceEventEmitter, Platform} from 'react-native';
|
||||||
|
|
||||||
|
import {Events} from '@constants';
|
||||||
import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
|
import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
|
||||||
import AppDatabaseMigrations from '@database/migration/app';
|
import AppDatabaseMigrations from '@database/migration/app';
|
||||||
import ServerDatabaseMigrations from '@database/migration/server';
|
import ServerDatabaseMigrations from '@database/migration/server';
|
||||||
|
|
@ -243,15 +244,16 @@ class DatabaseManagerSingleton {
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
public setActiveServerDatabase = async (serverUrl: string): Promise<void> => {
|
public setActiveServerDatabase = async (serverUrl: string, options?: ActiveServerOptions): Promise<void> => {
|
||||||
if (this.appDatabase?.database) {
|
if (this.appDatabase?.database) {
|
||||||
const database = this.appDatabase?.database;
|
const database = this.appDatabase?.database;
|
||||||
await database.write(async () => {
|
await database.write(async () => {
|
||||||
const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch();
|
const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch();
|
||||||
if (servers.length) {
|
if (servers.length) {
|
||||||
servers[0].update((server: ServersModel) => {
|
await servers[0].update((server: ServersModel) => {
|
||||||
server.lastActiveAt = Date.now();
|
server.lastActiveAt = Date.now();
|
||||||
});
|
});
|
||||||
|
DeviceEventEmitter.emit(Events.ACTIVE_SERVER_CHANGED, {serverUrl, options});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application';
|
||||||
import {deleteAsync, documentDirectory, getInfoAsync, makeDirectoryAsync, moveAsync} from 'expo-file-system';
|
import {deleteAsync, documentDirectory, getInfoAsync, makeDirectoryAsync, moveAsync} from 'expo-file-system';
|
||||||
import {DeviceEventEmitter, Platform} from 'react-native';
|
import {DeviceEventEmitter, Platform} from 'react-native';
|
||||||
|
|
||||||
|
import {Events} from '@constants';
|
||||||
import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
|
import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
|
||||||
import AppDatabaseMigrations from '@database/migration/app';
|
import AppDatabaseMigrations from '@database/migration/app';
|
||||||
import ServerDatabaseMigrations from '@database/migration/server';
|
import ServerDatabaseMigrations from '@database/migration/server';
|
||||||
|
|
@ -334,15 +335,16 @@ class DatabaseManagerSingleton {
|
||||||
* @param {string} serverUrl
|
* @param {string} serverUrl
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public setActiveServerDatabase = async (serverUrl: string): Promise<void> => {
|
public setActiveServerDatabase = async (serverUrl: string, options?: ActiveServerOptions): Promise<void> => {
|
||||||
if (this.appDatabase?.database) {
|
if (this.appDatabase?.database) {
|
||||||
const database = this.appDatabase?.database;
|
const database = this.appDatabase?.database;
|
||||||
await database.write(async () => {
|
await database.write(async () => {
|
||||||
const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch();
|
const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch();
|
||||||
if (servers.length) {
|
if (servers.length) {
|
||||||
servers[0].update((server: ServersModel) => {
|
await servers[0].update((server: ServersModel) => {
|
||||||
server.lastActiveAt = Date.now();
|
server.lastActiveAt = Date.now();
|
||||||
});
|
});
|
||||||
|
DeviceEventEmitter.emit(Events.ACTIVE_SERVER_CHANGED, {serverUrl, options});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[],
|
||||||
|
|
||||||
let {width, height} = fileInfo;
|
let {width, height} = fileInfo;
|
||||||
if (imageFile && !width) {
|
if (imageFile && !width) {
|
||||||
const size = await getImageSize(uri);
|
const size = await getImageSize(serverUrl, uri, b.fileId);
|
||||||
width = size.width;
|
width = size.width;
|
||||||
height = size.height;
|
height = size.height;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import ManagedApp from '@init/managed_app';
|
||||||
import PushNotifications from '@init/push_notifications';
|
import PushNotifications from '@init/push_notifications';
|
||||||
import GlobalEventHandler from '@managers/global_event_handler';
|
import GlobalEventHandler from '@managers/global_event_handler';
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import SecurityManager from '@managers/security_manager';
|
||||||
import SessionManager from '@managers/session_manager';
|
import SessionManager from '@managers/session_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
import {registerScreens} from '@screens/index';
|
import {registerScreens} from '@screens/index';
|
||||||
|
|
@ -44,6 +45,7 @@ export async function initialize() {
|
||||||
|
|
||||||
await DatabaseManager.init(serverUrls);
|
await DatabaseManager.init(serverUrls);
|
||||||
await NetworkManager.init(serverCredentials);
|
await NetworkManager.init(serverCredentials);
|
||||||
|
await SecurityManager.init();
|
||||||
|
|
||||||
GlobalEventHandler.init();
|
GlobalEventHandler.init();
|
||||||
ManagedApp.init();
|
ManagedApp.init();
|
||||||
|
|
|
||||||
387
app/managers/intune_manager/index.test.ts
Normal file
387
app/managers/intune_manager/index.test.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
// Unmock IntuneManager for this test file since we're testing it
|
||||||
|
jest.unmock('@managers/intune_manager');
|
||||||
|
|
||||||
|
import {Platform, type EventSubscription} from 'react-native';
|
||||||
|
|
||||||
|
import {License} from '@constants';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {getConfig, getLicense} from '@queries/servers/system';
|
||||||
|
import {isMinimumLicenseTier} from '@utils/helpers';
|
||||||
|
|
||||||
|
import IntuneManager from '.';
|
||||||
|
|
||||||
|
import type {MSALIdentity, MSALTokens, IntunePolicy} from './types';
|
||||||
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
import type {Database} from '@nozbe/watermelondb';
|
||||||
|
|
||||||
|
jest.mock('@database/manager', () => ({
|
||||||
|
getServerDatabaseAndOperator: jest.fn(),
|
||||||
|
getActiveServerUrl: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@queries/servers/system');
|
||||||
|
jest.mock('@utils/alerts');
|
||||||
|
jest.mock('@utils/helpers');
|
||||||
|
jest.mock('@mattermost/react-native-emm');
|
||||||
|
jest.mock('@managers/security_manager', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedGetConfig = jest.mocked(getConfig);
|
||||||
|
const mockedGetLicense = jest.mocked(getLicense);
|
||||||
|
const mockedIsMinimumLicenseTier = jest.mocked(isMinimumLicenseTier);
|
||||||
|
|
||||||
|
// Get mocked Intune from the global mock
|
||||||
|
const Intune = jest.requireMock<{default: any}>('@mattermost/intune').default;
|
||||||
|
|
||||||
|
describe('IntuneManager', () => {
|
||||||
|
const serverUrl = 'https://example.com';
|
||||||
|
const mockDatabase = {} as unknown as Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
Platform.OS = 'ios';
|
||||||
|
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({
|
||||||
|
database: mockDatabase,
|
||||||
|
operator: {} as ServerDataOperator,
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isIntuneMAMEnabledForServer', () => {
|
||||||
|
it('should return false when database does not exist', async () => {
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => {
|
||||||
|
throw new Error('Database not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when license is below EnterpriseAdvanced tier', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(mockedIsMinimumLicenseTier).toHaveBeenCalledWith({}, License.SKU_SHORT_NAME.EnterpriseAdvanced);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when IntuneMAMEnabled is false', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'false', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when IntuneScope is missing', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneAuthService: 'office365'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when IntuneAuthService is missing', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when all conditions are met', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
const scopes = ['User.Read', 'custom.scope'];
|
||||||
|
const mockTokens: MSALTokens = {
|
||||||
|
idToken: 'id_token',
|
||||||
|
accessToken: 'access_token',
|
||||||
|
identity: {upn: 'user@example.com', tid: 'tenant_id', oid: 'object_id'},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return tokens on successful login', async () => {
|
||||||
|
jest.mocked(Intune.login).mockResolvedValue(mockTokens);
|
||||||
|
|
||||||
|
const result = await IntuneManager.login(serverUrl, scopes);
|
||||||
|
expect(result).toEqual(mockTokens);
|
||||||
|
expect(jest.mocked(Intune.login)).toHaveBeenCalledWith(serverUrl, scopes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error on login failure', async () => {
|
||||||
|
const error = new Error('Login failed');
|
||||||
|
jest.mocked(Intune.login).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(IntuneManager.login(serverUrl, scopes)).rejects.toThrow('Login failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enrollServer', () => {
|
||||||
|
const mockIdentity: MSALIdentity = {
|
||||||
|
upn: 'user@example.com',
|
||||||
|
tid: 'tenant_id',
|
||||||
|
oid: 'object_id',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should call enrollInMAM on successful enrollment', async () => {
|
||||||
|
jest.mocked(Intune.enrollInMAM).mockResolvedValue();
|
||||||
|
|
||||||
|
await IntuneManager.enrollServer(serverUrl, mockIdentity);
|
||||||
|
expect(jest.mocked(Intune.enrollInMAM)).toHaveBeenCalledWith(serverUrl, mockIdentity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error on enrollment failure', async () => {
|
||||||
|
const error = new Error('Enrollment failed');
|
||||||
|
jest.mocked(Intune.enrollInMAM).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(IntuneManager.enrollServer(serverUrl, mockIdentity)).rejects.toThrow('Enrollment failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unenrollServer', () => {
|
||||||
|
it('should skip unenrollment when server not enrolled', async () => {
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(false);
|
||||||
|
|
||||||
|
await IntuneManager.unenrollServer(serverUrl, false);
|
||||||
|
expect(jest.mocked(Intune.deregisterAndUnenroll)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear current identity when unenrolling active server', async () => {
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(Intune.setCurrentIdentity).mockResolvedValue();
|
||||||
|
jest.mocked(Intune.deregisterAndUnenroll).mockResolvedValue();
|
||||||
|
|
||||||
|
await IntuneManager.unenrollServer(serverUrl, false);
|
||||||
|
expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(null);
|
||||||
|
expect(jest.mocked(Intune.deregisterAndUnenroll)).toHaveBeenCalledWith(serverUrl, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not clear identity when unenrolling non-active server', async () => {
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue('https://other.com');
|
||||||
|
jest.mocked(Intune.deregisterAndUnenroll).mockResolvedValue();
|
||||||
|
|
||||||
|
await IntuneManager.unenrollServer(serverUrl, false);
|
||||||
|
expect(jest.mocked(Intune.setCurrentIdentity)).not.toHaveBeenCalled();
|
||||||
|
expect(jest.mocked(Intune.deregisterAndUnenroll)).toHaveBeenCalledWith(serverUrl, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call deregisterAndUnenroll with doWipe = true', async () => {
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(Intune.deregisterAndUnenroll).mockResolvedValue();
|
||||||
|
|
||||||
|
await IntuneManager.unenrollServer(serverUrl, true);
|
||||||
|
expect(jest.mocked(Intune.deregisterAndUnenroll)).toHaveBeenCalledWith(serverUrl, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should catch and log errors on unenrollment failure', async () => {
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(Intune.deregisterAndUnenroll).mockRejectedValue(new Error('Unenroll failed'));
|
||||||
|
|
||||||
|
await IntuneManager.unenrollServer(serverUrl, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isManagedServer', () => {
|
||||||
|
it('should return true when server is managed', async () => {
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await IntuneManager.isManagedServer(serverUrl);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(jest.mocked(Intune.isManagedServer)).toHaveBeenCalledWith(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when server is not managed', async () => {
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await IntuneManager.isManagedServer(serverUrl);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false on error', async () => {
|
||||||
|
jest.mocked(Intune.isManagedServer).mockRejectedValue(new Error('Check failed'));
|
||||||
|
|
||||||
|
const result = await IntuneManager.isManagedServer(serverUrl);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setCurrentIdentity', () => {
|
||||||
|
it('should set null identity', async () => {
|
||||||
|
jest.mocked(Intune.setCurrentIdentity).mockResolvedValue();
|
||||||
|
|
||||||
|
await IntuneManager.setCurrentIdentity(null);
|
||||||
|
expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set identity when server is licensed', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(true);
|
||||||
|
jest.mocked(Intune.setCurrentIdentity).mockResolvedValue();
|
||||||
|
|
||||||
|
await IntuneManager.setCurrentIdentity(serverUrl);
|
||||||
|
expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear identity when server is not licensed', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'false'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(false);
|
||||||
|
jest.mocked(Intune.setCurrentIdentity).mockResolvedValue();
|
||||||
|
|
||||||
|
await IntuneManager.setCurrentIdentity(serverUrl);
|
||||||
|
expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPolicy', () => {
|
||||||
|
const mockPolicy: IntunePolicy = {
|
||||||
|
isPINRequired: true,
|
||||||
|
isContactSyncAllowed: false,
|
||||||
|
isWidgetContentSyncAllowed: false,
|
||||||
|
isSpotlightIndexingAllowed: false,
|
||||||
|
areSiriIntentsAllowed: false,
|
||||||
|
areAppIntentsAllowed: false,
|
||||||
|
isAppSharingAllowed: false,
|
||||||
|
isManagedBrowserRequired: false,
|
||||||
|
isFileEncryptionRequired: false,
|
||||||
|
isScreenCaptureAllowed: false,
|
||||||
|
shouldFileProviderEncryptFiles: false,
|
||||||
|
notificationPolicy: 0,
|
||||||
|
allowedSaveLocations: {
|
||||||
|
Other: false,
|
||||||
|
OneDriveForBusiness: true,
|
||||||
|
SharePoint: true,
|
||||||
|
LocalDrive: false,
|
||||||
|
PhotoLibrary: false,
|
||||||
|
CameraRoll: false,
|
||||||
|
FilesApp: false,
|
||||||
|
iCloudDrive: false,
|
||||||
|
},
|
||||||
|
allowedOpenLocations: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return null when Intune not enabled for server', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'false'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await IntuneManager.getPolicy(serverUrl);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when server not managed', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(true);
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await IntuneManager.getPolicy(serverUrl);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return policy when server is managed', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'saml'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(true);
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(Intune.getPolicy).mockResolvedValue(mockPolicy);
|
||||||
|
|
||||||
|
const result = await IntuneManager.getPolicy(serverUrl);
|
||||||
|
expect(result).toEqual(mockPolicy);
|
||||||
|
expect(jest.mocked(Intune.getPolicy)).toHaveBeenCalledWith(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null on error', async () => {
|
||||||
|
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
|
||||||
|
mockedGetLicense.mockResolvedValue({} as ClientLicense);
|
||||||
|
mockedIsMinimumLicenseTier.mockReturnValue(true);
|
||||||
|
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(Intune.getPolicy).mockRejectedValue(new Error('Get policy failed'));
|
||||||
|
|
||||||
|
const result = await IntuneManager.getPolicy(serverUrl);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Event Subscription Methods', () => {
|
||||||
|
const mockHandler = jest.fn();
|
||||||
|
const mockSubscription = {remove: jest.fn()} as unknown as EventSubscription;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(Intune.onIntunePolicyChanged).mockReturnValue(mockSubscription);
|
||||||
|
jest.mocked(Intune.onIntuneEnrollmentChanged).mockReturnValue(mockSubscription);
|
||||||
|
jest.mocked(Intune.onIntuneWipeRequested).mockReturnValue(mockSubscription);
|
||||||
|
jest.mocked(Intune.onIntuneAuthRequired).mockReturnValue(mockSubscription);
|
||||||
|
jest.mocked(Intune.onIntuneConditionalLaunchBlocked).mockReturnValue(mockSubscription);
|
||||||
|
jest.mocked(Intune.onIntuneIdentitySwitchRequired).mockReturnValue(mockSubscription);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeToPolicyChanges', () => {
|
||||||
|
it('should return EventSubscription when Intune library available', () => {
|
||||||
|
const result = IntuneManager.subscribeToPolicyChanges(mockHandler);
|
||||||
|
expect(result).toBe(mockSubscription);
|
||||||
|
expect(jest.mocked(Intune.onIntunePolicyChanged)).toHaveBeenCalledWith(mockHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeToEnrollmentChanges', () => {
|
||||||
|
it('should return EventSubscription when Intune library available', () => {
|
||||||
|
const result = IntuneManager.subscribeToEnrollmentChanges(mockHandler);
|
||||||
|
expect(result).toBe(mockSubscription);
|
||||||
|
expect(jest.mocked(Intune.onIntuneEnrollmentChanged)).toHaveBeenCalledWith(mockHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeToWipeRequests', () => {
|
||||||
|
it('should return EventSubscription when Intune library available', () => {
|
||||||
|
const result = IntuneManager.subscribeToWipeRequests(mockHandler);
|
||||||
|
expect(result).toBe(mockSubscription);
|
||||||
|
expect(jest.mocked(Intune.onIntuneWipeRequested)).toHaveBeenCalledWith(mockHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeToAuthRequired', () => {
|
||||||
|
it('should return EventSubscription when Intune library available', () => {
|
||||||
|
const result = IntuneManager.subscribeToAuthRequired(mockHandler);
|
||||||
|
expect(result).toBe(mockSubscription);
|
||||||
|
expect(jest.mocked(Intune.onIntuneAuthRequired)).toHaveBeenCalledWith(mockHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeToConditionalLaunchBlocked', () => {
|
||||||
|
it('should return EventSubscription when Intune library available', () => {
|
||||||
|
const result = IntuneManager.subscribeToConditionalLaunchBlocked(mockHandler);
|
||||||
|
expect(result).toBe(mockSubscription);
|
||||||
|
expect(jest.mocked(Intune.onIntuneConditionalLaunchBlocked)).toHaveBeenCalledWith(mockHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeToIdentitySwitchRequired', () => {
|
||||||
|
it('should return EventSubscription when Intune library available', () => {
|
||||||
|
const result = IntuneManager.subscribeToIdentitySwitchRequired(mockHandler);
|
||||||
|
expect(result).toBe(mockSubscription);
|
||||||
|
expect(jest.mocked(Intune.onIntuneIdentitySwitchRequired)).toHaveBeenCalledWith(mockHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
357
app/managers/intune_manager/index.ts
Normal file
357
app/managers/intune_manager/index.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import Emm from '@mattermost/react-native-emm';
|
||||||
|
import {Platform, type EventSubscription} from 'react-native';
|
||||||
|
|
||||||
|
import {getCurrentUserLocale} from '@actions/local/user';
|
||||||
|
import {License} from '@constants';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {getConfig, getLicense} from '@queries/servers/system';
|
||||||
|
import {showBiometricFailureAlertForOrganization} from '@utils/alerts';
|
||||||
|
import {isMinimumLicenseTier} from '@utils/helpers';
|
||||||
|
import {logDebug, logError, logWarning} from '@utils/log';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IntuneAuthRequiredEvent,
|
||||||
|
IntuneConditionalLaunchBlockedEvent,
|
||||||
|
IntuneEnrollmentChangedEvent,
|
||||||
|
IntuneIdentitySwitchRequiredEvent,
|
||||||
|
IntunePolicyChangedEvent,
|
||||||
|
IntuneWipeRequestedEvent,
|
||||||
|
MSALIdentity,
|
||||||
|
MSALTokens,
|
||||||
|
IntuneSpec,
|
||||||
|
IntunePolicy,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
let Intune: IntuneSpec | null = null;
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
try {
|
||||||
|
Intune = require('@mattermost/intune').default;
|
||||||
|
} catch {
|
||||||
|
// Intune library not available
|
||||||
|
logWarning('Intune library not available - MAM features disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IntuneManager - Thin wrapper for Microsoft Intune MAM integration
|
||||||
|
*
|
||||||
|
* Provides helper methods for enrollment, policy access, and identity management.
|
||||||
|
* Event handling is delegated to SecurityManager via subscription methods.
|
||||||
|
*/
|
||||||
|
export class IntuneManagerSingleton {
|
||||||
|
|
||||||
|
isIntuneEnabledForConfigAndLicense(config: ClientConfig, license?: ClientLicense): boolean {
|
||||||
|
return Boolean(Intune) && isMinimumLicenseTier(license, License.SKU_SHORT_NAME.EnterpriseAdvanced) &&
|
||||||
|
config.IntuneMAMEnabled === 'true' && Boolean(config.IntuneScope) && Boolean(config.IntuneAuthService);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isIntuneMAMEnabledForServer(serverUrl: string): Promise<boolean> {
|
||||||
|
if (!Intune) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
const config = await getConfig(database);
|
||||||
|
const license = await getLicense(database);
|
||||||
|
return this.isIntuneEnabledForConfigAndLicense(config, license);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native OIDC login: acquire MSAL tokens with custom scopes
|
||||||
|
* @param serverUrl - The Mattermost server URL (used to store scope)
|
||||||
|
* @param scopes - Array of OAuth scopes (e.g., IntuneScope from server)
|
||||||
|
* @returns MSALTokens containing idToken, accessToken, and identity
|
||||||
|
*/
|
||||||
|
async login(serverUrl: string, scopes: string[]): Promise<MSALTokens> {
|
||||||
|
if (!Intune) {
|
||||||
|
throw new Error('IntuneManager: Intune library not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logDebug('IntuneManager: Starting native OIDC login');
|
||||||
|
const tokens = await Intune.login(serverUrl, scopes);
|
||||||
|
return tokens;
|
||||||
|
} catch (error) {
|
||||||
|
logError('IntuneManager: Native OIDC login failed', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enroll a server in MAM
|
||||||
|
* Automatically acquires MAM consent before enrolling to prevent duplicate prompts
|
||||||
|
* @param serverUrl - The Mattermost server URL
|
||||||
|
* @param identity - The MSAL identity (upn, tid, oid)
|
||||||
|
*/
|
||||||
|
async enrollServer(serverUrl: string, identity: MSALIdentity): Promise<void> {
|
||||||
|
if (!Intune) {
|
||||||
|
logWarning('IntuneManager: Cannot enroll in MAM - Intune library not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logDebug('IntuneManager: Enrolling in MAM');
|
||||||
|
await Intune.enrollInMAM(serverUrl, identity);
|
||||||
|
} catch (error) {
|
||||||
|
logError('IntuneManager: MAM enrollment failed', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unenroll a server from Intune MAM
|
||||||
|
* @param serverUrl - The Mattermost server URL
|
||||||
|
* @param doWipe - Whether to perform selective wipe
|
||||||
|
*/
|
||||||
|
async unenrollServer(serverUrl: string, doWipe: boolean): Promise<void> {
|
||||||
|
if (!Intune) {
|
||||||
|
logWarning('IntuneManager: Cannot unenroll - Intune library not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentServer = await DatabaseManager.getActiveServerUrl();
|
||||||
|
const isManaged = await Intune.isManagedServer(serverUrl);
|
||||||
|
if (!isManaged) {
|
||||||
|
logDebug('IntuneManager: Server not enrolled in MAM, skipping unenrollment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logDebug('IntuneManager: Starting unenrollment', {doWipe});
|
||||||
|
if (currentServer === serverUrl) {
|
||||||
|
await Intune.setCurrentIdentity(null);
|
||||||
|
}
|
||||||
|
await Intune.deregisterAndUnenroll(serverUrl, doWipe);
|
||||||
|
} catch (error) {
|
||||||
|
logError('IntuneManager: Unenrollment failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup storage and MSAL account after selective wipe completes
|
||||||
|
* This is called by SecurityManager after wipe operations complete successfully
|
||||||
|
* to remove the OID-to-serverUrl mappings from keychain and delete the MSAL account.
|
||||||
|
* @param oid - The Object ID (OID) to cleanup
|
||||||
|
*/
|
||||||
|
async cleanupAfterWipe(oid: string): Promise<void> {
|
||||||
|
if (!Intune) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Intune.cleanupAfterWipe(oid);
|
||||||
|
} catch (error) {
|
||||||
|
logError('IntuneManager: Cleanup after wipe failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a server is managed by Intune
|
||||||
|
* @param serverUrl - The Mattermost server URL
|
||||||
|
* @returns true if server is Intune-managed
|
||||||
|
*/
|
||||||
|
async isManagedServer(serverUrl: string): Promise<boolean> {
|
||||||
|
if (!Intune) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Intune.isManagedServer(serverUrl);
|
||||||
|
} catch (error) {
|
||||||
|
logError('IntuneManager: Failed to check managed status', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current identity for Intune SDK context
|
||||||
|
* @param serverUrl - The server URL to set as current, or null to clear
|
||||||
|
*/
|
||||||
|
async setCurrentIdentity(serverUrl: string | null): Promise<void> {
|
||||||
|
if (!Intune) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (serverUrl) {
|
||||||
|
const isEnabled = await this.isIntuneMAMEnabledForServer(serverUrl);
|
||||||
|
if (!isEnabled) {
|
||||||
|
logDebug('IntuneManager: Server is not licensed or configured, clearing current identity');
|
||||||
|
await Intune.setCurrentIdentity(null);
|
||||||
|
logDebug('IntuneManager: Current identity cleared');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Intune.setCurrentIdentity(serverUrl);
|
||||||
|
const identity = serverUrl ? 'set' : 'cleared';
|
||||||
|
logDebug(`IntuneManager: Current identity ${identity}`);
|
||||||
|
} catch (error) {
|
||||||
|
logError('IntuneManager: Failed to set current identity', error);
|
||||||
|
if (serverUrl) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
Emm.enableBlurScreen(true);
|
||||||
|
Emm.applyBlurEffect(20);
|
||||||
|
const locale = await getCurrentUserLocale(serverUrl);
|
||||||
|
await showBiometricFailureAlertForOrganization(serverUrl, locale, () => {
|
||||||
|
Emm.removeBlurEffect();
|
||||||
|
this.setCurrentIdentity(serverUrl);
|
||||||
|
});
|
||||||
|
Emm.enableBlurScreen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Intune policy for a server
|
||||||
|
* @param serverUrl - The Mattermost server URL
|
||||||
|
* @returns IntunePolicy if server is managed, null otherwise
|
||||||
|
*/
|
||||||
|
async getPolicy(serverUrl: string): Promise<IntunePolicy | null> {
|
||||||
|
if (!Intune) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isIntuneEnabled = await this.isIntuneMAMEnabledForServer(serverUrl);
|
||||||
|
if (!isIntuneEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const isManaged = await Intune.isManagedServer(serverUrl);
|
||||||
|
if (!isManaged) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Intune.getPolicy(serverUrl);
|
||||||
|
} catch (error) {
|
||||||
|
logError('IntuneManager: Failed to get policy', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to Intune policy change events
|
||||||
|
* @param handler - The callback to handle policy changes
|
||||||
|
* @returns EventSubscription that can be removed
|
||||||
|
*/
|
||||||
|
subscribeToPolicyChanges(handler: (event: IntunePolicyChangedEvent) => void): EventSubscription | undefined {
|
||||||
|
if (!Intune) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Intune.onIntunePolicyChanged(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to Intune enrollment change events
|
||||||
|
* @param handler - The callback to handle enrollment changes
|
||||||
|
* @returns EventSubscription that can be removed
|
||||||
|
*/
|
||||||
|
subscribeToEnrollmentChanges(handler: (event: IntuneEnrollmentChangedEvent) => void): EventSubscription | undefined {
|
||||||
|
if (!Intune) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Intune.onIntuneEnrollmentChanged(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to Intune wipe requested events
|
||||||
|
* @param handler - The callback to handle wipe requests
|
||||||
|
* @returns EventSubscription that can be removed
|
||||||
|
*/
|
||||||
|
subscribeToWipeRequests(handler: (event: IntuneWipeRequestedEvent) => void): EventSubscription | undefined {
|
||||||
|
if (!Intune) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Intune.onIntuneWipeRequested(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to Intune auth required events
|
||||||
|
* @param handler - The callback to handle auth requirements
|
||||||
|
* @returns EventSubscription that can be removed
|
||||||
|
*/
|
||||||
|
subscribeToAuthRequired(handler: (event: IntuneAuthRequiredEvent) => void): EventSubscription | undefined {
|
||||||
|
if (!Intune) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Intune.onIntuneAuthRequired(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to Intune conditional launch blocked events
|
||||||
|
* @param handler - The callback to handle conditional launch blocks
|
||||||
|
* @returns EventSubscription that can be removed
|
||||||
|
*/
|
||||||
|
subscribeToConditionalLaunchBlocked(handler: (event: IntuneConditionalLaunchBlockedEvent) => void): EventSubscription | undefined {
|
||||||
|
if (!Intune) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Intune.onIntuneConditionalLaunchBlocked(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to Intune identity switch required events
|
||||||
|
* @param handler - The callback to handle identity switch requirements
|
||||||
|
* @returns EventSubscription that can be removed
|
||||||
|
*/
|
||||||
|
subscribeToIdentitySwitchRequired(handler: (event: IntuneIdentitySwitchRequiredEvent) => void): EventSubscription | undefined {
|
||||||
|
if (!Intune) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Intune.onIntuneIdentitySwitchRequired(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report wipe completion status to native layer
|
||||||
|
* Clears pending state if successful, retains for retry if failed
|
||||||
|
* @param oid - The OID to report completion for
|
||||||
|
* @param success - Whether the wipe was successful
|
||||||
|
*/
|
||||||
|
async reportWipeComplete(oid: string, success: boolean): Promise<void> {
|
||||||
|
if (!Intune) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Intune.reportWipeComplete(oid, success);
|
||||||
|
logDebug(`IntuneManager: Wipe completion reported (success: ${success})`);
|
||||||
|
} catch (error) {
|
||||||
|
logError('IntuneManager: Failed to report wipe completion', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending wipes that need to be retried
|
||||||
|
* Filters out stale wipes (> 7 days old) automatically
|
||||||
|
* @returns Array of pending wipes (empty array if none)
|
||||||
|
*/
|
||||||
|
async getPendingWipes(): Promise<Array<{oid: string; serverUrls: string[]; timestamp: number}>> {
|
||||||
|
if (!Intune) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pendingWipes = await Intune.getPendingWipes();
|
||||||
|
logDebug(`IntuneManager: Retrieved ${pendingWipes.length} pending wipe(s)`);
|
||||||
|
return pendingWipes;
|
||||||
|
} catch (error) {
|
||||||
|
logError('IntuneManager: Failed to get pending wipes', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const IntuneManager = new IntuneManagerSingleton();
|
||||||
|
export default IntuneManager;
|
||||||
148
app/managers/intune_manager/types.ts
Normal file
148
app/managers/intune_manager/types.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-exports Intune types and enums with fallback stubs when disabled.
|
||||||
|
* This allows other files to import Intune types without conditional logic.
|
||||||
|
*
|
||||||
|
* IMPORTANT: This file duplicates types from @mattermost/intune library.
|
||||||
|
* When updating Intune library types, update these types to match.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Platform, type EventSubscription} from 'react-native';
|
||||||
|
|
||||||
|
// Try to import enums from Intune library at runtime
|
||||||
|
let IntuneAuthRequiredReasonsExport: Record<string, string> | null = null;
|
||||||
|
let IntuneConditionalLaunchBlockedReasonsExport: Record<string, string> | null = null;
|
||||||
|
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
try {
|
||||||
|
const errors = require('@mattermost/intune/src/errors');
|
||||||
|
IntuneAuthRequiredReasonsExport = errors.IntuneAuthRequiredReasons;
|
||||||
|
IntuneConditionalLaunchBlockedReasonsExport = errors.IntuneConditionalLaunchBlockedReasons;
|
||||||
|
} catch {
|
||||||
|
// Intune library not available - use stub enums below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export enum objects - can be used both as type and runtime value
|
||||||
|
// These match the string values used by the native iOS module
|
||||||
|
export const IntuneConditionalLaunchBlockedReasons = IntuneConditionalLaunchBlockedReasonsExport ?? {
|
||||||
|
LAUNCH_BLOCKED: 'launch_blocked' as const,
|
||||||
|
LAUNCH_CANCELED: 'launch_canceled' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IntuneAuthRequiredReasons = IntuneAuthRequiredReasonsExport ?? {
|
||||||
|
CONSENT_DENIED: 'consent_denied' as const,
|
||||||
|
AUTH_FAILED: 'auth_failed' as const,
|
||||||
|
TOKEN_REFRESH_FAILED: 'refresh_token_failed' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type definitions - must match @mattermost/intune library types exactly
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type IntuneMAMSaveLocation = {
|
||||||
|
Other: boolean;
|
||||||
|
OneDriveForBusiness: boolean;
|
||||||
|
SharePoint: boolean;
|
||||||
|
LocalDrive: boolean;
|
||||||
|
PhotoLibrary: boolean;
|
||||||
|
CameraRoll: boolean;
|
||||||
|
FilesApp: boolean;
|
||||||
|
iCloudDrive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntunePolicy = Readonly<{
|
||||||
|
isPINRequired: boolean;
|
||||||
|
isContactSyncAllowed: boolean;
|
||||||
|
isWidgetContentSyncAllowed: boolean;
|
||||||
|
isSpotlightIndexingAllowed: boolean;
|
||||||
|
areSiriIntentsAllowed: boolean;
|
||||||
|
areAppIntentsAllowed: boolean;
|
||||||
|
isAppSharingAllowed: boolean;
|
||||||
|
shouldFileProviderEncryptFiles: boolean;
|
||||||
|
isManagedBrowserRequired: boolean;
|
||||||
|
isFileEncryptionRequired: boolean;
|
||||||
|
isScreenCaptureAllowed: boolean;
|
||||||
|
notificationPolicy: number;
|
||||||
|
allowedSaveLocations: IntuneMAMSaveLocation;
|
||||||
|
allowedOpenLocations: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MSALIdentity = Readonly<{
|
||||||
|
upn: string;
|
||||||
|
tid: string;
|
||||||
|
oid: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MSALTokens = Readonly<{
|
||||||
|
idToken: string;
|
||||||
|
accessToken: string;
|
||||||
|
identity: MSALIdentity;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IntuneAuthRequiredEvent = Readonly<{
|
||||||
|
oid: string;
|
||||||
|
serverUrls: string[];
|
||||||
|
reason?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IntuneConditionalLaunchBlockedEvent = Readonly<{
|
||||||
|
oid: string;
|
||||||
|
serverUrls: string[];
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IntuneEnrollmentChangedEvent = Readonly<{
|
||||||
|
enrolled: boolean;
|
||||||
|
oid: string;
|
||||||
|
serverUrls: string[];
|
||||||
|
reason?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IntuneIdentitySwitchRequiredEvent = Readonly<{
|
||||||
|
oid: string;
|
||||||
|
serverUrls: string[];
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IntunePolicyChangedEvent = Readonly<{
|
||||||
|
oid: string;
|
||||||
|
changed: boolean;
|
||||||
|
removed?: boolean;
|
||||||
|
policy?: IntunePolicy | null;
|
||||||
|
serverUrls: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IntuneWipeRequestedEvent = Readonly<{
|
||||||
|
oid: string;
|
||||||
|
serverUrls: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type PendingWipe = Readonly<{
|
||||||
|
oid: string;
|
||||||
|
serverUrls: string[];
|
||||||
|
timestamp: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IntuneSpec = {
|
||||||
|
addListener: (eventType: string) => void;
|
||||||
|
removeListeners: (count: number) => void;
|
||||||
|
login(serverUrl: string, scopes: string[]): Promise<MSALTokens>;
|
||||||
|
enrollInMAM(serverUrl: string, identity: MSALIdentity): Promise<void>;
|
||||||
|
isManagedServer(serverUrl: string): Promise<boolean>;
|
||||||
|
deregisterAndUnenroll(serverUrl: string, doWipe: boolean): Promise<void>;
|
||||||
|
cleanupAfterWipe(oid: string): Promise<void>;
|
||||||
|
reportWipeComplete(oid: string, success: boolean): Promise<void>;
|
||||||
|
getPendingWipes(): Promise<PendingWipe[]>;
|
||||||
|
setCurrentIdentity(serverUrl: string | null): Promise<void>;
|
||||||
|
getPolicy(serverUrl: string): Promise<IntunePolicy | null>;
|
||||||
|
|
||||||
|
onIntuneEnrollmentChanged: (listener: (event: IntuneEnrollmentChangedEvent) => void) => EventSubscription;
|
||||||
|
onIntunePolicyChanged: (listener: (event: IntunePolicyChangedEvent) => void) => EventSubscription;
|
||||||
|
onIntuneWipeRequested: (listener: (event: IntuneWipeRequestedEvent) => void) => EventSubscription;
|
||||||
|
onIntuneAuthRequired: (listener: (event: IntuneAuthRequiredEvent) => void) => EventSubscription;
|
||||||
|
onIntuneConditionalLaunchBlocked: (listener: (event: IntuneConditionalLaunchBlockedEvent) => void) => EventSubscription;
|
||||||
|
onIntuneIdentitySwitchRequired: (listener: (event: IntuneIdentitySwitchRequiredEvent) => void) => EventSubscription;
|
||||||
|
}
|
||||||
835
app/managers/security_manager/event_handlers.test.ts
Normal file
835
app/managers/security_manager/event_handlers.test.ts
Normal file
|
|
@ -0,0 +1,835 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
/* eslint-disable max-lines */
|
||||||
|
|
||||||
|
import Emm from '@mattermost/react-native-emm';
|
||||||
|
import {isRootedExperimentalAsync} from 'expo-device';
|
||||||
|
import {type AppStateStatus} from 'react-native';
|
||||||
|
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {getServerCredentials} from '@init/credentials';
|
||||||
|
import IntuneManager from '@managers/intune_manager';
|
||||||
|
import {
|
||||||
|
IntuneConditionalLaunchBlockedReasons,
|
||||||
|
type IntuneAuthRequiredEvent,
|
||||||
|
type IntuneConditionalLaunchBlockedEvent,
|
||||||
|
type IntuneEnrollmentChangedEvent,
|
||||||
|
type IntuneIdentitySwitchRequiredEvent,
|
||||||
|
type IntunePolicy,
|
||||||
|
type IntunePolicyChangedEvent,
|
||||||
|
type IntuneWipeRequestedEvent,
|
||||||
|
} from '@managers/intune_manager/types';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
import * as alerts from '@utils/alerts';
|
||||||
|
import {toMilliseconds} from '@utils/datetime';
|
||||||
|
|
||||||
|
import SecurityManager from '.';
|
||||||
|
|
||||||
|
jest.mock('@mattermost/react-native-emm', () => ({
|
||||||
|
isDeviceSecured: jest.fn(),
|
||||||
|
authenticate: jest.fn(),
|
||||||
|
openSecuritySettings: jest.fn(),
|
||||||
|
exitApp: jest.fn(),
|
||||||
|
enableBlurScreen: jest.fn(),
|
||||||
|
applyBlurEffect: jest.fn(),
|
||||||
|
removeBlurEffect: jest.fn(),
|
||||||
|
addListener: jest.fn(),
|
||||||
|
setAppGroupId: jest.fn(),
|
||||||
|
getManagedConfig: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-device', () => ({
|
||||||
|
isRootedExperimentalAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@actions/local/session', () => ({terminateSession: jest.fn(() => Promise.resolve())}));
|
||||||
|
jest.mock('@actions/local/user', () => ({getCurrentUserLocale: jest.fn(() => Promise.resolve('en'))}));
|
||||||
|
jest.mock('@actions/remote/session', () => ({
|
||||||
|
logout: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
jest.mock('@utils/log', () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
|
logDebug: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@utils/datetime', () => ({toMilliseconds: jest.fn(() => 25000)}));
|
||||||
|
jest.mock('@init/credentials', () => ({getServerCredentials: jest.fn().mockResolvedValue({token: 'token'})}));
|
||||||
|
jest.mock('@database/manager', () => ({
|
||||||
|
getActiveServerUrl: jest.fn(),
|
||||||
|
getServerDatabaseAndOperator: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SecurityManager - Event Handlers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
SecurityManager.initialized = false;
|
||||||
|
SecurityManager.serverConfig = {};
|
||||||
|
SecurityManager.activeServer = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAppStateChange', () => {
|
||||||
|
test('should handle app state changes', async () => {
|
||||||
|
await SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'true'} as SecurityClientConfig);
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: '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);
|
||||||
|
await SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'true'} as SecurityClientConfig);
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: '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('Intune Event Handlers', () => {
|
||||||
|
const serverUrl1 = 'https://test1.server.com';
|
||||||
|
const serverUrl2 = 'https://test2.server.com';
|
||||||
|
const mockPolicy: IntunePolicy = {
|
||||||
|
isPINRequired: true,
|
||||||
|
isContactSyncAllowed: false,
|
||||||
|
isWidgetContentSyncAllowed: false,
|
||||||
|
isSpotlightIndexingAllowed: false,
|
||||||
|
areSiriIntentsAllowed: false,
|
||||||
|
areAppIntentsAllowed: false,
|
||||||
|
isAppSharingAllowed: false,
|
||||||
|
shouldFileProviderEncryptFiles: true,
|
||||||
|
isManagedBrowserRequired: false,
|
||||||
|
isFileEncryptionRequired: true,
|
||||||
|
isScreenCaptureAllowed: false,
|
||||||
|
notificationPolicy: 0,
|
||||||
|
allowedSaveLocations: {
|
||||||
|
Other: false,
|
||||||
|
OneDriveForBusiness: true,
|
||||||
|
SharePoint: true,
|
||||||
|
LocalDrive: false,
|
||||||
|
PhotoLibrary: false,
|
||||||
|
CameraRoll: false,
|
||||||
|
FilesApp: false,
|
||||||
|
iCloudDrive: false,
|
||||||
|
},
|
||||||
|
allowedOpenLocations: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await SecurityManager.init();
|
||||||
|
await SecurityManager.addServer(serverUrl1, {SiteName: 'Test Server 1'} as SecurityClientConfig);
|
||||||
|
await SecurityManager.addServer(serverUrl2, {SiteName: 'Test Server 2'} as SecurityClientConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
SecurityManager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onIntunePolicyChanged', () => {
|
||||||
|
test('should skip if not initialized', () => {
|
||||||
|
SecurityManager.cleanup();
|
||||||
|
const event: IntunePolicyChangedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
changed: true,
|
||||||
|
removed: false,
|
||||||
|
policy: mockPolicy,
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
SecurityManager.onIntunePolicyChanged(event);
|
||||||
|
|
||||||
|
// After cleanup, handler should return early without modifying config
|
||||||
|
// Server still exists with null policy (from addServer)
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
expect(config?.intunePolicy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update policy for affected servers when policy changed', () => {
|
||||||
|
const event: IntunePolicyChangedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
changed: true,
|
||||||
|
removed: false,
|
||||||
|
policy: mockPolicy,
|
||||||
|
serverUrls: [serverUrl1, serverUrl2],
|
||||||
|
};
|
||||||
|
|
||||||
|
SecurityManager.onIntunePolicyChanged(event);
|
||||||
|
|
||||||
|
const config1 = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
const config2 = SecurityManager.getServerConfig(serverUrl2);
|
||||||
|
expect(config1?.intunePolicy).toEqual(mockPolicy);
|
||||||
|
expect(config2?.intunePolicy).toEqual(mockPolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove policy when policy removed', () => {
|
||||||
|
// First set a policy
|
||||||
|
SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy);
|
||||||
|
|
||||||
|
const event: IntunePolicyChangedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
changed: false,
|
||||||
|
removed: true,
|
||||||
|
policy: null,
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
SecurityManager.onIntunePolicyChanged(event);
|
||||||
|
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
expect(config?.intunePolicy).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should re-apply screen capture policy when active server affected', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
|
||||||
|
const event: IntunePolicyChangedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
changed: true,
|
||||||
|
removed: false,
|
||||||
|
policy: mockPolicy,
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
SecurityManager.onIntunePolicyChanged(event);
|
||||||
|
|
||||||
|
expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not re-apply when active server not affected', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
setScreenCaptureSpy.mockClear();
|
||||||
|
|
||||||
|
const event: IntunePolicyChangedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
changed: true,
|
||||||
|
removed: false,
|
||||||
|
policy: mockPolicy,
|
||||||
|
serverUrls: [serverUrl2],
|
||||||
|
};
|
||||||
|
|
||||||
|
SecurityManager.onIntunePolicyChanged(event);
|
||||||
|
|
||||||
|
// Should not be called again for serverUrl1
|
||||||
|
expect(setScreenCaptureSpy).not.toHaveBeenCalled();
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle multiple servers in event', () => {
|
||||||
|
const event: IntunePolicyChangedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
changed: true,
|
||||||
|
removed: false,
|
||||||
|
policy: mockPolicy,
|
||||||
|
serverUrls: [serverUrl1, serverUrl2],
|
||||||
|
};
|
||||||
|
|
||||||
|
SecurityManager.onIntunePolicyChanged(event);
|
||||||
|
|
||||||
|
const config1 = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
const config2 = SecurityManager.getServerConfig(serverUrl2);
|
||||||
|
expect(config1?.intunePolicy).toEqual(mockPolicy);
|
||||||
|
expect(config2?.intunePolicy).toEqual(mockPolicy);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onEnrollmentChanged', () => {
|
||||||
|
test('should call handleEnrollmentSuccess when enrolled', async () => {
|
||||||
|
const handleSuccessSpy = jest.spyOn(SecurityManager as any, 'handleEnrollmentSuccess');
|
||||||
|
const event: IntuneEnrollmentChangedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
enrolled: true,
|
||||||
|
reason: 'user_enrolled',
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
SecurityManager.onEnrollmentChanged(event);
|
||||||
|
|
||||||
|
await TestHelper.wait(10);
|
||||||
|
expect(handleSuccessSpy).toHaveBeenCalledWith([serverUrl1]);
|
||||||
|
handleSuccessSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call handleUnenrollment when not enrolled', async () => {
|
||||||
|
const handleUnenrollmentSpy = jest.spyOn(SecurityManager as any, 'handleUnenrollment');
|
||||||
|
const event: IntuneEnrollmentChangedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
enrolled: false,
|
||||||
|
reason: 'user_unenrolled',
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
SecurityManager.onEnrollmentChanged(event);
|
||||||
|
|
||||||
|
await TestHelper.wait(10);
|
||||||
|
expect(handleUnenrollmentSpy).toHaveBeenCalledWith([serverUrl1], 'user_unenrolled');
|
||||||
|
handleUnenrollmentSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onWipeRequested', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(getServerCredentials).mockResolvedValue({serverUrl: serverUrl1, userId: 'user-id', token: 'token'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should wipe background servers first, then active server', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const event: IntuneWipeRequestedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
serverUrls: [serverUrl1, serverUrl2],
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onWipeRequested(event);
|
||||||
|
|
||||||
|
// Background server wiped first with skipEvents=true
|
||||||
|
expect(performWipeSpy).toHaveBeenCalledWith(serverUrl2, true);
|
||||||
|
|
||||||
|
// Active server wiped last with skipEvents=false
|
||||||
|
expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, false);
|
||||||
|
|
||||||
|
performWipeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should proceed with wipe even without credentials (already deleted by native)', async () => {
|
||||||
|
jest.mocked(getServerCredentials).mockResolvedValue(null);
|
||||||
|
const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(true);
|
||||||
|
|
||||||
|
const event: IntuneWipeRequestedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onWipeRequested(event);
|
||||||
|
|
||||||
|
// Should still call wipe to clean up database (credentials already deleted by native)
|
||||||
|
expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, true);
|
||||||
|
performWipeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove active server after wipe', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined);
|
||||||
|
const removeServerSpy = jest.spyOn(SecurityManager, 'removeServer');
|
||||||
|
|
||||||
|
const event: IntuneWipeRequestedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onWipeRequested(event);
|
||||||
|
|
||||||
|
expect(removeServerSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
removeServerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAuthRequired', () => {
|
||||||
|
test('should apply blur screen and trigger wipe', async () => {
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showAuthenticationRequiredAlert').mockImplementation(async (_reason, _locale, onDismiss) => {
|
||||||
|
if (onDismiss) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const event: IntuneAuthRequiredEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onAuthRequired(event);
|
||||||
|
|
||||||
|
expect(Emm.applyBlurEffect).toHaveBeenCalled();
|
||||||
|
expect(showAlertSpy).toHaveBeenCalled();
|
||||||
|
await TestHelper.wait(10);
|
||||||
|
expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, true);
|
||||||
|
expect(Emm.removeBlurEffect).toHaveBeenCalled();
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
performWipeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove blur screen when alert dismissed', async () => {
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showAuthenticationRequiredAlert').mockImplementation(async (_reason, _locale, onDismiss) => {
|
||||||
|
if (onDismiss) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const event: IntuneAuthRequiredEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onAuthRequired(event);
|
||||||
|
|
||||||
|
expect(Emm.applyBlurEffect).toHaveBeenCalled();
|
||||||
|
await TestHelper.wait(10);
|
||||||
|
expect(Emm.removeBlurEffect).toHaveBeenCalled();
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onConditionalLaunchBlocked', () => {
|
||||||
|
test('should trigger wipe when launch blocked', async () => {
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showConditionalAccessAlert').mockImplementation(async (_locale, callback) => {
|
||||||
|
if (callback) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const event: IntuneConditionalLaunchBlockedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
reason: IntuneConditionalLaunchBlockedReasons.LAUNCH_BLOCKED,
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onConditionalLaunchBlocked(event);
|
||||||
|
|
||||||
|
expect(Emm.applyBlurEffect).toHaveBeenCalled();
|
||||||
|
expect(showAlertSpy).toHaveBeenCalled();
|
||||||
|
await TestHelper.wait(10);
|
||||||
|
expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, true);
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
performWipeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove blur screen when launch blocked alert dismissed', async () => {
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showConditionalAccessAlert').mockImplementation(async (_locale, callback) => {
|
||||||
|
if (callback) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const event: IntuneConditionalLaunchBlockedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
reason: IntuneConditionalLaunchBlockedReasons.LAUNCH_BLOCKED,
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onConditionalLaunchBlocked(event);
|
||||||
|
|
||||||
|
await TestHelper.wait(10);
|
||||||
|
expect(Emm.removeBlurEffect).toHaveBeenCalled();
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should retry authentication when launch canceled', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showBiometricFailureAlertForOrganization').mockImplementation(async (_server, _locale, retryCallback) => {
|
||||||
|
if (retryCallback) {
|
||||||
|
await retryCallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const setIdentitySpy = jest.spyOn(IntuneManager, 'setCurrentIdentity').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const event: IntuneConditionalLaunchBlockedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
reason: IntuneConditionalLaunchBlockedReasons.LAUNCH_CANCELED,
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onConditionalLaunchBlocked(event);
|
||||||
|
|
||||||
|
expect(showAlertSpy).toHaveBeenCalled();
|
||||||
|
expect(setIdentitySpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
setIdentitySpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove blur screen after retry', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showBiometricFailureAlertForOrganization').mockImplementation(async (_server, _locale, retryCallback) => {
|
||||||
|
if (retryCallback) {
|
||||||
|
await retryCallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
jest.spyOn(IntuneManager, 'setCurrentIdentity').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const event: IntuneConditionalLaunchBlockedEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
reason: IntuneConditionalLaunchBlockedReasons.LAUNCH_CANCELED,
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onConditionalLaunchBlocked(event);
|
||||||
|
|
||||||
|
expect(Emm.removeBlurEffect).toHaveBeenCalled();
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onIdentitySwitchRequired', () => {
|
||||||
|
test('should trigger wipe and show alert', async () => {
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showIdentitySwitchRequiredAlert').mockResolvedValue(undefined);
|
||||||
|
const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const event: IntuneIdentitySwitchRequiredEvent = {
|
||||||
|
oid: 'object-id',
|
||||||
|
serverUrls: [serverUrl1],
|
||||||
|
reason: 'new_identity_required',
|
||||||
|
};
|
||||||
|
|
||||||
|
await SecurityManager.onIdentitySwitchRequired(event);
|
||||||
|
|
||||||
|
expect(showAlertSpy).toHaveBeenCalled();
|
||||||
|
await TestHelper.wait(10);
|
||||||
|
expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, true);
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
performWipeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleEnrollmentSuccess', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl1);
|
||||||
|
jest.mocked(IntuneManager.getPolicy).mockResolvedValue(mockPolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fetch and update policy for all affected servers', async () => {
|
||||||
|
await (SecurityManager as any).handleEnrollmentSuccess([serverUrl1, serverUrl2]);
|
||||||
|
|
||||||
|
const config1 = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
const config2 = SecurityManager.getServerConfig(serverUrl2);
|
||||||
|
expect(config1?.intunePolicy).toEqual(mockPolicy);
|
||||||
|
expect(config2?.intunePolicy).toEqual(mockPolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set current identity if active server is affected', async () => {
|
||||||
|
await (SecurityManager as any).handleEnrollmentSuccess([serverUrl1, serverUrl2]);
|
||||||
|
|
||||||
|
expect(IntuneManager.setCurrentIdentity).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should re-apply screen capture policy for active server', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
|
||||||
|
await (SecurityManager as any).handleEnrollmentSuccess([serverUrl1]);
|
||||||
|
|
||||||
|
expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not set current identity if active server not affected', async () => {
|
||||||
|
jest.mocked(IntuneManager.setCurrentIdentity).mockClear();
|
||||||
|
|
||||||
|
await (SecurityManager as any).handleEnrollmentSuccess([serverUrl2]);
|
||||||
|
|
||||||
|
expect(IntuneManager.setCurrentIdentity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleUnenrollment', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy);
|
||||||
|
await SecurityManager.addServer(serverUrl2, {} as SecurityClientConfig, false, mockPolicy);
|
||||||
|
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove intunePolicy from all affected servers', async () => {
|
||||||
|
await (SecurityManager as any).handleUnenrollment([serverUrl1, serverUrl2], 'admin_unenrolled');
|
||||||
|
|
||||||
|
const config1 = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
const config2 = SecurityManager.getServerConfig(serverUrl2);
|
||||||
|
expect(config1?.intunePolicy).toBeNull();
|
||||||
|
expect(config2?.intunePolicy).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should re-apply screen capture policy if active server affected', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
|
||||||
|
await (SecurityManager as any).handleUnenrollment([serverUrl1], 'policy_removed');
|
||||||
|
|
||||||
|
expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not re-apply if active server not affected', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
setScreenCaptureSpy.mockClear();
|
||||||
|
|
||||||
|
await (SecurityManager as any).handleUnenrollment([serverUrl2]);
|
||||||
|
|
||||||
|
expect(setScreenCaptureSpy).not.toHaveBeenCalled();
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onConfigChanged', () => {
|
||||||
|
const mockConfig = {
|
||||||
|
SiteName: 'Updated Server',
|
||||||
|
MobileEnableBiometrics: 'true',
|
||||||
|
MobileJailbreakProtection: 'true',
|
||||||
|
MobilePreventScreenCapture: 'true',
|
||||||
|
} as SecurityClientConfig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(IntuneManager.getPolicy).mockResolvedValue(mockPolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update server config when config changes', async () => {
|
||||||
|
await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig});
|
||||||
|
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
expect(config?.siteName).toBe('Updated Server');
|
||||||
|
expect(config?.Biometrics).toBe(true);
|
||||||
|
expect(config?.JailbreakProtection).toBe(true);
|
||||||
|
expect(config?.PreventScreenCapture).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fetch and store Intune policy when config changes', async () => {
|
||||||
|
await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig});
|
||||||
|
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
expect(config?.intunePolicy).toEqual(mockPolicy);
|
||||||
|
expect(IntuneManager.getPolicy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should preserve existing config fields when updating', async () => {
|
||||||
|
await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, true);
|
||||||
|
SecurityManager.serverConfig[serverUrl1].lastAccessed = 12345;
|
||||||
|
|
||||||
|
await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig});
|
||||||
|
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
expect(config?.lastAccessed).toBe(12345);
|
||||||
|
expect(config?.authenticated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should trigger MAM enrollment check for active server', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true);
|
||||||
|
|
||||||
|
await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig});
|
||||||
|
|
||||||
|
expect(ensureMAMSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
ensureMAMSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should apply screen capture policy for active server after enrollment check', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true);
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
|
||||||
|
await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig});
|
||||||
|
|
||||||
|
expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not trigger enrollment for non-active server', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer');
|
||||||
|
|
||||||
|
await SecurityManager.onConfigChanged({serverUrl: serverUrl2, config: mockConfig});
|
||||||
|
|
||||||
|
expect(ensureMAMSpy).not.toHaveBeenCalled();
|
||||||
|
ensureMAMSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onLicenseChanged', () => {
|
||||||
|
const mockLicense = {
|
||||||
|
IsLicensed: 'true',
|
||||||
|
SkuShortName: 'enterprise',
|
||||||
|
} as ClientLicense;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(IntuneManager.getPolicy).mockResolvedValue(mockPolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create server config if not exists', async () => {
|
||||||
|
await SecurityManager.onLicenseChanged({serverUrl: 'https://new-server.com', license: mockLicense});
|
||||||
|
|
||||||
|
const config = SecurityManager.getServerConfig('https://new-server.com');
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
expect(config?.intunePolicy).toEqual(mockPolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fetch and update Intune policy when license changes', async () => {
|
||||||
|
await SecurityManager.onLicenseChanged({serverUrl: serverUrl1, license: mockLicense});
|
||||||
|
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl1);
|
||||||
|
expect(config?.intunePolicy).toEqual(mockPolicy);
|
||||||
|
expect(IntuneManager.getPolicy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should trigger MAM enrollment check for active server', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true);
|
||||||
|
|
||||||
|
await SecurityManager.onLicenseChanged({serverUrl: serverUrl1, license: mockLicense});
|
||||||
|
|
||||||
|
expect(ensureMAMSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
ensureMAMSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set current identity for active server after successful enrollment', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true);
|
||||||
|
|
||||||
|
await SecurityManager.onLicenseChanged({serverUrl: serverUrl1, license: mockLicense});
|
||||||
|
|
||||||
|
expect(IntuneManager.setCurrentIdentity).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not set identity if enrollment fails', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(false);
|
||||||
|
jest.mocked(IntuneManager.setCurrentIdentity).mockClear();
|
||||||
|
|
||||||
|
await SecurityManager.onLicenseChanged({serverUrl: serverUrl1, license: mockLicense});
|
||||||
|
|
||||||
|
expect(IntuneManager.setCurrentIdentity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not trigger enrollment for non-active server', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer');
|
||||||
|
|
||||||
|
await SecurityManager.onLicenseChanged({serverUrl: serverUrl2, license: mockLicense});
|
||||||
|
|
||||||
|
expect(ensureMAMSpy).not.toHaveBeenCalled();
|
||||||
|
ensureMAMSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canSaveToLocation', () => {
|
||||||
|
test('should return true when no Intune policy exists', () => {
|
||||||
|
const result = SecurityManager.canSaveToLocation(serverUrl1, 'PhotoLibrary');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return policy value when Intune policy exists', async () => {
|
||||||
|
await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy);
|
||||||
|
|
||||||
|
const canSaveToOneDrive = SecurityManager.canSaveToLocation(serverUrl1, 'OneDriveForBusiness');
|
||||||
|
const canSaveToPhotoLibrary = SecurityManager.canSaveToLocation(serverUrl1, 'PhotoLibrary');
|
||||||
|
|
||||||
|
expect(canSaveToOneDrive).toBe(true);
|
||||||
|
expect(canSaveToPhotoLibrary).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false for restricted locations', async () => {
|
||||||
|
await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy);
|
||||||
|
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'LocalDrive')).toBe(false);
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'CameraRoll')).toBe(false);
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'FilesApp')).toBe(false);
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'iCloudDrive')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true for allowed locations', async () => {
|
||||||
|
await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy);
|
||||||
|
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'OneDriveForBusiness')).toBe(true);
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'SharePoint')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setActiveServer options', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
|
||||||
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
||||||
|
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip MAM enrollment check when skipMAMEnrollmentCheck is true', async () => {
|
||||||
|
const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer');
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true}});
|
||||||
|
|
||||||
|
expect(ensureMAMSpy).not.toHaveBeenCalled();
|
||||||
|
ensureMAMSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should perform MAM enrollment check when skipMAMEnrollmentCheck is false', async () => {
|
||||||
|
const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true);
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: false}});
|
||||||
|
|
||||||
|
expect(ensureMAMSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
ensureMAMSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip jailbreak check when skipJailbreakCheck is true', async () => {
|
||||||
|
const jailbreakSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken');
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true}});
|
||||||
|
|
||||||
|
expect(jailbreakSpy).not.toHaveBeenCalled();
|
||||||
|
jailbreakSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should perform jailbreak check when skipJailbreakCheck is false', async () => {
|
||||||
|
const jailbreakSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken').mockResolvedValue(false);
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: false}});
|
||||||
|
|
||||||
|
expect(jailbreakSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
jailbreakSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip biometric auth when skipBiometricCheck is true', async () => {
|
||||||
|
const biometricSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded');
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
|
||||||
|
expect(biometricSpy).not.toHaveBeenCalled();
|
||||||
|
biometricSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should perform biometric auth when skipBiometricCheck is false', async () => {
|
||||||
|
const biometricSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded');
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: false}});
|
||||||
|
|
||||||
|
expect(biometricSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
biometricSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should force switch to same server when forceSwitch is true', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
setScreenCaptureSpy.mockClear();
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true, forceSwitch: true}});
|
||||||
|
|
||||||
|
expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not switch to same server when forceSwitch is false', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
setScreenCaptureSpy.mockClear();
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true, forceSwitch: false}});
|
||||||
|
|
||||||
|
expect(setScreenCaptureSpy).not.toHaveBeenCalled();
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,22 +1,28 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
/* eslint-disable max-lines */
|
||||||
|
|
||||||
import Emm from '@mattermost/react-native-emm';
|
import Emm from '@mattermost/react-native-emm';
|
||||||
import {isRootedExperimentalAsync} from 'expo-device';
|
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 {Screens} from '@constants';
|
||||||
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
|
import DatabaseManager from '@database/manager';
|
||||||
import {getServerCredentials} from '@init/credentials';
|
import IntuneManager from '@managers/intune_manager';
|
||||||
import TestHelper from '@test/test_helper';
|
import {
|
||||||
|
type IntunePolicy,
|
||||||
|
} from '@managers/intune_manager/types';
|
||||||
|
import {queryAllActiveServers} from '@queries/app/servers';
|
||||||
|
import {getSecurityConfig} from '@queries/servers/system';
|
||||||
|
import * as alerts from '@utils/alerts';
|
||||||
import {toMilliseconds} from '@utils/datetime';
|
import {toMilliseconds} from '@utils/datetime';
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
import SecurityManager, {exportsForTesting} from '.';
|
import SecurityManager from '.';
|
||||||
|
|
||||||
const messages = exportsForTesting.messages;
|
import type {Query} from '@nozbe/watermelondb';
|
||||||
|
import type {ServerDatabase} from '@typings/database/database';
|
||||||
|
import type ServersModel from '@typings/database/models/app/servers';
|
||||||
|
|
||||||
jest.mock('@mattermost/react-native-emm', () => ({
|
jest.mock('@mattermost/react-native-emm', () => ({
|
||||||
isDeviceSecured: jest.fn(),
|
isDeviceSecured: jest.fn(),
|
||||||
|
|
@ -24,21 +30,46 @@ jest.mock('@mattermost/react-native-emm', () => ({
|
||||||
openSecuritySettings: jest.fn(),
|
openSecuritySettings: jest.fn(),
|
||||||
exitApp: jest.fn(),
|
exitApp: jest.fn(),
|
||||||
enableBlurScreen: jest.fn(),
|
enableBlurScreen: jest.fn(),
|
||||||
|
applyBlurEffect: jest.fn(),
|
||||||
|
removeBlurEffect: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('expo-device', () => ({
|
jest.mock('expo-device', () => ({
|
||||||
isRootedExperimentalAsync: jest.fn(),
|
isRootedExperimentalAsync: jest.fn(),
|
||||||
}));
|
}));
|
||||||
jest.mock('@actions/app/server', () => ({switchToServer: jest.fn()}));
|
jest.mock('@actions/app/server', () => ({switchToServer: jest.fn()}));
|
||||||
jest.mock('@actions/remote/session', () => ({logout: jest.fn()}));
|
jest.mock('@actions/local/session', () => ({terminateSession: jest.fn()}));
|
||||||
|
jest.mock('@actions/local/user', () => ({getCurrentUserLocale: jest.fn(() => Promise.resolve('en'))}));
|
||||||
|
jest.mock('@actions/remote/session', () => ({
|
||||||
|
logout: jest.fn(),
|
||||||
|
}));
|
||||||
jest.mock('@utils/datetime', () => ({toMilliseconds: jest.fn(() => 25000)}));
|
jest.mock('@utils/datetime', () => ({toMilliseconds: jest.fn(() => 25000)}));
|
||||||
jest.mock('@utils/helpers', () => ({
|
jest.mock('@utils/helpers', () => ({
|
||||||
isMainActivity: jest.fn(() => true),
|
isMainActivity: jest.fn(() => true),
|
||||||
isTablet: jest.fn(() => false),
|
isTablet: jest.fn(() => false),
|
||||||
}));
|
}));
|
||||||
jest.mock('@utils/log', () => ({logError: jest.fn()}));
|
jest.mock('@utils/log', () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
|
logDebug: jest.fn(),
|
||||||
|
}));
|
||||||
jest.mock('@init/managed_app', () => ({enabled: true, inAppPinCode: false}));
|
jest.mock('@init/managed_app', () => ({enabled: true, inAppPinCode: false}));
|
||||||
jest.mock('@init/credentials', () => ({getServerCredentials: jest.fn().mockResolvedValue({token: 'token'})}));
|
jest.mock('@init/credentials', () => ({getServerCredentials: jest.fn().mockResolvedValue({token: 'token'})}));
|
||||||
|
jest.mock('@database/manager', () => ({
|
||||||
|
getActiveServerUrl: jest.fn(),
|
||||||
|
getServerDatabaseAndOperator: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@queries/servers/system', () => ({
|
||||||
|
getSecurityConfig: jest.fn(),
|
||||||
|
getConfig: jest.fn(),
|
||||||
|
getConfigValue: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@queries/app/servers', () => ({
|
||||||
|
queryAllActiveServers: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@queries/servers/user', () => ({
|
||||||
|
getCurrentUser: jest.fn(),
|
||||||
|
getCurrentUserLocale: jest.fn(() => Promise.resolve('en')),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('SecurityManager', () => {
|
describe('SecurityManager', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -49,178 +80,254 @@ describe('SecurityManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('init', () => {
|
describe('init', () => {
|
||||||
test('should initialize with servers', () => {
|
test('should initialize and load configs from all active servers', async () => {
|
||||||
const servers: Record<string, any> = {
|
const mockServers = [
|
||||||
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
|
{url: 'server-1'},
|
||||||
};
|
{url: 'server-2'},
|
||||||
SecurityManager.init(servers, 'server-1');
|
];
|
||||||
expect(SecurityManager.serverConfig['server-1']).toBeDefined();
|
const mockFetch = jest.fn().mockResolvedValue(mockServers);
|
||||||
expect(SecurityManager.activeServer).toBe('server-1');
|
jest.mocked(queryAllActiveServers).mockReturnValue({fetch: mockFetch} as unknown as Query<ServersModel>);
|
||||||
|
|
||||||
|
const mockConfig1 = {SiteName: 'Server One', MobileEnableBiometrics: 'true'} as SecurityClientConfig;
|
||||||
|
const mockConfig2 = {SiteName: 'Server Two', MobilePreventScreenCapture: 'true'} as SecurityClientConfig;
|
||||||
|
|
||||||
|
jest.mocked(getSecurityConfig).mockImplementation(async (database: any) => {
|
||||||
|
if (database === 'db-1') {
|
||||||
|
return mockConfig1;
|
||||||
|
}
|
||||||
|
return mockConfig2;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should initialize with servers and set active server', async () => {
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation((url: string) => {
|
||||||
const servers: Record<string, any> = {
|
if (url === 'server-1') {
|
||||||
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
|
return {database: 'db-1'} as unknown as ServerDatabase;
|
||||||
'server-2': {SiteName: 'Server Two', MobileEnableBiometrics: 'false'},
|
}
|
||||||
};
|
return {database: 'db-2'} as unknown as ServerDatabase;
|
||||||
await SecurityManager.init(servers, 'server-1');
|
});
|
||||||
|
|
||||||
|
jest.mocked(IntuneManager.getPolicy).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await SecurityManager.init();
|
||||||
|
|
||||||
|
expect(queryAllActiveServers).toHaveBeenCalled();
|
||||||
|
expect(mockFetch).toHaveBeenCalled();
|
||||||
|
expect(getSecurityConfig).toHaveBeenCalledTimes(2);
|
||||||
|
expect(IntuneManager.getPolicy).toHaveBeenCalledWith('server-1');
|
||||||
|
expect(IntuneManager.getPolicy).toHaveBeenCalledWith('server-2');
|
||||||
|
expect(SecurityManager.initialized).toBe(true);
|
||||||
expect(SecurityManager.serverConfig['server-1']).toBeDefined();
|
expect(SecurityManager.serverConfig['server-1']).toBeDefined();
|
||||||
expect(SecurityManager.serverConfig['server-2']).toBeDefined();
|
expect(SecurityManager.serverConfig['server-2']).toBeDefined();
|
||||||
expect(SecurityManager.activeServer).toBe('server-1');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not reinitialize if already initialized', async () => {
|
test('should not reinitialize if already initialized', async () => {
|
||||||
const servers: Record<string, any> = {
|
|
||||||
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
|
|
||||||
};
|
|
||||||
SecurityManager.initialized = true;
|
SecurityManager.initialized = true;
|
||||||
await SecurityManager.init(servers, 'server-1');
|
await SecurityManager.init();
|
||||||
|
expect(queryAllActiveServers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty server list', async () => {
|
||||||
|
const mockFetch = jest.fn().mockResolvedValue([]);
|
||||||
|
jest.mocked(queryAllActiveServers).mockReturnValue({fetch: mockFetch} as unknown as Query<ServersModel>);
|
||||||
|
|
||||||
|
await SecurityManager.init();
|
||||||
|
|
||||||
|
expect(queryAllActiveServers).toHaveBeenCalled();
|
||||||
|
expect(mockFetch).toHaveBeenCalled();
|
||||||
|
expect(SecurityManager.initialized).toBe(true);
|
||||||
|
expect(Object.keys(SecurityManager.serverConfig)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle undefined queryAllActiveServers result', async () => {
|
||||||
|
jest.mocked(queryAllActiveServers).mockReturnValue(undefined);
|
||||||
|
|
||||||
|
await SecurityManager.init();
|
||||||
|
|
||||||
|
expect(queryAllActiveServers).toHaveBeenCalled();
|
||||||
|
expect(SecurityManager.initialized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load Intune policies along with server configs', async () => {
|
||||||
|
const mockServers = [{url: 'server-1'}];
|
||||||
|
const mockFetch = jest.fn().mockResolvedValue(mockServers);
|
||||||
|
jest.mocked(queryAllActiveServers).mockReturnValue({fetch: mockFetch} as unknown as Query<ServersModel>);
|
||||||
|
|
||||||
|
const mockConfig = {SiteName: 'Server One', MobileEnableBiometrics: 'false'} as unknown as SecurityClientConfig;
|
||||||
|
const mockIntunePolicy: IntunePolicy = {
|
||||||
|
isPINRequired: false,
|
||||||
|
isContactSyncAllowed: true,
|
||||||
|
isWidgetContentSyncAllowed: true,
|
||||||
|
isSpotlightIndexingAllowed: true,
|
||||||
|
areSiriIntentsAllowed: true,
|
||||||
|
areAppIntentsAllowed: true,
|
||||||
|
isAppSharingAllowed: true,
|
||||||
|
shouldFileProviderEncryptFiles: false,
|
||||||
|
isManagedBrowserRequired: false,
|
||||||
|
isFileEncryptionRequired: false,
|
||||||
|
isScreenCaptureAllowed: false,
|
||||||
|
notificationPolicy: 0,
|
||||||
|
allowedSaveLocations: {
|
||||||
|
Other: false,
|
||||||
|
OneDriveForBusiness: true,
|
||||||
|
SharePoint: true,
|
||||||
|
LocalDrive: false,
|
||||||
|
PhotoLibrary: false,
|
||||||
|
CameraRoll: false,
|
||||||
|
FilesApp: false,
|
||||||
|
iCloudDrive: false,
|
||||||
|
},
|
||||||
|
allowedOpenLocations: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mocked(getSecurityConfig).mockResolvedValue(mockConfig);
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: 'db-1'} as unknown as ServerDatabase);
|
||||||
|
jest.mocked(IntuneManager.getPolicy).mockResolvedValue(mockIntunePolicy);
|
||||||
|
|
||||||
|
await SecurityManager.init();
|
||||||
|
|
||||||
|
expect(IntuneManager.getPolicy).toHaveBeenCalledWith('server-1');
|
||||||
|
expect(SecurityManager.serverConfig['server-1'].intunePolicy).toEqual(mockIntunePolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle errors when loading server config', async () => {
|
||||||
|
const mockServers = [{url: 'server-1'}, {url: 'server-2'}];
|
||||||
|
const mockFetch = jest.fn().mockResolvedValue(mockServers);
|
||||||
|
jest.mocked(queryAllActiveServers).mockReturnValue({fetch: mockFetch} as unknown as Query<ServersModel>);
|
||||||
|
|
||||||
|
// First server throws error, second succeeds
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation((url: string) => {
|
||||||
|
if (url === 'server-1') {
|
||||||
|
throw new Error('Database error');
|
||||||
|
}
|
||||||
|
return {database: 'db-2'} as unknown as ServerDatabase;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockConfig2 = {SiteName: 'Server Two'} as unknown as SecurityClientConfig;
|
||||||
|
jest.mocked(getSecurityConfig).mockResolvedValue(mockConfig2);
|
||||||
|
jest.mocked(IntuneManager.getPolicy).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await SecurityManager.init();
|
||||||
|
|
||||||
|
expect(SecurityManager.initialized).toBe(true);
|
||||||
expect(SecurityManager.serverConfig['server-1']).toBeUndefined();
|
expect(SecurityManager.serverConfig['server-1']).toBeUndefined();
|
||||||
});
|
expect(SecurityManager.serverConfig['server-2']).toBeDefined();
|
||||||
|
expect(logError).toHaveBeenCalled();
|
||||||
test('should set active server and authenticate if not jailbroken', async () => {
|
|
||||||
const servers: Record<string, any> = {
|
|
||||||
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
|
|
||||||
};
|
|
||||||
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
|
||||||
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false);
|
|
||||||
|
|
||||||
const isDeviceJailbrokenSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken');
|
|
||||||
const authenticateWithBiometricsIfNeededSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded');
|
|
||||||
|
|
||||||
await SecurityManager.init(servers, 'server-1');
|
|
||||||
expect(SecurityManager.activeServer).toBe('server-1');
|
|
||||||
expect(isDeviceJailbrokenSpy).toHaveBeenCalledWith('server-1');
|
|
||||||
expect(authenticateWithBiometricsIfNeededSpy).toHaveBeenCalledWith('server-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not authenticate if device is jailbroken', async () => {
|
|
||||||
const servers: Record<string, any> = {
|
|
||||||
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true', MobileJailbreakProtection: 'true'},
|
|
||||||
};
|
|
||||||
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
|
|
||||||
await SecurityManager.init(servers, 'server-1');
|
|
||||||
expect(SecurityManager.activeServer).toBe('server-1');
|
|
||||||
expect(SecurityManager.isDeviceJailbroken).toHaveBeenCalledWith('server-1');
|
|
||||||
expect(SecurityManager.authenticateWithBiometricsIfNeeded).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not set active server if not provided', async () => {
|
|
||||||
const servers: Record<string, any> = {
|
|
||||||
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
|
|
||||||
};
|
|
||||||
await SecurityManager.init(servers);
|
|
||||||
expect(SecurityManager.activeServer).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addServer', () => {
|
describe('addServer', () => {
|
||||||
test('should add server config with biometrics enabled', () => {
|
test('should add server config with biometrics enabled', async () => {
|
||||||
SecurityManager.addServer('server-1', {SiteName: 'Server One', MobileEnableBiometrics: 'true'} as ClientConfig);
|
await SecurityManager.addServer('server-1', {SiteName: 'Server One', MobileEnableBiometrics: 'true'} as unknown as SecurityClientConfig);
|
||||||
expect(SecurityManager.serverConfig['server-1']).toEqual({
|
expect(SecurityManager.serverConfig['server-1']).toEqual({
|
||||||
siteName: 'Server One',
|
siteName: 'Server One',
|
||||||
Biometrics: true,
|
Biometrics: true,
|
||||||
JailbreakProtection: false,
|
JailbreakProtection: false,
|
||||||
PreventScreenCapture: false,
|
PreventScreenCapture: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
intunePolicy: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add server config with jailbreak protection enabled', () => {
|
test('should add server config with jailbreak protection enabled', async () => {
|
||||||
SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobileJailbreakProtection: 'true'} as ClientConfig);
|
await SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobileJailbreakProtection: 'true'} as unknown as SecurityClientConfig);
|
||||||
expect(SecurityManager.serverConfig['server-2']).toEqual({
|
expect(SecurityManager.serverConfig['server-2']).toEqual({
|
||||||
siteName: 'Server Two',
|
siteName: 'Server Two',
|
||||||
Biometrics: false,
|
Biometrics: false,
|
||||||
JailbreakProtection: true,
|
JailbreakProtection: true,
|
||||||
PreventScreenCapture: false,
|
PreventScreenCapture: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
intunePolicy: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add server config with screen capture prevention enabled', () => {
|
test('should add server config with screen capture prevention enabled', async () => {
|
||||||
SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as ClientConfig);
|
await SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as unknown as SecurityClientConfig);
|
||||||
expect(SecurityManager.serverConfig['server-3']).toEqual({
|
expect(SecurityManager.serverConfig['server-3']).toEqual({
|
||||||
siteName: 'Server Three',
|
siteName: 'Server Three',
|
||||||
Biometrics: false,
|
Biometrics: false,
|
||||||
JailbreakProtection: false,
|
JailbreakProtection: false,
|
||||||
PreventScreenCapture: true,
|
PreventScreenCapture: true,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
intunePolicy: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add server config with all features enabled', () => {
|
test('should add server config with all features enabled', async () => {
|
||||||
SecurityManager.addServer('server-4', {
|
await SecurityManager.addServer('server-4', {
|
||||||
SiteName: 'Server Four',
|
SiteName: 'Server Four',
|
||||||
MobileEnableBiometrics: 'true',
|
MobileEnableBiometrics: 'true',
|
||||||
MobileJailbreakProtection: 'true',
|
MobileJailbreakProtection: 'true',
|
||||||
MobilePreventScreenCapture: 'true',
|
MobilePreventScreenCapture: 'true',
|
||||||
} as ClientConfig);
|
} as unknown as SecurityClientConfig);
|
||||||
expect(SecurityManager.serverConfig['server-4']).toEqual({
|
expect(SecurityManager.serverConfig['server-4']).toEqual({
|
||||||
siteName: 'Server Four',
|
siteName: 'Server Four',
|
||||||
Biometrics: true,
|
Biometrics: true,
|
||||||
JailbreakProtection: true,
|
JailbreakProtection: true,
|
||||||
PreventScreenCapture: true,
|
PreventScreenCapture: true,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
intunePolicy: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add server config with authenticated set to true', () => {
|
test('should add server config with authenticated set to true', async () => {
|
||||||
SecurityManager.addServer('server-5', {SiteName: 'Server Five'} as ClientConfig, true);
|
await SecurityManager.addServer('server-5', {SiteName: 'Server Five'} as unknown as SecurityClientConfig, true);
|
||||||
expect(SecurityManager.serverConfig['server-5']).toEqual({
|
expect(SecurityManager.serverConfig['server-5']).toEqual({
|
||||||
siteName: 'Server Five',
|
siteName: 'Server Five',
|
||||||
Biometrics: false,
|
Biometrics: false,
|
||||||
JailbreakProtection: false,
|
JailbreakProtection: false,
|
||||||
PreventScreenCapture: false,
|
PreventScreenCapture: false,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
intunePolicy: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add server config without config', () => {
|
test('should add server config without config', async () => {
|
||||||
SecurityManager.addServer('server-6');
|
await SecurityManager.addServer('server-6');
|
||||||
expect(SecurityManager.serverConfig['server-6']).toEqual({
|
expect(SecurityManager.serverConfig['server-6']).toEqual({
|
||||||
siteName: undefined,
|
siteName: undefined,
|
||||||
Biometrics: false,
|
Biometrics: false,
|
||||||
JailbreakProtection: false,
|
JailbreakProtection: false,
|
||||||
PreventScreenCapture: false,
|
PreventScreenCapture: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
intunePolicy: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update a server config previously added', () => {
|
test('should update a server config previously added', async () => {
|
||||||
SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as ClientConfig, true);
|
await SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as unknown as SecurityClientConfig, true);
|
||||||
expect(SecurityManager.serverConfig['server-3']).toEqual({
|
expect(SecurityManager.serverConfig['server-3']).toEqual({
|
||||||
siteName: 'Server Three',
|
siteName: 'Server Three',
|
||||||
Biometrics: false,
|
Biometrics: false,
|
||||||
JailbreakProtection: false,
|
JailbreakProtection: false,
|
||||||
PreventScreenCapture: true,
|
PreventScreenCapture: true,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
intunePolicy: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('removeServer', () => {
|
describe('removeServer', () => {
|
||||||
test('should remove server config and active server', async () => {
|
test('should remove server config and active server', async () => {
|
||||||
const servers: Record<string, any> = {
|
await SecurityManager.addServer('server-1', {SiteName: 'Server One', MobileEnableBiometrics: 'true'} as SecurityClientConfig);
|
||||||
'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'},
|
await SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobileEnableBiometrics: 'false'} as SecurityClientConfig);
|
||||||
'server-2': {SiteName: 'Server Two', MobileEnableBiometrics: 'false'},
|
await SecurityManager.setActiveServer({serverUrl: 'server-1'});
|
||||||
};
|
SecurityManager.initialized = true;
|
||||||
await SecurityManager.init(servers, 'server-1');
|
|
||||||
SecurityManager.removeServer('server-1');
|
SecurityManager.removeServer('server-1');
|
||||||
expect(SecurityManager.serverConfig['server-1']).toBeUndefined();
|
expect(SecurityManager.serverConfig['server-1']).toBeUndefined();
|
||||||
expect(SecurityManager.activeServer).toBeUndefined();
|
expect(SecurityManager.activeServer).toBeUndefined();
|
||||||
expect(SecurityManager.initialized).toBe(false);
|
expect(SecurityManager.initialized).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should remove server config', () => {
|
test('should remove server config', async () => {
|
||||||
SecurityManager.addServer('server-3');
|
await SecurityManager.addServer('server-3');
|
||||||
SecurityManager.removeServer('server-3');
|
SecurityManager.removeServer('server-3');
|
||||||
expect(SecurityManager.serverConfig['server-3']).toBeUndefined();
|
expect(SecurityManager.serverConfig['server-3']).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getServerConfig', () => {
|
describe('getServerConfig', () => {
|
||||||
test('should return server config', () => {
|
test('should return server config', async () => {
|
||||||
SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as ClientConfig);
|
await SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as SecurityClientConfig);
|
||||||
expect(SecurityManager.getServerConfig('server-4')?.siteName).toBe('Server Four');
|
expect(SecurityManager.getServerConfig('server-4')?.siteName).toBe('Server Four');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -230,27 +337,27 @@ describe('SecurityManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setActiveServer', () => {
|
describe('setActiveServer', () => {
|
||||||
test('should set active server and update lastAccessed', () => {
|
test('should set active server and update lastAccessed', async () => {
|
||||||
SecurityManager.addServer('server-5');
|
await SecurityManager.addServer('server-5');
|
||||||
const before = Date.now();
|
const before = Date.now();
|
||||||
SecurityManager.setActiveServer('server-5');
|
await SecurityManager.setActiveServer({serverUrl: 'server-5'});
|
||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
expect(SecurityManager.activeServer).toBe('server-5');
|
expect(SecurityManager.activeServer).toBe('server-5');
|
||||||
expect(SecurityManager.serverConfig['server-5'].lastAccessed).toBeGreaterThanOrEqual(before);
|
expect(SecurityManager.serverConfig['server-5'].lastAccessed).toBeGreaterThanOrEqual(before);
|
||||||
expect(SecurityManager.serverConfig['server-5'].lastAccessed).toBeLessThanOrEqual(after);
|
expect(SecurityManager.serverConfig['server-5'].lastAccessed).toBeLessThanOrEqual(after);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not set active server if server does not exist', () => {
|
test('should not set active server if server does not exist', async () => {
|
||||||
SecurityManager.setActiveServer('server-6');
|
await SecurityManager.setActiveServer({serverUrl: 'server-6'});
|
||||||
expect(SecurityManager.activeServer).toBeUndefined();
|
expect(SecurityManager.activeServer).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update active server and lastAccessed if a different server is set', () => {
|
test('should update active server and lastAccessed if a different server is set', async () => {
|
||||||
SecurityManager.addServer('server-7');
|
await SecurityManager.addServer('server-7');
|
||||||
SecurityManager.addServer('server-8');
|
await SecurityManager.addServer('server-8');
|
||||||
SecurityManager.setActiveServer('server-7');
|
await SecurityManager.setActiveServer({serverUrl: 'server-7'});
|
||||||
const before = Date.now();
|
const before = Date.now();
|
||||||
SecurityManager.setActiveServer('server-8');
|
await SecurityManager.setActiveServer({serverUrl: 'server-8'});
|
||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
expect(SecurityManager.activeServer).toBe('server-8');
|
expect(SecurityManager.activeServer).toBe('server-8');
|
||||||
expect(SecurityManager.serverConfig['server-8'].lastAccessed).toBeGreaterThanOrEqual(before);
|
expect(SecurityManager.serverConfig['server-8'].lastAccessed).toBeGreaterThanOrEqual(before);
|
||||||
|
|
@ -258,23 +365,115 @@ describe('SecurityManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not change active server or lastAccessed if the same server is set', async () => {
|
test('should not change active server or lastAccessed if the same server is set', async () => {
|
||||||
SecurityManager.addServer('server-9');
|
await SecurityManager.addServer('server-9');
|
||||||
SecurityManager.setActiveServer('server-9');
|
await SecurityManager.setActiveServer({serverUrl: 'server-9'});
|
||||||
const lastAccessed = SecurityManager.serverConfig['server-9'].lastAccessed;
|
const lastAccessed = SecurityManager.serverConfig['server-9'].lastAccessed;
|
||||||
SecurityManager.setActiveServer('server-9');
|
await SecurityManager.setActiveServer({serverUrl: 'server-9'});
|
||||||
expect(SecurityManager.activeServer).toBe('server-9');
|
expect(SecurityManager.activeServer).toBe('server-9');
|
||||||
expect(SecurityManager.serverConfig['server-9'].lastAccessed).toBe(lastAccessed);
|
expect(SecurityManager.serverConfig['server-9'].lastAccessed).toBe(lastAccessed);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setActiveServer options', () => {
|
||||||
|
const serverUrl1 = 'https://test1.server.com';
|
||||||
|
const serverUrl2 = 'https://test2.server.com';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
|
||||||
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
||||||
|
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
||||||
|
await SecurityManager.init();
|
||||||
|
await SecurityManager.addServer(serverUrl1, {SiteName: 'Test Server 1'} as SecurityClientConfig);
|
||||||
|
await SecurityManager.addServer(serverUrl2, {SiteName: 'Test Server 2'} as SecurityClientConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip MAM enrollment check when skipMAMEnrollmentCheck is true', async () => {
|
||||||
|
const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer');
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true}});
|
||||||
|
|
||||||
|
expect(ensureMAMSpy).not.toHaveBeenCalled();
|
||||||
|
ensureMAMSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should perform MAM enrollment check when skipMAMEnrollmentCheck is false', async () => {
|
||||||
|
const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true);
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: false}});
|
||||||
|
|
||||||
|
expect(ensureMAMSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
ensureMAMSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip jailbreak check when skipJailbreakCheck is true', async () => {
|
||||||
|
const jailbreakSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken');
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true}});
|
||||||
|
|
||||||
|
expect(jailbreakSpy).not.toHaveBeenCalled();
|
||||||
|
jailbreakSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should perform jailbreak check when skipJailbreakCheck is false', async () => {
|
||||||
|
const jailbreakSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken').mockResolvedValue(false);
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: false}});
|
||||||
|
|
||||||
|
expect(jailbreakSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
jailbreakSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip biometric auth when skipBiometricCheck is true', async () => {
|
||||||
|
const biometricSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded');
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
|
||||||
|
expect(biometricSpy).not.toHaveBeenCalled();
|
||||||
|
biometricSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should perform biometric auth when skipBiometricCheck is false', async () => {
|
||||||
|
const biometricSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded');
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: false}});
|
||||||
|
|
||||||
|
expect(biometricSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
biometricSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should force switch to same server when forceSwitch is true', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
setScreenCaptureSpy.mockClear();
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true, forceSwitch: true}});
|
||||||
|
|
||||||
|
expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1);
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not switch to same server when forceSwitch is false', async () => {
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}});
|
||||||
|
const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy');
|
||||||
|
setScreenCaptureSpy.mockClear();
|
||||||
|
|
||||||
|
await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true, forceSwitch: false}});
|
||||||
|
|
||||||
|
expect(setScreenCaptureSpy).not.toHaveBeenCalled();
|
||||||
|
setScreenCaptureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isScreenCapturePrevented', () => {
|
describe('isScreenCapturePrevented', () => {
|
||||||
test('should return true if screen capture prevention is enabled', () => {
|
test('should return true if screen capture prevention is enabled', async () => {
|
||||||
SecurityManager.addServer('server-1', {SiteName: 'Server One', MobilePreventScreenCapture: 'true'} as ClientConfig);
|
await SecurityManager.addServer('server-1', {SiteName: 'Server One', MobilePreventScreenCapture: 'true'} as SecurityClientConfig);
|
||||||
expect(SecurityManager.isScreenCapturePrevented('server-1')).toBe(true);
|
expect(SecurityManager.isScreenCapturePrevented('server-1')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return false if screen capture prevention is disabled', () => {
|
test('should return false if screen capture prevention is disabled', async () => {
|
||||||
SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobilePreventScreenCapture: 'false'} as ClientConfig);
|
await SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobilePreventScreenCapture: 'false'} as SecurityClientConfig);
|
||||||
expect(SecurityManager.isScreenCapturePrevented('server-2')).toBe(false);
|
expect(SecurityManager.isScreenCapturePrevented('server-2')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -282,8 +481,8 @@ describe('SecurityManager', () => {
|
||||||
expect(SecurityManager.isScreenCapturePrevented('server-3')).toBe(false);
|
expect(SecurityManager.isScreenCapturePrevented('server-3')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return false if PreventScreenCapture property is not set', () => {
|
test('should return false if PreventScreenCapture property is not set', async () => {
|
||||||
SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as ClientConfig);
|
await SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as SecurityClientConfig);
|
||||||
expect(SecurityManager.isScreenCapturePrevented('server-4')).toBe(false);
|
expect(SecurityManager.isScreenCapturePrevented('server-4')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -292,7 +491,7 @@ describe('SecurityManager', () => {
|
||||||
test('should handle biometric authentication if biometrics enabled and device secured', async () => {
|
test('should handle biometric authentication if biometrics enabled and device secured', async () => {
|
||||||
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
||||||
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
||||||
SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as ClientConfig);
|
await SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as SecurityClientConfig);
|
||||||
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-6')).resolves.toBe(true);
|
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-6')).resolves.toBe(true);
|
||||||
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
||||||
expect(Emm.authenticate).toHaveBeenCalled();
|
expect(Emm.authenticate).toHaveBeenCalled();
|
||||||
|
|
@ -300,8 +499,8 @@ describe('SecurityManager', () => {
|
||||||
|
|
||||||
test('should not prompt for biometric authentication if biometrics enabled but device is not secured', async () => {
|
test('should not prompt for biometric authentication if biometrics enabled but device is not secured', async () => {
|
||||||
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(false);
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(false);
|
||||||
const showNotSecuredAlertSpy = jest.spyOn(SecurityManager, 'showNotSecuredAlert');
|
const showNotSecuredAlertSpy = jest.spyOn(alerts, 'showNotSecuredAlert');
|
||||||
SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as ClientConfig);
|
await SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as SecurityClientConfig);
|
||||||
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-6')).resolves.toBe(false);
|
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-6')).resolves.toBe(false);
|
||||||
expect(showNotSecuredAlertSpy).toHaveBeenCalled();
|
expect(showNotSecuredAlertSpy).toHaveBeenCalled();
|
||||||
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
||||||
|
|
@ -309,7 +508,7 @@ describe('SecurityManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not attempt biometric authentication if biometrics not enabled', async () => {
|
test('should not attempt biometric authentication if biometrics not enabled', async () => {
|
||||||
SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'false'} as ClientConfig);
|
await SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'false'} as SecurityClientConfig);
|
||||||
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-8')).resolves.toBe(true);
|
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-8')).resolves.toBe(true);
|
||||||
expect(Emm.isDeviceSecured).not.toHaveBeenCalled();
|
expect(Emm.isDeviceSecured).not.toHaveBeenCalled();
|
||||||
expect(Emm.authenticate).not.toHaveBeenCalled();
|
expect(Emm.authenticate).not.toHaveBeenCalled();
|
||||||
|
|
@ -318,7 +517,7 @@ describe('SecurityManager', () => {
|
||||||
test('should resolve with true if biometric authentication succeeds', async () => {
|
test('should resolve with true if biometric authentication succeeds', async () => {
|
||||||
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
||||||
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
||||||
SecurityManager.addServer('server-9', {MobileEnableBiometrics: 'true'} as ClientConfig);
|
await SecurityManager.addServer('server-9', {MobileEnableBiometrics: 'true'} as SecurityClientConfig);
|
||||||
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-9')).resolves.toBe(true);
|
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-9')).resolves.toBe(true);
|
||||||
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
||||||
expect(Emm.authenticate).toHaveBeenCalled();
|
expect(Emm.authenticate).toHaveBeenCalled();
|
||||||
|
|
@ -327,7 +526,7 @@ describe('SecurityManager', () => {
|
||||||
test('should log error and resolve with false if biometric authentication fails', async () => {
|
test('should log error and resolve with false if biometric authentication fails', async () => {
|
||||||
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
||||||
jest.mocked(Emm.authenticate).mockResolvedValue(false);
|
jest.mocked(Emm.authenticate).mockResolvedValue(false);
|
||||||
SecurityManager.addServer('server-10', {MobileEnableBiometrics: 'true'} as ClientConfig);
|
await SecurityManager.addServer('server-10', {MobileEnableBiometrics: 'true'} as SecurityClientConfig);
|
||||||
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-10')).resolves.toBe(false);
|
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-10')).resolves.toBe(false);
|
||||||
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
||||||
expect(Emm.authenticate).toHaveBeenCalled();
|
expect(Emm.authenticate).toHaveBeenCalled();
|
||||||
|
|
@ -338,7 +537,7 @@ describe('SecurityManager', () => {
|
||||||
test('should log error and resolve with false if biometric authentication throws an error', async () => {
|
test('should log error and resolve with false if biometric authentication throws an error', async () => {
|
||||||
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
||||||
jest.mocked(Emm.authenticate).mockRejectedValue(new Error('Authorization cancelled'));
|
jest.mocked(Emm.authenticate).mockRejectedValue(new Error('Authorization cancelled'));
|
||||||
SecurityManager.addServer('server-11', {MobileEnableBiometrics: 'true'} as ClientConfig);
|
await SecurityManager.addServer('server-11', {MobileEnableBiometrics: 'true'} as SecurityClientConfig);
|
||||||
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-11')).resolves.toBe(false);
|
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-11')).resolves.toBe(false);
|
||||||
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
expect(Emm.isDeviceSecured).toHaveBeenCalled();
|
||||||
expect(Emm.authenticate).toHaveBeenCalled();
|
expect(Emm.authenticate).toHaveBeenCalled();
|
||||||
|
|
@ -359,7 +558,7 @@ describe('SecurityManager', () => {
|
||||||
Date.now = jest.fn(() => fixedTime);
|
Date.now = jest.fn(() => fixedTime);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
SecurityManager.addServer('server-12', {MobileEnableBiometrics: 'true'} as ClientConfig, true);
|
await SecurityManager.addServer('server-12', {MobileEnableBiometrics: 'true'} as SecurityClientConfig, true);
|
||||||
SecurityManager.serverConfig['server-12'].lastAccessed = oneMinuteAgo;
|
SecurityManager.serverConfig['server-12'].lastAccessed = oneMinuteAgo;
|
||||||
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-12')).resolves.toBe(true);
|
await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-12')).resolves.toBe(true);
|
||||||
expect(Emm.isDeviceSecured).not.toHaveBeenCalled();
|
expect(Emm.isDeviceSecured).not.toHaveBeenCalled();
|
||||||
|
|
@ -372,7 +571,7 @@ describe('SecurityManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not attempt biometric authentication if server was previously failed authentication even though lastAccess is less than 5 mins', async () => {
|
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);
|
await SecurityManager.addServer('server-13', {MobileEnableBiometrics: 'true'} as SecurityClientConfig);
|
||||||
SecurityManager.serverConfig['server-13'].authenticated = false;
|
SecurityManager.serverConfig['server-13'].authenticated = false;
|
||||||
SecurityManager.serverConfig['server-13'].lastAccessed = Date.now() - toMilliseconds({minutes: 1});
|
SecurityManager.serverConfig['server-13'].lastAccessed = Date.now() - toMilliseconds({minutes: 1});
|
||||||
await SecurityManager.authenticateWithBiometricsIfNeeded('server-13');
|
await SecurityManager.authenticateWithBiometricsIfNeeded('server-13');
|
||||||
|
|
@ -381,167 +580,66 @@ describe('SecurityManager', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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', () => {
|
|
||||||
afterAll(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should switch to previous server', async () => {
|
|
||||||
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
|
||||||
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
|
||||||
jest.mocked(switchToServer).mockImplementation((serverUrl: string) => {
|
|
||||||
SecurityManager.setActiveServer(serverUrl);
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
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());
|
|
||||||
expect(SecurityManager.activeServer).toBe('server-10');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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[messages.logout.id]);
|
|
||||||
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[messages.blocked_by.id].replace('{vendor}', siteName),
|
|
||||||
translations[messages.jailbreak.id].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[messages.blocked_by.id].replace('{vendor}', siteName),
|
|
||||||
translations[messages.biometric_failed.id],
|
|
||||||
expect.any(Array),
|
|
||||||
{cancelable: false},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isDeviceJailbroken', () => {
|
describe('isDeviceJailbroken', () => {
|
||||||
test('should check if device is jailbroken', async () => {
|
test('should check if device is jailbroken and return true', async () => {
|
||||||
const server = 'server-15';
|
const server = 'server-15';
|
||||||
const siteName = 'Site Name';
|
const siteName = 'Site Name';
|
||||||
SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as ClientConfig);
|
await SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as SecurityClientConfig);
|
||||||
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
|
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
|
||||||
const translations = getTranslations(DEFAULT_LOCALE);
|
|
||||||
|
|
||||||
const result = await SecurityManager.isDeviceJailbroken(server, siteName);
|
const result = await SecurityManager.isDeviceJailbroken(server, siteName);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
await TestHelper.wait(300);
|
expect(isRootedExperimentalAsync).toHaveBeenCalled();
|
||||||
expect(Alert.alert).toHaveBeenCalledWith(
|
|
||||||
translations[messages.blocked_by.id].replace('{vendor}', siteName),
|
|
||||||
translations[messages.jailbreak.id].replace('{vendor}', siteName),
|
|
||||||
expect.any(Array),
|
|
||||||
{cancelable: false},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return false if device is not jailbroken', async () => {
|
test('should return false if device is not jailbroken', async () => {
|
||||||
const server = 'server-16';
|
const server = 'server-16';
|
||||||
const siteName = 'Site Name';
|
const siteName = 'Site Name';
|
||||||
SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as ClientConfig);
|
await SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as SecurityClientConfig);
|
||||||
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false);
|
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false);
|
||||||
|
|
||||||
const result = await SecurityManager.isDeviceJailbroken(server, siteName);
|
const result = await SecurityManager.isDeviceJailbroken(server, siteName);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(Alert.alert).not.toHaveBeenCalled();
|
});
|
||||||
|
|
||||||
|
test('should return false if jailbreak protection is not enabled', async () => {
|
||||||
|
const server = 'server-17';
|
||||||
|
await SecurityManager.addServer(server, {MobileJailbreakProtection: 'false'} as SecurityClientConfig);
|
||||||
|
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await SecurityManager.isDeviceJailbroken(server);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(isRootedExperimentalAsync).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getShieldScreenId', () => {
|
describe('getShieldScreenId', () => {
|
||||||
test('should return the name of the screen shielded if prevent screen capture is enabled', () => {
|
test('should return the name of the screen shielded if prevent screen capture is enabled', () => {
|
||||||
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as ClientConfig);
|
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as SecurityClientConfig);
|
||||||
SecurityManager.activeServer = 'server-2';
|
SecurityManager.activeServer = 'server-2';
|
||||||
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen.shielded`);
|
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', () => {
|
test('should return the name of the screen without shielded if active server is different', () => {
|
||||||
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as ClientConfig);
|
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as SecurityClientConfig);
|
||||||
SecurityManager.activeServer = 'server-1';
|
SecurityManager.activeServer = 'server-1';
|
||||||
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen`);
|
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return the name of the screen shielded if prevent screen capture is disabled', () => {
|
test('should return the name of the screen shielded if prevent screen capture is disabled', () => {
|
||||||
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'false'} as ClientConfig);
|
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'false'} as SecurityClientConfig);
|
||||||
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen`);
|
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', () => {
|
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);
|
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'false'} as SecurityClientConfig);
|
||||||
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL, true)).toBe(`${Screens.CHANNEL}.screen.shielded`);
|
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL, true)).toBe(`${Screens.CHANNEL}.screen.shielded`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return the name of the screen as shielded but skip', () => {
|
test('should return the name of the screen as shielded but skip', () => {
|
||||||
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as ClientConfig);
|
SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as SecurityClientConfig);
|
||||||
SecurityManager.activeServer = 'server-2';
|
SecurityManager.activeServer = 'server-2';
|
||||||
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL, false, true)).toBe(`${Screens.CHANNEL}.screen.skip.shielded`);
|
expect(SecurityManager.getShieldScreenId(Screens.CHANNEL, false, true)).toBe(`${Screens.CHANNEL}.screen.skip.shielded`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
717
app/managers/security_manager/mam.test.ts
Normal file
717
app/managers/security_manager/mam.test.ts
Normal file
|
|
@ -0,0 +1,717 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
/* eslint-disable max-lines */
|
||||||
|
|
||||||
|
import Emm from '@mattermost/react-native-emm';
|
||||||
|
import {isRootedExperimentalAsync} from 'expo-device';
|
||||||
|
import {type EventSubscription} from 'react-native';
|
||||||
|
|
||||||
|
import {getCurrentUserLocale} from '@actions/local/user';
|
||||||
|
import {logout} from '@actions/remote/session';
|
||||||
|
import {Sso} from '@constants';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import IntuneManager from '@managers/intune_manager';
|
||||||
|
import {type IntunePolicy} from '@managers/intune_manager/types';
|
||||||
|
import {getConfig, getConfigValue} from '@queries/servers/system';
|
||||||
|
import {getCurrentUser} from '@queries/servers/user';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
import * as alerts from '@utils/alerts';
|
||||||
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
|
import SecurityManager from '.';
|
||||||
|
|
||||||
|
import type {ServerDatabase} from '@typings/database/database';
|
||||||
|
import type UserModel from '@typings/database/models/servers/user';
|
||||||
|
|
||||||
|
jest.mock('@mattermost/react-native-emm', () => ({
|
||||||
|
isDeviceSecured: jest.fn(),
|
||||||
|
authenticate: jest.fn(),
|
||||||
|
openSecuritySettings: jest.fn(),
|
||||||
|
exitApp: jest.fn(),
|
||||||
|
enableBlurScreen: jest.fn(),
|
||||||
|
applyBlurEffect: jest.fn(),
|
||||||
|
removeBlurEffect: jest.fn(),
|
||||||
|
addListener: jest.fn(),
|
||||||
|
setAppGroupId: jest.fn(),
|
||||||
|
getManagedConfig: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-device', () => ({
|
||||||
|
isRootedExperimentalAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@actions/local/session', () => ({terminateSession: jest.fn(() => Promise.resolve())}));
|
||||||
|
jest.mock('@actions/local/user', () => ({getCurrentUserLocale: jest.fn(() => Promise.resolve('en'))}));
|
||||||
|
jest.mock('@actions/remote/session', () => ({
|
||||||
|
logout: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
jest.mock('@utils/log', () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
|
logDebug: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@init/credentials', () => ({getServerCredentials: jest.fn().mockResolvedValue({token: 'token'})}));
|
||||||
|
jest.mock('@database/manager', () => ({
|
||||||
|
getActiveServerUrl: jest.fn(),
|
||||||
|
getServerDatabaseAndOperator: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@queries/servers/system', () => ({
|
||||||
|
getConfig: jest.fn(),
|
||||||
|
getConfigValue: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@queries/servers/user', () => ({
|
||||||
|
getCurrentUser: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SecurityManager - Intune MAM Integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
SecurityManager.initialized = false;
|
||||||
|
SecurityManager.serverConfig = {};
|
||||||
|
SecurityManager.activeServer = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default policy with strict restrictions (all policies enforced)
|
||||||
|
const mockRestrictiveIntunePolicy: IntunePolicy = {
|
||||||
|
isPINRequired: true,
|
||||||
|
isContactSyncAllowed: false,
|
||||||
|
isWidgetContentSyncAllowed: false,
|
||||||
|
isSpotlightIndexingAllowed: false,
|
||||||
|
areSiriIntentsAllowed: false,
|
||||||
|
areAppIntentsAllowed: false,
|
||||||
|
isAppSharingAllowed: false,
|
||||||
|
shouldFileProviderEncryptFiles: true,
|
||||||
|
isManagedBrowserRequired: true,
|
||||||
|
isFileEncryptionRequired: true,
|
||||||
|
isScreenCaptureAllowed: false,
|
||||||
|
notificationPolicy: 2, // Block all notifications
|
||||||
|
allowedSaveLocations: {
|
||||||
|
Other: false,
|
||||||
|
OneDriveForBusiness: true,
|
||||||
|
SharePoint: true,
|
||||||
|
LocalDrive: false,
|
||||||
|
PhotoLibrary: false,
|
||||||
|
CameraRoll: false,
|
||||||
|
FilesApp: false,
|
||||||
|
iCloudDrive: false,
|
||||||
|
},
|
||||||
|
allowedOpenLocations: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPermissiveIntunePolicy: IntunePolicy = {
|
||||||
|
isPINRequired: false,
|
||||||
|
isContactSyncAllowed: true,
|
||||||
|
isWidgetContentSyncAllowed: true,
|
||||||
|
isSpotlightIndexingAllowed: true,
|
||||||
|
areSiriIntentsAllowed: true,
|
||||||
|
areAppIntentsAllowed: true,
|
||||||
|
isAppSharingAllowed: true,
|
||||||
|
shouldFileProviderEncryptFiles: false,
|
||||||
|
isManagedBrowserRequired: false,
|
||||||
|
isFileEncryptionRequired: false,
|
||||||
|
isScreenCaptureAllowed: true,
|
||||||
|
notificationPolicy: 0, // Allow all notifications
|
||||||
|
allowedSaveLocations: {
|
||||||
|
Other: true,
|
||||||
|
OneDriveForBusiness: true,
|
||||||
|
SharePoint: true,
|
||||||
|
LocalDrive: true,
|
||||||
|
PhotoLibrary: true,
|
||||||
|
CameraRoll: true,
|
||||||
|
FilesApp: true,
|
||||||
|
iCloudDrive: true,
|
||||||
|
},
|
||||||
|
allowedOpenLocations: 255, // All locations
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('start', () => {
|
||||||
|
test('should set current Intune identity and apply policies', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig);
|
||||||
|
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
|
||||||
|
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false);
|
||||||
|
|
||||||
|
await SecurityManager.start();
|
||||||
|
|
||||||
|
expect(IntuneManager.setCurrentIdentity).toHaveBeenCalledWith(serverUrl);
|
||||||
|
expect(SecurityManager.activeServer).toBe(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle missing active server', async () => {
|
||||||
|
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue('');
|
||||||
|
|
||||||
|
await SecurityManager.start();
|
||||||
|
|
||||||
|
expect(IntuneManager.setCurrentIdentity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip biometric auth when device is jailbroken', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true', MobileJailbreakProtection: 'true'} as SecurityClientConfig);
|
||||||
|
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
|
||||||
|
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await SecurityManager.start();
|
||||||
|
|
||||||
|
expect(Emm.authenticate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isScreenCapturePrevented - MAM policy precedence', () => {
|
||||||
|
test('should return false (block capture) when MAM disallows even if server allows', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy);
|
||||||
|
|
||||||
|
expect(SecurityManager.isScreenCapturePrevented(serverUrl)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true (allow capture) when both MAM and server allow', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig, false, mockPermissiveIntunePolicy);
|
||||||
|
|
||||||
|
expect(SecurityManager.isScreenCapturePrevented(serverUrl)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true (block capture) when server prevents and MAM allows', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'true'} as SecurityClientConfig, false, mockPermissiveIntunePolicy);
|
||||||
|
|
||||||
|
expect(SecurityManager.isScreenCapturePrevented(serverUrl)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false (block capture) when MAM disallows regardless of server', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
|
||||||
|
// Server allows capture, but MAM blocks - MAM wins
|
||||||
|
SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy);
|
||||||
|
|
||||||
|
expect(SecurityManager.isScreenCapturePrevented(serverUrl)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use server config when no Intune policy', () => {
|
||||||
|
const serverUrl1 = 'https://test1.server.com';
|
||||||
|
const serverUrl2 = 'https://test2.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl1, {MobilePreventScreenCapture: 'true'} as SecurityClientConfig, false, null);
|
||||||
|
SecurityManager.addServer(serverUrl2, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig, false, null);
|
||||||
|
|
||||||
|
expect(SecurityManager.isScreenCapturePrevented(serverUrl1)).toBe(true);
|
||||||
|
expect(SecurityManager.isScreenCapturePrevented(serverUrl2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authenticateWithBiometricsIfNeeded - isPINRequired enforcement', () => {
|
||||||
|
test('should skip biometric auth when MAM requires PIN', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy);
|
||||||
|
|
||||||
|
const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(Emm.authenticate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use server biometric config when MAM PIN not required', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, mockPermissiveIntunePolicy);
|
||||||
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
||||||
|
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(Emm.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not require auth when server disables biometrics and MAM PIN not required', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'false'} as SecurityClientConfig, false, mockPermissiveIntunePolicy);
|
||||||
|
|
||||||
|
const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(Emm.authenticate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip auth when MAM requires PIN even if server enables biometrics', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
|
||||||
|
// Server wants biometrics, but MAM requires PIN - MAM wins
|
||||||
|
SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy);
|
||||||
|
|
||||||
|
const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(Emm.authenticate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use server config when no Intune policy', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, null);
|
||||||
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
||||||
|
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(Emm.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authenticateWithBiometrics - isPINRequired enforcement', () => {
|
||||||
|
test('should skip authentication when MAM handles PIN', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy);
|
||||||
|
|
||||||
|
const result = await SecurityManager.authenticateWithBiometrics(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(Emm.isDeviceSecured).not.toHaveBeenCalled();
|
||||||
|
expect(Emm.authenticate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should attempt authentication when MAM does not require PIN', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
const siteName = 'Test Site';
|
||||||
|
SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as unknown as SecurityClientConfig, false, mockPermissiveIntunePolicy);
|
||||||
|
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
|
||||||
|
jest.mocked(Emm.authenticate).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await SecurityManager.authenticateWithBiometrics(serverUrl, siteName);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(Emm.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
test('should remove all Intune event subscriptions', () => {
|
||||||
|
const mockSubscription = {remove: jest.fn()} as unknown as EventSubscription;
|
||||||
|
SecurityManager.intunePolicySubscription = mockSubscription;
|
||||||
|
SecurityManager.intuneEnrollmentSubscription = mockSubscription;
|
||||||
|
SecurityManager.intuneWipeSubscription = mockSubscription;
|
||||||
|
SecurityManager.intuneAuthSubscription = mockSubscription;
|
||||||
|
SecurityManager.intuneBlockedSubscription = mockSubscription;
|
||||||
|
SecurityManager.intuneIdentitySwitchSubscription = mockSubscription;
|
||||||
|
|
||||||
|
SecurityManager.cleanup();
|
||||||
|
|
||||||
|
expect(mockSubscription.remove).toHaveBeenCalledTimes(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle missing subscriptions gracefully', () => {
|
||||||
|
SecurityManager.intunePolicySubscription = undefined;
|
||||||
|
SecurityManager.intuneEnrollmentSubscription = undefined;
|
||||||
|
|
||||||
|
expect(() => SecurityManager.cleanup()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addServer with Intune policy', () => {
|
||||||
|
test('should store restrictive Intune policy', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy);
|
||||||
|
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl);
|
||||||
|
expect(config?.intunePolicy).toEqual(mockRestrictiveIntunePolicy);
|
||||||
|
expect(config?.intunePolicy?.isPINRequired).toBe(true);
|
||||||
|
expect(config?.intunePolicy?.isScreenCaptureAllowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should store permissive Intune policy', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockPermissiveIntunePolicy);
|
||||||
|
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl);
|
||||||
|
expect(config?.intunePolicy).toEqual(mockPermissiveIntunePolicy);
|
||||||
|
expect(config?.intunePolicy?.isPINRequired).toBe(false);
|
||||||
|
expect(config?.intunePolicy?.isScreenCaptureAllowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should store null when not enrolled in Intune', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, null);
|
||||||
|
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl);
|
||||||
|
expect(config?.intunePolicy).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update policy when server re-enrolls', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
|
||||||
|
// First add without policy
|
||||||
|
SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, null);
|
||||||
|
expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy).toBeNull();
|
||||||
|
|
||||||
|
// Then update with restrictive policy
|
||||||
|
SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy);
|
||||||
|
const config = SecurityManager.getServerConfig(serverUrl);
|
||||||
|
expect(config?.intunePolicy?.isPINRequired).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update from restrictive to permissive policy', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy);
|
||||||
|
expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy?.isPINRequired).toBe(true);
|
||||||
|
|
||||||
|
SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockPermissiveIntunePolicy);
|
||||||
|
expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy?.isPINRequired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should clear policy when unenrolled', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy);
|
||||||
|
expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy).not.toBeNull();
|
||||||
|
|
||||||
|
SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, null);
|
||||||
|
expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ensureMAMEnrollmentForActiveServer', () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
const mockDatabase = {database: 'mock-db'};
|
||||||
|
const mockUser = {id: 'user-id', authService: Sso.OFFICE365};
|
||||||
|
const mockConfig = {IntuneScope: 'https://msmamservice.api.application/.default', IntuneAuthService: Sso.OFFICE365, SiteName: 'Test Server'};
|
||||||
|
const mockTokens = {
|
||||||
|
identity: {upn: 'user@test.com', tid: 'tenant-id', oid: 'object-id'},
|
||||||
|
idToken: 'id-token',
|
||||||
|
accessToken: 'access-token',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
SecurityManager.isEnrolling = false;
|
||||||
|
await SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig);
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue(mockDatabase as unknown as ServerDatabase);
|
||||||
|
jest.mocked(getCurrentUser).mockResolvedValue(mockUser as unknown as UserModel);
|
||||||
|
jest.mocked(getConfig).mockResolvedValue(mockConfig as ClientConfig);
|
||||||
|
jest.mocked(getConfigValue).mockResolvedValue(Sso.OFFICE365);
|
||||||
|
jest.mocked(getCurrentUserLocale).mockResolvedValue('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip enrollment if already enrolling (race condition prevention)', async () => {
|
||||||
|
SecurityManager.isEnrolling = true;
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(IntuneManager.isIntuneMAMEnabledForServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true if Intune MAM not enabled for server', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(IntuneManager.isIntuneMAMEnabledForServer).toHaveBeenCalledWith(serverUrl);
|
||||||
|
expect(IntuneManager.isManagedServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true if server is already managed', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(IntuneManager.isManagedServer).toHaveBeenCalledWith(serverUrl);
|
||||||
|
expect(getCurrentUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true if user auth service is not Office365', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(getCurrentUser).mockResolvedValue({...mockUser, authService: 'SAML'} as unknown as UserModel);
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(getCurrentUser).toHaveBeenCalledWith(mockDatabase.database);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false if IntuneScope not configured', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(getConfig).mockResolvedValue({IntuneScope: ''} as ClientConfig);
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(logError).toHaveBeenCalledWith('ensureMAMEnrollment: IntuneScope not configured');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show enrollment required alert with blur screen enabled', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async () => {
|
||||||
|
// User cancels enrollment - don't call callbacks
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't await - we're testing the alert is shown
|
||||||
|
SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
// Wait for blur to be applied
|
||||||
|
await TestHelper.wait(300);
|
||||||
|
|
||||||
|
expect(Emm.enableBlurScreen).toHaveBeenCalledWith(true);
|
||||||
|
expect(Emm.applyBlurEffect).toHaveBeenCalledWith(20);
|
||||||
|
expect(showAlertSpy).toHaveBeenCalledWith(
|
||||||
|
'Test Server',
|
||||||
|
'en',
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully enroll after user accepts', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens);
|
||||||
|
jest.mocked(IntuneManager.enrollServer).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => {
|
||||||
|
// User accepts enrollment
|
||||||
|
onAccept();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(IntuneManager.login).toHaveBeenCalledWith(serverUrl, [mockConfig.IntuneScope]);
|
||||||
|
expect(IntuneManager.enrollServer).toHaveBeenCalledWith(serverUrl, mockTokens.identity);
|
||||||
|
expect(Emm.removeBlurEffect).toHaveBeenCalled();
|
||||||
|
expect(SecurityManager.isEnrolling).toBe(false);
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should clear isEnrolling flag after successful enrollment', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens);
|
||||||
|
jest.mocked(IntuneManager.enrollServer).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => {
|
||||||
|
onAccept();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(SecurityManager.isEnrolling).toBe(false);
|
||||||
|
await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
expect(SecurityManager.isEnrolling).toBe(false);
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show enrollment failed alert on MSAL login failure', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(IntuneManager.login).mockRejectedValue(new Error('MSAL login failed'));
|
||||||
|
|
||||||
|
const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => {
|
||||||
|
onAccept();
|
||||||
|
});
|
||||||
|
const failedAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentFailedAlert').mockImplementation(async (_locale, onDismiss) => {
|
||||||
|
if (onDismiss) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(failedAlertSpy).toHaveBeenCalledWith('en', expect.any(Function));
|
||||||
|
expect(logout).toHaveBeenCalledWith(serverUrl, undefined, {removeServer: true});
|
||||||
|
expect(logError).toHaveBeenCalled();
|
||||||
|
|
||||||
|
enrollmentAlertSpy.mockRestore();
|
||||||
|
failedAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show enrollment failed alert on MAM enrollment failure', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens);
|
||||||
|
jest.mocked(IntuneManager.enrollServer).mockRejectedValue(new Error('Enrollment failed'));
|
||||||
|
|
||||||
|
const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => {
|
||||||
|
onAccept();
|
||||||
|
});
|
||||||
|
const failedAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentFailedAlert').mockImplementation(async (_locale, onDismiss) => {
|
||||||
|
if (onDismiss) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(failedAlertSpy).toHaveBeenCalledWith('en', expect.any(Function));
|
||||||
|
|
||||||
|
enrollmentAlertSpy.mockRestore();
|
||||||
|
failedAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should clear isEnrolling flag after enrollment failure', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(IntuneManager.login).mockRejectedValue(new Error('Failed'));
|
||||||
|
|
||||||
|
const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => {
|
||||||
|
onAccept();
|
||||||
|
});
|
||||||
|
const failedAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentFailedAlert').mockImplementation(async (_locale, onDismiss) => {
|
||||||
|
if (onDismiss) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
expect(SecurityManager.isEnrolling).toBe(false);
|
||||||
|
|
||||||
|
enrollmentAlertSpy.mockRestore();
|
||||||
|
failedAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show MAM declined alert when user cancels', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, _onAccept, onCancel) => {
|
||||||
|
onCancel();
|
||||||
|
});
|
||||||
|
const declinedAlertSpy = jest.spyOn(alerts, 'showMAMDeclinedAlert').mockImplementation(async (_url, _siteName, _locale, onDismiss) => {
|
||||||
|
onDismiss(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(declinedAlertSpy).toHaveBeenCalledWith(
|
||||||
|
serverUrl,
|
||||||
|
'Test Server',
|
||||||
|
'en',
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
enrollmentAlertSpy.mockRestore();
|
||||||
|
declinedAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow retry after user cancels', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens);
|
||||||
|
jest.mocked(IntuneManager.enrollServer).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, _onAccept, onCancel) => {
|
||||||
|
// User cancels first
|
||||||
|
onCancel();
|
||||||
|
});
|
||||||
|
const declinedAlertSpy = jest.spyOn(alerts, 'showMAMDeclinedAlert').mockImplementation(async (_url, _siteName, _locale, _onDismiss, onRetry) => {
|
||||||
|
// User chooses to retry
|
||||||
|
onRetry();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(IntuneManager.login).toHaveBeenCalled();
|
||||||
|
expect(IntuneManager.enrollServer).toHaveBeenCalled();
|
||||||
|
|
||||||
|
enrollmentAlertSpy.mockRestore();
|
||||||
|
declinedAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove blur screen after enrollment completion', async () => {
|
||||||
|
jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true);
|
||||||
|
jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false);
|
||||||
|
jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens);
|
||||||
|
jest.mocked(IntuneManager.enrollServer).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const showAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => {
|
||||||
|
onAccept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl);
|
||||||
|
|
||||||
|
expect(Emm.removeBlurEffect).toHaveBeenCalled();
|
||||||
|
expect(Emm.enableBlurScreen).toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
|
showAlertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canSaveToLocation', () => {
|
||||||
|
const serverUrl1 = 'https://test1.server.com';
|
||||||
|
const serverUrl2 = 'https://test2.server.com';
|
||||||
|
const mockPolicy: IntunePolicy = {
|
||||||
|
isPINRequired: true,
|
||||||
|
isContactSyncAllowed: false,
|
||||||
|
isWidgetContentSyncAllowed: false,
|
||||||
|
isSpotlightIndexingAllowed: false,
|
||||||
|
areSiriIntentsAllowed: false,
|
||||||
|
areAppIntentsAllowed: false,
|
||||||
|
isAppSharingAllowed: false,
|
||||||
|
shouldFileProviderEncryptFiles: true,
|
||||||
|
isManagedBrowserRequired: false,
|
||||||
|
isFileEncryptionRequired: true,
|
||||||
|
isScreenCaptureAllowed: false,
|
||||||
|
notificationPolicy: 0,
|
||||||
|
allowedSaveLocations: {
|
||||||
|
Other: false,
|
||||||
|
OneDriveForBusiness: true,
|
||||||
|
SharePoint: true,
|
||||||
|
LocalDrive: false,
|
||||||
|
PhotoLibrary: false,
|
||||||
|
CameraRoll: false,
|
||||||
|
FilesApp: false,
|
||||||
|
iCloudDrive: false,
|
||||||
|
},
|
||||||
|
allowedOpenLocations: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await SecurityManager.init();
|
||||||
|
await SecurityManager.addServer(serverUrl1, {SiteName: 'Test Server 1'} as SecurityClientConfig);
|
||||||
|
await SecurityManager.addServer(serverUrl2, {SiteName: 'Test Server 2'} as SecurityClientConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
SecurityManager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true when no Intune policy exists', () => {
|
||||||
|
const result = SecurityManager.canSaveToLocation(serverUrl1, 'PhotoLibrary');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return policy value when Intune policy exists', async () => {
|
||||||
|
await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy);
|
||||||
|
|
||||||
|
const canSaveToOneDrive = SecurityManager.canSaveToLocation(serverUrl1, 'OneDriveForBusiness');
|
||||||
|
const canSaveToPhotoLibrary = SecurityManager.canSaveToLocation(serverUrl1, 'PhotoLibrary');
|
||||||
|
|
||||||
|
expect(canSaveToOneDrive).toBe(true);
|
||||||
|
expect(canSaveToPhotoLibrary).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false for restricted locations', async () => {
|
||||||
|
await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy);
|
||||||
|
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'LocalDrive')).toBe(false);
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'CameraRoll')).toBe(false);
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'FilesApp')).toBe(false);
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'iCloudDrive')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true for allowed locations', async () => {
|
||||||
|
await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy);
|
||||||
|
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'OneDriveForBusiness')).toBe(true);
|
||||||
|
expect(SecurityManager.canSaveToLocation(serverUrl1, 'SharePoint')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,20 +4,28 @@
|
||||||
import CookieManager from '@react-native-cookies/cookies';
|
import CookieManager from '@react-native-cookies/cookies';
|
||||||
import {AppState, DeviceEventEmitter, Platform} from 'react-native';
|
import {AppState, DeviceEventEmitter, Platform} from 'react-native';
|
||||||
|
|
||||||
import {cancelSessionNotification, logout, scheduleSessionNotification} from '@actions/remote/session';
|
import {cancelAllSessionNotifications} from '@actions/local/session';
|
||||||
|
import {logout, scheduleSessionNotification} from '@actions/remote/session';
|
||||||
import {Events} from '@constants';
|
import {Events} from '@constants';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {getAllServerCredentials, removeServerCredentials} from '@init/credentials';
|
import {getAllServerCredentials, removeServerCredentials} from '@init/credentials';
|
||||||
import {relaunchApp} from '@init/launch';
|
import {relaunchApp} from '@init/launch';
|
||||||
import PushNotifications from '@init/push_notifications';
|
import PushNotifications from '@init/push_notifications';
|
||||||
|
import IntuneManager from '@managers/intune_manager';
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
import SecurityManager from '@managers/security_manager';
|
import SecurityManager from '@managers/security_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
|
import {queryGlobalValue} from '@queries/app/global';
|
||||||
|
import {getAllServers, getServerDisplayName} from '@queries/app/servers';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
|
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
|
||||||
import {isMainActivity} from '@utils/helpers';
|
import {isMainActivity} from '@utils/helpers';
|
||||||
|
|
||||||
import {SessionManagerSingleton as SessionManagerClass} from './session_manager';
|
import {SessionManagerSingleton as SessionManagerClass} from './session_manager';
|
||||||
|
|
||||||
|
import type {Query} from '@nozbe/watermelondb';
|
||||||
|
import type GlobalModel from '@typings/database/models/app/global';
|
||||||
|
|
||||||
jest.mock('@react-native-cookies/cookies', () => ({
|
jest.mock('@react-native-cookies/cookies', () => ({
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
clearByName: jest.fn(),
|
clearByName: jest.fn(),
|
||||||
|
|
@ -34,12 +42,35 @@ jest.mock('@database/manager', () => ({
|
||||||
serverDatabases: {},
|
serverDatabases: {},
|
||||||
}));
|
}));
|
||||||
jest.mock('@i18n');
|
jest.mock('@i18n');
|
||||||
|
jest.mock('@actions/local/session', () => {
|
||||||
|
const actual = jest.requireActual('@actions/local/session');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
cancelAllSessionNotifications: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
jest.mock('@init/credentials');
|
jest.mock('@init/credentials');
|
||||||
jest.mock('@init/launch');
|
jest.mock('@init/launch');
|
||||||
jest.mock('@init/push_notifications');
|
jest.mock('@init/push_notifications');
|
||||||
|
jest.mock('@managers/intune_manager', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
unenrollServer: jest.fn().mockResolvedValue(undefined),
|
||||||
|
subscribeToPolicyChanges: jest.fn().mockReturnValue({remove: jest.fn()}),
|
||||||
|
subscribeToEnrollmentChanges: jest.fn().mockReturnValue({remove: jest.fn()}),
|
||||||
|
subscribeToWipeRequests: jest.fn().mockReturnValue({remove: jest.fn()}),
|
||||||
|
subscribeToAuthRequired: jest.fn().mockReturnValue({remove: jest.fn()}),
|
||||||
|
subscribeToConditionalLaunchBlocked: jest.fn().mockReturnValue({remove: jest.fn()}),
|
||||||
|
subscribeToIdentitySwitchRequired: jest.fn().mockReturnValue({remove: jest.fn()}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
jest.mock('@managers/network_manager');
|
jest.mock('@managers/network_manager');
|
||||||
jest.mock('@managers/security_manager');
|
jest.mock('@managers/security_manager');
|
||||||
jest.mock('@managers/websocket_manager');
|
jest.mock('@managers/websocket_manager');
|
||||||
|
jest.mock('@queries/app/global', () => ({
|
||||||
|
queryGlobalValue: jest.fn(),
|
||||||
|
storeGlobal: jest.fn(),
|
||||||
|
}));
|
||||||
jest.mock('@queries/app/servers');
|
jest.mock('@queries/app/servers');
|
||||||
jest.mock('@queries/servers/user');
|
jest.mock('@queries/servers/user');
|
||||||
jest.mock('@screens/navigation');
|
jest.mock('@screens/navigation');
|
||||||
|
|
@ -64,6 +95,13 @@ describe('SessionManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mocked(getAllServerCredentials).mockResolvedValue([{serverUrl: mockServerUrl, userId: 'user_id', token: 'token'}]);
|
jest.mocked(getAllServerCredentials).mockResolvedValue([{serverUrl: mockServerUrl, userId: 'user_id', token: 'token'}]);
|
||||||
|
jest.mocked(getAllServers).mockResolvedValue([]);
|
||||||
|
jest.mocked(getServerDisplayName).mockResolvedValue(mockServerDisplayName);
|
||||||
|
|
||||||
|
// Mock queryGlobalValue to return a resolved promise for cache migration check
|
||||||
|
jest.mocked(queryGlobalValue).mockReturnValue({
|
||||||
|
fetch: jest.fn().mockResolvedValue([{value: true}]),
|
||||||
|
} as unknown as Query<GlobalModel>);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
@ -71,6 +109,11 @@ describe('SessionManager', () => {
|
||||||
AppState.currentState = 'active';
|
AppState.currentState = 'active';
|
||||||
Platform.OS = 'ios';
|
Platform.OS = 'ios';
|
||||||
|
|
||||||
|
// Reset queryGlobalValue mock to return cache migration as done
|
||||||
|
jest.mocked(queryGlobalValue).mockReturnValue({
|
||||||
|
fetch: jest.fn().mockResolvedValue([{value: true}]),
|
||||||
|
} as unknown as Query<GlobalModel>);
|
||||||
|
|
||||||
(AppState.addEventListener as jest.Mock).mockImplementation((event, callback) => {
|
(AppState.addEventListener as jest.Mock).mockImplementation((event, callback) => {
|
||||||
if (event === 'change' || event === 'focus' || event === 'blur') {
|
if (event === 'change' || event === 'focus' || event === 'blur') {
|
||||||
appStateCallback = callback;
|
appStateCallback = callback;
|
||||||
|
|
@ -81,7 +124,16 @@ describe('SessionManager', () => {
|
||||||
SessionManager = new SessionManagerClass();
|
SessionManager = new SessionManagerClass();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initialization', () => {
|
afterEach(() => {
|
||||||
|
// Clear all timers to prevent Jest from hanging
|
||||||
|
jest.clearAllTimers();
|
||||||
|
|
||||||
|
// Remove all event listeners
|
||||||
|
DeviceEventEmitter.removeAllListeners(Events.SERVER_LOGOUT);
|
||||||
|
DeviceEventEmitter.removeAllListeners(Events.SESSION_EXPIRED);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
it('should construct with Android correctly', async () => {
|
it('should construct with Android correctly', async () => {
|
||||||
Platform.OS = 'android';
|
Platform.OS = 'android';
|
||||||
const manager = new SessionManagerClass();
|
const manager = new SessionManagerClass();
|
||||||
|
|
@ -93,8 +145,37 @@ describe('SessionManager', () => {
|
||||||
|
|
||||||
describe('initialization', () => {
|
describe('initialization', () => {
|
||||||
it('should initialize correctly', async () => {
|
it('should initialize correctly', async () => {
|
||||||
await SessionManager.init();
|
SessionManager.init();
|
||||||
expect(cancelSessionNotification).toHaveBeenCalled();
|
expect(cancelAllSessionNotifications).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete legacy cache on first init', async () => {
|
||||||
|
// Mock cache migration as not done
|
||||||
|
jest.mocked(queryGlobalValue).mockReturnValueOnce({
|
||||||
|
fetch: jest.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as Query<GlobalModel>);
|
||||||
|
|
||||||
|
SessionManager.init();
|
||||||
|
|
||||||
|
// Wait for the async promise chain to complete
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(deleteFileCacheByDir).toHaveBeenCalledWith('com.hackemist.SDImageCache');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not delete legacy cache if migration already done', async () => {
|
||||||
|
// Mock cache migration as already done
|
||||||
|
jest.mocked(queryGlobalValue).mockReturnValueOnce({
|
||||||
|
fetch: jest.fn().mockResolvedValue([{value: true}]),
|
||||||
|
} as unknown as Query<GlobalModel>);
|
||||||
|
|
||||||
|
SessionManager.init();
|
||||||
|
|
||||||
|
// Wait for the async promise chain to complete
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(deleteFileCacheByDir).not.toHaveBeenCalledWith('com.hackemist.SDImageCache');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -103,28 +184,31 @@ describe('SessionManager', () => {
|
||||||
const event = {serverUrl: mockServerUrl, removeServer: true};
|
const event = {serverUrl: mockServerUrl, removeServer: true};
|
||||||
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, event);
|
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, event);
|
||||||
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await TestHelper.wait(50);
|
||||||
|
|
||||||
expect(removeServerCredentials).toHaveBeenCalledWith(mockServerUrl);
|
expect(removeServerCredentials).toHaveBeenCalledWith(mockServerUrl);
|
||||||
expect(PushNotifications.removeServerNotifications).toHaveBeenCalledWith(mockServerUrl);
|
expect(PushNotifications.removeServerNotifications).toHaveBeenCalledWith(mockServerUrl);
|
||||||
expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl);
|
expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl);
|
||||||
expect(WebsocketManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl);
|
expect(WebsocketManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl);
|
||||||
expect(SecurityManager.removeServer).toHaveBeenCalledWith(mockServerUrl);
|
expect(SecurityManager.removeServer).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(IntuneManager.unenrollServer).toHaveBeenCalledWith(mockServerUrl, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle session expiration', async () => {
|
it('should handle session expiration', async () => {
|
||||||
DeviceEventEmitter.emit(Events.SESSION_EXPIRED, mockServerUrl);
|
DeviceEventEmitter.emit(Events.SESSION_EXPIRED, mockServerUrl);
|
||||||
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await TestHelper.wait(50);
|
||||||
|
|
||||||
expect(logout).toHaveBeenCalledWith(mockServerUrl, undefined, {skipEvents: true, skipServerLogout: true});
|
expect(logout).toHaveBeenCalledWith(mockServerUrl, undefined, {skipEvents: true, skipServerLogout: true});
|
||||||
|
expect(SecurityManager.removeServer).toHaveBeenCalledWith(mockServerUrl);
|
||||||
|
expect(IntuneManager.unenrollServer).toHaveBeenCalledWith(mockServerUrl, true);
|
||||||
expect(relaunchApp).toHaveBeenCalled();
|
expect(relaunchApp).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('app state changes', () => {
|
describe('app state changes', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await SessionManager.init();
|
SessionManager.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle active state', async () => {
|
it('should handle active state', async () => {
|
||||||
|
|
@ -132,8 +216,7 @@ describe('SessionManager', () => {
|
||||||
if (appStateCallback) {
|
if (appStateCallback) {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
appStateCallback('active');
|
appStateCallback('active');
|
||||||
jest.runAllTimers();
|
expect(cancelAllSessionNotifications).toHaveBeenCalled();
|
||||||
expect(cancelSessionNotification).toHaveBeenCalled();
|
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -141,67 +224,59 @@ describe('SessionManager', () => {
|
||||||
it('should handle inactive state', async () => {
|
it('should handle inactive state', async () => {
|
||||||
expect(appStateCallback).toBeDefined();
|
expect(appStateCallback).toBeDefined();
|
||||||
if (appStateCallback) {
|
if (appStateCallback) {
|
||||||
jest.useFakeTimers();
|
|
||||||
appStateCallback('inactive');
|
appStateCallback('inactive');
|
||||||
jest.runAllTimers();
|
await TestHelper.wait(50);
|
||||||
expect(scheduleSessionNotification).toHaveBeenCalled();
|
expect(scheduleSessionNotification).toHaveBeenCalled();
|
||||||
jest.useRealTimers();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cleanup operations', () => {
|
describe('cleanup operations', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
const mockCookies = {
|
const mockCookies = {
|
||||||
cookie1: {name: 'cookie1'},
|
cookie1: {name: 'cookie1'},
|
||||||
cookie2: {name: 'cookie2'},
|
cookie2: {name: 'cookie2'},
|
||||||
};
|
};
|
||||||
(CookieManager.get as jest.Mock).mockResolvedValue(mockCookies);
|
(CookieManager.get as jest.Mock).mockResolvedValue(mockCookies);
|
||||||
await SessionManager.init();
|
SessionManager.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear cookies correctly - iOS', async () => {
|
it('should clear cookies correctly - iOS', async () => {
|
||||||
Platform.OS = 'ios';
|
Platform.OS = 'ios';
|
||||||
jest.useFakeTimers();
|
|
||||||
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {
|
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {
|
||||||
serverUrl: mockServerUrl,
|
serverUrl: mockServerUrl,
|
||||||
removeServer: true,
|
removeServer: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.runAllTimers();
|
await TestHelper.wait(450);
|
||||||
|
|
||||||
expect(CookieManager.clearByName).toHaveBeenCalledWith(mockServerUrl, 'cookie1', false);
|
expect(CookieManager.clearByName).toHaveBeenCalledWith(mockServerUrl, 'cookie1', false);
|
||||||
expect(CookieManager.clearByName).toHaveBeenCalledWith(mockServerUrl, 'cookie2', false);
|
expect(CookieManager.clearByName).toHaveBeenCalledWith(mockServerUrl, 'cookie2', false);
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear cookies correctly - Android', async () => {
|
it('should clear cookies correctly - Android', async () => {
|
||||||
Platform.OS = 'android';
|
Platform.OS = 'android';
|
||||||
jest.useFakeTimers();
|
|
||||||
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {
|
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {
|
||||||
serverUrl: mockServerUrl,
|
serverUrl: mockServerUrl,
|
||||||
removeServer: true,
|
removeServer: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.runAllTimers();
|
await TestHelper.wait(50);
|
||||||
|
|
||||||
expect(CookieManager.flush).toHaveBeenCalled();
|
expect(CookieManager.flush).toHaveBeenCalled();
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear file caches', async () => {
|
it('should clear file caches', async () => {
|
||||||
jest.useFakeTimers();
|
|
||||||
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {
|
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {
|
||||||
serverUrl: mockServerUrl,
|
serverUrl: mockServerUrl,
|
||||||
removeServer: true,
|
removeServer: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.runAllTimers();
|
await TestHelper.wait(50);
|
||||||
|
|
||||||
expect(deleteFileCache).toHaveBeenCalledWith(mockServerUrl);
|
expect(deleteFileCache).toHaveBeenCalledWith(mockServerUrl);
|
||||||
expect(deleteFileCacheByDir).toHaveBeenCalledWith('mmPasteInput');
|
expect(deleteFileCacheByDir).toHaveBeenCalledWith('mmPasteInput');
|
||||||
expect(deleteFileCacheByDir).toHaveBeenCalledWith('thumbnails');
|
expect(deleteFileCacheByDir).toHaveBeenCalledWith('thumbnails');
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,24 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import CookieManager, {type Cookie} from '@react-native-cookies/cookies';
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import {AppState, type AppStateStatus, DeviceEventEmitter, Platform} from 'react-native';
|
import {AppState, type AppStateStatus, DeviceEventEmitter, Platform} from 'react-native';
|
||||||
|
|
||||||
import {removePushDisabledInServerAcknowledged, storeOnboardingViewedValue} from '@actions/app/global';
|
import {storeGlobal, storeOnboardingViewedValue} from '@actions/app/global';
|
||||||
import {cancelSessionNotification, logout, scheduleSessionNotification} from '@actions/remote/session';
|
import {cancelAllSessionNotifications, terminateSession} from '@actions/local/session';
|
||||||
|
import {logout, scheduleSessionNotification} from '@actions/remote/session';
|
||||||
import {Events, Launch} from '@constants';
|
import {Events, Launch} from '@constants';
|
||||||
|
import {GLOBAL_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {resetMomentLocale} from '@i18n';
|
import {getAllServerCredentials} from '@init/credentials';
|
||||||
import {getAllServerCredentials, removeServerCredentials} from '@init/credentials';
|
|
||||||
import {relaunchApp} from '@init/launch';
|
import {relaunchApp} from '@init/launch';
|
||||||
import PushNotifications from '@init/push_notifications';
|
import IntuneManager from '@managers/intune_manager';
|
||||||
import NetworkManager from '@managers/network_manager';
|
|
||||||
import SecurityManager from '@managers/security_manager';
|
import SecurityManager from '@managers/security_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import {queryGlobalValue} from '@queries/app/global';
|
||||||
import {getAllServers, getServerDisplayName} from '@queries/app/servers';
|
import {getAllServers, getServerDisplayName} from '@queries/app/servers';
|
||||||
import {getCurrentUser} from '@queries/servers/user';
|
|
||||||
import {getThemeFromState} from '@screens/navigation';
|
import {getThemeFromState} from '@screens/navigation';
|
||||||
import EphemeralStore from '@store/ephemeral_store';
|
import EphemeralStore from '@store/ephemeral_store';
|
||||||
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
|
import {deleteFileCacheByDir} from '@utils/file';
|
||||||
import {isMainActivity} from '@utils/helpers';
|
import {isMainActivity} from '@utils/helpers';
|
||||||
import {urlSafeBase64Encode} from '@utils/security';
|
|
||||||
import {addNewServer} from '@utils/server';
|
import {addNewServer} from '@utils/server';
|
||||||
|
|
||||||
import type {LaunchType} from '@typings/launch';
|
import type {LaunchType} from '@typings/launch';
|
||||||
|
|
@ -56,36 +52,26 @@ export class SessionManagerSingleton {
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.cancelAllSessionNotifications();
|
cancelAllSessionNotifications();
|
||||||
}
|
|
||||||
|
|
||||||
private cancelAllSessionNotifications = async () => {
|
let updateToMigrationDone = false;
|
||||||
const serverCredentials = await getAllServerCredentials();
|
queryGlobalValue(GLOBAL_IDENTIFIERS.CACHE_MIGRATION)?.fetch().then((records) => {
|
||||||
for (const {serverUrl} of serverCredentials) {
|
const cacheMigrationDone = Boolean(records?.[0]?.value);
|
||||||
cancelSessionNotification(serverUrl);
|
if (!cacheMigrationDone) {
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private clearCookies = async (serverUrl: string, webKit: boolean) => {
|
|
||||||
try {
|
|
||||||
const cookies = await CookieManager.get(serverUrl, webKit);
|
|
||||||
const values = Object.values(cookies);
|
|
||||||
values.forEach((cookie: Cookie) => {
|
|
||||||
CookieManager.clearByName(serverUrl, cookie.name, webKit);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Nothing to clear
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private clearCookiesForServer = async (serverUrl: string) => {
|
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
this.clearCookies(serverUrl, false);
|
deleteFileCacheByDir('com.hackemist.SDImageCache');
|
||||||
this.clearCookies(serverUrl, true);
|
|
||||||
} else if (Platform.OS === 'android') {
|
} else if (Platform.OS === 'android') {
|
||||||
CookieManager.flush();
|
deleteFileCacheByDir('image_cache');
|
||||||
|
deleteFileCacheByDir('image_manager_disk_cache');
|
||||||
|
}
|
||||||
|
updateToMigrationDone = true;
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (updateToMigrationDone) {
|
||||||
|
storeGlobal(GLOBAL_IDENTIFIERS.CACHE_MIGRATION, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
private scheduleAllSessionNotifications = async () => {
|
private scheduleAllSessionNotifications = async () => {
|
||||||
if (!this.scheduling) {
|
if (!this.scheduling) {
|
||||||
|
|
@ -101,43 +87,6 @@ export class SessionManagerSingleton {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private resetLocale = async () => {
|
|
||||||
if (Object.keys(DatabaseManager.serverDatabases).length) {
|
|
||||||
const serverDatabase = await DatabaseManager.getActiveServerDatabase();
|
|
||||||
const user = await getCurrentUser(serverDatabase!);
|
|
||||||
resetMomentLocale(user?.locale);
|
|
||||||
} else {
|
|
||||||
resetMomentLocale();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private terminateSession = async (serverUrl: string, removeServer: boolean) => {
|
|
||||||
cancelSessionNotification(serverUrl);
|
|
||||||
await removeServerCredentials(serverUrl);
|
|
||||||
PushNotifications.removeServerNotifications(serverUrl);
|
|
||||||
SecurityManager.removeServer(serverUrl);
|
|
||||||
|
|
||||||
NetworkManager.invalidateClient(serverUrl);
|
|
||||||
WebsocketManager.invalidateClient(serverUrl);
|
|
||||||
|
|
||||||
if (removeServer) {
|
|
||||||
await removePushDisabledInServerAcknowledged(urlSafeBase64Encode(serverUrl));
|
|
||||||
await DatabaseManager.destroyServerDatabase(serverUrl);
|
|
||||||
} else {
|
|
||||||
await DatabaseManager.deleteServerDatabase(serverUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resetLocale();
|
|
||||||
this.clearCookiesForServer(serverUrl);
|
|
||||||
Image.clearDiskCache();
|
|
||||||
deleteFileCache(serverUrl);
|
|
||||||
deleteFileCacheByDir('mmPasteInput');
|
|
||||||
deleteFileCacheByDir('thumbnails');
|
|
||||||
if (Platform.OS === 'android') {
|
|
||||||
deleteFileCacheByDir('image_cache');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onAppStateChange = async (appState: AppStateStatus) => {
|
private onAppStateChange = async (appState: AppStateStatus) => {
|
||||||
if (appState === this.previousAppState || !isMainActivity()) {
|
if (appState === this.previousAppState || !isMainActivity()) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -146,7 +95,7 @@ export class SessionManagerSingleton {
|
||||||
this.previousAppState = appState;
|
this.previousAppState = appState;
|
||||||
switch (appState) {
|
switch (appState) {
|
||||||
case 'active':
|
case 'active':
|
||||||
setTimeout(this.cancelAllSessionNotifications, 750);
|
setTimeout(cancelAllSessionNotifications, 750);
|
||||||
break;
|
break;
|
||||||
case 'inactive':
|
case 'inactive':
|
||||||
this.scheduleAllSessionNotifications();
|
this.scheduleAllSessionNotifications();
|
||||||
|
|
@ -158,11 +107,16 @@ export class SessionManagerSingleton {
|
||||||
if (this.terminatingSessionUrl.has(serverUrl)) {
|
if (this.terminatingSessionUrl.has(serverUrl)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
this.terminatingSessionUrl.add(serverUrl);
|
this.terminatingSessionUrl.add(serverUrl);
|
||||||
|
|
||||||
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
|
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
|
||||||
const activeServerDisplayName = await DatabaseManager.getActiveServerDisplayName();
|
const activeServerDisplayName = await DatabaseManager.getActiveServerDisplayName();
|
||||||
await this.terminateSession(serverUrl, removeServer);
|
await terminateSession(serverUrl, removeServer);
|
||||||
|
SecurityManager.removeServer(serverUrl);
|
||||||
|
|
||||||
|
// We do not unenroll with Wipe as we already removed all the data during terminateSession
|
||||||
|
await IntuneManager.unenrollServer(serverUrl, false);
|
||||||
|
|
||||||
if (activeServerUrl === serverUrl) {
|
if (activeServerUrl === serverUrl) {
|
||||||
let displayName = '';
|
let displayName = '';
|
||||||
|
|
@ -184,17 +138,22 @@ export class SessionManagerSingleton {
|
||||||
|
|
||||||
relaunchApp({launchType, serverUrl, displayName});
|
relaunchApp({launchType, serverUrl, displayName});
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
this.terminatingSessionUrl.delete(serverUrl);
|
this.terminatingSessionUrl.delete(serverUrl);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onSessionExpired = async (serverUrl: string) => {
|
private onSessionExpired = async (serverUrl: string) => {
|
||||||
this.terminatingSessionUrl.add(serverUrl);
|
this.terminatingSessionUrl.add(serverUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
// logout is not doing anything in this scenario, but we keep it
|
// logout is not doing anything in this scenario, but we keep it
|
||||||
// to keep the same flow as other logout scenarios.
|
// to keep the same flow as other logout scenarios.
|
||||||
await logout(serverUrl, undefined, {skipServerLogout: true, skipEvents: true});
|
await logout(serverUrl, undefined, {skipServerLogout: true, skipEvents: true});
|
||||||
|
|
||||||
await this.terminateSession(serverUrl, false);
|
await terminateSession(serverUrl, false);
|
||||||
|
SecurityManager.removeServer(serverUrl);
|
||||||
|
await IntuneManager.unenrollServer(serverUrl, true);
|
||||||
|
|
||||||
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
|
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
|
||||||
const serverDisplayName = await getServerDisplayName(serverUrl);
|
const serverDisplayName = await getServerDisplayName(serverUrl);
|
||||||
|
|
@ -205,7 +164,9 @@ export class SessionManagerSingleton {
|
||||||
} else {
|
} else {
|
||||||
EphemeralStore.theme = undefined;
|
EphemeralStore.theme = undefined;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
this.terminatingSessionUrl.delete(serverUrl);
|
this.terminatingSessionUrl.delete(serverUrl);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -250,12 +250,11 @@ describe('PostUpdate', () => {
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(setButtons).toHaveBeenCalled();
|
expect(setButtons).toHaveBeenCalled();
|
||||||
});
|
|
||||||
|
|
||||||
const lastCall = jest.mocked(setButtons).mock.calls[jest.mocked(setButtons).mock.calls.length - 1];
|
const lastCall = jest.mocked(setButtons).mock.calls[jest.mocked(setButtons).mock.calls.length - 1];
|
||||||
const rightButton = lastCall[1]?.rightButtons?.[0];
|
const rightButton = lastCall[1]?.rightButtons?.[0];
|
||||||
expect(rightButton?.enabled).toBe(true);
|
expect(rightButton?.enabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should disable save button when message is empty', async () => {
|
it('should disable save button when message is empty', async () => {
|
||||||
const props = getBaseProps();
|
const props = getBaseProps();
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ describe('Servers Queries', () => {
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('queryAllActiveServers should return all active servers query', () => {
|
test('queryAllActiveServers should return all active servers query sorted by last_active_at descending', () => {
|
||||||
queryAllActiveServers();
|
queryAllActiveServers();
|
||||||
expect(mockDatabase.get).toHaveBeenCalledWith(SERVERS);
|
expect(mockDatabase.get).toHaveBeenCalledWith(SERVERS);
|
||||||
expect(mockServerModel.query).toHaveBeenCalledWith(
|
expect(mockServerModel.query).toHaveBeenCalledWith(
|
||||||
|
|
@ -78,6 +78,7 @@ describe('Servers Queries', () => {
|
||||||
Q.where('identifier', Q.notEq('')),
|
Q.where('identifier', Q.notEq('')),
|
||||||
Q.where('last_active_at', Q.gt(0)),
|
Q.where('last_active_at', Q.gt(0)),
|
||||||
),
|
),
|
||||||
|
Q.sortBy('last_active_at', Q.desc),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export const queryAllActiveServers = () => {
|
||||||
Q.where('identifier', Q.notEq('')),
|
Q.where('identifier', Q.notEq('')),
|
||||||
Q.where('last_active_at', Q.gt(0)),
|
Q.where('last_active_at', Q.gt(0)),
|
||||||
),
|
),
|
||||||
|
Q.sortBy('last_active_at', Q.desc),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -183,11 +183,11 @@ describe('observeIsMinimumLicenseTier', () => {
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
operator.handleSystem({
|
operator.handleSystem({
|
||||||
systems: [
|
systems: [
|
||||||
{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: 'Professional'}},
|
{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: 'professional'}},
|
||||||
],
|
],
|
||||||
prepareRecordsOnly: false,
|
prepareRecordsOnly: false,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
observeIsMinimumLicenseTier(database, 'Professional').subscribe((isMinimumTier) => {
|
observeIsMinimumLicenseTier(database, 'professional').subscribe((isMinimumTier) => {
|
||||||
expect(isMinimumTier).toBe(false);
|
expect(isMinimumTier).toBe(false);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators';
|
||||||
import {Preferences, License} from '@constants';
|
import {Preferences, License} from '@constants';
|
||||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import {PUSH_PROXY_STATUS_UNKNOWN} from '@constants/push_proxy';
|
import {PUSH_PROXY_STATUS_UNKNOWN} from '@constants/push_proxy';
|
||||||
import {isMinimumServerVersion} from '@utils/helpers';
|
import {isMinimumLicenseTier, isMinimumServerVersion, type LicenseTierSku} from '@utils/helpers';
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
|
@ -628,25 +628,19 @@ export const observeReportAProblemMetadata = (database: Database) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const observeIsMinimumLicenseTier = (database: Database, shortSku: string) => {
|
export const observeIsMinimumLicenseTier = (database: Database, shortSku: LicenseTierSku) => {
|
||||||
const license = observeLicense(database);
|
const license = observeLicense(database);
|
||||||
const isEnterpriseReady = observeConfigBooleanValue(database, 'BuildEnterpriseReady', false);
|
const isEnterpriseReady = observeConfigBooleanValue(database, 'BuildEnterpriseReady', false);
|
||||||
|
|
||||||
return combineLatest([license, isEnterpriseReady]).pipe(
|
return combineLatest([license, isEnterpriseReady]).pipe(
|
||||||
switchMap(([lic, isEnt]) => {
|
switchMap(([lic, isEnt]) => {
|
||||||
if (!shortSku) {
|
if (!shortSku || !isEnt) {
|
||||||
return of$(false);
|
|
||||||
}
|
|
||||||
const isLicensed = lic?.IsLicensed === 'true';
|
|
||||||
if (!isEnt || !isLicensed) {
|
|
||||||
return of$(false);
|
return of$(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tier = License.LicenseSkuTier[lic.SkuShortName];
|
const meetsMinimumTier = isMinimumLicenseTier(lic, shortSku);
|
||||||
const targetTier = License.LicenseSkuTier[shortSku] || 0;
|
|
||||||
const isMinimumTier = Boolean(tier) && Boolean(targetTier) && isEnt && isLicensed && tier >= targetTier;
|
|
||||||
|
|
||||||
return of$(isMinimumTier);
|
return of$(meetsMinimumTier);
|
||||||
}),
|
}),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import {chunk} from 'lodash';
|
import {chunk} from 'lodash';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {View} from 'react-native';
|
import {View} from 'react-native';
|
||||||
|
|
||||||
import {buildAbsoluteUrl} from '@actions/remote/file';
|
import {buildAbsoluteUrl} from '@actions/remote/file';
|
||||||
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {getLastPictureUpdate} from '@utils/user';
|
||||||
|
|
||||||
import type UserModel from '@typings/database/models/servers/user';
|
import type UserModel from '@typings/database/models/servers/user';
|
||||||
|
|
||||||
|
|
@ -41,8 +42,10 @@ const Group = ({theme, users}: Props) => {
|
||||||
const groups = rows.map((c, k) => {
|
const groups = rows.map((c, k) => {
|
||||||
const group = c.map((u, i) => {
|
const group = c.map((u, i) => {
|
||||||
const pictureUrl = buildProfileImageUrlFromUser(serverUrl, u);
|
const pictureUrl = buildProfileImageUrlFromUser(serverUrl, u);
|
||||||
|
const lastPictureUpdateAt = getLastPictureUpdate(u);
|
||||||
return (
|
return (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`user-${u.id}-${lastPictureUpdateAt}`}
|
||||||
key={pictureUrl + i.toString()}
|
key={pictureUrl + i.toString()}
|
||||||
style={[styles.profile, {transform: [{translateX: -(i * 24)}]}]}
|
style={[styles.profile, {transform: [{translateX: -(i * 24)}]}]}
|
||||||
source={{uri: buildAbsoluteUrl(serverUrl, pictureUrl)}}
|
source={{uri: buildAbsoluteUrl(serverUrl, pictureUrl)}}
|
||||||
|
|
@ -60,11 +63,7 @@ const Group = ({theme, users}: Props) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return groups;
|
||||||
<>
|
|
||||||
{groups}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Group;
|
export default Group;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {View} from 'react-native';
|
import {View} from 'react-native';
|
||||||
|
|
||||||
import {buildAbsoluteUrl} from '@actions/remote/file';
|
import {buildAbsoluteUrl} from '@actions/remote/file';
|
||||||
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {getLastPictureUpdate} from '@utils/user';
|
||||||
|
|
||||||
import type UserModel from '@typings/database/models/servers/user';
|
import type UserModel from '@typings/database/models/servers/user';
|
||||||
|
|
||||||
|
|
@ -39,8 +40,10 @@ const GroupAvatars = ({users}: Props) => {
|
||||||
|
|
||||||
const group = users.map((u, i) => {
|
const group = users.map((u, i) => {
|
||||||
const pictureUrl = buildProfileImageUrlFromUser(serverUrl, u);
|
const pictureUrl = buildProfileImageUrlFromUser(serverUrl, u);
|
||||||
|
const lastPictureUpdateAt = getLastPictureUpdate(u);
|
||||||
return (
|
return (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={`user-${u.id}-${lastPictureUpdateAt}`}
|
||||||
key={pictureUrl + i.toString()}
|
key={pictureUrl + i.toString()}
|
||||||
style={[styles.profile, {transform: [{translateX: -(i * 12)}]}]}
|
style={[styles.profile, {transform: [{translateX: -(i * 12)}]}]}
|
||||||
source={{uri: buildAbsoluteUrl(serverUrl, pictureUrl)}}
|
source={{uri: buildAbsoluteUrl(serverUrl, pictureUrl)}}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React, {memo, useCallback} from 'react';
|
import React, {memo, useCallback} from 'react';
|
||||||
import {StyleSheet, View} from 'react-native';
|
import {StyleSheet, View} from 'react-native';
|
||||||
|
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import FileIcon from '@components/files/file_icon';
|
import FileIcon from '@components/files/file_icon';
|
||||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||||
import {EMOJI_ROW_MARGIN, EMOJI_SIZE} from '@constants/emoji';
|
import {EMOJI_ROW_MARGIN, EMOJI_SIZE} from '@constants/emoji';
|
||||||
|
|
@ -50,8 +50,9 @@ const ImageEmoji = ({file, imageUrl, onEmojiPress, path}: ImageEmojiProps) => {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{Boolean(imageUrl) &&
|
{Boolean(imageUrl) &&
|
||||||
<Image
|
<ExpoImage
|
||||||
source={{uri: path}}
|
id={`emoji-${path}`}
|
||||||
|
source={{uri: imageUrl}}
|
||||||
style={styles.imageEmoji}
|
style={styles.imageEmoji}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {StyleSheet, View} from 'react-native';
|
||||||
import Action from './action';
|
import Action from './action';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
allowSaveToLocation: boolean;
|
||||||
canDownloadFiles: boolean;
|
canDownloadFiles: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
enablePublicLinks: boolean;
|
enablePublicLinks: boolean;
|
||||||
|
|
@ -26,8 +27,9 @@ const styles = StyleSheet.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
const Actions = ({
|
const Actions = ({
|
||||||
canDownloadFiles, disabled, enablePublicLinks, fileId, onCopyPublicLink,
|
allowSaveToLocation, canDownloadFiles, disabled,
|
||||||
onDownload, onShare,
|
enablePublicLinks, fileId,
|
||||||
|
onCopyPublicLink, onDownload, onShare,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const managedConfig = useManagedConfig<ManagedConfig>();
|
const managedConfig = useManagedConfig<ManagedConfig>();
|
||||||
const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyAndPasteProtection !== 'true';
|
const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyAndPasteProtection !== 'true';
|
||||||
|
|
@ -42,11 +44,13 @@ const Actions = ({
|
||||||
/>}
|
/>}
|
||||||
{canDownloadFiles &&
|
{canDownloadFiles &&
|
||||||
<>
|
<>
|
||||||
|
{allowSaveToLocation &&
|
||||||
<Action
|
<Action
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
iconName='download-outline'
|
iconName='download-outline'
|
||||||
onPress={onDownload}
|
onPress={onDownload}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
<Action
|
<Action
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
iconName='export-variant'
|
iconName='export-variant'
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {StyleSheet, View} from 'react-native';
|
import {StyleSheet, View} from 'react-native';
|
||||||
|
|
||||||
import {buildAbsoluteUrl} from '@actions/remote/file';
|
import {buildAbsoluteUrl} from '@actions/remote/file';
|
||||||
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {urlSafeBase64Encode} from '@utils/security';
|
||||||
import {changeOpacity} from '@utils/theme';
|
import {changeOpacity} from '@utils/theme';
|
||||||
|
import {getLastPictureUpdate} from '@utils/user';
|
||||||
|
|
||||||
import type UserModel from '@typings/database/models/servers/user';
|
import type UserModel from '@typings/database/models/servers/user';
|
||||||
|
|
||||||
|
|
@ -41,14 +43,19 @@ const Avatar = ({
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
let uri = overrideIconUrl;
|
let uri = overrideIconUrl;
|
||||||
if (!uri && author) {
|
let id = 'avatar-override';
|
||||||
|
if (uri) {
|
||||||
|
id = `avatar-override-${urlSafeBase64Encode(uri)}`;
|
||||||
|
} else if (author) {
|
||||||
uri = buildProfileImageUrlFromUser(serverUrl, author);
|
uri = buildProfileImageUrlFromUser(serverUrl, author);
|
||||||
|
id = `user-${author.id}-${getLastPictureUpdate(author)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let picture;
|
let picture;
|
||||||
if (uri) {
|
if (uri) {
|
||||||
picture = (
|
picture = (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={id}
|
||||||
source={{uri: buildAbsoluteUrl(serverUrl, uri)}}
|
source={{uri: buildAbsoluteUrl(serverUrl, uri)}}
|
||||||
style={[styles.avatar, styles.avatarRadius]}
|
style={[styles.avatar, styles.avatarRadius]}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import {Events} from '@constants';
|
import {Events} from '@constants';
|
||||||
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
|
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import SecurityManager from '@managers/security_manager';
|
||||||
import {changeOpacity} from '@utils/theme';
|
import {changeOpacity} from '@utils/theme';
|
||||||
import {ensureString} from '@utils/types';
|
import {ensureString} from '@utils/types';
|
||||||
import {displayUsername} from '@utils/user';
|
import {displayUsername} from '@utils/user';
|
||||||
|
|
@ -18,6 +20,7 @@ import CopyPublicLink from './copy_public_link';
|
||||||
import Details from './details';
|
import Details from './details';
|
||||||
import DownloadWithAction from './download_with_action';
|
import DownloadWithAction from './download_with_action';
|
||||||
|
|
||||||
|
import type {IntuneMAMSaveLocation} from '@managers/intune_manager/types';
|
||||||
import type PostModel from '@typings/database/models/servers/post';
|
import type PostModel from '@typings/database/models/servers/post';
|
||||||
import type UserModel from '@typings/database/models/servers/user';
|
import type UserModel from '@typings/database/models/servers/user';
|
||||||
import type {GalleryAction, GalleryItemType} from '@typings/screens/gallery';
|
import type {GalleryAction, GalleryItemType} from '@typings/screens/gallery';
|
||||||
|
|
@ -59,6 +62,7 @@ const Footer = ({
|
||||||
enablePostIconOverride, enablePostUsernameOverride, enablePublicLink, enableSecureFilePreview,
|
enablePostIconOverride, enablePostUsernameOverride, enablePublicLink, enableSecureFilePreview,
|
||||||
hideActions, isDirectChannel, item, post, style, teammateNameDisplay,
|
hideActions, isDirectChannel, item, post, style, teammateNameDisplay,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
const showActions = !hideActions && Boolean(item.id) && !item.id?.startsWith('uid');
|
const showActions = !hideActions && Boolean(item.id) && !item.id?.startsWith('uid');
|
||||||
const [action, setAction] = useState<GalleryAction>('none');
|
const [action, setAction] = useState<GalleryAction>('none');
|
||||||
const {bottom} = useSafeAreaInsets();
|
const {bottom} = useSafeAreaInsets();
|
||||||
|
|
@ -91,6 +95,14 @@ const Footer = ({
|
||||||
setAction('sharing');
|
setAction('sharing');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const allowSaveToLocation = useMemo(() => {
|
||||||
|
let location: keyof IntuneMAMSaveLocation = 'CameraRoll';
|
||||||
|
if (item.type === 'file') {
|
||||||
|
location = 'FilesApp';
|
||||||
|
}
|
||||||
|
return canDownloadFiles && SecurityManager.canSaveToLocation(serverUrl, location);
|
||||||
|
}, [canDownloadFiles, item.type, serverUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = DeviceEventEmitter.addListener(Events.GALLERY_ACTIONS, (value: GalleryAction) => {
|
const listener = DeviceEventEmitter.addListener(Events.GALLERY_ACTIONS, (value: GalleryAction) => {
|
||||||
setAction(value);
|
setAction(value);
|
||||||
|
|
@ -134,6 +146,7 @@ const Footer = ({
|
||||||
</View>
|
</View>
|
||||||
{showActions &&
|
{showActions &&
|
||||||
<Actions
|
<Actions
|
||||||
|
allowSaveToLocation={allowSaveToLocation}
|
||||||
disabled={action !== 'none'}
|
disabled={action !== 'none'}
|
||||||
canDownloadFiles={!enableSecureFilePreview && canDownloadFiles}
|
canDownloadFiles={!enableSecureFilePreview && canDownloadFiles}
|
||||||
enablePublicLinks={!enableSecureFilePreview && enablePublicLink && item.type !== 'avatar'}
|
enablePublicLinks={!enableSecureFilePreview && enablePublicLink && item.type !== 'avatar'}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
|
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
|
||||||
import {BackHandler} from 'react-native';
|
import {BackHandler} from 'react-native';
|
||||||
import Animated, {runOnJS, runOnUI, useAnimatedReaction, type SharedValue} from 'react-native-reanimated';
|
import {runOnJS, runOnUI, useAnimatedReaction, type SharedValue} from 'react-native-reanimated';
|
||||||
|
|
||||||
import {buildFilePreviewUrl} from '@actions/remote/file';
|
import {buildFilePreviewUrl} from '@actions/remote/file';
|
||||||
|
import {ExpoImageAnimated} from '@components/expo_image';
|
||||||
import {useGallery} from '@context/gallery';
|
import {useGallery} from '@context/gallery';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {isGif} from '@utils/file';
|
import {isGif} from '@utils/file';
|
||||||
|
|
@ -20,8 +20,6 @@ import GalleryViewer from './viewer';
|
||||||
|
|
||||||
import type {GalleryItemType, GalleryPagerItem} from '@typings/screens/gallery';
|
import type {GalleryItemType, GalleryPagerItem} from '@typings/screens/gallery';
|
||||||
|
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
|
||||||
|
|
||||||
interface GalleryProps {
|
interface GalleryProps {
|
||||||
headerAndFooterHidden: SharedValue<boolean>;
|
headerAndFooterHidden: SharedValue<boolean>;
|
||||||
galleryIdentifier: string;
|
galleryIdentifier: string;
|
||||||
|
|
@ -58,10 +56,10 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
||||||
lightboxRef.current?.closeLightbox();
|
lightboxRef.current?.closeLightbox();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLocalIndex = (index: number) => {
|
const onLocalIndex = useCallback((index: number) => {
|
||||||
setLocalIndex(index);
|
setLocalIndex(index);
|
||||||
onIndexChange?.(index);
|
onIndexChange?.(index);
|
||||||
};
|
}, [onIndexChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runOnUI(() => {
|
runOnUI(() => {
|
||||||
|
|
@ -73,6 +71,9 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
||||||
const th = item.height / scaleFactor;
|
const th = item.height / scaleFactor;
|
||||||
sharedValues.targetHeight.value = th;
|
sharedValues.targetHeight.value = th;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// sharedValues do not trigger re-renders
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [item, targetDimensions.width]);
|
}, [item, targetDimensions.width]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -106,7 +107,10 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
||||||
|
|
||||||
runOnJS(onLocalIndex)(nextIndex);
|
runOnJS(onLocalIndex)(nextIndex);
|
||||||
sharedValues.activeIndex.value = nextIndex;
|
sharedValues.activeIndex.value = nextIndex;
|
||||||
}, []);
|
|
||||||
|
// sharedValues do not trigger re-renders
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [onLocalIndex]);
|
||||||
|
|
||||||
const renderBackdropComponent = useCallback(
|
const renderBackdropComponent = useCallback(
|
||||||
({animatedStyles, translateY}: BackdropProps) => {
|
({animatedStyles, translateY}: BackdropProps) => {
|
||||||
|
|
@ -146,13 +150,18 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
||||||
sharedValues.y.value = 0;
|
sharedValues.y.value = 0;
|
||||||
|
|
||||||
runOnJS(onHide)();
|
runOnJS(onHide)();
|
||||||
|
|
||||||
|
// sharedValues do not trigger re-renders
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onRenderItem = useCallback((info: RenderItemInfo) => {
|
const onRenderItem = useCallback((info: RenderItemInfo) => {
|
||||||
if (item.type === 'video' && item.posterUri) {
|
const currentItem = items[localIndex];
|
||||||
|
if (currentItem.type === 'video' && currentItem.posterUri) {
|
||||||
return (
|
return (
|
||||||
<AnimatedImage
|
<ExpoImageAnimated
|
||||||
placeholder={{uri: item.posterUri}}
|
id={currentItem.cacheKey}
|
||||||
|
source={{uri: currentItem.posterUri}}
|
||||||
style={info.itemStyles}
|
style={info.itemStyles}
|
||||||
placeholderContentFit='cover'
|
placeholderContentFit='cover'
|
||||||
/>
|
/>
|
||||||
|
|
@ -160,7 +169,7 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [item]);
|
}, [items, localIndex]);
|
||||||
|
|
||||||
const onRenderPage = useCallback((props: GalleryPagerItem, idx: number) => {
|
const onRenderPage = useCallback((props: GalleryPagerItem, idx: number) => {
|
||||||
switch (props.item.type) {
|
switch (props.item.type) {
|
||||||
|
|
@ -183,7 +192,7 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [hideHeaderAndFooter, initialIndex]);
|
||||||
|
|
||||||
const source = useMemo(() => {
|
const source = useMemo(() => {
|
||||||
if (isGif(fileInfo) && fileInfo.id && !fileInfo.id.startsWith('uid')) {
|
if (isGif(fileInfo) && fileInfo.id && !fileInfo.id.startsWith('uid')) {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
|
|
||||||
import {type ImageSource} from 'expo-image';
|
import {type ImageSource} from 'expo-image';
|
||||||
import React, {forwardRef, useImperativeHandle, useMemo} from 'react';
|
import React, {forwardRef, useImperativeHandle, useMemo} from 'react';
|
||||||
import {type ImageSize, type ImageStyle, type ViewStyle} from 'react-native';
|
import {type ImageSize, type ImageStyle, type StyleProp} from 'react-native';
|
||||||
import {
|
import {
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
useAnimatedReaction, useSharedValue, withTiming,
|
useAnimatedReaction, useSharedValue, withTiming,
|
||||||
|
type AnimatedStyle,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
|
@ -22,7 +23,7 @@ export interface RenderItemInfo {
|
||||||
source: ImageSource;
|
source: ImageSource;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
itemStyles: ViewStyle | ImageStyle;
|
itemStyles: StyleProp<AnimatedStyle<StyleProp<ImageStyle>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LightboxSwipeoutProps {
|
interface LightboxSwipeoutProps {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image, type ImageSource} from 'expo-image';
|
import {type ImageSource} from 'expo-image';
|
||||||
import React, {useEffect, useMemo, useState} from 'react';
|
import React, {useEffect, useMemo, useState} from 'react';
|
||||||
import {type ImageStyle, StyleSheet, View, type ViewStyle} from 'react-native';
|
import {type ImageStyle, type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate, runOnJS, runOnUI,
|
interpolate, runOnJS, runOnUI,
|
||||||
useAnimatedStyle, withTiming,
|
useAnimatedStyle, withTiming,
|
||||||
|
type AnimatedStyle,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import {ExpoImageAnimated} from '@components/expo_image';
|
||||||
import {calculateDimensions} from '@utils/images';
|
import {calculateDimensions} from '@utils/images';
|
||||||
|
|
||||||
import {pagerTimingConfig} from '../animation_config/timing';
|
import {pagerTimingConfig} from '../animation_config/timing';
|
||||||
|
|
@ -22,7 +24,7 @@ export interface RenderItemInfo {
|
||||||
source: ImageSource;
|
source: ImageSource;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
itemStyles: ViewStyle | ImageStyle;
|
itemStyles: ViewStyle | ImageStyle | StyleProp<AnimatedStyle<StyleProp<ImageStyle>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LightboxProps {
|
interface LightboxProps {
|
||||||
|
|
@ -33,8 +35,6 @@ interface LightboxProps {
|
||||||
source: ImageSource | string;
|
source: ImageSource | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
@ -87,7 +87,7 @@ export default function Lightbox({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// no need to add animateOnMount as this function uses references and is only needed on mount
|
// We only want to run this on mount and unmount
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -177,7 +177,9 @@ export default function Lightbox({
|
||||||
itemStyles,
|
itemStyles,
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<AnimatedImage
|
<ExpoImageAnimated
|
||||||
|
id={target.cacheKey}
|
||||||
|
source={imageSource}
|
||||||
placeholder={imageSource}
|
placeholder={imageSource}
|
||||||
placeholderContentFit='cover'
|
placeholderContentFit='cover'
|
||||||
style={itemStyles}
|
style={itemStyles}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import Animated from 'react-native-reanimated';
|
||||||
|
|
||||||
import FileIcon from '@components/files/file_icon';
|
import FileIcon from '@components/files/file_icon';
|
||||||
import {Events, Preferences} from '@constants';
|
import {Events, Preferences} from '@constants';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import SecurityManager from '@managers/security_manager';
|
||||||
import DownloadWithAction from '@screens/gallery/footer/download_with_action';
|
import DownloadWithAction from '@screens/gallery/footer/download_with_action';
|
||||||
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
|
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
|
||||||
import {isDocument, isPdf} from '@utils/file';
|
import {isDocument, isPdf} from '@utils/file';
|
||||||
|
|
@ -54,6 +56,10 @@ const messages = defineMessages({
|
||||||
id: 'gallery.unsupported',
|
id: 'gallery.unsupported',
|
||||||
defaultMessage: "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.",
|
defaultMessage: "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.",
|
||||||
},
|
},
|
||||||
|
unsupportedAndBlockedDownload: {
|
||||||
|
id: 'gallery.unsupported_and_blocked_download',
|
||||||
|
defaultMessage: "Preview isn't supported for this file type, and downloads are disabled by your administrator.",
|
||||||
|
},
|
||||||
openFile: {
|
openFile: {
|
||||||
id: 'gallery.open_file',
|
id: 'gallery.open_file',
|
||||||
defaultMessage: 'Open file',
|
defaultMessage: 'Open file',
|
||||||
|
|
@ -66,6 +72,7 @@ const messages = defineMessages({
|
||||||
|
|
||||||
const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, hideHeaderAndFooter}: Props) => {
|
const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, hideHeaderAndFooter}: Props) => {
|
||||||
const {formatMessage} = useIntl();
|
const {formatMessage} = useIntl();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
const file = useMemo(() => galleryItemToFileInfo(item), [item]);
|
const file = useMemo(() => galleryItemToFileInfo(item), [item]);
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
const isSupported = useMemo(() => isDocument(file), [file]);
|
const isSupported = useMemo(() => isDocument(file), [file]);
|
||||||
|
|
@ -81,13 +88,16 @@ const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, hide
|
||||||
}, [canDownloadFiles, enableSecureFilePreview, file, isSupported]);
|
}, [canDownloadFiles, enableSecureFilePreview, file, isSupported]);
|
||||||
|
|
||||||
const optionText = useMemo(() => {
|
const optionText = useMemo(() => {
|
||||||
|
const allowSaveToLocation = SecurityManager.canSaveToLocation(serverUrl, 'FilesApp');
|
||||||
if (enableSecureFilePreview && !isPdf(file)) {
|
if (enableSecureFilePreview && !isPdf(file)) {
|
||||||
return formatMessage(messages.onlyPdf);
|
return formatMessage(messages.onlyPdf);
|
||||||
} else if (!isSupported) {
|
} else if (!isSupported && allowSaveToLocation) {
|
||||||
return formatMessage(messages.unsupported);
|
return formatMessage(messages.unsupported);
|
||||||
|
} else if (!isSupported && !allowSaveToLocation) {
|
||||||
|
return formatMessage(messages.unsupportedAndBlockedDownload);
|
||||||
}
|
}
|
||||||
return formatMessage(messages.openFile);
|
return formatMessage(messages.openFile);
|
||||||
}, [enableSecureFilePreview, file, formatMessage, isSupported]);
|
}, [enableSecureFilePreview, file, formatMessage, isSupported, serverUrl]);
|
||||||
|
|
||||||
const setGalleryAction = useCallback((action: GalleryAction) => {
|
const setGalleryAction = useCallback((action: GalleryAction) => {
|
||||||
DeviceEventEmitter.emit(Events.GALLERY_ACTIONS, action);
|
DeviceEventEmitter.emit(Events.GALLERY_ACTIONS, action);
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ function ImageRenderer({
|
||||||
return (
|
return (
|
||||||
<TransfrormerProvider sharedValues={sharedValues}>
|
<TransfrormerProvider sharedValues={sharedValues}>
|
||||||
<ImageTransformer
|
<ImageTransformer
|
||||||
|
cacheKey={item.cacheKey}
|
||||||
isPageActive={isPageActive}
|
isPageActive={isPageActive}
|
||||||
targetDimensions={targetDimensions}
|
targetDimensions={targetDimensions}
|
||||||
height={targetHeight}
|
height={targetHeight}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image, type ImageSource} from 'expo-image';
|
import {type ImageSource} from 'expo-image';
|
||||||
import React, {useCallback} from 'react';
|
import React, {useCallback} from 'react';
|
||||||
import {StyleSheet} from 'react-native';
|
import {StyleSheet} from 'react-native';
|
||||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
|
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
|
||||||
import Animated, {useAnimatedReaction, useAnimatedStyle} from 'react-native-reanimated';
|
import Animated, {useAnimatedReaction, useAnimatedStyle} from 'react-native-reanimated';
|
||||||
import {SvgUri} from 'react-native-svg';
|
import {SvgUri} from 'react-native-svg';
|
||||||
|
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
|
|
||||||
import {useTransformerSharedValues} from './context';
|
import {useTransformerSharedValues} from './context';
|
||||||
import useTransformerDoubleTap from './gestures/useTransformerDoubleTap';
|
import useTransformerDoubleTap from './gestures/useTransformerDoubleTap';
|
||||||
import useTransformerPanGesture from './gestures/useTransformerPanGesture';
|
import useTransformerPanGesture from './gestures/useTransformerPanGesture';
|
||||||
|
|
@ -33,6 +35,7 @@ const styles = StyleSheet.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ImageTransformerProps extends Omit<GalleryPagerItem, 'index' | 'item' | 'isPagerInProgress'> {
|
interface ImageTransformerProps extends Omit<GalleryPagerItem, 'index' | 'item' | 'isPagerInProgress'> {
|
||||||
|
cacheKey: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
isSvg: boolean;
|
isSvg: boolean;
|
||||||
source: ImageSource | string;
|
source: ImageSource | string;
|
||||||
|
|
@ -41,7 +44,7 @@ interface ImageTransformerProps extends Omit<GalleryPagerItem, 'index' | 'item'
|
||||||
|
|
||||||
const ImageTransformer = (
|
const ImageTransformer = (
|
||||||
{
|
{
|
||||||
enabled = true, height, isPageActive,
|
cacheKey, enabled = true, height, isPageActive,
|
||||||
onPageStateChange, source, isSvg,
|
onPageStateChange, source, isSvg,
|
||||||
targetDimensions, width, pagerPanGesture, pagerTapGesture, lightboxPanGesture,
|
targetDimensions, width, pagerPanGesture, pagerTapGesture, lightboxPanGesture,
|
||||||
}: ImageTransformerProps) => {
|
}: ImageTransformerProps) => {
|
||||||
|
|
@ -57,11 +60,14 @@ const ImageTransformer = (
|
||||||
|
|
||||||
const setInteractionsEnabled = useCallback((value: boolean) => {
|
const setInteractionsEnabled = useCallback((value: boolean) => {
|
||||||
interactionsEnabled.value = value;
|
interactionsEnabled.value = value;
|
||||||
|
|
||||||
|
// SharedValue does not trigger re-renders
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onLoadImageSuccess = useCallback(() => {
|
const onLoadImageSuccess = useCallback(() => {
|
||||||
setInteractionsEnabled(true);
|
setInteractionsEnabled(true);
|
||||||
}, []);
|
}, [setInteractionsEnabled]);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -136,7 +142,8 @@ const ImageTransformer = (
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
element = (
|
element = (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={cacheKey}
|
||||||
onLoad={onLoadImageSuccess}
|
onLoad={onLoadImageSuccess}
|
||||||
source={imageSource}
|
source={imageSource}
|
||||||
style={{width, height}}
|
style={{width, height}}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React, {type Dispatch, type SetStateAction, useCallback, useState} from 'react';
|
import React, {type Dispatch, type SetStateAction, useCallback, useState} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {useIntl} from 'react-intl';
|
||||||
import {StyleSheet, Text, TouchableWithoutFeedback, useWindowDimensions, View} from 'react-native';
|
import {StyleSheet, Text, TouchableWithoutFeedback, useWindowDimensions, View} from 'react-native';
|
||||||
|
|
@ -9,6 +8,7 @@ import Animated from 'react-native-reanimated';
|
||||||
|
|
||||||
import Button from '@components/button';
|
import Button from '@components/button';
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
import {Preferences} from '@constants';
|
import {Preferences} from '@constants';
|
||||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||||
|
|
@ -45,6 +45,7 @@ const styles = StyleSheet.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
cacheKey: string;
|
||||||
canDownloadFiles: boolean;
|
canDownloadFiles: boolean;
|
||||||
enableSecureFilePreview: boolean;
|
enableSecureFilePreview: boolean;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
|
@ -57,7 +58,7 @@ type Props = {
|
||||||
hideHeaderAndFooter: (hide?: boolean) => void;
|
hideHeaderAndFooter: (hide?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height, isDownloading, isRemote, hideHeaderAndFooter, posterUri, setDownloading, width}: Props) => {
|
const VideoError = ({cacheKey, canDownloadFiles, enableSecureFilePreview, filename, height, isDownloading, isRemote, hideHeaderAndFooter, posterUri, setDownloading, width}: Props) => {
|
||||||
const [hasPoster, setHasPoster] = useState(false);
|
const [hasPoster, setHasPoster] = useState(false);
|
||||||
const [loadPosterError, setLoadPosterError] = useState(false);
|
const [loadPosterError, setLoadPosterError] = useState(false);
|
||||||
const {headerAndFooterHidden} = useLightboxSharedValues();
|
const {headerAndFooterHidden} = useLightboxSharedValues();
|
||||||
|
|
@ -87,7 +88,8 @@ const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height
|
||||||
if (posterUri && !loadPosterError) {
|
if (posterUri && !loadPosterError) {
|
||||||
const imageDimensions = calculateDimensions(height, width, dimensions.width);
|
const imageDimensions = calculateDimensions(height, width, dimensions.width);
|
||||||
poster = (
|
poster = (
|
||||||
<Image
|
<ExpoImage
|
||||||
|
id={cacheKey}
|
||||||
source={{uri: posterUri}}
|
source={{uri: posterUri}}
|
||||||
style={hasPoster && imageDimensions}
|
style={hasPoster && imageDimensions}
|
||||||
onLoad={handlePosterSet}
|
onLoad={handlePosterSet}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {getTranscriptionUri, hasCaptions} from '@calls/utils';
|
||||||
import {Events} from '@constants';
|
import {Events} from '@constants';
|
||||||
import {ANDROID_VIDEO_INSET, GALLERY_FOOTER_HEIGHT, VIDEO_INSET} from '@constants/gallery';
|
import {ANDROID_VIDEO_INSET, GALLERY_FOOTER_HEIGHT, VIDEO_INSET} from '@constants/gallery';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
|
import SecurityManager from '@managers/security_manager';
|
||||||
import {transformerTimingConfig} from '@screens/gallery/animation_config/timing';
|
import {transformerTimingConfig} from '@screens/gallery/animation_config/timing';
|
||||||
import DownloadWithAction from '@screens/gallery/footer/download_with_action';
|
import DownloadWithAction from '@screens/gallery/footer/download_with_action';
|
||||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||||
|
|
@ -298,7 +299,8 @@ const VideoRenderer = ({canDownloadFiles, enableSecureFilePreview, height, index
|
||||||
}
|
}
|
||||||
{hasError &&
|
{hasError &&
|
||||||
<VideoError
|
<VideoError
|
||||||
canDownloadFiles={canDownloadFiles}
|
cacheKey={item.cacheKey}
|
||||||
|
canDownloadFiles={canDownloadFiles && SecurityManager.canSaveToLocation(serverUrl, 'CameraRoll')}
|
||||||
enableSecureFilePreview={enableSecureFilePreview}
|
enableSecureFilePreview={enableSecureFilePreview}
|
||||||
filename={item.name}
|
filename={item.name}
|
||||||
isDownloading={downloading}
|
isDownloading={downloading}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {View} from 'react-native';
|
import {View} from 'react-native';
|
||||||
|
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
@ -45,9 +45,10 @@ const ScheduledPostEmptyComponent = () => {
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
testID='scheduled_post_empty_component'
|
testID='scheduled_post_empty_component'
|
||||||
>
|
>
|
||||||
<Image
|
<ExpoImage
|
||||||
source={scheduled_message_image}
|
source={scheduled_message_image}
|
||||||
style={styles.image}
|
style={styles.image}
|
||||||
|
cachePolicy='memory'
|
||||||
/>
|
/>
|
||||||
<FormattedText
|
<FormattedText
|
||||||
id='scheduled_post.empty.title'
|
id='scheduled_post.empty.title'
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Image} from 'expo-image';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {StyleSheet, TouchableOpacity, View} from 'react-native';
|
import {StyleSheet, TouchableOpacity, View} from 'react-native';
|
||||||
|
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import ExpoImage from '@components/expo_image';
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
import {Preferences} from '@constants';
|
import {Preferences} from '@constants';
|
||||||
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED, type DraftType} from '@constants/draft';
|
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED, type DraftType} from '@constants/draft';
|
||||||
|
|
@ -55,9 +55,10 @@ const DraftScheduledPostTooltip = ({draftType, onClose}: Props) => {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<Image
|
<ExpoImage
|
||||||
source={longPressGestureHandLogo}
|
source={longPressGestureHandLogo}
|
||||||
style={styles.image}
|
style={styles.image}
|
||||||
|
cachePolicy='memory'
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.close}
|
style={styles.close}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {useIntl} from 'react-intl';
|
||||||
import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native';
|
import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native';
|
||||||
import {enableFreeze, enableScreens} from 'react-native-screens';
|
import {enableFreeze, enableScreens} from 'react-native-screens';
|
||||||
|
|
||||||
import {initializeSecurityManager} from '@actions/app/server';
|
|
||||||
import {autoUpdateTimezone} from '@actions/remote/user';
|
import {autoUpdateTimezone} from '@actions/remote/user';
|
||||||
import ServerVersion from '@components/server_version';
|
import ServerVersion from '@components/server_version';
|
||||||
import {Events, Launch, Screens} from '@constants';
|
import {Events, Launch, Screens} from '@constants';
|
||||||
|
|
@ -69,7 +68,7 @@ export function HomeScreen(props: HomeProps) {
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeSecurityManager();
|
SecurityManager.start();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFindChannels = useCallback(() => {
|
const handleFindChannels = useCallback(() => {
|
||||||
|
|
@ -143,6 +142,9 @@ export function HomeScreen(props: HomeProps) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only run on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue