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:
Elias Nahum 2025-12-10 13:07:28 +02:00 committed by GitHub
parent 6fdfd57c11
commit 44eb76bed7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
165 changed files with 9015 additions and 1904 deletions

View file

@ -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::"

View 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"

View 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::"

View 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"

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View 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
View file

@ -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
View 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

View file

@ -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');
}); });

View file

@ -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;
} }

View file

@ -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;
}, []); }, []);

View 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();
});
});
});

View 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 {};
};

View file

@ -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);

View file

@ -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');
});
});

View file

@ -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;
}
};

View file

@ -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(),
}; };

View file

@ -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 {};

View file

@ -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);
});
});

View file

@ -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 {

View file

@ -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'};

View file

@ -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,

View file

@ -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}
/> />

View file

@ -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}

View file

@ -83,6 +83,7 @@ const ChannelBookmarkOptions = ({
type, type,
lastPictureUpdate: 0, lastPictureUpdate: 0,
uri: '', uri: '',
cacheKey: fileInfo.id!,
}; };
return item; return item;

View file

@ -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));

View file

@ -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]}
/> />

View file

@ -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}) => {

View 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;

View file

@ -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(

View file

@ -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]));

View file

@ -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;

View file

@ -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,
}, },
})); }));

View file

@ -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',
}; };

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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]);

View file

@ -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,

View file

@ -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'

View file

@ -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",
}, },
] ]

View file

@ -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",
}, },
] ]

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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>

View file

@ -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>
); );
}; };

View file

@ -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}

View file

@ -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'

View file

@ -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}
/> />

View file

@ -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}

View file

@ -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);

View file

@ -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",
}, },
] ]

View file

@ -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> {

View file

@ -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 {

View file

@ -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',

View file

@ -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,
}); });

View file

@ -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});
} }
}); });
} }

View file

@ -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});
} }
}); });
} }

View file

@ -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;
} }

View file

@ -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();

View 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);
});
});
});
});

View 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;

View 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;
}

View 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();
});
});
});
});

View file

@ -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

View 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);
});
});
});

View file

@ -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();
}); });
}); });
}); });

View file

@ -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);
}
}; };
} }

View file

@ -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();

View file

@ -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),
); );
}); });

View file

@ -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;

View file

@ -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();
}); });

View file

@ -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(),
); );

View file

@ -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;

View file

@ -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)}}

View file

@ -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}
/> />
} }

View file

@ -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'

View file

@ -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]}
/> />

View file

@ -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'}

View file

@ -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')) {

View file

@ -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 {

View file

@ -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}

View file

@ -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);

View file

@ -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}

View file

@ -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}}

View file

@ -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}

View file

@ -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}

View file

@ -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'

View file

@ -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}

View file

@ -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