Compare commits

..

6 commits

Author SHA1 Message Date
unified-ci-app[bot]
b645c2bc2e
Bump app build number to 500 (#7721)
Some checks failed
github-release / test (push) Has been cancelled
github-release / build-ios-unsigned (push) Has been cancelled
github-release / build-android-unsigned (push) Has been cancelled
github-release / release (push) Has been cancelled
Co-authored-by: runner <runner@Mac-1702642798759.local>
2023-12-15 13:49:21 +01:00
Mario Vitale
46c0f59692
Cherrypick bump build 497 for 2.11 (#7693) 2023-11-29 14:18:40 +01:00
Caleb Roseland
502d304101
Merge pull request #7689 from mattermost/release-2.11-7613 2023-11-28 12:28:04 -06:00
Caleb Roseland
48f31e753b Merge pull request #7613 from mattermost/MM-53902-cont 2023-11-28 11:43:04 -06:00
Mario Vitale
9465cb199a
Manual cherrypick of PR #7668 (#7669) 2023-11-16 17:43:54 +01:00
Mattermost Build
bcbbc34f1e
Allow connecting to a previous server if credentials are not in the keychain (#7661) (#7662) 2023-11-14 22:04:20 +08:00
2810 changed files with 100174 additions and 310023 deletions

115
.eslintrc.json Normal file
View file

@ -0,0 +1,115 @@
{
"extends": [
"./eslint/eslint-mattermost",
"./eslint/eslint-react",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"import"
],
"settings": {
"react": {
"pragma": "React",
"version": "17.0"
}
},
"env": {
"jest": true
},
"globals": {
"__DEV__": true
},
"rules": {
"eol-last": ["error", "always"],
"global-require": 0,
"no-undefined": 0,
"no-shadow": "off",
"react/display-name": [2, { "ignoreTranspilerName": false }],
"react/jsx-filename-extension": 0,
"react-hooks/exhaustive-deps": 0,
"camelcase": [
0,
{
"properties": "never"
}
],
"@typescript-eslint/ban-types": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-unused-vars": [
2,
{
"vars": "all",
"args": "after-used"
}
],
"@typescript-eslint/no-shadow": ["error"],
"@typescript-eslint/no-explicit-any": "warn",
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/explicit-module-boundary-types": "off",
"no-underscore-dangle": "off",
"indent": [2, 4, {"SwitchCase": 1}],
"key-spacing": [2, {
"singleLine": {
"beforeColon": false,
"afterColon": true
}}],
"@typescript-eslint/member-delimiter-style": 2,
"@typescript-eslint/no-unsafe-declaration-merging": "off",
"import/order": [
2,
{
"groups": ["builtin", "external", "parent", "sibling", "index", "type"],
"newlines-between": "always",
"pathGroups": [
{
"pattern": "{@(@actions|@app|@assets|@calls|@client|@components|@constants|@context|@database|@helpers|@hooks|@init|@managers|@queries|@screens|@selectors|@share|@store|@telemetry|@typings|@test|@utils)/**,@(@constants|@i18n|@notifications|@store|@websocket)}",
"group": "external",
"position": "after"
},
{
"pattern": "app/**",
"group": "parent",
"position": "before"
}
],
"alphabetize": {
"order": "asc",
"caseInsensitive": true
},
"pathGroupsExcludedImportTypes": ["type"]
}
]
},
"overrides": [
{
"files": ["*.test.js", "*.test.jsx"],
"env": {
"jest": true
}
},
{
"files": ["detox/e2e/**"],
"globals": {
"by": true,
"detox": true,
"device": true,
"element": true,
"waitFor": true
},
"rules": {
"func-names": 0,
"import/no-unresolved": 0,
"max-nested-callbacks": 0,
"no-process-env": 0,
"no-unused-expressions": 0
}
}
]
}

View file

@ -27,7 +27,6 @@ Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fie
- [ ] Has UI changes
- [ ] Includes text changes and localization file updates
- [ ] Have tested against the 5 core themes to ensure consistency between them.
- [ ] Have run E2E tests by adding label `E2E iOS tests for PR`.
#### Device Information
This PR was tested on: <!-- Device name(s), OS version(s) -->

View file

@ -1,71 +0,0 @@
name: bandwidth-throttling
description: Action to throttle the bandwidth on MacOS runner
inputs:
test_server_host:
description: The host of the test server, no protocol
required: true
download_speed:
description: The download speed limit (in Kbit/s)
required: false
default: "3300"
upload_speed:
description: The upload speed limit (in Kbit/s)
required: false
default: "3300"
latency:
description: The latency (in ms) each way
required: false
default: "500"
disable:
description: Disable throttling
required: false
default: "false"
runs:
using: composite
steps:
- name: disable first
if: ${{ inputs.disable == 'true' }}
shell: bash
continue-on-error: true
run: |
sudo pfctl -d
sleep 2;
- name: throttle bandwidth down
shell: bash
run: |
# reset pf and dnctl
sudo dnctl -q flush
sudo dnctl -q pipe flush
sudo pfctl -f /etc/pf.conf
sudo pfctl -E
sleep 2;
sudo pfctl -d
sudo dnctl -q flush
sudo dnctl -q pipe flush
echo "dummynet in from ${{ inputs.test_server_host }} to ! 127.0.0.1 pipe 1
dummynet out from ! 127.0.0.1 to ${{ inputs.test_server_host }} pipe 2" | sudo pfctl -f -
# pipe 1 is download
sudo dnctl pipe 1 config bw ${{ inputs.download_speed }}Kbit/s delay ${{ inputs.latency }}ms
# pipe 2 is upload
sudo dnctl pipe 2 config bw ${{ inputs.upload_speed }}Kbit/s delay ${{ inputs.latency }}ms
sleep 5;
sudo pfctl -E
sleep 5;
- name: test curl after throttling
shell: bash
run: |
curl -o /dev/null -m 20 --retry 2 -s -w 'Total: %{time_total}s\n' 'https://${{ inputs.test_server_host }}/api/v4/system/ping?get_server_status=true'

View file

@ -1,39 +0,0 @@
# Copyright 2024 Mattermost, Inc.
name: "generate-specs"
description: This action used to split Detox integration tests based on the parallelism provided
inputs:
search_path:
description: The path to look for from within the directory
required: true
parallelism:
description: The parallelism for the tests
required: true
device_name:
description: The name of Device used for the tests
required: false
default: "iPhone 15"
device_os_version:
description: The os of the device used for the tests
required: false
default: "iOS 17.1"
outputs:
specs:
description: The specs generated for the strategy
value: ${{ steps.generate-specs.outputs.specs }}
runs:
using: "composite"
steps:
- name: ci/generate-specs
id: generate-specs
env:
PARALLELISM: ${{ inputs.parallelism }}
SEARCH_PATH: ${{ inputs.search_path }}
DEVICE_NAME: ${{ inputs.device_name }}
DEVICE_OS_VERSION: ${{ inputs.device_os_version }}
run: |
set -e
node ${{ github.action_path }}/split-tests.js | tee output.json
echo "specs=$(cat output.json)" >> $GITHUB_OUTPUT
shell: bash

View file

@ -1,96 +0,0 @@
const fs = require('fs');
const path = require('path');
class DeviceInfo {
constructor(deviceName, deviceOsVersion) {
this.deviceName = deviceName;
this.deviceOsVersion = deviceOsVersion;
}
}
class SpecGroup {
constructor(runId, specs, deviceInfo) {
this.runId = runId;
this.specs = specs;
this.deviceName = deviceInfo.deviceName;
this.deviceOsVersion = deviceInfo.deviceOsVersion;
}
}
class Specs {
constructor(searchPath, parallelism, deviceInfo) {
this.searchPath = searchPath;
this.parallelism = parallelism;
this.rawFiles = [];
this.groupedFiles = [];
this.deviceInfo = deviceInfo;
}
findFiles() {
const dirPath = path.join(this.searchPath);
const fileRegex = /\.e2e\.ts$/;
const walkSync = (currentPath) => {
const files = fs.readdirSync(currentPath);
files.forEach((file) => {
const filePath = path.join(currentPath, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
walkSync(filePath);
} else if (fileRegex.test(filePath)) {
const relativeFilePath = filePath.replace(dirPath + '/', '');
const fullPath = path.join(this.searchPath, relativeFilePath);
this.rawFiles.push(fullPath);
}
});
};
walkSync(dirPath);
}
generateSplits() {
const chunkSize = Math.floor(this.rawFiles.length / this.parallelism);
let remainder = this.rawFiles.length % this.parallelism;
let runNo = 1;
let start = 0;
for (let i = 0; i < this.parallelism; i++) {
let end = start + chunkSize + (remainder > 0 ? 1 : 0);
const fileGroup = this.rawFiles.slice(start, end).join(' ');
const specFileGroup = new SpecGroup(runNo.toString(), fileGroup, this.deviceInfo);
this.groupedFiles.push(specFileGroup);
start = end;
runNo++;
if (remainder > 0) {
remainder--;
}
}
}
dumpSplits() {
const output = {
include: this.groupedFiles,
};
console.log(JSON.stringify(output));
}
}
function main() {
const searchPath = process.env.SEARCH_PATH;
const parallelism = parseInt(process.env.PARALLELISM, 10);
const deviceName = process.env.DEVICE_NAME;
const deviceOsVersion = process.env.DEVICE_OS_VERSION;
const deviceInfo = new DeviceInfo(deviceName, deviceOsVersion);
const specs = new Specs(searchPath, parallelism, deviceInfo);
specs.findFiles();
specs.generateSplits();
specs.dumpSplits();
}
main();

View file

@ -12,18 +12,14 @@ runs:
- name: ci/prepare-mobile-build
uses: ./.github/actions/prepare-mobile-build
- name: ci/apply-16kb-pagesize-patch
shell: bash
run: |
echo "::group::Apply 16KB page size compatibility patch"
npm run apply-16kb-pagesize-patch
echo "::endgroup::"
- name: ci/setup-java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
# Disable this since we are not caching anything for now
# - name: ci/install-gradle-dependencies
# shell: bash
# working-directory: android
# run: |
# echo "::group::install-gradle-dependencies"
# ./gradlew dependencies
# echo "::endgroup::"
- name: ci/jetify-android-libraries
shell: bash
@ -34,7 +30,7 @@ runs:
- name: ci/checkout-private-repo
if: ${{ inputs.sign == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
with:
repository: mattermost/mattermost-mobile-private
token: ${{ env.MATTERMOST_BUILD_GH_TOKEN }}

View file

@ -1,61 +1,25 @@
name: prepare-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:
using: composite
steps:
- name: ci/setup-xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: '26.1'
- name: ci/install-os-deps
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
shell: bash
run: |
echo "::group::install-os-deps"
brew install watchman
echo "::endgroup::"
- name: ci/prepare-mobile-build
uses: ./.github/actions/prepare-mobile-build
- 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
run: |
if [ -e "libraries/@mattermost/intune/.git" ]; then
INTUNE_HASH=$(cd libraries/@mattermost/intune && git rev-parse --short HEAD)
echo "hash=${INTUNE_HASH}" >> $GITHUB_OUTPUT
else
echo "hash=none" >> $GITHUB_OUTPUT
fi
- name: Cache Pods
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
ios/Pods
libraries/@mattermost/intune/ios/Frameworks
key: ${{ runner.os }}-pods-v4-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }}-${{ github.ref_name }}
restore-keys: |
${{ runner.os }}-pods-v4-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }}-
- name: ci/install-pods-dependencies
shell: bash
env:
INTUNE_ENABLED: ${{ inputs.intune-enabled }}
run: |
echo "::group::install-pods-dependencies"
echo "INTUNE_ENABLED=$INTUNE_ENABLED"
echo "::group::install-pods-dependencies"
npm run ios-gems
npm run pod-install
echo "::endgroup::"

View file

@ -1,106 +0,0 @@
name: Prepare Low Bandwidth Environment (MacOS & iOS Simulators only)
description: prepare any workflow for low bandwidth testing
inputs:
test_server_url:
description: The URL of the test server
required: true
device_name:
description: The iOS simulator name
required: true
download_speed:
description: The download speed limit (in Kbit/s)
required: false
default: "3300"
upload_speed:
description: The upload speed limit (in Kbit/s)
required: false
default: "3300"
latency:
description: The latency (in ms) each way
required: false
default: "500"
runs:
using: composite
steps:
- name: delete the zip file and trash (to free up space)
shell: bash
run: |
rm -rf mobile-artifacts/*.zip
sudo rm -rf ~/.Trash/*
- name: check disk space
shell: bash
run: df -h
- name: remove protocol from SITE_1_URL
id: remove-protocol
shell: bash
run: |
echo "SITE_1_HOST=${{ inputs.test_server_url }}" | sed -e 's/http:\/\///g' -e 's/https:\/\///g' >> ${GITHUB_OUTPUT}
- name: Throttle Bandwidth 1
id: throttle-bandwidth-1
continue-on-error: true
uses: ./.github/actions/bandwidth-throttling
with:
test_server_host: ${{ steps.remove-protocol.outputs.SITE_1_HOST }}
download_speed: ${{ inputs.download_speed }}
upload_speed: ${{ inputs.upload_speed }}
latency: ${{ inputs.latency }}
- name: Throttle Bandwidth 2
if: steps.throttle-bandwidth-1.outcome != 'success'
id: throttle-bandwidth-2
uses: ./.github/actions/bandwidth-throttling
with:
test_server_host: ${{ steps.remove-protocol.outputs.SITE_1_HOST }}
download_speed: ${{ inputs.download_speed}}
upload_speed: ${{ inputs.upload_speed }}
latency: ${{ inputs.latency }}
disable: "true"
- name: Install mitmproxy & pm2 (process manager)
id: install-mitmproxy-pm2
shell: bash
run: |
brew install mitmproxy
npm i -g pm2
- name: Start mitmproxy via mitmdump and stop it (to get .mitmproxy folder)
shell: bash
run: |
pm2 start mitmdump --log /Users/runner/work/mattermost-mobile/mattermost-mobile/mitmdump.log -- --allow-hosts '${{ steps.remove-protocol.outputs.SITE_1_HOST }}' --ignore-hosts 'localhost' -s /Users/runner/work/mattermost-mobile/mattermost-mobile/scripts/mitmdump-flow-parsing.py
sleep 5;
pm2 stop mitmdump
# we need to wait for mitmdump to stop so it'll produce the .mitmproxy folder
sleep 5;
- name: Get simulator UDID
id: get-simulator-udid
shell: bash
run: |
simulator_udid=$(xcrun simctl list devices "${{ inputs.device_name }}" -j | jq '.devices' | jq '."com.apple.CoreSimulator.SimRuntime.iOS-17-4"[0]["udid"]')
echo "simulator_udid="$(echo $simulator_udid) >> ${GITHUB_OUTPUT}
- name: install certificate
shell: bash
run: |
sudo security add-trusted-cert -d -p ssl -p basic -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem
# must boot first before adding root cert
xcrun simctl boot ${{ steps.get-simulator-udid.outputs.simulator_udid }}
xcrun simctl keychain booted add-root-cert ~/.mitmproxy/mitmproxy-ca-cert.pem
sleep 5;
- name: show me booted simulators
shell: bash
run: xcrun simctl list devices booted | grep Booted

View file

@ -4,8 +4,9 @@ description: Action to prepare environment for mobile build
runs:
using: composite
steps:
# The required ruby version is mentioned in '.ruby-version'
- uses: ruby/setup-ruby@cb0fda56a307b8c78d38320cd40d9eb22a3bf04e # v1.242.0
- uses: ruby/setup-ruby@9669f3ee51dc3f4eda8447ab696b3ab19a90d14b # v1.144.0
with:
ruby-version: "2.7.7"
- name: ci/setup-fastlane-dependencies
shell: bash
@ -15,13 +16,5 @@ runs:
echo "::endgroup::"
working-directory: ./fastlane
- name: Cache Ruby gems
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gems-
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps

View file

@ -5,7 +5,7 @@ runs:
using: composite
steps:
- name: ci/setup-node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
with:
node-version-file: ".nvmrc"
cache: "npm"
@ -21,14 +21,6 @@ runs:
node node_modules/\@sentry/cli/scripts/install.js
echo "::endgroup::"
- name: Cache Node.js modules
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: ci/patch-npm-dependencies
shell: bash
run: |

View file

@ -1,27 +0,0 @@
# 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

@ -1,60 +0,0 @@
# 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

@ -1,23 +0,0 @@
# 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

@ -1,36 +0,0 @@
name: Start Proxy
description: Action to throttle the bandwidth on MacOS runner
inputs:
test_server_url:
description: The host of the test server, no protocol
required: true
runs:
using: composite
steps:
- name: restart mitmdump
shell: bash
run: |
pm2 restart mitmdump
sleep 5;
- name: start proxy
shell: bash
run: |
networksetup -setwebproxy Ethernet "127.0.0.1" "8080"
networksetup -setsecurewebproxy Ethernet "127.0.0.1" "8080"
sleep 5;
networksetup -getwebproxy Ethernet
networksetup -getsecurewebproxy Ethernet
- name: test curl and direct it into proxy
shell: bash
run: |
curl -o /dev/null -m 20 -s -w 'Total: %{time_total}s\n' '${{ inputs.test_server_url }}/api/v4/system/ping?get_server_status=true'
curl --proxy "127.0.0.1:8080" -o /dev/null -m 20 -s -w 'Total: %{time_total}s\n' '${{ inputs.test_server_url }}/api/v4/system/ping?get_server_status=true'

View file

@ -1,146 +0,0 @@
# This action is used to test the coverage of the mobile repo
# It will download the coverage result from the main branch and compare it with the current branch
# If the coverage is lower than the main branch (1% or more), it will post a warning along with
# the coverage report to the PR.
# If this action is run on the main branch, it will upload the coverage result to the main branch
# It will also generate a run id and cache it, so that the next time the action is run in the PR,
# it will use the cached run id to download the coverage result from the main branch
name: test-coverage
description: Test coverage tracking for mobile repo
inputs:
run_id:
description: The run id to use to download the coverage result
required: true
runs:
using: composite
steps:
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
- name: ci/get-last-run-id
if: github.event_name == 'pull_request'
id: get-last-run-id
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
continue-on-error: true
with:
path: run-id.txt
key: last-run-id-${{ inputs.run_id }}
restore-keys: |
last-run-id-
- name: ci/set-pr-condition
if: github.event_name == 'pull_request'
shell: bash
run: |
echo "::group::set-pr-condition"
if [ -f "run-id.txt" ]; then
echo "HAS_MAIN_RUN_ID=true" >> $GITHUB_ENV
echo "LAST_RUN_ID=$(cat run-id.txt)" >> $GITHUB_ENV
fi
echo "::endgroup::"
- name: ci/download-main-coverage
if: env.HAS_MAIN_RUN_ID == 'true'
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: test-coverage-result-${{ env.LAST_RUN_ID }}
path: main-coverage/
github-token: ${{ github.token }}
run-id: ${{ env.LAST_RUN_ID }}
- name: ci/check-coverage-download
if: env.HAS_MAIN_RUN_ID == 'true'
shell: bash
run: |
echo "::group::check-coverage-download"
if [ -f "main-coverage/coverage-summary.json" ]; then
echo "HAS_COVERAGE_FROM_MAIN=true" >> $GITHUB_ENV
fi
echo "::endgroup::"
- name: ci/read-coverage
if: env.HAS_COVERAGE_FROM_MAIN == 'true'
shell: bash
run: |
echo "::group::read-coverage"
./scripts/read-coverage.sh ./main-coverage/coverage-summary.json
echo "::endgroup::"
- name: ci/run-tests-with-coverage
shell: bash
run: |
echo "::group::run-tests"
npm run test:ci:coverage
echo "::endgroup::"
- name: ci/compare-coverage
if: env.HAS_COVERAGE_FROM_MAIN == 'true'
id: compare-coverage
shell: bash
run: |
echo "::group::compare-coverage"
echo "IS_FORK=false" >> $GITHUB_ENV
output=$(./scripts/compare-coverage.sh \
./main-coverage \
./coverage \
${{ github.event.pull_request.number }})
echo "report<<EOF" >> $GITHUB_ENV
echo "$output" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
if [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
echo "IS_FORK=true" >> $GITHUB_ENV
echo "::warning::PR is from a fork. Coverage report will be displayed in the workflow summary."
echo "### Coverage Report from Fork" >> $GITHUB_STEP_SUMMARY
echo "$output" >> $GITHUB_STEP_SUMMARY
fi
echo "::endgroup::"
- name: ci/post-coverage-report
if: env.HAS_COVERAGE_FROM_MAIN == 'true' && env.IS_FORK == 'false'
uses: thollander/actions-comment-pull-request@v3
with:
message: ${{ env.report }}
comment-tag: coverage-report
create-if-not-exists: true
- name: ci/exit-on-test-coverage-failure
if: env.HAS_COVERAGE_FROM_MAIN == 'true'
shell: bash
run: |
echo "::group::exit-on-test-coverage-failure"
exit ${{ steps.compare-coverage.outputs.status }}
echo "::endgroup::"
- name: ci/upload-coverage
if: github.ref_name == 'main'
id: upload-coverage
uses: actions/upload-artifact@v4
with:
name: test-coverage-result-${{ inputs.run_id }}
path: coverage/coverage-summary.json
overwrite: true
github-token: ${{ github.token }}
- name: ci/set-upload-success
if: github.ref_name == 'main' && steps.upload-coverage.outcome == 'success'
shell: bash
run: echo "UPLOAD_SUCCESS=true" >> $GITHUB_ENV
- name: ci/generate-run-id-file
if: env.UPLOAD_SUCCESS == 'true'
shell: bash
run: |
echo "::group::generate-last-run-id"
echo "${{ inputs.run_id }}" > run-id.txt
echo "::endgroup::"
- name: ci/cache-run-id-file
if: env.UPLOAD_SUCCESS == 'true'
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with:
path: run-id.txt
key: last-run-id-${{ inputs.run_id }}

View file

@ -13,16 +13,12 @@ runs:
echo "::group::check-styles"
npm run check
echo "::endgroup::"
- name: ci/run-tests
# main and PR will run test:coverage
if: startsWith(github.ref_name, 'release-')
shell: bash
run: |
echo "::group::run-tests"
npm run test:ci
npm test
echo "::endgroup::"
- name: ci/check-i18n
shell: bash
run: |

View file

@ -1,18 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directories:
- "/.github/workflows"
- "/.github/actions/**/*"
reviewers:
- "mattermost/core-build-engineers"
open-pull-requests-limit: 5
groups:
Github Actions updates:
applies-to: version-updates
dependency-type: production
schedule:
# Check for updates to GitHub Actions every week
day: "monday"
time: "09:00"
interval: "weekly"

View file

@ -5,10 +5,11 @@ on:
push:
branches:
- build-beta-[0-9]+
- build-beta-android-[0-9]+
- build-android-[0-9]+
- build-android-beta-[0-9]+
env:
NODE_VERSION: 22.14.0
NODE_VERSION: 18.7.0
TERM: xterm
jobs:
@ -16,7 +17,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
@ -24,12 +25,9 @@ jobs:
runs-on: ubuntu-22.04
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
@ -57,25 +55,7 @@ jobs:
working-directory: ./fastlane
- name: ci/upload-android-beta-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: android-build-beta-${{ github.run_id }}
path: "*.apk"
# New AWS bucket deployment steps
- name: Configure AWS credentials new bucket
id: configure-aws-credentials-new-bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-android-beta-new-bucket
env:
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
run: |
echo "::group::Upload to new S3 bucket"
bundle exec fastlane android upload_artifacts_to_s3 --env android.beta
echo "::endgroup::"
working-directory: ./fastlane

View file

@ -8,7 +8,7 @@ on:
- build-release-android-[0-9]+
env:
NODE_VERSION: 22.14.0
NODE_VERSION: 18.7.0
TERM: xterm
jobs:
@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
@ -24,12 +24,9 @@ jobs:
runs-on: ubuntu-22.04
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
@ -57,23 +54,7 @@ jobs:
working-directory: ./fastlane
- name: ci/upload-android-release-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: android-build-release-${{ github.run_id }}
path: "*.apk"
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-android-release-new-bucket
env:
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
run: |
echo "::group::Upload to new S3 bucket"
bundle exec fastlane android upload_artifacts_to_s3 --env android.release
echo "::endgroup::"
working-directory: ./fastlane

View file

@ -9,36 +9,29 @@ on:
- build-beta-sim-[0-9]+
env:
NODE_VERSION: 22.14.0
NODE_VERSION: 18.7.0
TERM: xterm
INTUNE_ENABLED: 'true'
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
build-ios-simulator:
runs-on: macos-15-large
runs-on: macos-12
if: ${{ !contains(github.ref_name, 'beta-ios') }}
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-simulator
env:
@ -51,49 +44,31 @@ jobs:
working-directory: ./fastlane
- name: ci/upload-ios-pr-simulator
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
# Logic for new bucket upload
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-ios-simulator-new-bucket
env:
TAG: "${{ github.ref_name }}"
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios upload_artifacts_to_s3 --env ios.simulator output_file:"Mattermost-simulator-x86_64.app.zip"
working-directory: ./fastlane
build-and-deploy-ios-beta:
runs-on: macos-15-large
runs-on: macos-12
if: ${{ !contains(github.ref_name, 'beta-sim') }}
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/setup-ssh-key
uses: ./.github/actions/setup-ssh-key
with:
ssh-private-key: ${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}
- name: ci/output-ssh-private-key
shell: bash
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/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-and-deploy-ios-beta
env:
@ -118,24 +93,7 @@ jobs:
working-directory: ./fastlane
- name: ci/upload-ios-beta-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-beta-${{ github.run_id }}
path: "*.ipa"
# Logic for new bucket upload
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-ios-beta-new-bucket
env:
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
run: |
echo "::group::Upload to new S3 bucket"
bundle exec fastlane ios upload_artifacts_to_s3 --env ios.beta
echo "::endgroup::"
working-directory: ./fastlane

View file

@ -9,47 +9,43 @@ on:
- build-release-sim-[0-9]+
env:
NODE_VERSION: 22.14.0
NODE_VERSION: 18.7.0
TERM: xterm
INTUNE_ENABLED: 'true'
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
build-and-deploy-ios-release:
runs-on: macos-15-large
runs-on: macos-12
if: ${{ !contains(github.ref_name, 'release-sim') }}
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
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 }}
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/output-ssh-private-key
shell: bash
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
env:
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_RELEASE_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_RELEASE_AWS_SECRET_ACCESS_KEY }}"
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
@ -69,45 +65,22 @@ jobs:
working-directory: ./fastlane
- name: ci/upload-ios-release-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-release-${{ github.run_id }}
path: "*.ipa"
# Upload artifacts to new bucket
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-ios-release-to-new-bucket
env:
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
run: |
echo "::group::Build"
bundle exec fastlane ios upload_artifacts_to_s3 --env ios.release
echo "::endgroup::"
working-directory: ./fastlane
build-ios-simulator:
runs-on: macos-15-large
runs-on: macos-12
if: ${{ !contains(github.ref_name , 'release-ios') }}
needs:
- test
permissions:
id-token: write
contents: read
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-simulator
env:
@ -120,22 +93,7 @@ jobs:
working-directory: ./fastlane
- name: ci/upload-ios-pr-simulator
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
# Logic for new bucket upload
- name: Configure AWS credentials new bucket
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.MM_MOBILE_RELEASE_AWS_ROLE_TO_ASSUME }}
- name: ci/upload-ios-simulator-new-bucket
env:
TAG: "${{ github.ref_name }}"
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
run: bundle exec fastlane ios simulator --env ios.simulator output_file:"Mattermost-simulator-x86_64.app.zip"
working-directory: ./fastlane

View file

@ -1,52 +1,49 @@
---
name: build-pr
on:
pull_request:
types:
- labeled
push:
branches:
- build-pr-*
- build-pr-android-*
- build-pr-ios-*
env:
NODE_VERSION: 22.14.0
NODE_VERSION: 18.7.0
TERM: xterm
INTUNE_ENABLED: 'true'
jobs:
test:
runs-on: ubuntu-22.04
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' || github.event.label.name == 'Build App for Android' }}
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
build-ios-pr:
runs-on: macos-15-large
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' }}
runs-on: macos-12
if: ${{ !contains(github.ref_name, 'android') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
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 }}
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/output-ssh-private-key
shell: bash
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
env:
BRANCH_TO_BUILD: "${{ github.event.pull_request.head.ref }}"
BRANCH_TO_BUILD: "${{ github.ref_name }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_PR_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_PR_AWS_SECRET_ACCESS_KEY }}"
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
@ -60,21 +57,19 @@ jobs:
working-directory: ./fastlane
- name: ci/upload-ios-pr-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: ios-build-pr-${{ github.run_id }}
path: "*.ipa"
build-android-pr:
runs-on: ubuntu-22.04
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for Android' }}
if: ${{ !contains(github.ref_name, 'ios') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
@ -86,7 +81,7 @@ jobs:
- name: ci/build-android-pr
env:
BRANCH_TO_BUILD: "${{ github.event.pull_request.head.ref }}"
BRANCH_TO_BUILD: "${{ github.ref_name }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_PR_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_PR_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_PR_MATTERMOST_WEBHOOK_URL }}"
@ -94,7 +89,7 @@ jobs:
working-directory: ./fastlane
- name: ci/upload-android-pr-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
name: android-build-pr-${{ github.run_id }}
path: "*.apk"

View file

@ -1,17 +1,10 @@
---
name: ci
on:
push:
branches:
- main
- 'release*'
pull_request:
permissions:
pull-requests: write
env:
NODE_VERSION: 22.14.0
NODE_VERSION: 18.7.0
TERM: xterm
jobs:
@ -19,11 +12,6 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
- name: ci/test-coverage
if: github.event_name == 'pull_request' || github.ref_name == 'main'
uses: ./.github/actions/test-coverage
with:
run_id: ${{ github.run_id }}

View file

@ -26,17 +26,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
# Autobuild attempts to build any compiled languages
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v2

View file

@ -1,129 +0,0 @@
name: Compatibility Matrix Testing
on:
workflow_dispatch:
inputs:
CMT_MATRIX:
description: "A JSON object representing the testing matrix"
required: true
type: string
MOBILE_VERSION:
description: "The mattermost mobile version to test"
required: true
jobs:
## This is picked up after the finish for cleanup
upload-cmt-server-detals:
runs-on: ubuntu-22.04
steps:
- name: cmt/generate-instance-details-file
run: echo '${{ inputs.CMT_MATRIX }}' > instance-details.json
- name: cmt/upload-instance-details
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: instance-details.json
path: instance-details.json
retention-days: 1
calculate-commit-hash:
runs-on: ubuntu-22.04
outputs:
MOBILE_SHA: ${{ steps.repo.outputs.MOBILE_SHA }}
steps:
- name: cmt/checkout-mobile
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: cmt/calculate-mattermost-sha
id: repo
run: echo "MOBILE_SHA=$(git rev-parse HEAD)" >> ${GITHUB_OUTPUT}
update-initial-status:
runs-on: ubuntu-22.04
needs:
- calculate-commit-hash
steps:
- uses: mattermost/actions/delivery/update-commit-status@d5174b860704729f4c14ef8489ae075742bfa08a
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ needs.calculate-commit-hash.outputs.MOBILE_SHA }}
context: e2e/compatibility-matrix-testing
description: "Compatibility Matrix Testing for ${{ inputs.MOBILE_VERSION }} version"
status: pending
# Input follows the below schema
# {
# "server": [
# {
# "version": "9.6.1",
# "url": "https://delivery-cmt-8467830017-9-6-1.test.mattermost.cloud/"
# },
# {
# "version": "9.5.2",
# "url": "https://delivery-cmt-8467830017-9-5-2.test.mattermost.cloud/"
# }
# ]
# }
build-ios-simulator:
runs-on: macos-14
needs:
- update-initial-status
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
- name: Build iOS Simulator
env:
TAG: "${{ inputs.MOBILE_VERSION }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios simulator --env ios.simulator
working-directory: ./fastlane
- name: Upload iOS Simulator Build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
detox-e2e:
name: mobile-cmt-${{ matrix.server.version }}
uses: ./.github/workflows/e2e-detox-template.yml
needs:
- build-ios-simulator
strategy:
fail-fast: false
matrix: ${{ fromJson(inputs.CMT_MATRIX) }}
secrets: inherit
with:
run-ios-tests: true
run-type: "RELEASE"
MM_TEST_SERVER_URL: ${{ matrix.server.url }}
MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }}
update-final-status:
runs-on: ubuntu-22.04
needs:
- calculate-commit-hash
- detox-e2e
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ needs.calculate-commit-hash.outputs.MOBILE_SHA }}
context: e2e/compatibility-matrix-testing
description: Compatibility Matrix Testing for ${{ inputs.MOBILE_VERSION }} version
status: ${{ needs.detox-e2e.outputs.STATUS }}
target_url: ${{ needs.detox-e2e.outputs.TARGET_URL }}

View file

@ -1,345 +0,0 @@
name: Detox Android E2E Tests Template
on:
workflow_call:
inputs:
MM_TEST_SERVER_URL:
description: "The test server URL"
required: false
type: string
MM_TEST_USER_NAME:
description: "The admin username of the test instance"
required: false
type: string
MM_TEST_PASSWORD:
description: "The admin password of the test instance"
required: false
type: string
MOBILE_VERSION:
description: "The mobile version to test"
required: false
default: ${{ github.head_ref || github.ref }}
type: string
run-android-tests:
description: "Run Android tests"
required: true
type: boolean
run-type:
type: string
required: false
default: "PR"
testcase_failure_fatal:
description: "Should failures be considered fatal"
required: false
type: boolean
default: false
record_tests_in_zephyr:
description: "Record test results in Zephyr, typically for nightly and release runs"
required: false
type: string
default: 'false'
low_bandwidth_mode:
description: "Enable low bandwidth mode"
required: false
type: boolean
default: false
android_avd_name:
description: "Android Emulator name"
required: false
type: string
default: "detox_pixel_4_xl"
android_api_level:
description: "Android API level"
required: false
type: string
default: "34"
outputs:
STATUS:
value: ${{ jobs.generate-report.outputs.STATUS }}
TARGET_URL:
value: ${{ jobs.generate-report.outputs.TARGET_URL }}
FAILURES:
value: ${{ jobs.generate-report.outputs.FAILURES }}
env:
AWS_REGION: "us-east-1"
ADMIN_EMAIL: ${{ secrets.MM_MOBILE_E2E_ADMIN_EMAIL }}
ADMIN_USERNAME: ${{ secrets.MM_MOBILE_E2E_ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }}
BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}
COMMIT_HASH: ${{ github.sha }}
DEVICE_NAME: ${{ inputs.android_avd_name }} # This is needed to split tests as same code is used in iOS job
DEVICE_OS_VERSION: ${{ inputs.android_api_level }}
DETOX_AWS_S3_BUCKET: "mattermost-detox-report"
HEADLESS: "true"
TYPE: ${{ inputs.run-type }}
PULL_REQUEST: "https://github.com/mattermost/mattermost-mobile/pull/${{ github.event.number }}"
SITE_1_URL: ${{ inputs.MM_TEST_SERVER_URL || 'https://mobile-e2e-site-1.test.mattermost.cloud' }}
SITE_2_URL: "https://mobile-e2e-site-2.test.mattermost.cloud"
SITE_3_URL: "https://mobile-e2e-site-3.test.mattermost.cloud"
ZEPHYR_ENABLE: ${{ inputs.record_tests_in_zephyr }}
JIRA_PROJECT_KEY: "MM"
ZEPHYR_API_KEY: ${{ secrets.MM_MOBILE_E2E_ZEPHYR_API_KEY }}
ZEPHYR_FOLDER_ID: "3233873"
TEST_CYCLE_LINK_PREFIX: ${{ secrets.MM_MOBILE_E2E_TEST_CYCLE_LINK_PREFIX }}
WEBHOOK_URL: ${{ secrets.MM_MOBILE_E2E_WEBHOOK_URL }}
FAILURE_MESSAGE: "Something has failed"
IOS: "false"
RUNNING_E2E: "true"
AVD_NAME: ${{ inputs.android_avd_name }}
SDK_VERSION: ${{ inputs.android_api_level }}
jobs:
generate-specs:
runs-on: ubuntu-22.04
outputs:
specs: ${{ steps.generate-specs.outputs.specs }}
build_id: ${{ steps.resolve-device.outputs.BUILD_ID }}
mobile_sha: ${{ steps.resolve-device.outputs.MOBILE_SHA }}
mobile_ref: ${{ steps.resolve-device.outputs.MOBILE_REF }}
workflow_hash: ${{ steps.resolve-device.outputs.WORKFLOW_HASH }}
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: Set Build ID
id: resolve-device
run: |
BUILD_ID="${{ github.run_id }}-${{ env.AVD_NAME }}-${{ env.SDK_VERSION}}"
WORKFLOW_HASH=$(tr -dc a-z0-9 </dev/urandom | head -c 10)
## We need that hash to separate the artifacts
echo "WORKFLOW_HASH=${WORKFLOW_HASH}" >> ${GITHUB_OUTPUT}
echo "BUILD_ID=$(echo ${BUILD_ID} | sed 's/ /_/g')" >> ${GITHUB_OUTPUT}
echo "MOBILE_SHA=$(git rev-parse HEAD)" >> ${GITHUB_OUTPUT}
echo "MOBILE_REF=$(git rev-parse --abbrev-ref HEAD)" >> ${GITHUB_OUTPUT}
- name: Generate Test Specs
id: generate-specs
uses: ./.github/actions/generate-specs
with:
parallelism: 10
search_path: detox/e2e/test
device_name: ${{ env.AVD_NAME }}
device_os_version: ${{ env.SDK_VERSION }}
e2e-android:
name: machine-${{ matrix.runId }}-api-${{ matrix.deviceOsVersion }}
runs-on: ubuntu-latest-8-cores
continue-on-error: true
timeout-minutes: 240
env:
ANDROID_HOME: /usr/local/lib/android/sdk
ANDROID_SDK_ROOT: /usr/local/lib/android/sdk
needs:
- generate-specs
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.generate-specs.outputs.specs) }}
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpulse0
sudo apt-get install -y scrot ffmpeg xvfb
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Prepare Android Build
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}"
STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}"
STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}"
MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}"
- name: Install Detox Dependencies
run: |
cd detox
npm install
- name: Set .env with RUNNING_E2E=true
run: |
cat > .env <<EOF
RUNNING_E2E=true
EOF
- name: Create destination path
run: mkdir -p android/app/build
- name: Download APK artifact
uses: actions/download-artifact@v4
with:
name: android-build-files-${{ github.run_id }}
path: android/app/build
- name: Set up Android SDK
run: |
export ANDROID_HOME=/usr/local/lib/android/sdk
export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools:$PATH
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
echo "PATH=$PATH" >> $GITHUB_ENV
- name: Start Xvfb
run: |
Xvfb :99 -screen 0 1920x1080x24 &
export DISPLAY=:99
echo "DISPLAY=:99" >> $GITHUB_ENV
- name: Accept Android licenses
run: |
mkdir -p ~/.android
yes | sdkmanager --licenses > sdk_licenses_output.txt || true
# Check if "All SDK package licenses accepted" appears in output
if grep -q "All SDK package licenses accepted" sdk_licenses_output.txt; then
echo "✅ All licenses accepted successfully."
else
echo "❌ Licenses not fully accepted."
cat sdk_licenses_output.txt
exit 1
fi
- name: Install Android SDK components
run: |
yes | sdkmanager --install "platform-tools" "emulator" "platforms;android-34" "system-images;android-34;default;x86_64" "system-images;android-34;google_apis;x86_64"
env:
JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }}
- name: Create and run Android Emulator
run: |
cd detox
chmod +x ./create_android_emulator.sh
CI=true ./create_android_emulator.sh ${{ env.SDK_VERSION }} ${{ env.AVD_NAME }} ${{ matrix.specs }}
continue-on-error: true # We want to run all the tests
- name: Upload Android Test Report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-results-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: detox/artifacts/
generate-report:
runs-on: ubuntu-22.04
needs:
- generate-specs
- e2e-android
outputs:
TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }}
STATUS: ${{ steps.determine-status.outputs.STATUS }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
- name: Download Android Artifacts
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
path: detox/artifacts/
pattern: android-results-${{ needs.generate-specs.outputs.workflow_hash }}-*
continue-on-error: true
- name: Generate Report Path
id: s3
run: |
path="${{ needs.generate-specs.outputs.build_id }}-${{ needs.generate-specs.outputs.mobile_sha }}-${{ needs.generate-specs.outputs.mobile_ref }}"
echo "path=$(echo "${path}" | sed 's/\./-/g')" >> ${GITHUB_OUTPUT}
- name: Save report Detox Dependencies
id: report-link
run: |
cd detox
npm ci
npm run e2e:save-report
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
BUILD_ID: ${{ needs.generate-specs.outputs.build_id }}
REPORT_PATH: ${{ steps.s3.outputs.path }}
## These are needed for the MM Webhook report
COMMIT_HASH: ${{ needs.generate-specs.outputs.mobile_sha }}
BRANCH: ${{ needs.generate-specs.outputs.mobile_ref }}
- name: Calculate failures
id: summary
run: |
echo "FAILURES=$(cat detox/artifacts/summary.json | jq .stats.failures)" >> ${GITHUB_OUTPUT}
echo "PASSES=$(cat detox/artifacts/summary.json | jq .stats.passes)" >> ${GITHUB_OUTPUT}
echo "SKIPPED=$(cat detox/artifacts/summary.json | jq .stats.skipped)" >> ${GITHUB_OUTPUT}
echo "TOTAL=$(cat detox/artifacts/summary.json | jq .stats.tests)" >> ${GITHUB_OUTPUT}
echo "ERRORS=$(cat detox/artifacts/summary.json | jq .stats.errors)" >> ${GITHUB_OUTPUT}
echo "PERCENTAGE=$(cat detox/artifacts/summary.json | jq .stats.passPercent)" >> ${GITHUB_OUTPUT}
- name: Set Target URL
id: set-url
run: |
echo "TARGET_URL=https://${{ env.DETOX_AWS_S3_BUCKET }}.s3.amazonaws.com/${{ steps.s3.outputs.path }}/jest-stare/android-report.html" >> ${GITHUB_OUTPUT}
- name: Determine Status
id: determine-status
run: |
if [[ ${{ steps.summary.outputs.failures }} -gt 0 && "${{ inputs.testcase_failure_fatal }}" == "true" ]]; then
echo "STATUS=failure" >> ${GITHUB_OUTPUT}
else
echo "STATUS=success" >> ${GITHUB_OUTPUT}
fi
- name: Generate Summary
run: |
echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: | Errors :warning: | " >> ${GITHUB_STEP_SUMMARY}
echo "|:---:|:---:|:---:|:---:|:---:|" >> ${GITHUB_STEP_SUMMARY}
echo "| ${{ steps.summary.outputs.TOTAL }} | ${{ steps.summary.outputs.PASSES }} | ${{ steps.summary.outputs.FAILURES }} | ${{ steps.summary.outputs.SKIPPED }} | ${{ steps.summary.outputs.ERRORS }} |" >> ${GITHUB_STEP_SUMMARY}
echo "" >> ${GITHUB_STEP_SUMMARY}
echo "You can check the full report [here](${{ steps.set-url.outputs.TARGET_URL }})" >> ${GITHUB_STEP_SUMMARY}
echo "There was **${{ steps.summary.outputs.PERCENTAGE }}%** success rate." >> ${GITHUB_STEP_SUMMARY}
- name: Comment report on the PR
if: ${{ github.event_name == 'pull_request' }}
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const commentBody = `**Android E2E Test Report**: ${process.env.MOBILE_SHA} | ${process.env.PERCENTAGE}% (${process.env.PASSES}/${process.env.TOTAL}) | [full report](${process.env.TARGET_URL})
| Tests | Passed ✅ | Failed ❌ | Skipped ⏭️ | Errors ⚠️ |
|:---:|:---:|:---:|:---:|:---:|
| ${process.env.TOTAL} | ${process.env.PASSES} | ${process.env.FAILURES} | ${process.env.SKIPPED} | ${process.env.ERRORS} |
`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: commentBody,
});
env:
STATUS: ${{ steps.determine-status.outputs.STATUS }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
PASSES: ${{ steps.summary.outputs.PASSES }}
SKIPPED: ${{ steps.summary.outputs.SKIPPED }}
TOTAL: ${{ steps.summary.outputs.TOTAL }}
ERRORS: ${{ steps.summary.outputs.ERRORS }}
PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }}
BUILD_ID: ${{ needs.generate-specs.outputs.build_id }}
RUN_TYPE: ${{ inputs.run-type }}
MOBILE_REF: ${{ needs.generate-specs.outputs.mobile_ref }}
MOBILE_SHA: ${{ needs.generate-specs.outputs.mobile_sha }}
TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }}

View file

@ -1,224 +0,0 @@
# Can be used to run Detox E2E tests on pull requests for the Mattermost mobile app with low bandwidth
# by using 'E2E iOS tests for PR (LBW 1)' instead.
name: E2E
on:
pull_request:
branches:
- main
types:
- labeled
concurrency:
group: "${{ github.workflow }}-${{ github.event.pull_request.number }}-${{ github.event.label.name }}"
cancel-in-progress: true
env:
INTUNE_ENABLED: 'true'
jobs:
update-initial-status-ios:
if: contains(github.event.label.name, 'E2E iOS tests for PR')
runs-on: ubuntu-22.04
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
if: contains(github.event.label.name, 'E2E iOS tests for PR')
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.event.pull_request.head.sha }}
context: e2e/detox-ios-tests
description: Detox iOS tests for mattermost mobile app have started ...
status: pending
update-initial-status-android:
runs-on: ubuntu-22.04
if: contains(github.event.label.name, 'E2E Android tests for PR')
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.event.pull_request.head.sha }}
context: e2e/detox-android-tests
description: Detox Android tests for mattermost mobile app have started ...
status: pending
build-ios-simulator:
if: contains(github.event.label.name, 'E2E iOS tests for PR')
runs-on: macos-26
needs:
- update-initial-status-ios
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Set .env with RUNNING_E2E=true
run: |
echo "RUNNING_E2E=true" > .env
- name: Build iOS Simulator
env:
TAG: "${{ github.event.pull_request.head.sha }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios simulator --env ios.simulator skip_upload_to_s3_bucket:true
working-directory: ./fastlane
- name: Upload iOS Simulator Build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-*.app.zip
build-android-apk:
runs-on: ubuntu-latest-8-cores
if: contains(github.event.label.name, 'E2E Android tests for PR')
needs:
- update-initial-status-android
env:
ORG_GRADLE_PROJECT_jvmargs: -Xmx8g
steps:
- name: Prune Docker to free up space
run: docker system prune -af
- name: Remove npm Temporary Files
run: |
rm -rf ~/.npm/_cacache
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Prepare Android Build
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}"
STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}"
STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}"
MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}"
- name: Install Dependencies
run: sudo apt-get clean && sudo apt-get update && sudo apt-get install -y default-jdk
- name: Detox build
run: |
cd detox
npm install
npm install -g detox-cli
npm run e2e:android-inject-settings
npm run e2e:android-build
- name: Upload Android Build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-build-files-${{ github.run_id }}
path: "android/app/build/**/*"
run-ios-tests-on-pr:
if: contains(github.event.label.name, 'E2E iOS tests for PR')
name: iOS
uses: ./.github/workflows/e2e-ios-template.yml
needs:
- build-ios-simulator
with:
run-type: "PR"
MOBILE_VERSION: ${{ github.event.pull_request.head.sha }}
low_bandwidth_mode: ${{ contains(github.event.label.name,'LBW') && true || false }}
secrets: inherit
run-android-tests-on-pr:
if: contains(github.event.label.name, 'E2E Android tests for PR')
name: Android
uses: ./.github/workflows/e2e-android-template.yml
needs:
- build-android-apk
with:
run-android-tests: true
run-type: "PR"
MOBILE_VERSION: ${{ github.event.pull_request.head.sha }}
secrets: inherit
update-final-status-ios:
runs-on: ubuntu-22.04
if: contains(github.event.label.name, 'E2E iOS tests for PR')
needs:
- run-ios-tests-on-pr
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.event.pull_request.head.sha }}
context: e2e/detox-ios-tests
description: Completed with ${{ needs.run-ios-tests-on-pr.outputs.FAILURES }} failures
status: ${{ needs.run-ios-tests-on-pr.outputs.STATUS }}
target_url: ${{ needs.run-ios-tests-on-pr.outputs.TARGET_URL }}
update-final-status-android:
runs-on: ubuntu-22.04
if: contains(github.event.label.name, 'E2E Android tests for PR')
needs:
- run-android-tests-on-pr
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.event.pull_request.head.sha }}
context: e2e/detox-android-tests
description: Completed with ${{ needs.run-android-tests-on-pr.outputs.FAILURES }} failures
status: ${{ needs.run-android-tests-on-pr.outputs.STATUS }}
target_url: ${{ needs.run-android-tests-on-pr.outputs.TARGET_URL }}
e2e-remove-ios-label:
runs-on: ubuntu-22.04
needs:
- run-ios-tests-on-pr
steps:
- name: e2e/remove-label-from-pr
uses: actions/github-script@e7aeb8c663f696059ebb5f9ab1425ed2ef511bdb # v7.0.1
continue-on-error: true # Label might have been removed manually
with:
script: |
const iosLabel = 'E2E iOS tests for PR';
context.payload.pull_request.labels.forEach(label => {
if (label.name.includes(iosLabel)) {
github.rest.issues.removeLabel({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name,
});
}
});
e2e-remove-android-label:
runs-on: ubuntu-22.04
needs:
- run-android-tests-on-pr
steps:
- name: e2e/remove-label-from-pr
uses: actions/github-script@e7aeb8c663f696059ebb5f9ab1425ed2ef511bdb # v7.0.1
continue-on-error: true # Label might have been removed manually
with:
script: |
const androidLabel = 'E2E Android tests for PR';
context.payload.pull_request.labels.forEach(label => {
if (label.name.includes(androidLabel)) {
github.rest.issues.removeLabel({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name,
});
}
});

View file

@ -1,170 +0,0 @@
name: Detox E2E Tests Release
on:
push:
branches:
- release-*
env:
INTUNE_ENABLED: 'true'
jobs:
update-initial-status-ios:
runs-on: ubuntu-22.04
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.sha }}
context: e2e/detox-ios-tests
description: Detox iOS tests for mattermost mobile app have started ...
status: pending
update-initial-status-android:
runs-on: ubuntu-22.04
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.sha }}
context: e2e/detox-android-tests
description: Detox Android tests for mattermost mobile app have started ...
status: pending
build-ios-simulator:
runs-on: macos-15
needs:
- update-initial-status-ios
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Build iOS Simulator
env:
TAG: "${{ github.ref }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios simulator --env ios.simulator
working-directory: ./fastlane
- name: Upload iOS Simulator Build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
build-android-apk:
runs-on: ubuntu-latest-8-cores
needs:
- update-initial-status-android
env:
ORG_GRADLE_PROJECT_jvmargs: -Xmx8g
steps:
- name: Prune Docker to free up space
run: docker system prune -af
- name: Remove npm Temporary Files
run: |
rm -rf ~/.npm/_cacache
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: Prepare Android Build
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}"
STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}"
STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}"
MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}"
- name: Install Dependencies
run: sudo apt-get clean && sudo apt-get update && sudo apt-get install -y default-jdk
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: ~/.gradle/caches/modules-2/
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-
- name: Detox build
run: |
cd detox
npm install
npm install -g detox-cli
npm run e2e:android-build
- name: Upload Android Build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-build-files-${{ github.run_id }}
path: "android/app/build/**/*"
run-ios-tests-on-release:
name: iOS Mobile Tests on Release
uses: ./.github/workflows/e2e-ios-template.yml
needs:
- build-ios-simulator
with:
run-type: "RELEASE"
record_tests_in_zephyr: 'true'
MOBILE_VERSION: ${{ github.ref }}
secrets: inherit
run-android-tests-on-release:
name: Android Mobile Tests on Release
uses: ./.github/workflows/e2e-android-template.yml
needs:
- build-android-apk
with:
run-android-tests: true
run-type: "RELEASE"
record_tests_in_zephyr: 'true'
MOBILE_VERSION: ${{ github.ref }}
secrets: inherit
update-final-status-ios:
runs-on: ubuntu-22.04
needs:
- run-ios-tests-on-release
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.sha }}
context: e2e/detox-ios-tests
description: Completed with ${{ needs.run-ios-tests-on-release.outputs.FAILURES }} failures
status: ${{ needs.run-ios-tests-on-release.outputs.STATUS }}
target_url: ${{ needs.run-ios-tests-on-release.outputs.TARGET_URL }}
update-final-status-android:
runs-on: ubuntu-22.04
needs:
- run-android-tests-on-release
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.sha }}
context: e2e/detox-android-tests
description: Completed with ${{ needs.run-android-tests-on-release.outputs.FAILURES }} failures
status: ${{ needs.run-android-tests-on-release.outputs.STATUS }}
target_url: ${{ needs.run-android-tests-on-release.outputs.TARGET_URL }}

View file

@ -1,169 +0,0 @@
name: Detox E2E Tests (Scheduled)
on:
schedule:
- cron: "0 0 * * 4,5" # Wednesday and Thursday midnight
env:
INTUNE_ENABLED: 'true'
jobs:
update-initial-status-ios:
runs-on: ubuntu-22.04
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.sha }}
context: e2e/detox-ios-tests
description: Detox iOS tests for mattermost mobile app have started ...
status: pending
update-initial-status-android:
runs-on: ubuntu-22.04
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.sha }}
context: e2e/detox-android-tests
description: Detox Android tests for mattermost mobile app have started ...
status: pending
build-ios-simulator:
runs-on: macos-15
needs:
- update-initial-status-ios
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Build iOS Simulator
env:
TAG: "${{ github.ref }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios simulator --env ios.simulator
working-directory: ./fastlane
- name: Upload iOS Simulator Build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
build-android-apk:
runs-on: ubuntu-latest-8-cores
needs:
- update-initial-status-android
env:
ORG_GRADLE_PROJECT_jvmargs: -Xmx8g
steps:
- name: Prune Docker to free up space
run: docker system prune -af
- name: Remove npm Temporary Files
run: |
rm -rf ~/.npm/_cacache
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: Prepare Android Build
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}"
STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}"
STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}"
MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}"
- name: Install Dependencies
run: sudo apt-get clean && sudo apt-get update && sudo apt-get install -y default-jdk
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: ~/.gradle/caches/modules-2/
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-
- name: Detox build
run: |
cd detox
npm install
npm install -g detox-cli
npm run e2e:android-build
- name: Upload Android Build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-build-files-${{ github.run_id }}
path: "android/app/build/**/*"
run-ios-tests-on-main-scheduled:
name: iOS Mobile Tests on Main (Scheduled)
uses: ./.github/workflows/e2e-ios-template.yml
needs:
- build-ios-simulator
with:
run-type: "MAIN"
record_tests_in_zephyr: 'true'
MOBILE_VERSION: ${{ github.ref }}
secrets: inherit
run-android-tests-on-main-scheduled:
name: Android Mobile Tests on Main (Scheduled)
uses: ./.github/workflows/e2e-android-template.yml
needs:
- build-android-apk
with:
run-android-tests: true
run-type: "MAIN"
record_tests_in_zephyr: 'true'
MOBILE_VERSION: ${{ github.ref }}
secrets: inherit
update-final-status-ios:
runs-on: ubuntu-22.04
needs:
- run-ios-tests-on-main-scheduled
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.sha }}
context: e2e/detox-ios-tests
description: Completed with ${{ needs.run-ios-tests-on-main-scheduled.outputs.FAILURES }} failures
status: ${{ needs.run-ios-tests-on-main-scheduled.outputs.STATUS }}
target_url: ${{ needs.run-ios-tests-on-main-scheduled.outputs.TARGET_URL }}
update-final-status-android:
runs-on: ubuntu-22.04
needs:
- run-android-tests-on-main-scheduled
steps:
- uses: mattermost/actions/delivery/update-commit-status@main
env:
GITHUB_TOKEN: ${{ github.token }}
with:
repository_full_name: ${{ github.repository }}
commit_sha: ${{ github.sha }}
context: e2e/detox-android-tests
description: Completed with ${{ needs.run-android-tests-on-main-scheduled.outputs.FAILURES }} failures
status: ${{ needs.run-android-tests-on-main-scheduled.outputs.STATUS }}
target_url: ${{ needs.run-android-tests-on-main-scheduled.outputs.TARGET_URL }}

View file

@ -1,494 +0,0 @@
name: Detox iOS E2E Tests Template
on:
workflow_call:
inputs:
MM_TEST_SERVER_URL:
description: "The test server URL"
required: false
type: string
MM_TEST_USER_NAME:
description: "The admin username of the test instance"
required: false
type: string
MM_TEST_PASSWORD:
description: "The admin password of the test instance"
required: false
type: string
MOBILE_VERSION:
description: "The mobile version to test"
required: false
default: ${{ github.head_ref || github.ref }}
type: string
run-type:
type: string
required: false
default: "PR"
testcase_failure_fatal:
description: "Should failures be considered fatal"
required: false
type: boolean
default: false
record_tests_in_zephyr:
description: "Record test results in Zephyr, typically for nightly and release runs"
required: false
type: string
default: 'false'
ios_device_name:
description: "iPhone simulator name"
required: false
type: string
default: "iPhone 17 Pro"
ios_device_os_name:
description: "iPhone simulator OS version"
required: false
type: string
default: "iOS 26.2"
low_bandwidth_mode:
description: "Enable low bandwidth mode"
required: false
type: boolean
default: false
outputs:
STATUS:
value: ${{ jobs.generate-report.outputs.STATUS }}
TARGET_URL:
value: ${{ jobs.generate-report.outputs.TARGET_URL }}
FAILURES:
value: ${{ jobs.generate-report.outputs.FAILURES }}
env:
AWS_REGION: "us-east-1"
ADMIN_EMAIL: ${{ secrets.MM_MOBILE_E2E_ADMIN_EMAIL }}
ADMIN_USERNAME: ${{ secrets.MM_MOBILE_E2E_ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }}
BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}
COMMIT_HASH: ${{ github.sha }}
DEVICE_NAME: ${{ inputs.ios_device_name }}
DEVICE_OS_VERSION: ${{ inputs.ios_device_os_name }}
DETOX_AWS_S3_BUCKET: "mattermost-detox-report"
HEADLESS: "true"
TYPE: ${{ inputs.run-type }}
PULL_REQUEST: "https://github.com/mattermost/mattermost-mobile/pull/${{ github.event.number }}"
SITE_1_URL: ${{ inputs.MM_TEST_SERVER_URL || 'https://mobile-e2e-site-1.test.mattermost.cloud' }}
SITE_2_URL: "https://mobile-e2e-site-2.test.mattermost.cloud"
SITE_3_URL: "https://mobile-e2e-site-3.test.mattermost.cloud"
ZEPHYR_ENABLE: ${{ inputs.record_tests_in_zephyr }}
JIRA_PROJECT_KEY: "MM"
ZEPHYR_API_KEY: ${{ secrets.MM_MOBILE_E2E_ZEPHYR_API_KEY }}
ZEPHYR_FOLDER_ID: "3233873"
TEST_CYCLE_LINK_PREFIX: ${{ secrets.MM_MOBILE_E2E_TEST_CYCLE_LINK_PREFIX }}
WEBHOOK_URL: ${{ secrets.MM_MOBILE_E2E_WEBHOOK_URL }}
FAILURE_MESSAGE: "Something has failed"
IOS: "true"
RUNNING_E2E: "true"
jobs:
generate-specs:
runs-on: ubuntu-22.04
outputs:
specs: ${{ steps.generate-specs.outputs.specs }}
build_id: ${{ steps.resolve-device.outputs.BUILD_ID }}
mobile_sha: ${{ steps.resolve-device.outputs.MOBILE_SHA }}
mobile_ref: ${{ steps.resolve-device.outputs.MOBILE_REF }}
workflow_hash: ${{ steps.resolve-device.outputs.WORKFLOW_HASH }}
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: Set Build ID
id: resolve-device
run: |
BUILD_ID="${{ github.run_id }}-${{ env.DEVICE_NAME }}-${{ env.DEVICE_OS_VERSION}}"
WORKFLOW_HASH=$(tr -dc a-z0-9 </dev/urandom | head -c 10)
## We need that hash to separate the artifacts
echo "WORKFLOW_HASH=${WORKFLOW_HASH}" >> ${GITHUB_OUTPUT}
echo "BUILD_ID=$(echo ${BUILD_ID} | sed 's/ /_/g')" >> ${GITHUB_OUTPUT}
echo "MOBILE_SHA=$(git rev-parse HEAD)" >> ${GITHUB_OUTPUT}
echo "MOBILE_REF=$(git rev-parse --abbrev-ref HEAD)" >> ${GITHUB_OUTPUT}
- name: Generate Test Specs
id: generate-specs
uses: ./.github/actions/generate-specs
with:
parallelism: 10
search_path: detox/e2e/test
device_name: ${{ env.DEVICE_NAME }}
device_os_version: ${{ env.DEVICE_OS_VERSION }}
e2e-ios:
name: machine-${{ matrix.runId }}-os-${{ matrix.deviceOsVersion }}
runs-on: macos-15
continue-on-error: true
timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }}
env:
IOS: true
needs:
- generate-specs
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.generate-specs.outputs.specs) }}
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
- name: Install Homebrew Dependencies
run: |
brew tap wix/brew
brew install applesimutils
- name: Download iOS Simulator Build
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
name: ios-build-simulator-${{ github.run_id }}
path: mobile-artifacts
- name: Unzip iOS Simulator Build
run: |
unzip -o mobile-artifacts/*.zip -d mobile-artifacts/
# delete zip file
rm -f mobile-artifacts/*.zip
- name: Prepare Low Bandwidth Environment
id: prepare-low-bandwidth
uses: ./.github/actions/prepare-low-bandwidth
if: ${{ inputs.low_bandwidth_mode }}
with:
test_server_url: ${{ env.SITE_1_URL }}
device_name: ${{ env.DEVICE_NAME }}
# all these value should be configurable
download_speed: "3300"
upload_speed: "3300"
latency: "500"
- name: Install Detox Dependencies
run: cd detox && npm i
- name: Pre-boot iOS Simulator
run: |
set -e
echo "Looking for simulator: ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}"
# Find or create the simulator
SIMULATOR_ID=$(xcrun simctl list devices | grep "${{ env.DEVICE_NAME }}" | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | grep -oE '([0-9A-F-]{36})' || true)
if [ -z "$SIMULATOR_ID" ]; then
echo "Simulator not found. Creating simulator ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}"
# Get device type (use exact match with parentheses to avoid partial matches)
DEVICE_TYPE=$(xcrun simctl list devicetypes | grep "${{ env.DEVICE_NAME }} (" | head -1 | awk -F'[()]' '{print $(NF-1)}')
if [ -z "$DEVICE_TYPE" ]; then
echo "Error: Could not find device type for ${{ env.DEVICE_NAME }}"
exit 1
fi
echo "Found device type: $DEVICE_TYPE"
# Get runtime (extract the identifier after the last " - ")
RUNTIME=$(xcrun simctl list runtimes | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | sed 's/.* - \(.*\)/\1/')
if [ -z "$RUNTIME" ]; then
echo "Error: Could not find runtime for ${{ env.DEVICE_OS_VERSION }}"
exit 1
fi
echo "Found runtime: $RUNTIME"
# Create simulator
SIMULATOR_ID=$(xcrun simctl create "CI-${{ env.DEVICE_NAME }}" "$DEVICE_TYPE" "$RUNTIME")
echo "Created simulator with ID: $SIMULATOR_ID"
else
echo "Found existing simulator: $SIMULATOR_ID"
fi
# Boot simulator first to initialize directory structure
echo "Initializing simulator directories (first boot)..."
xcrun simctl boot "$SIMULATOR_ID"
xcrun simctl bootstatus "$SIMULATOR_ID"
# Shut down to apply password autofill restrictions
echo "Shutting down to apply password autofill restrictions..."
xcrun simctl shutdown "$SIMULATOR_ID"
sleep 3
echo "Disabling password autofill (simulator is shut down)"
# Ensure the directory exists (should now exist after first boot)
SETTINGS_DIR="$HOME/Library/Developer/CoreSimulator/Devices/$SIMULATOR_ID/data/Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles"
mkdir -p "$SETTINGS_DIR"
# Use the JavaScript utility to disable password autofill
cd detox
node utils/disable_ios_autofill.js --simulator-id "$SIMULATOR_ID"
if [ $? -ne 0 ]; then
echo "⚠️ Failed to disable password autofill"
exit 1
fi
cd ..
echo "Booting simulator with password autofill disabled..."
xcrun simctl boot "$SIMULATOR_ID"
# Wait for boot to complete
xcrun simctl bootstatus "$SIMULATOR_ID"
# Open Simulator.app in background to speed up UI rendering
open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID"
# Give Simulator.app time to fully initialize UI (iOS 26.2 needs more time)
sleep 5
echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV
- name: Start Proxy
if: ${{ inputs.low_bandwidth_mode }}
id: start-proxy
uses: ./.github/actions/start-proxy
with:
test_server_url: ${{ env.SITE_1_URL }}
- name: Set .env with RUNNING_E2E=true
run: |
echo "RUNNING_E2E=true" > .env
- name: Verify Simulator Health
run: |
echo "Verifying simulator health before running tests..."
# Check if simulator is still responsive
if ! xcrun simctl bootstatus "$SIMULATOR_ID" -b 2>/dev/null; then
echo "⚠️ Simulator not responsive, attempting recovery..."
# Try to shutdown and reboot
xcrun simctl shutdown "$SIMULATOR_ID" || true
sleep 3
xcrun simctl boot "$SIMULATOR_ID"
xcrun simctl bootstatus "$SIMULATOR_ID"
fi
echo "✅ Simulator is healthy and ready"
- name: Start Metro Bundler and Run Detox E2E Tests
continue-on-error: true # We want to run all the tests
run: |
# Start Metro bundler in background
npm run start > metro.log 2>&1 &
METRO_PID=$!
echo "Metro PID: $METRO_PID"
echo "METRO_PID=$METRO_PID" >> $GITHUB_ENV
# Wait for Metro to be ready with health check endpoint polling
timeout=120
elapsed=0
echo "Waiting for Metro bundler to be ready..."
while [ $elapsed -lt $timeout ]; do
# Try health check endpoint first (more reliable)
if curl -s http://localhost:8081/status 2>/dev/null | grep -q "packager-status:running"; then
echo "✅ Metro bundler is ready after ${elapsed}s (verified via health check)"
break
# Fallback to log checking
elif grep -q "Fast - Scalable - Integrated" metro.log 2>/dev/null; then
echo "✅ Metro bundler is ready after ${elapsed}s (verified via logs)"
break
fi
# Show progress every 10 seconds
if [ $((elapsed % 10)) -eq 0 ] && [ $elapsed -gt 0 ]; then
echo "Still waiting for Metro... (${elapsed}s elapsed)"
fi
sleep 2
elapsed=$((elapsed + 2))
done
if [ $elapsed -ge $timeout ]; then
echo "⚠️ Warning: Metro bundler may not be fully ready after ${timeout}s, proceeding anyway"
echo "Metro process status:"
ps aux | grep -E "metro|node.*start" | grep -v grep || echo "Metro process not found"
echo ""
echo "Last 30 lines of metro.log:"
tail -30 metro.log
else
echo "Metro is serving on http://localhost:8081"
# Give Metro extra time to fully initialize for iOS 26.2 (React Native bridge needs more time)
echo "Allowing Metro to fully stabilize..."
sleep 8
fi
cd detox
npm run detox:config-gen
npm run e2e:ios-test -- ${{ matrix.specs }}
env:
DETOX_DISABLE_HIERARCHY_DUMP: "YES"
DETOX_DISABLE_SCREENSHOT_TRACKING: "YES"
DETOX_LOGLEVEL: "debug"
DETOX_DEVICE_TYPE: ${{ env.DEVICE_NAME }}
DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }}
LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode }}
- name: Cleanup Simulator State
if: always()
run: |
echo "Cleaning up simulator state after tests..."
# Stop Metro bundler if still running
if [ ! -z "$METRO_PID" ]; then
echo "Stopping Metro bundler (PID: $METRO_PID)..."
kill -9 "$METRO_PID" 2>/dev/null || true
fi
# Terminate app gracefully
if [ ! -z "$SIMULATOR_ID" ]; then
echo "Terminating app on simulator..."
xcrun simctl terminate "$SIMULATOR_ID" com.tokilabs.mattermost 2>/dev/null || true
sleep 2
fi
echo "✅ Simulator cleanup complete"
- name: reset network settings
if: ${{ inputs.low_bandwidth_mode || failure() }}
run: |
networksetup -setwebproxystate Ethernet "off"
networksetup -setsecurewebproxystate Ethernet "off"
if (sudo pfctl -q -sa | grep 'Status: Enabled') then sudo pfctl -d; fi
if (command -v pm2 &> /dev/null) then pm2 stop mitmdump; fi
sleep 5;
- name: Upload mitmdump Flow Output
if: ${{ inputs.low_bandwidth_mode }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-mitmdump-flow-output-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: |
/Users/runner/work/mattermost-mobile/mattermost-mobile/flow-output.csv
/Users/runner/work/mattermost-mobile/mattermost-mobile/mitmdump.log
- name: Upload iOS Test Report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-results-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: detox/artifacts/
generate-report:
runs-on: ubuntu-22.04
needs:
- generate-specs
- e2e-ios
outputs:
TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }}
STATUS: ${{ steps.determine-status.outputs.STATUS }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
- name: Download iOS Artifacts
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
path: detox/artifacts/
pattern: ios-results-${{ needs.generate-specs.outputs.workflow_hash }}-*
- name: Generate Report Path
id: s3
run: |
path="${{ needs.generate-specs.outputs.build_id }}-${{ needs.generate-specs.outputs.mobile_sha }}-${{ needs.generate-specs.outputs.mobile_ref }}"
echo "path=$(echo "${path}" | sed 's/\./-/g')" >> ${GITHUB_OUTPUT}
- name: Save report Detox Dependencies
id: report-link
run: |
cd detox
npm ci
npm run e2e:save-report
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
BUILD_ID: ${{ needs.generate-specs.outputs.build_id }}
REPORT_PATH: ${{ steps.s3.outputs.path }}
## These are needed for the MM Webhook report
COMMIT_HASH: ${{ needs.generate-specs.outputs.mobile_sha }}
BRANCH: ${{ needs.generate-specs.outputs.mobile_ref }}
- name: Calculate failures
id: summary
run: |
echo "FAILURES=$(cat detox/artifacts/summary.json | jq .stats.failures)" >> ${GITHUB_OUTPUT}
echo "PASSES=$(cat detox/artifacts/summary.json | jq .stats.passes)" >> ${GITHUB_OUTPUT}
echo "SKIPPED=$(cat detox/artifacts/summary.json | jq .stats.skipped)" >> ${GITHUB_OUTPUT}
echo "TOTAL=$(cat detox/artifacts/summary.json | jq .stats.tests)" >> ${GITHUB_OUTPUT}
echo "ERRORS=$(cat detox/artifacts/summary.json | jq .stats.errors)" >> ${GITHUB_OUTPUT}
echo "PERCENTAGE=$(cat detox/artifacts/summary.json | jq .stats.passPercent)" >> ${GITHUB_OUTPUT}
- name: Set Target URL
id: set-url
run: |
echo "TARGET_URL=https://${{ env.DETOX_AWS_S3_BUCKET }}.s3.amazonaws.com/${{ steps.s3.outputs.path }}/jest-stare/ios-report.html" >> ${GITHUB_OUTPUT}
- name: Determine Status
id: determine-status
run: |
if [[ ${{ steps.summary.outputs.failures }} -gt 0 && "${{ inputs.testcase_failure_fatal }}" == "true" ]]; then
echo "STATUS=failure" >> ${GITHUB_OUTPUT}
else
echo "STATUS=success" >> ${GITHUB_OUTPUT}
fi
- name: Generate Summary
run: |
echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: | Errors :warning: | " >> ${GITHUB_STEP_SUMMARY}
echo "|:---:|:---:|:---:|:---:|:---:|" >> ${GITHUB_STEP_SUMMARY}
echo "| ${{ steps.summary.outputs.TOTAL }} | ${{ steps.summary.outputs.PASSES }} | ${{ steps.summary.outputs.FAILURES }} | ${{ steps.summary.outputs.SKIPPED }} | ${{ steps.summary.outputs.ERRORS }} |" >> ${GITHUB_STEP_SUMMARY}
echo "" >> ${GITHUB_STEP_SUMMARY}
echo "You can check the full report [here](${{ steps.set-url.outputs.TARGET_URL }})" >> ${GITHUB_STEP_SUMMARY}
echo "There was **${{ steps.summary.outputs.PERCENTAGE }}%** success rate." >> ${GITHUB_STEP_SUMMARY}
- name: Comment report on the PR
if: ${{ github.event_name == 'pull_request' }}
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const commentBody = `**iOS E2E Test Report**: ${process.env.MOBILE_SHA} | ${process.env.PERCENTAGE}% (${process.env.PASSES}/${process.env.TOTAL}) | [full report](${process.env.TARGET_URL})
| Tests | Passed ✅ | Failed ❌ | Skipped ⏭️ | Errors ⚠️ |
|:---:|:---:|:---:|:---:|:---:|
| ${process.env.TOTAL} | ${process.env.PASSES} | ${process.env.FAILURES} | ${process.env.SKIPPED} | ${process.env.ERRORS} |
`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: commentBody,
});
env:
STATUS: ${{ steps.determine-status.outputs.STATUS }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
PASSES: ${{ steps.summary.outputs.PASSES }}
SKIPPED: ${{ steps.summary.outputs.SKIPPED }}
TOTAL: ${{ steps.summary.outputs.TOTAL }}
ERRORS: ${{ steps.summary.outputs.ERRORS }}
PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }}
BUILD_ID: ${{ needs.generate-specs.outputs.build_id }}
RUN_TYPE: ${{ inputs.run-type }}
MOBILE_REF: ${{ needs.generate-specs.outputs.mobile_ref }}
MOBILE_SHA: ${{ needs.generate-specs.outputs.mobile_sha }}
TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }}

View file

@ -4,84 +4,56 @@ on:
push:
tags:
- v[0-9]+.[0-9]+.[0-9]+*
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v2.10.0)"
required: true
type: string
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
SNYK_VERSION: "1.1297.2"
CYCLONEDX_VERSION: "v0.27.2"
INTUNE_ENABLED: 'true'
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/test
uses: ./.github/actions/test
build-ios-unsigned:
runs-on: macos-15-large
runs-on: macos-12
needs:
- test
steps:
- name: ci/checkout-repo
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 }}
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/output-ssh-private-key
shell: bash
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
env:
TAG: "${{ env.RELEASE_TAG }}"
TAG: "${{ github.ref_name }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios unsigned
working-directory: ./fastlane
- name: ci/upload-ios-unsigned
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
path: Mattermost-unsigned.ipa
name: Mattermost-unsigned.ipa
- name: ci/install-snyk
run: npm install -g snyk@${{ env.SNYK_VERSION }}
- name: ci/generate-ios-sbom
env:
SNYK_TOKEN: "${{ secrets.SNYK_TOKEN }}"
run: |
snyk sbom --format=cyclonedx1.6+json --json-file-output=../sbom-ios.json --all-projects
working-directory: ./ios
shell: bash
- name: ci/upload-ios-sbom
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
path: sbom-ios.json
name: sbom-ios.json
build-android-unsigned:
runs-on: ubuntu-22.04
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
@ -90,145 +62,39 @@ jobs:
- name: ci/build-android-beta
env:
TAG: "${{ env.RELEASE_TAG }}"
TAG: "${{ github.ref_name }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane android unsigned
working-directory: ./fastlane
- name: ci/upload-android-unsigned-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
with:
path: Mattermost-unsigned.apk
name: Mattermost-unsigned.apk
- name: ci/install-snyk
run: npm install -g snyk@${{ env.SNYK_VERSION }}
- name: ci/generate-android-sbom
env:
SNYK_TOKEN: "${{ secrets.SNYK_TOKEN }}"
run: |
snyk sbom --format=cyclonedx1.6+json --all-projects --json-file-output=../sbom-android.json
working-directory: ./android
shell: bash
- name: ci/upload-android-sbom
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
path: sbom-android.json
name: sbom-android.json
generate-consolidated-sbom:
runs-on: ubuntu-22.04
needs:
- build-ios-unsigned
- build-android-unsigned
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/download-sboms
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: sbom-*.json
path: ${{ github.workspace }}
merge-multiple: true
- name: ci/install-snyk
run: npm install -g snyk@${{ env.SNYK_VERSION }}
- name: ci/setup-cyclonedx-cli
run: |
set -e
CYCLONEDX_BINARY="cyclonedx-linux-x64"
CYCLONEDX_URL="https://github.com/CycloneDX/cyclonedx-cli/releases/download/${{ env.CYCLONEDX_VERSION }}/${CYCLONEDX_BINARY}"
# Download with better error handling and retry
echo "Downloading CycloneDX CLI ${{ env.CYCLONEDX_VERSION }}..."
curl -sSfL --retry 3 --retry-delay 5 "${CYCLONEDX_URL}" -o cyclonedx
# Verify the binary is executable and not corrupted
if [ ! -s cyclonedx ]; then
echo "Error: Downloaded file is empty or corrupted"
exit 1
fi
# Make executable and move to PATH
chmod +x cyclonedx
sudo mv cyclonedx /usr/local/bin/
# Verify installation
cyclonedx --version
- name: ci/generate-consolidated-sbom
env:
SNYK_TOKEN: "${{ secrets.SNYK_TOKEN }}"
SBOM_FILENAME: "sbom-${{ github.event.repository.name }}-${{ env.RELEASE_TAG }}.json"
run: |
# Check if required SBOM files are available
if [ ! -f "sbom-android.json" ]; then
echo "Error: sbom-android.json not found. Android SBOM generation may have failed."
exit 1
fi
if [ ! -f "sbom-ios.json" ]; then
echo "Error: sbom-ios.json not found. iOS SBOM generation may have failed."
exit 1
fi
echo "All required SBOM files are available. Proceeding with consolidation..."
# Generate top-level SBOM
snyk sbom --format=cyclonedx1.6+json --json-file-output=sbom-top-level.json
# Consolidate SBOMs
cyclonedx merge \
--input-files "sbom-top-level.json" "sbom-android.json" "sbom-ios.json" \
--input-format=json \
--output-file="$SBOM_FILENAME" \
--output-format=json \
--output-version=v1_6
# Validate the consolidated SBOM
cyclonedx validate --input-file="$SBOM_FILENAME"
shell: bash
- name: ci/upload-consolidated-sbom
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
path: sbom-${{ github.event.repository.name }}-${{ env.RELEASE_TAG }}.json
name: sbom-${{ github.event.repository.name }}-${{ env.RELEASE_TAG }}.json
release:
runs-on: ubuntu-22.04
needs:
- build-ios-unsigned
- build-android-unsigned
- generate-consolidated-sbom
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
- uses: ruby/setup-ruby@cb0fda56a307b8c78d38320cd40d9eb22a3bf04e # v1.242.0
- uses: ruby/setup-ruby@9669f3ee51dc3f4eda8447ab696b3ab19a90d14b # v1.144.0
with:
ruby-version: "2.7"
- name: release/setup-fastlane-dependencies
run: bundle install
working-directory: ./fastlane
- name: ci/download-artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: ${{ github.workspace }}
merge-multiple: true
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
- name: release/create-github-release
env:
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane github
working-directory: ./fastlane
- name: release/upload-sbom-to-release
env:
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: |
gh release upload "${{ env.RELEASE_TAG }}" "sbom-${{ github.event.repository.name }}-${{ env.RELEASE_TAG }}.json"

View file

@ -1,145 +0,0 @@
---
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)"

294
.gitignore vendored
View file

@ -1,199 +1,117 @@
# Sorted combined gitignore from .gitignore and .gitignore_my
!build/notice-file
!debug.keystore
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
!gradle-wrapper.jar
!gradle-wrapper.properties
!libraries/@mattermost/intune/.gitkeep
!src/**/build/
**/build/
**/fastlane/.env
**/fastlane/Preview.html
**/fastlane/report.xml
**/fastlane/screenshots
**/fastlane/test_output
*.aab
*.apk
*.hmap
*.hprof
*.iml
*.ipa
*.jks
*.jsbundle
*.keystore
*.lcov
*.log
*.mattermost-license
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
*.pid
*.pid.lock
*.seed
*.tgz
*.tsbuildinfo
*.un~
*.xccheckout
*.xcscmblueprint
*.xcuserstate
assets/override
dist
build-ios
*.zip
*/**/compass-icons.ttf
*~
.AppleDB
.AppleDesktop
.AppleDouble
.DS_Store
.DocumentRevisions-V100
.LSOverride
.Spotlight-V100
.TemporaryItems
.Trash-*
.Trashes
.VolumeIcon.icns
._*
.aider*
.apdisk
.buckconfig.local
.buckd/
.buckversion
.bundle
.cache
.cache/
.classpath
.claude/settings.local.json
.com.apple.timemachine.donotpresent
.cxx/
.directory
.docusaurus
.dynamodb/
server.PID
mattermost.keystore
tmp/
.env
.env.development.local
.env.local
.env.production.local
.env.test.local
.eslintcache
.expo
.externalNativeBuild/
.fakebuckversion
.fseventsd
.fuse_hidden*
.fusebox/
.gradle
.gradle/
.gradletasknamecache
.grunt
.idea
.idea/
.lock-wscript
.metro-health-check*
.netrwhist
.next
.nfs*
.node_repl_history
.npm
.npminstall
.nuxt
.nyc_output
.parcel-cache
.pnp.*
.pnpm-debug.log*
.podinstall
.project
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.scannerwork
.serverless/
.settings
.stylelintcache
.temp
.tern-port
.tmp
.tsbuildinfo.precommit
.vscode/*
!.vscode/launch.json
!.vscode/settings.json
.vscode-test
.vuepress/dist
.yarn-integrity
.yarn/build-state.yml
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
.yarninstall
env.d.ts
*.apk
*.aab
*.ipa
*/**/compass-icons.ttf
# OSX
#
.DS_Store
# Xcode
#
ios/build/*
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
Icon
Network Trash Folder
Session.vim
Temporary Items
[._]*.s[a-w][a-z]
[._]s[a-w][a-z]
__generated__
*.hmap
*.xcuserstate
project.xcworkspace
ios/Pods
.podinstall
ios/.xcode.env.local
# Android/IntelliJ
#
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
*.keystore
!debug.keystore
android/app/bin
android/app/build
android/app/src/main/res/raw/*
android/build
.settings
.project
.classpath
# node.js
#
node_modules/
npm-debug.log
.npminstall
yarn-error.log
.yarninstall
# Vim
[._]*.s[a-w][a-z]
[._]s[a-w][a-z]
*.un~
Session.vim
.netrwhist
*~
tags
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
**/fastlane/.env
# Sentry
android/sentry.properties
assets/override
bower_components
buck-out/
build-ios
build/Release
captures/
ios/sentry.properties
# Testing
.nyc_output
coverage
deploymentTargetDropDown.xml
.tmp
# E2E testing
mattermost-license.txt
*.mattermost-license
detox/artifacts
detox/detox_pixel_*
dist
env.d.ts
google-services.json
gradle-app.setting
ios/.xcode.env.local
ios/Pods
ios/build/*
ios/sentry.properties
jspm_packages/
lerna-debug.log*
lib-cov
libraries/**/**/.build
libraries/**/**/build
libraries/@mattermost/intune/*
local.properties
logs
mattermost-license.txt
mattermost.keystore
misc.xml
node_modules/
node_modules/@mattermost/intune
npm-debug.log
npm-debug.log*
out
output.json
pids
project.xcworkspace
render.experimental.xml
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
server.PID
tags
tmp/
web_modules/
xcuserdata
xcuserdata/
yarn-debug.log*
yarn-error.log
yarn-error.log*
# 새로 적용된 .gitignore 규칙에 의해 무시되었으나, 기존 프로젝트에서 관리(Tracking)되고 있던 필수 파일들 예외 처리
!android/app/debug.keystore
!android/app/google-services.json
!build/notice-file/Readme.md
!build/notice-file/config.yaml
!env.d.ts
!fastlane/screenshots/README.txt
# Bundle artifact
*.jsbundle
.bundle
#editor-settings
.vscode
.scannerwork
launch.json
# Notice.txt generation
!build/notice-file
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

11
.gitmodules vendored
View file

@ -1,11 +0,0 @@
# 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

@ -1 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
sh ./scripts/pre-commit.sh

View file

@ -1,3 +0,0 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

View file

@ -1 +0,0 @@
-

View file

@ -1 +1 @@
22.14.0
18.17

2
.nvmrc
View file

@ -1 +1 @@
22.14.0
18.17

View file

@ -1 +1 @@
3.2.0
2.7.8

View file

@ -32,14 +32,14 @@
{
"rule": "cli",
"binary": "ruby",
"semver": ">=3.2.0",
"semver": ">=2.7.1 <3.0.0",
"error": "visit rvm install https://rvm.io/rvm/install",
"platform": "darwin"
},
{
"rule": "cli",
"binary": "pod",
"semver": "1.16.1",
"semver": "1.12.1",
"platform": "darwin"
}
],

31
.vscode/launch.json vendored
View file

@ -1,31 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Metro: Start",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "bash",
"runtimeArgs": [
"-c",
"lsof -ti:8081 | xargs kill -9 2>/dev/null; npm start"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"name": "Android: Run App",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "bash",
"runtimeArgs": [
"-c",
"adb devices | grep -q emulator || (emulator -avd Pixel_7_Pro &); adb -e wait-for-device && npm run android -- --deviceId emulator-5554"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}

17
.vscode/settings.json vendored
View file

@ -1,17 +0,0 @@
{
"react-native.packager.port": 8081,
"terminal.integrated.env.osx": {
"NVM_DIR": "${env:HOME}/.nvm",
"JAVA_HOME": "/opt/homebrew/opt/openjdk@17",
"ANDROID_HOME": "${env:HOME}/Library/Android/sdk",
"ANDROID_SDK_ROOT": "${env:HOME}/Library/Android/sdk"
},
"terminal.integrated.profiles.osx": {
"zsh": {
"path": "/bin/zsh",
"args": ["-l"]
}
},
"terminal.integrated.defaultProfile.osx": "zsh",
"npm.scriptExplorerAction": "run"
}

View file

@ -1,3 +0,0 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

View file

@ -1,209 +0,0 @@
---
name: scaffold-agent-ops
description: 프로젝트 상태를 판별하고 에이전트 진입 파일과 agent-ops 기본 스캐폴드를 생성하기 위한 초기 규칙
version: 1.0.0
---
# 목적
이 스킬은 프로젝트에 Agent-Ops 구조가 없거나 불완전할 때,
현재 프로젝트 상태를 분석하여 다음을 세팅한다.
1. 에이전트 진입 파일
2. agent-ops 기본 폴더 구조
3. global rule
4. domain rule 템플릿
5. skill router
6. skill 템플릿
7. 초기 domain / skill 세팅
이 스킬은 과도한 초기 생성보다
"현재 프로젝트에 맞는 최소 스캐폴드" 생성을 우선한다.
# 핵심 원칙
- 기존 구조를 우선한다.
- 새 파일 생성은 꼭 필요한 최소 범위로 제한한다.
- 에이전트별 md는 얇은 진입점으로 둔다.
- 실제 규칙은 agent-ops 아래로 모은다.
- 도메인은 발명하지 말고 현재 구조에서 발견한다.
- 처음에는 핵심 도메인만 생성한다.
- 반복되는 작업만 초기 skill로 만든다.
- 불확실한 내용은 후보로 제시한다.
# 상태 판별
## 신규 프로젝트
다음 조건이 많으면 신규 프로젝트로 본다.
- 코드/폴더 구조가 단순하다
- 도메인 경계가 아직 약하다
- Agent-Ops 관련 파일이 없다
- 반복 작업 패턴이 아직 적다
## 운영중 프로젝트
다음 조건이 많으면 운영중 프로젝트로 본다.
- 모듈/폴더/패키지 경계가 보인다
- 반복 작업이 드러난다
- 기존 규칙/문서/관례의 흔적이 있다
- 핵심 책임 경계가 식별된다
# 생성 대상
이 스킬은 아래 구조를 기준으로 scaffold를 제안하거나 생성한다.
- CLAUDE.md
- GEMINI.md
- .cursorrules
- agent-ops/rules/global.md
- agent-ops/rules/_templates/domain-rule-template.md
- agent-ops/rules/domain/<domain>/rules.md
- agent-ops/skills/router.md
- agent-ops/skills/_templates/skill-template.md
필요 시 아래 초기 skill도 함께 제안한다.
- agent-ops/skills/find-domain/SKILL.md
- agent-ops/skills/code-change/SKILL.md
# 에이전트 진입 파일 원칙
CLAUDE.md, GEMINI.md, .cursorrules 는 공통적으로 얇게 유지한다.
각 파일에는 아래만 둔다.
- 응답 언어
- 기존 구조 우선
- 새 파일 생성보다 기존 수정 우선
- 작업 전 agent-ops/skills/router.md 참조
- 관련 rule / skill 만 최소 로드
- 세부 규칙은 agent-ops 아래 파일을 따른다
각 에이전트 진입 파일에 프로젝트별 상세 규칙을 중복 작성하지 않는다.
# Rule 구조 원칙
## global.md
전 프로젝트 공통 규칙만 둔다.
포함 항목:
- 응답 언어
- 기존 구조 우선
- 새 파일 생성보다 기존 수정 우선
- 코드 변경 전 관련 domain rule 확인
- 요청 범위를 넘는 변경 금지
- 불확실하면 단정하지 말고 후보 제시
- 플랫폼 특화 규칙은 global에 두지 않음
## domain rule
각 도메인 rules.md 는 아래만 둔다.
- 목적 / 책임
- 포함 경로
- 제외 경로
- 주요 구성 요소
- 유지할 패턴
- 다른 도메인과의 경계
- 금지 사항
# DDD 기준
도메인은 아래 기준으로 분류한다.
## Core Domain
핵심 가치와 주요 유즈케이스를 담당하는 영역
## Supporting Domain
핵심 도메인을 지원하는 영역
## Generic / Common
여러 도메인이 공통으로 사용하는 범용 영역
도메인은 반드시 실제 폴더, 모듈, 패키지, 네임스페이스, 책임 경계와 연결되어야 한다.
# Skill 구조 원칙
## router.md
router.md 는 작업 유형에 따라 필요한 skill 또는 domain rule만 로드하게 한다.
최소 라우팅 축:
- 구조 분석 / 스캐폴드
- 도메인 판별
- 코드 변경
- 흐름 추적
- 문서/릴리즈 정리
처음부터 너무 많은 분기를 만들지 않는다.
## skill-template.md
각 skill 은 아래 형식을 따른다.
- 목적
- 언제 호출할지
- 입력
- 먼저 확인할 것
- 실행 절차
- 출력 형식
- 금지 사항
# 신규 프로젝트 처리
신규 프로젝트일 때는 템플릿 중심으로 세팅한다.
1. 언어/구조를 확인한다.
2. 에이전트 진입 파일 3개를 얇게 생성한다.
3. agent-ops/rules/global.md 생성
4. domain-rule-template.md 생성
5. skills/router.md 생성
6. skill-template.md 생성
7. 핵심 domain placeholder 2~4개만 제안
8. 초기 skill 은 최소 2개만 제안한다.
신규 프로젝트에서는 domain rules 를 과도하게 채우지 않는다.
# 운영중 프로젝트 처리
운영중 프로젝트일 때는 현재 구조 기반으로 자동 채움한다.
1. 구조를 분석한다.
2. Core / Supporting / Generic 도메인 후보를 식별한다.
3. 실제 경로를 반영한 domain rules 초안을 제안한다.
4. 반복 작업 기반으로 필요한 초기 skill 을 제안한다.
5. router.md 에 기본 라우팅을 채운다.
6. 신규 프로젝트보다 한 단계 더 구체적으로 scaffold 한다.
운영중 프로젝트에서는 실제 코드 구조에 맞는 domain / skill 을 일부 채워 넣는다.
# 출력 형식
## 상태 판별
- Agent-Ops 상태: 없음 / 부분 적용 / 운영중
- 프로젝트 상태: 신규 / 운영중
- 근거: 짧게 요약
## 스캐폴드 계획
- 생성할 파일
- 바로 채울 파일
- placeholder 로 둘 파일
## 도메인 제안
- Core Domain
- Supporting Domain
- Generic / Common
## 초기 skill 제안
- skill 이름
- 필요한 이유
## 주의사항
- 지금 만들지 말아야 할 것
- 아직 확정하면 안 되는 것
# 금지
- 플랫폼 전용 규칙을 기본 scaffold 에 넣지 않는다.
- agent 별 md 에 상세 규칙을 중복 작성하지 않는다.
- 실제 구조보다 앞선 추상 구조를 강요하지 않는다.
- 처음부터 많은 domain / skill 을 만들지 않는다.
- scaffold 단계에서 불필요한 세부 구현까지 생성하지 않는다.

View file

@ -1,172 +0,0 @@
# iOS Simulator Setup Guide for AI Agents
This guide documents the process for setting up and running the Mattermost Mobile app on an iOS simulator.
## Prerequisites Check
Before starting, verify these are installed:
```bash
# Check Xcode
xcodebuild -version
# Check CocoaPods
pod --version
# Check Node.js
node --version
# Check npm dependencies
test -d node_modules && echo "node_modules exists" || echo "Run: npm install"
```
## Step 1: iOS Simulator Runtime
Check if iOS simulators are available:
```bash
xcrun simctl list devices available
```
If no devices are listed or you see "Install Started", you need to download the iOS platform:
```bash
xcodebuild -downloadPlatform iOS
```
**Note:** This downloads ~8GB and takes several minutes. The command runs in the foreground and shows progress.
## Step 2: CocoaPods Installation
### Critical: Set UTF-8 Encoding
CocoaPods requires UTF-8 encoding. Always set this before running pod commands:
```bash
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
```
### Install Pods (Apple Silicon Mac)
```bash
cd ios
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
```
### Common CocoaPods Issues
#### Issue: "Unicode Normalization not appropriate for ASCII-8BIT"
**Solution:** Set UTF-8 encoding as shown above.
#### Issue: Podfile.lock version conflicts
**Solution:** Clean install:
```bash
cd ios
rm -rf Pods Podfile.lock build
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
```
#### Issue: GitHub SSH timeouts during pod install
**Symptom:** Errors like "Connection to github.com port 22: Operation timed out"
**Solution:** Temporarily configure git to use HTTPS instead of SSH:
```bash
git config --global url."https://github.com/".insteadOf git@github.com:
```
**IMPORTANT:** Revert this after pod install completes, or the user won't be able to push:
```bash
git config --global --unset url.https://github.com/.insteadof
```
**Warning:** Never leave git URL rewrites in place without informing the user!
## Step 3: Start Metro Bundler (if not already running)
```bash
npm start
```
Wait for "Dev server ready" message before proceeding.
## Step 4: Run iOS App (if not already built)
### Using npm script
```bash
npm run ios -- --simulator="iPhone 17 Pro"
```
### Or specify a different simulator
List available simulators first:
```bash
xcrun simctl list devices available | grep -E "iPhone|iPad"
```
Then run with your chosen device:
```bash
npm run ios -- --simulator="iPhone 16e"
```
## Build Times
- **First build:** 10-30 minutes (compiles all native code)
- **Subsequent builds:** Much faster (incremental)
- **JS/TS changes:** ~3 seconds hot reload (no native rebuild needed)
## Troubleshooting Build Failures
### Getting Better Error Messages
If `npm run ios` fails, run xcodebuild directly for clearer errors:
```bash
xcodebuild -workspace ios/Mattermost.xcworkspace \
-configuration Debug \
-scheme Mattermost \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
2>&1 | grep -E "error:|fatal error"
```
### Clean Build
If builds fail mysteriously, try a clean build:
```bash
# Clean Xcode derived data
rm -rf ~/Library/Developer/Xcode/DerivedData/Mattermost-*
# Clean and reinstall pods
cd ios
rm -rf Pods build
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
```
### Opening in Xcode
For complex build issues, open in Xcode for better debugging:
```bash
open ios/Mattermost.xcworkspace
```
Then select your simulator target and press Cmd+B to build.
## Quick Reference
```bash
# Full setup sequence (Apple Silicon)
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
cd ios && RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install && cd ..
npm start &
sleep 10
npm run ios -- --simulator="iPhone 17 Pro"
```
## Do NOT Modify
- **Git configuration:** Never permanently change git URL rewrites without reverting
- **Podfile:** Don't add modular_headers to pods unless you understand the implications
- **New Architecture:** Keep `RCT_NEW_ARCH_ENABLED=0` as the project uses the old architecture

View file

@ -1,3 +0,0 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

View file

@ -1,3 +0,0 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

View file

@ -1,4 +0,0 @@
source "https://rubygems.org"
gem "cocoapods", "1.16.1"
gem "rexml", ">= 3.4.2"

1437
NOTICE.txt

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
# Mattermost Mobile v2
- **Minimum Server versions:** Current ESR version (10.11.0+)
- **Supported iOS versions:** 16.0+
- **Minimum Server versions:** Current ESR version (8.1.0+)
- **Supported iOS versions:** 12.4+
- **Supported Android versions:** 7.0+
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 21 languages. Learn more at [https://mattermost.com](https://mattermost.com).
@ -12,34 +12,6 @@ We plan on releasing monthly updates with new features - check the [changelog](h
**Important:** If you self-compile the Mattermost Mobile apps you also need to deploy your own [Mattermost Push Notification Service](https://github.com/mattermost/mattermost-push-proxy/releases).
# Android 16KB Page Size Support
**Temporary Requirement:** To comply with Google Play's 16KB page size requirement for Android devices, this project includes a compatibility patch that must be applied before building for Android.
### When to Apply the Patch
- **CI/CD Builds:** The patch is automatically applied in GitHub Actions for Android builds
- **Local Development:** If you're building Android locally and encounter 16KB page size related issues, run:
```bash
npm run apply-16kb-pagesize-patch
```
This script will:
1. Update package dependencies to compatible versions
2. Apply necessary code changes for 16KB page size support
3. Update patch files for modified dependencies
4. Regenerate `package-lock.json`
### ⚠️ Important Warnings
- **DO NOT commit the changes** applied by this patch to the repository
- **These changes will break iOS builds** if committed
- The patch is designed to be applied only during Android CI builds
- For local development, revert all changes after building Android
**Note:** This is a temporary solution until all dependencies natively support 16KB page sizes.
# How to Contribute
### Testing
@ -47,7 +19,7 @@ This script will:
To help with testing app updates before they're released, you can:
1. Sign up to be a beta tester
- [Android](https://play.google.com/apps/testing/com.tokilabs.mattermost)
- [Android](https://play.google.com/apps/testing/com.mattermost.rnbeta)
- [iOS](https://testflight.apple.com/join/Q7Rx7K9P) - Open this link from your iOS device
2. Install the `Mattermost Beta` app. New updates in the Beta app are released periodically. You will receive a notification when the new updates are available.
3. File any bugs you find by filing a [GitHub issue](https://github.com/mattermost/mattermost-mobile/issues) with:
@ -59,7 +31,7 @@ To help with testing app updates before they're released, you can:
- Join the [Native Mobile Apps channel](https://community.mattermost.com/core/channels/native-mobile-apps) to see what's new and discuss feedback with other contributors and the core team
You can leave the Beta testing program at any time:
- On Android, [click this link](https://play.google.com/apps/testing/com.tokilabs.mattermost) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
- On Android, [click this link](https://play.google.com/apps/testing/com.mattermost.rnbeta) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
- On iOS, access the `Mattermost Beta` app page in TestFlight and click **Stop Testing**.
### Contribute Code

View file

@ -1,32 +0,0 @@
---
name: <domain-name>
description: <도메인 설명>
type: core | supporting | generic
---
# <Domain Name>
## 목적 / 책임
< 도메인이 담당하는 핵심 책임>
## 포함 경로
- `app/<path>/`
- `app/actions/local/<file>.ts`
- `app/actions/remote/<file>.ts`
## 제외 경로
- < 도메인에 속하지 않는 경로>
## 주요 구성 요소
- **<component>**: <역할>
- **<component>**: <역할>
## 유지할 패턴
- < 도메인에서 따라야 패턴>
## 다른 도메인과의 경계
- **database 도메인**: DB 조작은 database 도메인 패턴을 따른다
- **network 도메인**: API 호출은 ClientMix 패턴을 따른다
## 금지 사항
- < 도메인에서 하면 되는 >

View file

@ -1,46 +0,0 @@
---
name: database
description: WatermelonDB 이중 DB 시스템 - 앱 DB와 서버별 DB
type: supporting
---
# Database Domain
## 목적 / 책임
WatermelonDB 기반 이중 데이터베이스를 관리한다. DatabaseManager 싱글톤, operator, 스키마, 모델을 담당한다.
## 포함 경로
- `app/database/` (전체)
- `app/database/manager/` - DatabaseManager 싱글톤
- `app/database/models/app/` - 앱 전역 모델 (서버 목록 등)
- `app/database/models/server/` - 서버별 모델 (채널, 유저, 포스트 등)
- `app/database/operator/` - 데이터 쓰기 operator
- `app/database/schema/` - DB 스키마 정의
- `docs/database/` - DB 스키마 문서
## 제외 경로
- `app/queries/` (Query Layer는 별도 - 여기서 DB를 읽는다)
## 주요 구성 요소
- **DatabaseManager**: 모든 DB 인스턴스 관리 싱글톤 (`app/database/manager/index.ts`)
- **App Database**: 서버 목록 등 전역 앱 상태 (하나만 존재)
- **Server Database**: 서버별 채널/유저/포스트 데이터 (서버당 하나)
- **Operators**: transformer/handler/comparator 패턴으로 데이터 쓰기
- **Schema**: 테이블 정의 및 마이그레이션
## 유지할 패턴
- DB 경로: iOS는 App Group, Android는 `${documentDirectory}/databases/`
- Operator를 통해 batch 작업으로 데이터 쓰기
- Sync handler는 create/update/delete 전체 라이프사이클 처리 필수
- 스키마 변경 시 반드시 버전 번호 증가 + 문서(`docs/database/`) 업데이트
- Query Layer(`app/queries/`)는 `query*`, `observe*`, `get*`, `prepare*` 패턴
## 다른 도메인과의 경계
- **모든 도메인**: DB 직접 접근보다 Query Layer(`app/queries/`) 사용 권장
- **network 도메인**: remote action이 API 응답을 operator에 전달
## 금지 사항
- 스키마 변경 후 `docs/database/` 문서 미업데이트
- Sync handler에서 stale 레코드 삭제 누락 (`prepareDestroyPermanently()` 필수)
- 테스트에서 `extraLokiOptions: {autosave: false}` 누락 (메모리 누수)
- 테스트 후 `DatabaseManager.destroyServerDatabase()` cleanup 누락

View file

@ -1,45 +0,0 @@
---
name: messaging
description: 채널, 포스트, 스레드, 드래프트 등 핵심 메시징 기능
type: core
---
# Messaging Domain
## 목적 / 책임
Mattermost의 핵심 메시징 기능: 채널, 포스트, 스레드, 리액션, 드래프트, 예약 포스트를 담당한다.
## 포함 경로
- `app/actions/local/channel.ts`, `post.ts`, `thread.ts`, `draft.ts`, `reactions.ts`, `scheduled_post.ts`
- `app/actions/remote/channel.ts`, `post.ts`, `thread.ts`, `reactions.ts`, `search.ts`
- `app/queries/servers/channel.ts`, `post.ts`, `thread.ts`, `drafts.ts`, `scheduled_post.ts`, `reaction.ts`
- `app/database/models/server/channel*.ts`, `post.ts`, `thread*.ts`, `draft.ts`, `reaction.ts`, `scheduled_post.ts`
- `app/screens/channel/`, `app/screens/thread/`, `app/screens/edit_post/`
- `app/components/post_list/`, `app/components/post_draft/`
## 제외 경로
- `app/products/` (products는 각 product 도메인)
- `app/actions/remote/user.ts` (user 도메인)
## 주요 구성 요소
- **Channel**: 채널 CRUD, 멤버십, 카테고리 관리
- **Post**: 포스트 생성/수정/삭제, 파일 첨부, 리액션
- **Thread**: 스레드 팔로우, 언급, 참여자 관리
- **Draft**: 임시저장, 예약 포스트
- **Search**: 채널/포스트 검색
## 유지할 패턴
- Remote action: `fetchXxx()` → API 호출 → operator로 DB 저장 → `{error}` 반환
- Local action: DB 조작만 수행
- Sync handler는 create/update/delete 전체 라이프사이클을 처리한다
- `fetchMissingProfilesByIds()` 사용 - 직접 `client.getProfilesByIds()` 호출 금지
## 다른 도메인과의 경계
- **database 도메인**: WatermelonDB 스키마 변경 시 `docs/database/server/server.md` 업데이트 필요
- **network 도메인**: API 호출은 `client/rest.ts`의 ClientMix 패턴 사용
- **ui 도메인**: 화면 렌더링은 ui 도메인, 데이터 로직은 이 도메인
## 금지 사항
- `console.error()` / `console.log()` 직접 사용 (→ `logError()`, `logDebug()` 사용)
- `TouchableOpacity` 사용 (→ `Pressable` 사용)
- DB sync handler에서 삭제 누락 (stale 레코드는 `prepareDestroyPermanently()` 처리)

View file

@ -1,49 +0,0 @@
---
name: products-agents
description: AI 에이전트 기능 - WebSocket 스트리밍, AI 봇, 툴 호출
type: core
---
# Products/Agents Domain
## 목적 / 책임
AI 에이전트 기능을 담당한다. WebSocket 스트리밍, AI 봇 관리, 툴 호출/승인, 어노테이션을 처리한다.
## 포함 경로
- `app/products/agents/` (전체)
- `app/products/agents/actions/`
- `app/products/agents/client/rest.ts`
- `app/products/agents/components/`
- `app/products/agents/constants.ts`
- `app/products/agents/hooks/`
- `app/products/agents/screens/`
- `app/products/agents/store/`
- `app/products/agents/types/`
- `app/products/agents/websocket/`
## 제외 경로
- `app/actions/remote/` (messaging 도메인)
- `app/products/calls/` (calls 도메인)
## 주요 구성 요소
- **WebSocket Handler**: `custom_mattermost-ai_postupdate` 이벤트 처리 (start, message, reasoning, tool_call, annotation, end)
- **Streaming Store**: 스트리밍 중 ephemeral 상태 관리
- **AI Bot**: AI 봇 목록 조회 및 DB 동기화 (`handleAIBots`)
- **Tool Call**: 툴 호출 승인/거절 처리
- **Client (ClientMix)**: `app/products/agents/client/rest.ts`
## 유지할 패턴
- **WebSocket 스트리밍**: `ENDED` 이벤트에서 로컬 스트리밍 상태를 반드시 초기화 (stale state 방지)
- **ClientMix 패턴**: API 호출은 `client/rest.ts` 내에서만 수행, action에서 직접 `doFetch()` 금지
- **PostType 추가 시**: `types/api/posts.d.ts``PostType` union에 타입 추가
- **Sync handler**: create/update/delete 모두 처리 (`prepareDestroyPermanently()` 필수)
## 다른 도메인과의 경계
- **messaging 도메인**: 포스트 데이터는 `POST_EDITED` WebSocket 이벤트로 수신
- **database 도메인**: Agents 전용 DB 모델 사용
- **network 도메인**: ClientMix를 통해 NetworkManager와 연결
## 금지 사항
- 툴 호출 상태 변경을 별도 WebSocket 이벤트로 처리 (→ `POST_EDITED`로 수신)
- `ENDED` 이벤트 후 스트리밍 상태 미초기화
- action 파일에서 직접 `client.doFetch()` 호출

View file

@ -1,41 +0,0 @@
---
name: products-calls
description: 음성/화상 통화 기능
type: core
---
# Products/Calls Domain
## 목적 / 책임
음성/화상 통화 기능을 담당한다. 통화 연결, 상태 관리, UI를 처리한다.
## 포함 경로
- `app/products/calls/` (전체)
- `app/products/calls/actions/`
- `app/products/calls/client/rest.ts`
- `app/products/calls/components/`
- `app/products/calls/constants.ts`
- `app/products/calls/hooks/`
- `app/products/calls/screens/`
- `app/products/calls/store/`
- `app/products/calls/types/`
## 제외 경로
- `app/products/agents/` (agents 도메인)
- `app/products/playbooks/` (playbooks 도메인)
## 주요 구성 요소
- **Call Connection**: WebRTC 기반 통화 연결 관리
- **Call State**: Ephemeral store로 통화 상태 관리
- **Client (ClientMix)**: `app/products/calls/client/rest.ts`
## 유지할 패턴
- **ClientMix 패턴**: API 호출은 `client/rest.ts` 내에서만 수행
- **Ephemeral Store**: 통화 상태는 ephemeral store로 관리 (DB 아님)
## 다른 도메인과의 경계
- **messaging 도메인**: 채널 정보는 messaging 도메인 쿼리 사용
- **network 도메인**: ClientMix를 통해 NetworkManager와 연결
## 금지 사항
- action 파일에서 직접 `client.doFetch()` 호출

View file

@ -1,53 +0,0 @@
---
name: ui
description: 컴포넌트, 스크린, 네비게이션, 스타일링
type: generic
---
# UI Domain
## 목적 / 책임
React Native UI 컴포넌트, 스크린, 네비게이션, 스타일링을 담당한다.
## 포함 경로
- `app/components/` - 재사용 컴포넌트
- `app/screens/` - 화면 단위 컴포넌트
- `app/hooks/` - 공통 React hooks
- `app/utils/` - UI 유틸리티
- `share_extension/` - Android 공유 확장 (React Navigation 사용)
## 제외 경로
- `app/products/*/components/` (각 product 도메인)
- `app/actions/` (비즈니스 로직은 messaging/product 도메인)
## 주요 구성 요소
- **Navigation**: `react-native-navigation` v7 (메인 앱), `React Navigation` (share extension)
- **Components**: 60+ 재사용 컴포넌트
- **Screens**: 63+ 화면 컴포넌트 (각각 독립 등록)
- **Themes**: `PREFERENCES.THEMES` 상수 사용
## 유지할 패턴
- `Pressable` 사용 (→ `TouchableOpacity` 금지), 항상 pressed 피드백 추가
- `typography()` 사용 (→ raw `fontSize`/`fontWeight`/`fontFamily` 금지)
- `makeStyleSheetFromTheme` 사용 시 `StyleSheet.create` 불필요
- `getStyleSheet` 는 파일 상단(imports 이후)에 위치
- 정적 객체(hitSlop 등)는 모듈 레벨 상수로 추출
- `FormattedText` 사용 (→ `<Text>{intl.formatMessage(...)}</Text>` 지양)
- `useServerUrl()` hook 사용 (→ `serverUrl` prop 전달 지양)
- `usePreventDoubleTap` hook 사용 (버튼 중복 클릭 방지)
- 리스트 렌더링: 인라인 화살표 함수 금지, 자식 컴포넌트에서 ID 받아 처리
- `useDidMount` (→ `useEffect(cb, [])` 대신), `useInitialValue` (→ `useMemo(f, [])` 대신)
## 다른 도메인과의 경계
- **messaging/product 도메인**: UI는 데이터 표시만, 비즈니스 로직은 해당 도메인
- **database 도메인**: `observe*` 쿼리를 통해 반응형 데이터 구독
## 금지 사항
- `TouchableOpacity` 사용
- raw `fontSize`, `fontWeight`, `fontFamily` 스타일 직접 사용
- `Object.hasOwn()` 사용 (ES2022+, RN 미지원 → `'key' in obj` 사용)
- `Animated` API 사용 (→ `react-native-reanimated` 사용)
- `new URL()` 사용 (→ `urlParse` 사용)
- `Linking.openURL()` 직접 사용 (→ `tryOpenURL()` 사용)
- `ActivityIndicator` 직접 사용 (→ `<Loading>` 컴포넌트 사용)
- 커스텀 React 컴포넌트를 Markdown 컴포넌트 내부에 인라인 삽입

View file

@ -1,23 +0,0 @@
---
name: global
description: 전 프로젝트 공통 규칙
version: 1.0.0
---
# Global Rules
## 응답 언어
- 한국어로 응답한다.
## 기존 구조 우선
- 새 파일 생성보다 기존 파일 수정을 우선한다.
- 기존 패턴과 컨벤션을 따른다.
## 코드 변경 원칙
- 코드 변경 전 관련 domain rule을 확인한다.
- 요청 범위를 넘는 변경을 금지한다.
- 불확실하면 단정하지 말고 후보로 제시한다.
## 작업 전 확인
- `agent-ops/skills/router.md` 를 먼저 참조하여 필요한 skill 또는 domain rule만 로드한다.
- 관련 rule / skill 만 최소로 로드한다.

View file

@ -1,35 +0,0 @@
---
name: <skill-name>
description: <skill 설명>
version: 1.0.0
---
# <Skill Name>
## 목적
< skill이 해결하는 문제>
## 언제 호출할지
- < skill을 사용해야 하는 상황>
- < skill을 사용해야 하는 상황>
## 입력
- `<param>`: <설명> (필수/선택)
## 먼저 확인할 것
- [ ] <사전 확인 항목>
- [ ] <사전 확인 항목>
## 실행 절차
1. <단계>
2. <단계>
3. <단계>
## 출력 형식
```
<출력 예시>
```
## 금지 사항
- <하면 되는 >
- <하면 되는 >

View file

@ -1,48 +0,0 @@
---
name: code-change
description: 코드 변경 절차 - 읽기 → 도메인 rule 확인 → 변경 → 검증
version: 1.0.0
---
# Code Change
## 목적
코드 변경 시 도메인 규칙을 준수하고 기존 패턴을 따른다.
## 언제 호출할지
- 새 기능을 추가할 때
- 버그를 수정할 때
- 기존 코드를 수정할 때
## 입력
- `변경 대상`: 파일 경로 또는 기능 설명 (필수)
- `변경 내용`: 무엇을 어떻게 변경할지 (필수)
## 먼저 확인할 것
- [ ] `find-domain` skill로 대상 도메인 판별
- [ ] 해당 domain rule 로드 (`agent-ops/rules/domain/<domain>/rules.md`)
- [ ] 변경할 파일을 Read tool로 먼저 읽기
- [ ] 기존 패턴과 컨벤션 파악
## 실행 절차
1. 대상 파일 읽기 (Read tool 사용)
2. 도메인 rule의 "유지할 패턴"과 "금지 사항" 확인
3. 변경 범위를 요청 범위로 제한 (추가 리팩터링 금지)
4. 변경 적용 (Edit tool 우선, 전면 재작성은 Write tool)
5. TypeScript 타입 확인 (`npm run tsc`)
6. 린팅 확인 (`npm run fix`)
## 출력 형식
변경 후:
```
변경 파일: <path>
변경 내용: <요약>
확인 필요: <있으면 명시>
```
## 금지 사항
- 파일을 읽기 전에 변경하지 않는다
- 요청 범위를 넘는 리팩터링, 주석 추가, 타입 어노테이션 추가
- 도메인 rule의 금지 사항 위반
- `console.log()` / `console.error()` 직접 사용
- 다른 언어 파일 수정 (`en.json` 외 i18n 파일 수정 금지)

View file

@ -1,96 +0,0 @@
---
name: create-domain-rule
description: 새로운 도메인 rules.md 파일을 생성하기 위한 범용 스킬
version: 1.0.0
---
# Create Domain Rule
## 목적
`agent-ops/rules/domain/<domain-name>/rules.md` 파일을 올바른 형식으로 생성한다.
실제 프로젝트 폴더 구조를 분석하여 경로와 구성 요소를 자동으로 채운다.
생성 후 router.md 의 코드 변경 라우팅 테이블에 항목을 추가한다.
## 언제 호출할지
- 새로운 도메인 영역의 코드를 처음 변경하기 전에
- 기존 domain rule이 없는 경로에 대해 규칙이 필요할 때
- 사용자가 특정 도메인의 rule 파일을 만들어 달라고 요청할 때
- agent-ops 초기 scaffold 이후 도메인을 추가 확장할 때
## 입력
- `domain-name`: 생성할 도메인 이름, kebab-case (필수)
- `domain-type`: `core` / `supporting` / `generic` 중 하나 (필수)
- `path-hints`: 이 도메인에 해당하는 경로 힌트 목록 (선택, 없으면 자동 탐색)
## 먼저 확인할 것
- [ ] `agent-ops/rules/domain/<domain-name>/rules.md` 가 이미 존재하는지 확인
- [ ] `agent-ops/rules/_templates/domain-rule-template.md` 를 읽어 최신 템플릿 형식 파악
- [ ] `agent-ops/rules/domain/` 하위 기존 domain rule 목록을 확인하여 중복·유사 도메인 여부 판단
## 실행 절차
1. **중복 확인**
- `agent-ops/rules/domain/<domain-name>/` 폴더 존재 여부 확인
- 책임이 겹치는 기존 domain rule 이 있으면 사용자에게 알리고 중단한다
2. **경로 탐색**
- `path-hints` 가 제공된 경우: 해당 경로를 기준으로 폴더 구조 확인
- `path-hints` 가 없는 경우: `domain-name` 과 연관된 폴더명·파일명으로 프로젝트를 탐색
- 실제로 존재하는 경로만 포함 경로에 기재한다
- 존재하지 않는 경로는 기재하지 않는다
3. **도메인 분석**
- 탐색된 경로의 주요 파일·모듈을 읽어 아래를 도출한다
- 도메인의 목적 / 책임 한 줄 요약
- 포함 경로 목록
- 제외 경로 (인접 도메인과 겹칠 수 있는 경로)
- 주요 구성 요소 (파일, 모듈, 클래스, 함수 등)
- 유지할 패턴 (네이밍 규칙, 아키텍처 패턴 등)
- 다른 도메인과의 경계
- 금지 사항
4. **rules.md 생성**
- 경로: `agent-ops/rules/domain/<domain-name>/rules.md`
- `domain-rule-template.md` 형식을 따른다
- 실제 코드에서 확인된 내용만 기재한다
- 불확실한 항목은 `<!-- TODO: 확인 필요 -->` 주석으로 남긴다
5. **router.md 업데이트**
- `agent-ops/skills/router.md` 를 읽는다
- 코드 변경 섹션의 경로-도메인 라우팅 테이블에 아래 형식으로 추가한다
```
| `<path-pattern>` | `agent-ops/rules/domain/<domain-name>/rules.md` |
```
- 경로 패턴은 실제 포함 경로를 기준으로 작성한다
- 기존 테이블 항목을 삭제하거나 재정렬하지 않는다
6. **결과 보고**
- 생성한 파일 경로
- router.md 에 추가한 항목
- 불확실하여 TODO로 남긴 항목 (해당 시)
## 출력 형식
```
## 생성 완료
- Domain Rule 경로: agent-ops/rules/domain/<domain-name>/rules.md
- 도메인 유형: <core | supporting | generic>
- router.md 추가 경로 패턴: <path-pattern>
## TODO 항목 (확인 필요)
- <불확실하여 직접 확인이 필요한 항목> (해당 시)
```
## 금지 사항
- 이미 존재하는 rules.md 를 덮어쓰지 않는다
- 실제 존재하지 않는 경로를 포함 경로에 넣지 않는다
- 추측으로 패턴이나 금지 사항을 채우지 않는다 — 확인된 내용만 기재한다
- router.md 의 기존 항목을 삭제하거나 재정렬하지 않는다
- 하나의 domain rule 에 여러 독립 도메인의 책임을 묶지 않는다
- 코드 변경을 수행하지 않는다 — 이 skill 은 rule 파일 생성만 담당한다

View file

@ -1,83 +0,0 @@
---
name: create-skill
description: 새로운 SKILL.md 파일을 생성하기 위한 범용 스킬
version: 1.0.0
---
# Create Skill
## 목적
`agent-ops/skills/<skill-name>/SKILL.md` 파일을 올바른 형식으로 생성한다.
기존 skill-template.md 를 기반으로, 요청 목적에 맞는 내용을 채워 넣는다.
생성 후 router.md 에 라우팅 항목을 추가한다.
## 언제 호출할지
- 새로운 반복 작업 패턴이 생겨 skill로 정의해야 할 때
- 기존 skill이 없는 작업 유형을 처음 수행하기 전에
- 사용자가 특정 작업을 skill로 만들어 달라고 요청할 때
## 입력
- `skill-name`: 생성할 skill 이름, kebab-case (필수)
- `purpose`: 이 skill이 해결하는 문제 한 줄 요약 (필수)
- `trigger-cases`: 이 skill을 호출해야 하는 상황 목록 (선택)
## 먼저 확인할 것
- [ ] `agent-ops/skills/` 하위에 동일 이름의 디렉터리가 이미 존재하는지 확인
- [ ] `agent-ops/skills/router.md` 에 이미 유사한 라우팅 항목이 있는지 확인
- [ ] `agent-ops/skills/_templates/skill-template.md` 를 읽어 최신 템플릿 형식 파악
## 실행 절차
1. **중복 확인**
- `agent-ops/skills/<skill-name>/` 폴더 존재 여부 확인
- 기능이 겹치는 기존 skill이 있으면 사용자에게 알리고 중단한다
2. **목적 분석**
- `purpose``trigger-cases` 를 바탕으로 아래 항목을 도출한다
- 언제 호출할지 (2~4개)
- 필요한 입력 파라미터
- 사전 확인 항목
- 실행 절차 (3~7단계)
- 출력 형식
- 금지 사항
3. **SKILL.md 생성**
- 경로: `agent-ops/skills/<skill-name>/SKILL.md`
- `skill-template.md` 형식을 따른다
- 프로젝트 특화 내용보다 범용 절차를 우선한다
- 절차는 구체적이되 지나치게 세부 구현을 기술하지 않는다
4. **router.md 업데이트**
- `agent-ops/skills/router.md` 를 읽는다
- 이 skill이 속할 라우팅 축(구조 분석/코드 변경/흐름 추적 등)을 판단한다
- 해당 축에 `→ agent-ops/skills/<skill-name>/SKILL.md` 항목을 추가한다
- 기존 라우팅 구조를 깨지 않는다
5. **결과 보고**
- 생성한 파일 경로
- router.md 에 추가한 항목
- 이 skill이 다루지 않는 범위(필요 시)
## 출력 형식
```
## 생성 완료
- SKILL 경로: agent-ops/skills/<skill-name>/SKILL.md
- router.md 추가 항목: <라우팅 ><skill-name>
## 주의사항 (해당 시)
- < skill이 다루지 않는 범위 또는 주의할 >
```
## 금지 사항
- 이미 존재하는 skill 을 덮어쓰지 않는다
- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다
- skill 생성과 무관한 코드 파일을 수정하지 않는다
- router.md 의 기존 항목을 삭제하거나 재정렬하지 않는다
- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다

View file

@ -1,40 +0,0 @@
---
name: find-domain
description: 파일 경로 또는 기능으로 해당 도메인을 판별한다
version: 1.0.0
---
# Find Domain
## 목적
변경하거나 분석할 코드가 어느 도메인에 속하는지 판별한다.
## 언제 호출할지
- 어떤 파일을 수정해야 하는지 불분명할 때
- 기능이 어느 도메인에 속하는지 모를 때
- 여러 도메인에 걸친 변경이 필요한지 파악할 때
## 입력
- `경로 또는 기능 설명`: 파일 경로, 컴포넌트 이름, 또는 기능 설명 (필수)
## 먼저 확인할 것
- [ ] `agent-ops/skills/router.md`의 경로 패턴 테이블 확인
- [ ] CLAUDE.md의 TypeScript Path Aliases 섹션 확인
## 실행 절차
1. 입력이 파일 경로면 → router.md 테이블과 매칭
2. 입력이 기능 설명이면 → 관련 파일을 Glob/Grep으로 탐색
3. 도메인 후보를 식별하고 해당 domain rule 로드
4. 여러 도메인에 걸친 경우 → 주 도메인 + 영향 도메인 명시
## 출력 형식
```
도메인: <domain-name>
근거: <판별 근거>
관련 rule: agent-ops/rules/domain/<domain>/rules.md
영향 도메인: <있으면 명시>
```
## 금지 사항
- 도메인을 단정하지 않고 불확실하면 후보를 제시한다
- 관련 없는 domain rule까지 로드하지 않는다

View file

@ -1,48 +0,0 @@
---
name: router
description: 작업 유형별 skill / domain rule 라우팅
version: 1.0.0
---
# Skill Router
작업 유형에 따라 필요한 skill 또는 domain rule만 로드한다.
## 라우팅 축
### 구조 분석 / 스캐폴드
`agent-ops/skills/find-domain/SKILL.md`
`agent-ops/rules/global.md`
### 새 Skill 생성
`agent-ops/skills/create-skill/SKILL.md`
### 새 Domain Rule 생성
`agent-ops/skills/create-domain-rule/SKILL.md`
### 도메인 판별
`agent-ops/skills/find-domain/SKILL.md`
### 코드 변경
`agent-ops/skills/code-change/SKILL.md`
→ 변경 대상 경로에 해당하는 domain rule 로드
| 경로 패턴 | Domain Rule |
|---|---|
| `app/products/agents/` | `agent-ops/rules/domain/products-agents/rules.md` |
| `app/products/calls/` | `agent-ops/rules/domain/products-calls/rules.md` |
| `app/database/`, `app/queries/` | `agent-ops/rules/domain/database/rules.md` |
| `app/components/`, `app/screens/`, `app/hooks/` | `agent-ops/rules/domain/ui/rules.md` |
| `app/actions/`, 채널/포스트/스레드 관련 | `agent-ops/rules/domain/messaging/rules.md` |
### 흐름 추적
→ 흐름에 관련된 domain rule 로드
`agent-ops/skills/find-domain/SKILL.md`로 경로 판별 후 진행
### 문서 / 릴리즈 정리
`agent-ops/rules/global.md`
`docs/` 폴더 직접 확인
## 원칙
- 필요한 rule만 로드한다. 전체를 로드하지 않는다.
- 불확실한 도메인은 `find-domain` skill로 먼저 판별한다.

View file

@ -1,6 +1,6 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
apply plugin: 'kotlin-android'
/**
* This is the configuration block to customize your React Native Android app.
@ -9,14 +9,14 @@ apply plugin: "com.facebook.react"
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js
// cliFile = file("../../node_modules/react-native/cli.js")
// The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen
// codegenDir = file("../node_modules/react-native-codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
// cliFile = file("../node_modules/react-native/cli.js")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
@ -47,9 +47,6 @@ apply plugin: "com.facebook.react"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
@ -70,7 +67,6 @@ if (System.getenv("SENTRY_ENABLED") == "true") {
* and want to have separate APKs to upload to the Play Store
*/
def enableSeparateBuildPerCPUArchitecture = project.hasProperty('separateApk') ? project.property('separateApk').toBoolean() : false
def enableUniversalBuild = project.hasProperty('universalApk') ? project.property('universalApk').toBoolean() : false
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
@ -102,9 +98,8 @@ def reactNativeArchitectures() {
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdkVersion rootProject.ext.compileSdkVersion
namespace "com.tokilabs.mattermost"
namespace "com.mattermost.rnbeta"
lintOptions {
checkReleaseBuilds false
@ -112,11 +107,11 @@ android {
}
defaultConfig {
applicationId "com.tokilabs.mattermost"
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 730
versionName "2.38.0"
versionCode 500
versionName "2.11.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
@ -141,7 +136,7 @@ android {
abi {
reset()
enable enableSeparateBuildPerCPUArchitecture
universalApk enableUniversalBuild // If true, also generate a universal APK
universalApk enableSeparateBuildPerCPUArchitecture // If true, also generate a universal APK
include (*reactNativeArchitectures())
}
}
@ -170,7 +165,6 @@ android {
matchingFallbacks = ['release']
}
}
// applicationVariants are e.g. debug, release
applicationVariants.all { variant ->
variant.outputs.each { output ->
@ -190,67 +184,65 @@ repositories {
maven {
url 'https://maven.google.com'
}
maven { url 'https://jitpack.io' }
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group:'com.squareup.okhttp3', module:'okhttp'
}
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation 'io.reactivex.rxjava3:rxjava:3.1.6'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
implementation 'androidx.window:window-rxjava3:1.0.0'
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.8.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "com.google.firebase:firebase-messaging:$firebaseVersion"
androidTestImplementation('com.wix:detox:+')
androidTestImplementation 'androidx.test:core:1.6.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'com.wix:detox:20.26.2'
// For animated GIF support
implementation 'com.facebook.fresco:animated-gif:3.6.0'
// For WebP support, including animated WebP
implementation 'com.facebook.fresco:animated-webp:3.6.0'
implementation 'com.facebook.fresco:webpsupport:3.6.0'
implementation project(':reactnativenotifications')
implementation project(':watermelondb')
implementation project(':watermelondb-jsi')
api('io.jsonwebtoken:jjwt-api:0.12.5')
runtimeOnly('io.jsonwebtoken:jjwt-impl:0.12.5')
runtimeOnly('io.jsonwebtoken:jjwt-orgjson:0.12.5') {
exclude(group: 'org.json', module: 'json') //provided by Android natively
}
}
configurations.all {
resolutionStrategy {
force 'androidx.test:core:1.6.1'
eachDependency { DependencyResolveDetails details ->
if (details.requested.name == 'play-services-base') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.5.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.1.0'
}
if (details.requested.name == 'play-services-tasks') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.2.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.0.2'
}
if (details.requested.name == 'play-services-stats') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '17.0.3'
}
if (details.requested.name == 'play-services-basement') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.5.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '18.1.0'
}
if (details.requested.name == 'okhttp') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.12.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0'
}
if (details.requested.name == 'okhttp-tls') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.12.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0'
}
if (details.requested.name == 'okhttp-urlconnection') {
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.12.0'
details.useTarget group: details.requested.group, name: details.requested.name, version: '4.10.0'
}
}
}
@ -258,9 +250,10 @@ configurations.all {
// Run this once to be able to run the application with BUCK
// puts all compile dependencies into folder libs for BUCK to use
tasks.register('copyDownloadableDepsToLibs', Copy) {
task copyDownloadableDepsToLibs(type: Copy) {
from configurations.implementation
into 'libs'
}
apply plugin: 'com.google.gms.google-services'
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)

View file

@ -1,29 +1,104 @@
{
"project_info": {
"project_number": "1047648748539",
"project_id": "mattermost-6ac08",
"storage_bucket": "mattermost-6ac08.firebasestorage.app"
"project_number": "184930218130",
"firebase_url": "https://api-7231322553409637977-752355.firebaseio.com",
"project_id": "api-7231322553409637977-752355",
"storage_bucket": "api-7231322553409637977-752355.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1047648748539:android:818bf70bfbb3f9d070415e",
"mobilesdk_app_id": "1:184930218130:android:a0e553d1a1f043b5",
"android_client_info": {
"package_name": "com.tokilabs.mattermost"
"package_name": "com.mattermost.react.native"
}
},
"oauth_client": [],
"oauth_client": [
{
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyAW6j_oPl9MVcm93qS3JgaEPD5ywp-TzZ0"
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
}
],
"services": {
"analytics_service": {
"status": 2
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:184930218130:android:c7debfa7ea3f75a7",
"android_client_info": {
"package_name": "com.mattermost.rnbeta"
}
},
"oauth_client": [
{
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
}
],
"services": {
"analytics_service": {
"status": 2
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:184930218130:android:2db4058a5b5d6506",
"android_client_info": {
"package_name": "com.mattermost.rn"
}
},
"oauth_client": [
{
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
}
],
"services": {
"analytics_service": {
"status": 2
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
}
],
"configuration_version": "1"
}
}

View file

@ -8,8 +8,3 @@
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
-keepattributes InnerClasses
-keep class io.jsonwebtoken.** { *; }
-keepnames class io.jsonwebtoken.* { *; }
-keepnames interface io.jsonwebtoken.* { *; }

View file

@ -1,4 +1,4 @@
package com.tokilabs.mattermost;
package com.mattermost.rnbeta;
import com.wix.detox.Detox;
import com.wix.detox.config.DetoxConfig;

View file

@ -2,8 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application
android:usesCleartextTraffic="true"
tools:targetApi="28"
tools:ignore="GoogleAppIndexingWarning"/>
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name="com.facebook.react.devsupport.DevSettingsActivity"
android:exported="false"
/>
</application>
</manifest>

View file

@ -0,0 +1,63 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
* directory of this source tree.
*/
package com.mattermost.flipper;
import android.content.Context;
import com.facebook.flipper.android.AndroidFlipperClient;
import com.facebook.flipper.android.utils.FlipperUtils;
import com.facebook.flipper.core.FlipperClient;
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
import com.facebook.react.ReactInstanceEventListener;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import com.mattermost.networkclient.RCTOkHttpClientFactory;
/**
* Class responsible of loading Flipper inside your React Native application. This is the debug
* flavor of it. Here you can add your own plugins and customize the Flipper setup.
*/
public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
if (FlipperUtils.shouldEnableFlipper(context)) {
final FlipperClient client = AndroidFlipperClient.getInstance(context);
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
client.addPlugin(new DatabasesFlipperPlugin(context));
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance());
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
RCTOkHttpClientFactory.Companion.setFlipperPlugin(networkFlipperPlugin);
NetworkingModule.setCustomClientBuilder(
builder -> builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin)));
client.addPlugin(networkFlipperPlugin);
client.start();
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
// Hence we run if after all native modules have been initialized
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
if (reactContext == null) {
reactInstanceManager.addReactInstanceEventListener(
new ReactInstanceEventListener() {
@Override
public void onReactContextInitialized(ReactContext reactContext) {
reactInstanceManager.removeReactInstanceEventListener(this);
reactContext.runOnNativeModulesQueueThread(
() -> client.addPlugin(new FrescoFlipperPlugin()));
}
});
} else {
client.addPlugin(new FrescoFlipperPlugin());
}
}
}
}

View file

@ -1,25 +1,19 @@
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission-sdk-23 android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="SelectedPhotoAccess" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH"
@ -39,8 +33,6 @@
<application
android:name=".MainApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
@ -56,6 +48,7 @@
android:resource="@xml/app_restrictions" />
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
@ -77,7 +70,7 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mmauth_tokilabs" />
<data android:scheme="mmauthbeta" />
</intent-filter>
</activity>
<service android:name=".NotificationDismissService"
@ -90,12 +83,14 @@
android:name="com.reactnativenavigation.controllers.NavigationActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:resizeableActivity="true"
android:exported="false"
android:exported="true"
/>
<activity
android:name="com.mattermost.rnshare.ShareActivity"
android:name="com.mattermost.share.ShareActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize"
android:label="@string/app_name"
android:screenOrientation="portrait"
android:theme="@style/AppTheme"
android:taskAffinity="com.mattermost.share"
android:exported="true"
@ -110,20 +105,6 @@
</activity>
<!-- For Calls microphone to work in the background -->
<service
android:name="com.voximplant.foregroundservice.VIForegroundService"
android:foregroundServiceType="microphone"
android:exported="false"
/>
<!-- Android 14 requires the correct Foreground Service (FGS) type for RNShare -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false"
android:stopWithTask="false"
tools:node="merge"
/>
<service android:name="com.voximplant.foregroundservice.VIForegroundService"/>
</application>
</manifest>

View file

@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View file

@ -0,0 +1,48 @@
package com.mattermost.helpers;
import androidx.annotation.Nullable;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.WritableMap;
import com.oblador.keychain.KeychainModule;
public class Credentials {
public static void getCredentialsForServer(ReactApplicationContext context, String serverUrl, ResolvePromise promise) {
final KeychainModule keychainModule = new KeychainModule(context);
final WritableMap options = Arguments.createMap();
// KeyChain module fails if `authenticationPrompt` is not set
final WritableMap authPrompt = Arguments.createMap();
authPrompt.putString("title", "Authenticate to retrieve secret");
authPrompt.putString("cancel", "Cancel");
options.putMap("authenticationPrompt", authPrompt);
options.putString("service", serverUrl);
keychainModule.getGenericPasswordForOptions(options, promise);
}
public static String getCredentialsForServerSync(ReactApplicationContext context, String serverUrl) {
final String[] token = new String[1];
Credentials.getCredentialsForServer(context, serverUrl, new ResolvePromise() {
@Override
public void resolve(@Nullable Object value) {
WritableMap map = (WritableMap) value;
if (map != null) {
token[0] = map.getString("password");
String service = map.getString("service");
assert service != null;
if (service.isEmpty()) {
String[] credentials = token[0].split(", *");
if (credentials.length == 2) {
token[0] = credentials[0];
}
}
}
}
});
return token[0];
}
}

View file

@ -19,7 +19,7 @@ import android.graphics.RectF;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
@ -28,36 +28,25 @@ import androidx.core.app.Person;
import androidx.core.app.RemoteInput;
import androidx.core.graphics.drawable.IconCompat;
import com.tokilabs.mattermost.*;
import com.mattermost.rnutils.helpers.NotificationHelper;
import com.nozbe.watermelondb.WMDatabase;
import com.mattermost.turbolog.TurboLog;
import com.mattermost.rnbeta.*;
import com.nozbe.watermelondb.Database;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Date;
import java.util.Objects;
import io.jsonwebtoken.IncorrectClaimException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MissingClaimException;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import static com.mattermost.helpers.database_extension.GeneralKt.getDatabaseForServer;
import static com.mattermost.helpers.database_extension.GeneralKt.getDeviceToken;
import static com.mattermost.helpers.database_extension.SystemKt.queryConfigServerVersion;
import static com.mattermost.helpers.database_extension.SystemKt.queryConfigSigningKey;
import static com.mattermost.helpers.database_extension.UserKt.getLastPictureUpdate;
public class CustomPushNotificationHelper {
public static final String CHANNEL_HIGH_IMPORTANCE_ID = "channel_01";
public static final String CHANNEL_MIN_IMPORTANCE_ID = "channel_02";
public static final String KEY_TEXT_REPLY = "CAN_REPLY";
public static final int MESSAGE_NOTIFICATION_ID = 435345;
public static final String NOTIFICATION_ID = "notificationId";
public static final String NOTIFICATION = "notification";
public static final String PUSH_TYPE_MESSAGE = "message";
@ -92,7 +81,7 @@ public class CustomPushNotificationHelper {
.setKey(senderId)
.setName(senderName);
if (serverUrl != null && type != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) {
@ -144,7 +133,7 @@ public class CustomPushNotificationHelper {
private static void addNotificationReplyAction(Context context, NotificationCompat.Builder notification, Bundle bundle, int notificationId) {
String postId = bundle.getString("post_id");
String serverUrl = bundle.getString("server_url");
boolean canReply = bundle.containsKey("category") && Objects.equals(bundle.getString("category"), CATEGORY_CAN_REPLY);
boolean canReply = bundle.containsKey("category") && bundle.getString("category").equals(CATEGORY_CAN_REPLY);
if (android.text.TextUtils.isEmpty(postId) || serverUrl == null || !canReply) {
return;
@ -192,9 +181,9 @@ public class CustomPushNotificationHelper {
String channelId = bundle.getString("channel_id");
String postId = bundle.getString("post_id");
String rootId = bundle.getString("root_id");
int notificationId = postId != null ? postId.hashCode() : NotificationHelper.INSTANCE.MESSAGE_NOTIFICATION_ID;
int notificationId = postId != null ? postId.hashCode() : MESSAGE_NOTIFICATION_ID;
boolean is_crt_enabled = bundle.containsKey("is_crt_enabled") && Objects.equals(bundle.getString("is_crt_enabled"), "true");
boolean is_crt_enabled = bundle.containsKey("is_crt_enabled") && bundle.getString("is_crt_enabled").equals("true");
String groupId = is_crt_enabled && !android.text.TextUtils.isEmpty(rootId) ? rootId : channelId;
addNotificationExtras(notification, bundle);
@ -238,154 +227,6 @@ public class CustomPushNotificationHelper {
}
}
public static boolean verifySignature(final Context context, String signature, String serverUrl, String ackId) {
if (signature == null) {
// Backward compatibility with old push proxies
TurboLog.Companion.i("Mattermost Notifications Signature verification", "No signature in the notification");
return true;
}
if (serverUrl == null) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "No server_url for server_id");
return false;
}
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper == null) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "Cannot access the database");
return false;
}
WMDatabase db = getDatabaseForServer(dbHelper, context, serverUrl);
if (db == null) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "Cannot access the server database");
return false;
}
if (signature.equals("NO_SIGNATURE")) {
String version = queryConfigServerVersion(db);
if (version == null) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "No server version");
return false;
}
if (!version.matches("[0-9]+(\\.[0-9]+)*")) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "Invalid server version");
return false;
}
String[] parts = version.split("\\.");
int major = parts.length > 0 ? Integer.parseInt(parts[0]) : 0;
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int patch = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int[][] targets = {{9,8,0},{9,7,3},{9,6,3},{9,5,5},{8,1,14}};
boolean rejected = false;
for (int i = 0; i < targets.length; i++) {
boolean first = i == 0;
int[] targetVersion = targets[i];
int majorTarget = targetVersion[0];
int minorTarget = targetVersion[1];
int patchTarget = targetVersion[2];
if (major > majorTarget) {
// Only reject if we are considering the first (highest) version.
// Any version in between should be acceptable.
rejected = first;
break;
}
if (major < majorTarget) {
// Continue to see if it complies with a smaller target
continue;
}
// Same major
if (minor > minorTarget) {
// Only reject if we are considering the first (highest) version.
// Any version in between should be acceptable.
rejected = first;
break;
}
if (minor < minorTarget) {
// Continue to see if it complies with a smaller target
continue;
}
// Same major and same minor
if (patch >= patchTarget) {
rejected = true;
break;
}
// Patch is lower than target
return true;
}
if (rejected) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "Server version should send signature");
return false;
}
// Version number is below any of the targets, so it should not send the signature
return true;
}
String signingKey = queryConfigSigningKey(db);
if (signingKey == null) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "No signing key");
return false;
}
try {
byte[] encoded = Base64.decode(signingKey, 0);
KeyFactory kf = KeyFactory.getInstance("EC");
PublicKey pubKey = (PublicKey) kf.generatePublic(new X509EncodedKeySpec(encoded));
String storedDeviceToken = getDeviceToken(dbHelper);
if (storedDeviceToken == null) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "No device token stored");
return false;
}
String[] tokenParts = storedDeviceToken.split(":", 2);
if (tokenParts.length != 2) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "Wrong stored device token format");
return false;
}
String deviceToken = tokenParts[1].substring(0, tokenParts[1].length() -1 );
if (deviceToken.isEmpty()) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", "Empty stored device token");
return false;
}
Jwts.parser()
.require("ack_id", ackId)
.require("device_id", deviceToken)
.verifyWith((PublicKey) pubKey)
.build()
.parseSignedClaims(signature);
} catch (MissingClaimException e) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", String.format("Missing claim: %s", e.getMessage()));
e.printStackTrace();
return false;
} catch (IncorrectClaimException e) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", String.format("Incorrect claim: %s", e.getMessage()));
e.printStackTrace();
return false;
} catch (JwtException e) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", String.format("Cannot verify JWT: %s", e.getMessage()));
e.printStackTrace();
return false;
} catch (Exception e) {
TurboLog.Companion.i("Mattermost Notifications Signature verification", String.format("Exception while parsing JWT: %s", e.getMessage()));
e.printStackTrace();
return false;
}
return true;
}
private static Bitmap getCircleBitmap(Bitmap bitmap) {
final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(),
bitmap.getHeight(), Bitmap.Config.ARGB_8888);
@ -434,7 +275,7 @@ public class CustomPushNotificationHelper {
.setKey(senderId)
.setName("Me");
if (serverUrl != null && type != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride);
if (avatar != null) {
@ -572,12 +413,12 @@ public class CustomPushNotificationHelper {
Double lastUpdateAt = 0.0;
if (!TextUtils.isEmpty(urlOverride)) {
Request request = new Request.Builder().url(urlOverride).build();
TurboLog.Companion.i("ReactNative", String.format("Fetch override profile image %s", urlOverride));
Log.i("ReactNative", String.format("Fetch override profile image %s", urlOverride));
response = client.newCall(request).execute();
} else {
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper != null) {
WMDatabase db = getDatabaseForServer(dbHelper, context, serverUrl);
Database db = getDatabaseForServer(dbHelper, context, serverUrl);
if (db != null) {
lastUpdateAt = getLastPictureUpdate(db, userId);
if (lastUpdateAt == null) {
@ -594,7 +435,7 @@ public class CustomPushNotificationHelper {
bitmapCache.removeBitmap(userId, serverUrl);
String url = String.format("api/v4/users/%s/image", userId);
TurboLog.Companion.i("ReactNative", String.format("Fetch profile image %s", url));
Log.i("ReactNative", String.format("Fetch profile image %s", url));
response = Network.getSync(serverUrl, url, null);
}

View file

@ -1,21 +1,17 @@
package com.mattermost.helpers
import android.content.Context
import android.database.Cursor
import android.net.Uri
import com.facebook.react.bridge.WritableMap
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import java.lang.Exception
import org.json.JSONArray
import org.json.JSONObject
typealias QueryArgs = Array<Any?>
class DatabaseHelper {
var defaultDatabase: WMDatabase? = null
var defaultDatabase: Database? = null
val onlyServerUrl: String?
get() {
@ -43,7 +39,7 @@ class DatabaseHelper {
private fun setDefaultDatabase(context: Context) {
val databaseName = "app.db"
val databasePath = Uri.fromFile(context.filesDir).toString() + "/" + databaseName
defaultDatabase = WMDatabase.getInstance(databasePath, context)
defaultDatabase = Database.getInstance(databasePath, context)
}
internal fun JSONObject.toMap(): Map<String, Any?> = keys().asSequence().associateWith { it ->
@ -77,15 +73,3 @@ class DatabaseHelper {
private set
}
}
fun WritableMap.mapCursor(cursor: Cursor) {
for (i in 0 until cursor.columnCount) {
when (cursor.getType(i)) {
Cursor.FIELD_TYPE_NULL -> putNull(cursor.getColumnName(i))
Cursor.FIELD_TYPE_INTEGER -> putDouble(cursor.getColumnName(i), cursor.getDouble(i))
Cursor.FIELD_TYPE_FLOAT -> putDouble(cursor.getColumnName(i), cursor.getDouble(i))
Cursor.FIELD_TYPE_STRING -> putString(cursor.getColumnName(i), cursor.getString(i))
else -> putString(cursor.getColumnName(i), "")
}
}
}

View file

@ -8,26 +8,22 @@ import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.ReadableMap;
import com.mattermost.networkclient.ApiClientModuleImpl;
import com.mattermost.networkclient.APIClientModule;
import com.mattermost.networkclient.enums.RetryTypes;
import com.mattermost.turbolog.TurboLog;
import okhttp3.HttpUrl;
import okhttp3.Response;
public class Network {
private static ApiClientModuleImpl clientModule;
private static APIClientModule clientModule;
private static final WritableMap clientOptions = Arguments.createMap();
private static final Promise emptyPromise = new ResolvePromise();
public static void init(Context context) {
if (clientModule == null) {
clientModule = new ApiClientModuleImpl(context);
createClientOptions();
} else {
TurboLog.Companion.i("ReactNative", "Network already initialized");
}
final ReactApplicationContext reactContext = (APIClientModule.context == null) ? new ReactApplicationContext(context) : APIClientModule.context;
clientModule = new APIClientModule(reactContext);
createClientOptions();
}
public static void get(String baseUrl, String endpoint, ReadableMap options, Promise promise) {

View file

@ -0,0 +1,312 @@
package com.mattermost.helpers;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import androidx.core.app.NotificationManagerCompat;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
public class NotificationHelper {
public static final String PUSH_NOTIFICATIONS = "PUSH_NOTIFICATIONS";
public static final String NOTIFICATIONS_IN_GROUP = "notificationsInGroup";
private static final String VERSION_PREFERENCE = "VERSION_PREFERENCE";
public static void cleanNotificationPreferencesIfNeeded(Context context) {
try {
PackageInfo pInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
String version = String.valueOf(pInfo.versionCode);
String storedVersion = null;
SharedPreferences pSharedPref = context.getSharedPreferences(VERSION_PREFERENCE, Context.MODE_PRIVATE);
if (pSharedPref != null) {
storedVersion = pSharedPref.getString("Version", "");
}
if (!version.equals(storedVersion)) {
if (pSharedPref != null) {
SharedPreferences.Editor editor = pSharedPref.edit();
editor.putString("Version", version);
editor.apply();
}
Map<String, JSONObject> inputMap = new HashMap<>();
saveMap(context, inputMap);
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static int getNotificationId(Bundle notification) {
final String postId = notification.getString("post_id");
final String channelId = notification.getString("channel_id");
int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
notificationId = postId.hashCode();
} else if (channelId != null) {
notificationId = channelId.hashCode();
}
return notificationId;
}
public static StatusBarNotification[] getDeliveredNotifications(Context context) {
final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
return notificationManager.getActiveNotifications();
}
public static boolean addNotificationToPreferences(Context context, int notificationId, Bundle notification) {
try {
boolean createSummary = true;
final String serverUrl = notification.getString("server_url");
final String channelId = notification.getString("channel_id");
final String rootId = notification.getString("root_id");
final boolean isCRTEnabled = notification.containsKey("is_crt_enabled") && notification.getString("is_crt_enabled").equals("true");
final boolean isThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
final String groupId = isThreadNotification ? rootId : channelId;
Map<String, JSONObject> notificationsPerServer = loadMap(context);
JSONObject notificationsInServer = notificationsPerServer.get(serverUrl);
if (notificationsInServer == null) {
notificationsInServer = new JSONObject();
}
JSONObject notificationsInGroup = notificationsInServer.optJSONObject(groupId);
if (notificationsInGroup == null) {
notificationsInGroup = new JSONObject();
}
if (notificationsInGroup.length() > 0) {
createSummary = false;
}
notificationsInGroup.put(String.valueOf(notificationId), false);
if (createSummary) {
// Add the summary notification id as well
notificationsInGroup.put(String.valueOf(notificationId + 1), true);
}
notificationsInServer.put(groupId, notificationsInGroup);
notificationsPerServer.put(serverUrl, notificationsInServer);
saveMap(context, notificationsPerServer);
return createSummary;
} catch(Exception e) {
e.printStackTrace();
return false;
}
}
public static void dismissNotification(Context context, Bundle notification) {
final boolean isCRTEnabled = notification.containsKey("is_crt_enabled") && notification.getString("is_crt_enabled").equals("true");
final String serverUrl = notification.getString("server_url");
final String channelId = notification.getString("channel_id");
final String rootId = notification.getString("root_id");
int notificationId = getNotificationId(notification);
if (!android.text.TextUtils.isEmpty(serverUrl) && !android.text.TextUtils.isEmpty(channelId)) {
boolean isThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
String notificationIdStr = String.valueOf(notificationId);
String groupId = isThreadNotification ? rootId : channelId;
Map<String, JSONObject> notificationsPerServer = loadMap(context);
JSONObject notificationsInServer = notificationsPerServer.get(serverUrl);
if (notificationsInServer == null) {
return;
}
JSONObject notificationsInGroup = notificationsInServer.optJSONObject(groupId);
if (notificationsInGroup == null) {
return;
}
boolean isSummary = notificationsInGroup.optBoolean(notificationIdStr);
notificationsInGroup.remove(notificationIdStr);
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.cancel(notificationId);
StatusBarNotification[] statusNotifications = getDeliveredNotifications(context);
boolean hasMore = false;
for (final StatusBarNotification status : statusNotifications) {
Bundle bundle = status.getNotification().extras;
if (isThreadNotification) {
hasMore = bundle.containsKey("root_id") && bundle.getString("root_id").equals(rootId);
} else {
hasMore = bundle.containsKey("channel_id") && bundle.getString("channel_id").equals(channelId);
}
if (hasMore) break;
}
if (!hasMore || isSummary) {
notificationsInServer.remove(groupId);
} else {
try {
notificationsInServer.put(groupId, notificationsInGroup);
} catch (JSONException e) {
e.printStackTrace();
}
}
notificationsPerServer.put(serverUrl, notificationsInServer);
saveMap(context, notificationsPerServer);
}
}
public static void removeChannelNotifications(Context context, String serverUrl, String channelId) {
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
Map<String, JSONObject> notificationsPerServer = loadMap(context);
JSONObject notificationsInServer = notificationsPerServer.get(serverUrl);
if (notificationsInServer != null) {
notificationsInServer.remove(channelId);
notificationsPerServer.put(serverUrl, notificationsInServer);
saveMap(context, notificationsPerServer);
}
StatusBarNotification[] notifications = getDeliveredNotifications(context);
for (StatusBarNotification sbn:notifications) {
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
String cId = bundle.getString("channel_id");
String rootId = bundle.getString("root_id");
boolean isCRTEnabled = bundle.containsKey("is_crt_enabled") && bundle.getString("is_crt_enabled").equals("true");
boolean skipThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
if (Objects.equals(cId, channelId) && !skipThreadNotification) {
notificationManager.cancel(sbn.getId());
}
}
}
public static void removeThreadNotifications(Context context, String serverUrl, String threadId) {
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
Map<String, JSONObject> notificationsPerServer = loadMap(context);
JSONObject notificationsInServer = notificationsPerServer.get(serverUrl);
StatusBarNotification[] notifications = getDeliveredNotifications(context);
for (StatusBarNotification sbn:notifications) {
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
String rootId = bundle.getString("root_id");
String postId = bundle.getString("post_id");
if (Objects.equals(rootId, threadId)) {
notificationManager.cancel(sbn.getId());
}
if (Objects.equals(postId, threadId)) {
String channelId = bundle.getString("channel_id");
int id = sbn.getId();
if (notificationsInServer != null && channelId != null) {
JSONObject notificationsInChannel = notificationsInServer.optJSONObject(channelId);
if (notificationsInChannel != null) {
notificationsInChannel.remove(String.valueOf(id));
try {
notificationsInServer.put(channelId, notificationsInChannel);
} catch (JSONException e) {
e.printStackTrace();
}
}
}
notificationManager.cancel(id);
}
}
if (notificationsInServer != null) {
notificationsInServer.remove(threadId);
notificationsPerServer.put(serverUrl, notificationsInServer);
saveMap(context, notificationsPerServer);
}
}
public static void removeServerNotifications(Context context, String serverUrl) {
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
Map<String, JSONObject> notificationsPerServer = loadMap(context);
notificationsPerServer.remove(serverUrl);
saveMap(context, notificationsPerServer);
StatusBarNotification[] notifications = getDeliveredNotifications(context);
for (StatusBarNotification sbn:notifications) {
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
String url = bundle.getString("server_url");
if (Objects.equals(url, serverUrl)) {
notificationManager.cancel(sbn.getId());
}
}
}
public static void clearChannelOrThreadNotifications(Context context, Bundle notification) {
final String serverUrl = notification.getString("server_url");
final String channelId = notification.getString("channel_id");
final String rootId = notification.getString("root_id");
if (channelId != null) {
final boolean isCRTEnabled = notification.containsKey("is_crt_enabled") && notification.getString("is_crt_enabled").equals("true");
// rootId is available only when CRT is enabled & clearing the thread
final boolean isClearThread = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
if (isClearThread) {
removeThreadNotifications(context, serverUrl, rootId);
} else {
removeChannelNotifications(context, serverUrl, channelId);
}
}
}
/**
* Map Structure
*
* { serverUrl: { groupId: { notification1: true, notification2: false } } }
* summary notification has a value of true
*
*/
private static void saveMap(Context context, Map<String, JSONObject> inputMap) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
if (pSharedPref != null) {
JSONObject json = new JSONObject(inputMap);
String jsonString = json.toString();
SharedPreferences.Editor editor = pSharedPref.edit();
editor.remove(NOTIFICATIONS_IN_GROUP).apply();
editor.putString(NOTIFICATIONS_IN_GROUP, jsonString);
editor.apply();
}
}
private static Map<String, JSONObject> loadMap(Context context) {
Map<String, JSONObject> outputMap = new HashMap<>();
if (context != null) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
try {
if (pSharedPref != null) {
String jsonString = pSharedPref.getString(NOTIFICATIONS_IN_GROUP, (new JSONObject()).toString());
JSONObject json = new JSONObject(jsonString);
Iterator<String> servers = json.keys();
while (servers.hasNext()) {
String serverUrl = servers.next();
JSONObject notificationGroup = json.getJSONObject(serverUrl);
outputMap.put(serverUrl, notificationGroup);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
return outputMap;
}
}

View file

@ -2,29 +2,31 @@ package com.mattermost.helpers
import android.content.Context
import android.os.Bundle
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.database_extension.getDatabaseForServer
import com.mattermost.helpers.database_extension.saveToDatabase
import com.mattermost.helpers.push_notification.addToDefaultCategoryIfNeeded
import com.mattermost.helpers.push_notification.fetchMyChannel
import com.mattermost.helpers.push_notification.fetchMyTeamCategories
import com.mattermost.helpers.push_notification.fetchNeededUsers
import com.mattermost.helpers.push_notification.fetchPosts
import com.mattermost.helpers.push_notification.fetchTeamIfNeeded
import com.mattermost.helpers.push_notification.fetchThread
import com.mattermost.turbolog.TurboLog
import kotlinx.coroutines.Dispatchers
import com.mattermost.helpers.database_extension.*
import com.mattermost.helpers.push_notification.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class PushNotificationDataHelper(private val context: Context) {
suspend fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
return withContext(Dispatchers.Default) {
PushNotificationDataRunnable.start(context, initialData, isReactInit)
private var coroutineScope = CoroutineScope(Dispatchers.Default)
fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
var result: Bundle? = null
val job = coroutineScope.launch(Dispatchers.Default) {
result = PushNotificationDataRunnable.start(context, initialData, isReactInit)
}
runBlocking {
job.join()
}
return result
}
}
@ -35,8 +37,8 @@ class PushNotificationDataRunnable {
private val mutex = Mutex()
suspend fun start(context: Context, initialData: Bundle, isReactInit: Boolean): Bundle? {
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
mutex.withLock {
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
val serverUrl: String = initialData.getString("server_url") ?: return null
val db = dbHelper.getDatabaseForServer(context, serverUrl)
var result: Bundle? = null
@ -48,40 +50,32 @@ class PushNotificationDataRunnable {
val postId = initialData.getString("post_id")
val rootId = initialData.getString("root_id")
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
val ackId = initialData.getString("ack_id")
TurboLog.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId and ack=$ackId")
Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId")
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val notificationData = Arguments.createMap()
var channel: ReadableMap? = null
var myTeam: ReadableMap? = null
if (!teamId.isNullOrEmpty()) {
val res = fetchTeamIfNeeded(db, serverUrl, teamId)
res.first?.let { notificationData.putMap("team", it) }
myTeam = res.second
myTeam?.let { notificationData.putMap("myTeam", it) }
res.second?.let { notificationData.putMap("myTeam", it) }
}
if (channelId != null && postId != null) {
val channelRes = fetchMyChannel(db, serverUrl, channelId, isCRTEnabled)
channel = channelRes.first
channel?.let { notificationData.putMap("channel", it) }
channelRes.first?.let { notificationData.putMap("channel", it) }
channelRes.second?.let { notificationData.putMap("myChannel", it) }
val loadedProfiles = channelRes.third
// Fetch categories if needed
if (!teamId.isNullOrEmpty() && myTeam != null) {
if (!teamId.isNullOrEmpty() && notificationData.getMap("myTeam") != null) {
// should load all categories
val res = fetchMyTeamCategories(db, serverUrl, teamId)
res?.let { notificationData.putMap("categories", it) }
} else if (channel != null) {
} else if (notificationData.getMap("channel") != null) {
// check if the channel is in the category for the team
val res = addToDefaultCategoryIfNeeded(db, channel)
val res = addToDefaultCategoryIfNeeded(db, notificationData.getMap("channel")!!)
res?.let { notificationData.putArray("categoryChannels", it) }
}
@ -95,7 +89,7 @@ class PushNotificationDataRunnable {
getThreadList(notificationThread, postData?.getArray("threads"))?.let {
val threadsArray = Arguments.createArray()
for (item in it) {
for(item in it) {
threadsArray.pushMap(item)
}
notificationData.putArray("threads", threadsArray)
@ -111,15 +105,13 @@ class PushNotificationDataRunnable {
dbHelper.saveToDatabase(db, notificationData, teamId, channelId, receivingThreads)
}
TurboLog.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId and ack=$ackId")
Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId")
}
} catch (e: Exception) {
e.printStackTrace()
val eMessage = e.message ?: "Error with no message"
TurboLog.e("ReactNative", "Error processing push notification error=$eMessage")
} finally {
db?.close()
TurboLog.i("ReactNative", "DONE fetching notification data")
Log.i("ReactNative", "DONE fetching notification data")
}
return result
@ -135,22 +127,21 @@ class PushNotificationDataRunnable {
threadsArray.add(thread)
}
for(i in 0 until it.size()) {
it.getMap(i)?.let { thread ->
val threadId = thread.getString("id")
if (threadId != null) {
if (threadIds.contains(threadId)) {
// replace the values for participants and is_following
val index = threadsArray.indexOfFirst { el -> el.getString("id") == threadId }
val prev = threadsArray[index]
val merge = Arguments.createMap()
merge.merge(prev)
merge.putBoolean("is_following", thread.getBoolean("is_following"))
merge.putArray("participants", thread.getArray("participants"))
threadsArray[index] = merge
} else {
threadsArray.add(thread)
threadIds.add(threadId)
}
val thread = it.getMap(i)
val threadId = thread.getString("id")
if (threadId != null) {
if (threadIds.contains(threadId)) {
// replace the values for participants and is_following
val index = threadsArray.indexOfFirst { el -> el.getString("id") == threadId }
val prev = threadsArray[index]
val merge = Arguments.createMap()
merge.merge(prev)
merge.putBoolean("is_following", thread.getBoolean("is_following"))
merge.putArray("participants", thread.getArray("participants"))
threadsArray[index] = merge
} else {
threadsArray.add(thread)
threadIds.add(threadId)
}
}
}

View file

@ -1,11 +1,22 @@
package com.mattermost.helpers
import java.util.UUID
import kotlin.math.floor
class RandomId {
companion object {
private const val alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
private const val alphabetLength = alphabet.length
private const val idLength = 16
fun generate(): String {
return UUID.randomUUID().toString()
var id = ""
for (i in 1.rangeTo((idLength / 2))) {
val random = floor(Math.random() * alphabetLength * alphabetLength)
id += alphabet[floor(random / alphabetLength).toInt()]
id += alphabet[(random % alphabetLength).toInt()]
}
return id
}
}
}

View file

@ -0,0 +1,274 @@
package com.mattermost.helpers;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.content.ContentResolver;
import android.os.Environment;
import android.webkit.MimeTypeMap;
import android.util.Log;
import android.text.TextUtils;
import android.os.ParcelFileDescriptor;
import java.io.*;
import java.nio.channels.FileChannel;
// Class based on DocumentHelper https://gist.github.com/steveevers/a5af24c226f44bb8fdc3
public class RealPathUtil {
public static final String CACHE_DIR_NAME = "mmShare";
public static String getRealPathFromURI(final Context context, final Uri uri) {
// DocumentProvider
if (DocumentsContract.isDocumentUri(context, uri)) {
// ExternalStorageProvider
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
if ("primary".equalsIgnoreCase(type)) {
return Environment.getExternalStorageDirectory() + "/" + split[1];
}
} else if (isDownloadsDocument(uri)) {
// DownloadsProvider
final String id = DocumentsContract.getDocumentId(uri);
if (!TextUtils.isEmpty(id)) {
if (id.startsWith("raw:")) {
return id.replaceFirst("raw:", "");
}
try {
return getPathFromSavingTempFile(context, uri);
} catch (NumberFormatException e) {
Log.e("ReactNative", "DownloadsProvider unexpected uri " + uri);
return null;
}
}
} else if (isMediaDocument(uri)) {
// MediaProvider
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
Uri contentUri = null;
if ("image".equals(type)) {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
} else if ("video".equals(type)) {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
} else if ("audio".equals(type)) {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}
final String selection = "_id=?";
final String[] selectionArgs = new String[] {
split[1]
};
String name = getDataColumn(context, contentUri, selection, selectionArgs);
if (!TextUtils.isEmpty(name)) {
return name;
}
return getPathFromSavingTempFile(context, uri);
}
}
if ("content".equalsIgnoreCase(uri.getScheme())) {
// MediaStore (and general)
if (isGooglePhotosUri(uri)) {
return uri.getLastPathSegment();
}
// Try save to tmp file, and return tmp file path
return getPathFromSavingTempFile(context, uri);
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
return uri.getPath();
}
return null;
}
public static String getPathFromSavingTempFile(Context context, final Uri uri) {
File tmpFile;
String fileName = "";
if (uri == null || uri.isRelative()) {
return null;
}
// Try and get the filename from the Uri
try {
Cursor returnCursor =
context.getContentResolver().query(uri, null, null, null, null);
int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
returnCursor.moveToFirst();
fileName = sanitizeFilename(returnCursor.getString(nameIndex));
returnCursor.close();
} catch (Exception e) {
// just continue to get the filename with the last segment of the path
}
try {
if (TextUtils.isEmpty(fileName)) {
fileName = sanitizeFilename(uri.getLastPathSegment().trim());
}
File cacheDir = new File(context.getCacheDir(), CACHE_DIR_NAME);
boolean cacheDirExists = cacheDir.exists();
if (!cacheDirExists) {
cacheDirExists = cacheDir.mkdirs();
}
if (cacheDirExists) {
tmpFile = new File(cacheDir, fileName);
boolean fileCreated = tmpFile.createNewFile();
if (fileCreated) {
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r");
try (FileInputStream inputSrc = new FileInputStream(pfd.getFileDescriptor())) {
FileChannel src = inputSrc.getChannel();
try (FileOutputStream outputDst = new FileOutputStream(tmpFile)) {
FileChannel dst = outputDst.getChannel();
dst.transferFrom(src, 0, src.size());
src.close();
dst.close();
}
}
pfd.close();
}
return tmpFile.getAbsolutePath();
}
} catch (IOException ex) {
ex.printStackTrace();
}
return null;
}
public static String getDataColumn(Context context, Uri uri, String selection,
String[] selectionArgs) {
Cursor cursor = null;
final String column = "_data";
final String[] projection = {
column
};
try {
cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs,
null);
if (cursor != null && cursor.moveToFirst()) {
final int index = cursor.getColumnIndexOrThrow(column);
return cursor.getString(index);
}
} finally {
if (cursor != null)
cursor.close();
}
return null;
}
public static boolean isExternalStorageDocument(Uri uri) {
return "com.android.externalstorage.documents".equals(uri.getAuthority());
}
public static boolean isDownloadsDocument(Uri uri) {
return "com.android.providers.downloads.documents".equals(uri.getAuthority());
}
public static boolean isMediaDocument(Uri uri) {
return "com.android.providers.media.documents".equals(uri.getAuthority());
}
public static boolean isGooglePhotosUri(Uri uri) {
return "com.google.android.apps.photos.content".equals(uri.getAuthority());
}
public static String getExtension(String uri) {
String extension = "";
if (uri == null) {
return extension;
}
extension = MimeTypeMap.getFileExtensionFromUrl(uri);
if (!extension.equals("")) {
return extension;
}
int dot = uri.lastIndexOf(".");
if (dot >= 0) {
return uri.substring(dot);
} else {
// No extension.
return "";
}
}
public static String getMimeType(File file) {
String extension = getExtension(file.getName());
if (extension.length() > 0)
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.substring(1));
return "application/octet-stream";
}
public static String getMimeType(String filePath) {
File file = new File(filePath);
return getMimeType(file);
}
public static String getMimeTypeFromUri(final Context context, final Uri uri) {
try {
ContentResolver cR = context.getContentResolver();
return cR.getType(uri);
} catch (Exception e) {
return "application/octet-stream";
}
}
public static void deleteTempFiles(final File dir) {
try {
if (dir.isDirectory()) {
deleteRecursive(dir);
}
} catch (Exception e) {
// do nothing
}
}
private static void deleteRecursive(File fileOrDirectory) {
if (fileOrDirectory.isDirectory()) {
File[] files = fileOrDirectory.listFiles();
if (files != null) {
for (File child : files)
deleteRecursive(child);
}
}
if (!fileOrDirectory.delete()) {
Log.i("ReactNative", "Couldn't delete file " + fileOrDirectory.getName());
}
}
private static String sanitizeFilename(String filename) {
if (filename == null) {
return null;
}
File f = new File(filename);
return f.getName();
}
}

View file

@ -1,7 +1,6 @@
package com.mattermost.helpers;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableMap;
@ -10,59 +9,58 @@ import com.facebook.react.bridge.WritableMap;
* ResolvePromise: Helper class that abstracts boilerplate
*/
public class ResolvePromise implements Promise {
@Override
public void reject(@NonNull String s) {
public void resolve(@javax.annotation.Nullable Object value) {
}
@Override
public void resolve(@Nullable Object o) {
public void reject(String code, String message) {
}
@Override
public void reject(@NonNull String s, @Nullable String s1) {
public void reject(String code, @NonNull WritableMap map) {
}
@Override
public void reject(@NonNull String s, @Nullable Throwable throwable) {
public void reject(String code, Throwable e) {
}
@Override
public void reject(@NonNull String s, @Nullable String s1, @Nullable Throwable throwable) {
public void reject(Throwable e, WritableMap map) {
}
@Override
public void reject(@NonNull Throwable throwable) {
public void reject(String code, Throwable e, WritableMap map) {
}
@Override
public void reject(@NonNull Throwable throwable, @NonNull WritableMap writableMap) {
public void reject(String code, String message, Throwable e, WritableMap map) {
}
@Override
public void reject(@NonNull String s, @NonNull WritableMap writableMap) {
public void reject(String code, String message, Throwable e) {
}
@Override
public void reject(@NonNull String s, @Nullable Throwable throwable, @NonNull WritableMap writableMap) {
public void reject(String code, String message, @NonNull WritableMap map) {
}
@Override
public void reject(@NonNull String s, @Nullable String s1, @NonNull WritableMap writableMap) {
public void reject(String message) {
}
@Override
public void reject(@Nullable String s, @Nullable String s1, @Nullable Throwable throwable, @Nullable WritableMap writableMap) {
public void reject(Throwable reason) {
}
}

View file

@ -2,9 +2,9 @@ package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
fun insertCategory(db: WMDatabase, category: ReadableMap) {
fun insertCategory(db: Database, category: ReadableMap) {
try {
val id = category.getString("id") ?: return
val collapsed = false
@ -31,7 +31,7 @@ fun insertCategory(db: WMDatabase, category: ReadableMap) {
}
}
fun insertCategoryChannels(db: WMDatabase, categoryId: String, teamId: String, channelIds: ReadableArray) {
fun insertCategoryChannels(db: Database, categoryId: String, teamId: String, channelIds: ReadableArray) {
try {
for (i in 0 until channelIds.size()) {
val channelId = channelIds.getString(i)
@ -50,41 +50,36 @@ fun insertCategoryChannels(db: WMDatabase, categoryId: String, teamId: String, c
}
}
fun insertCategoriesWithChannels(db: WMDatabase, orderCategories: ReadableMap) {
fun insertCategoriesWithChannels(db: Database, orderCategories: ReadableMap) {
val categories = orderCategories.getArray("categories") ?: return
for (i in 0 until categories.size()) {
val category = categories.getMap(i)
category?.let { cat ->
val id = cat.getString("id")
val teamId = cat.getString("team_id")
val channelIds = cat.getArray("channel_ids")
insertCategory(db, cat)
if (id != null && teamId != null) {
channelIds?.let { insertCategoryChannels(db, id, teamId, it) }
}
val id = category.getString("id")
val teamId = category.getString("team_id")
val channelIds = category.getArray("channel_ids")
insertCategory(db, category)
if (id != null && teamId != null) {
channelIds?.let { insertCategoryChannels(db, id, teamId, it) }
}
}
}
fun insertChannelToDefaultCategory(db: WMDatabase, categoryChannels: ReadableArray) {
fun insertChannelToDefaultCategory(db: Database, categoryChannels: ReadableArray) {
try {
for (i in 0 until categoryChannels.size()) {
categoryChannels.getMap(i)?.let { cc ->
val id = cc.getString("id")
val categoryId = cc.getString("category_id")
val channelId = cc.getString("channel_id")
if (id != null && categoryId != null && channelId != null) {
val count = countByColumn(db, "CategoryChannel", "category_id", categoryId)
db.execute(
"""
INSERT INTO CategoryChannel
(id, category_id, channel_id, sort_order, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, categoryId, channelId, if (count > 0) count + 1 else count)
)
}
}
val cc = categoryChannels.getMap(i)
val id = cc.getString("id")
val categoryId = cc.getString("category_id")
val channelId = cc.getString("channel_id")
val count = countByColumn(db, "CategoryChannel", "category_id", categoryId)
db.execute(
"""
INSERT INTO CategoryChannel
(id, category_id, channel_id, sort_order, _changed, _status)
VALUES (?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, categoryId, channelId, if (count > 0) count + 1 else count)
)
}
} catch (e: Exception) {
e.printStackTrace()

View file

@ -3,11 +3,11 @@ package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.ReadableMapUtils
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import org.json.JSONException
import org.json.JSONObject
fun findChannel(db: WMDatabase?, channelId: String): Boolean {
fun findChannel(db: Database?, channelId: String): Boolean {
if (db != null) {
val team = find(db, "Channel", channelId)
return team != null
@ -15,7 +15,7 @@ fun findChannel(db: WMDatabase?, channelId: String): Boolean {
return false
}
fun findMyChannel(db: WMDatabase?, channelId: String): Boolean {
fun findMyChannel(db: Database?, channelId: String): Boolean {
if (db != null) {
val team = find(db, "MyChannel", channelId)
return team != null
@ -23,7 +23,7 @@ fun findMyChannel(db: WMDatabase?, channelId: String): Boolean {
return false
}
internal fun handleChannel(db: WMDatabase, channel: ReadableMap) {
internal fun handleChannel(db: Database, channel: ReadableMap) {
try {
val exists = channel.getString("id")?.let { findChannel(db, it) } ?: false
if (!exists) {
@ -37,7 +37,7 @@ internal fun handleChannel(db: WMDatabase, channel: ReadableMap) {
}
}
internal fun DatabaseHelper.handleMyChannel(db: WMDatabase, myChannel: ReadableMap, postsData: ReadableMap?, receivingThreads: Boolean) {
internal fun DatabaseHelper.handleMyChannel(db: Database, myChannel: ReadableMap, postsData: ReadableMap?, receivingThreads: Boolean) {
try {
val json = ReadableMapUtils.toJSONObject(myChannel)
val exists = myChannel.getString("id")?.let { findMyChannel(db, it) } ?: false
@ -71,7 +71,7 @@ internal fun DatabaseHelper.handleMyChannel(db: WMDatabase, myChannel: ReadableM
}
}
fun insertChannel(db: WMDatabase, channel: JSONObject): Boolean {
fun insertChannel(db: Database, channel: JSONObject): Boolean {
val id = try { channel.getString("id") } catch (e: JSONException) { return false }
val createAt = try { channel.getDouble("create_at") } catch (e: JSONException) { 0 }
val deleteAt = try { channel.getDouble("delete_at") } catch (e: JSONException) { 0 }
@ -104,7 +104,7 @@ fun insertChannel(db: WMDatabase, channel: JSONObject): Boolean {
}
}
fun insertChannelInfo(db: WMDatabase, channel: JSONObject) {
fun insertChannelInfo(db: Database, channel: JSONObject) {
val id = try { channel.getString("id") } catch (e: JSONException) { return }
val header = try { channel.getString("header") } catch (e: JSONException) { "" }
val purpose = try { channel.getString("purpose") } catch (e: JSONException) { "" }
@ -123,7 +123,7 @@ fun insertChannelInfo(db: WMDatabase, channel: JSONObject) {
}
}
fun insertMyChannel(db: WMDatabase, myChanel: JSONObject): Boolean {
fun insertMyChannel(db: Database, myChanel: JSONObject): Boolean {
return try {
val id = try { myChanel.getString("id") } catch (e: JSONException) { return false }
val roles = try { myChanel.getString("roles") } catch (e: JSONException) { "" }
@ -156,7 +156,7 @@ fun insertMyChannel(db: WMDatabase, myChanel: JSONObject): Boolean {
}
}
fun insertMyChannelSettings(db: WMDatabase, myChanel: JSONObject) {
fun insertMyChannelSettings(db: Database, myChanel: JSONObject) {
try {
val id = try { myChanel.getString("id") } catch (e: JSONException) { return }
val notifyProps = try { myChanel.getString("notify_props") } catch (e: JSONException) { return }
@ -173,7 +173,7 @@ fun insertMyChannelSettings(db: WMDatabase, myChanel: JSONObject) {
}
}
fun insertChannelMember(db: WMDatabase, myChanel: JSONObject) {
fun insertChannelMember(db: Database, myChanel: JSONObject) {
try {
val userId = queryCurrentUserId(db) ?: return
val channelId = try { myChanel.getString("id") } catch (e: JSONException) { return }
@ -193,7 +193,7 @@ fun insertChannelMember(db: WMDatabase, myChanel: JSONObject) {
}
}
fun updateMyChannel(db: WMDatabase, myChanel: JSONObject) {
fun updateMyChannel(db: Database, myChanel: JSONObject) {
try {
val id = try { myChanel.getString("id") } catch (e: JSONException) { return }
val msgCount = try { myChanel.getInt("message_count") } catch (e: JSONException) { 0 }

View file

@ -1,9 +1,9 @@
package com.mattermost.helpers.database_extension
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import org.json.JSONArray
internal fun insertCustomEmojis(db: WMDatabase, customEmojis: JSONArray) {
internal fun insertCustomEmojis(db: Database, customEmojis: JSONArray) {
for (i in 0 until customEmojis.length()) {
try {
val emoji = customEmojis.getJSONObject(i)

View file

@ -1,10 +1,10 @@
package com.mattermost.helpers.database_extension
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import org.json.JSONArray
import org.json.JSONException
internal fun insertFiles(db: WMDatabase, files: JSONArray) {
internal fun insertFiles(db: Database, files: JSONArray) {
try {
for (i in 0 until files.length()) {
val file = files.getJSONObject(i)

View file

@ -1,17 +1,17 @@
package com.mattermost.helpers.database_extension
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.text.TextUtils
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.QueryArgs
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
import java.util.Arrays
import com.nozbe.watermelondb.Database
import com.nozbe.watermelondb.QueryArgs
import com.nozbe.watermelondb.mapCursor
import java.util.*
import kotlin.Exception
internal fun DatabaseHelper.saveToDatabase(db: WMDatabase, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) {
internal fun DatabaseHelper.saveToDatabase(db: Database, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) {
db.transaction {
val posts = data.getMap("posts")
data.getMap("team")?.let { insertTeam(db, it) }
@ -26,7 +26,7 @@ internal fun DatabaseHelper.saveToDatabase(db: WMDatabase, data: ReadableMap, te
data.getArray("threads")?.let {
val threadsArray = ArrayList<ReadableMap>()
for (i in 0 until it.size()) {
it.getMap(i)?.let { map -> threadsArray.add(map) }
threadsArray.add(it.getMap(i))
}
handleThreads(db, threadsArray, teamId)
}
@ -50,14 +50,14 @@ fun DatabaseHelper.getServerUrlForIdentifier(identifier: String): String? {
return null
}
fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): WMDatabase? {
fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): Database? {
try {
val query = "SELECT db_path FROM Servers WHERE url=?"
defaultDatabase!!.rawQuery(query, arrayOf(serverUrl)).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
val databasePath = String.format("file://%s", cursor.getString(0))
return WMDatabase.buildDatabase(databasePath, context!!, SQLiteDatabase.CREATE_IF_NECESSARY)
val databasePath = cursor.getString(0)
return Database.getInstance(databasePath, context!!)
}
}
} catch (e: Exception) {
@ -67,23 +67,7 @@ fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): W
return null
}
fun DatabaseHelper.getDeviceToken(): String? {
try {
val query = "SELECT value FROM Global WHERE id=?"
defaultDatabase!!.rawQuery(query, arrayOf("deviceToken")).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getString(0)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
fun find(db: WMDatabase, tableName: String, id: String?): ReadableMap? {
fun find(db: Database, tableName: String, id: String?): ReadableMap? {
try {
db.rawQuery(
"SELECT * FROM $tableName WHERE id == ? LIMIT 1",
@ -103,7 +87,7 @@ fun find(db: WMDatabase, tableName: String, id: String?): ReadableMap? {
}
}
fun findByColumns(db: WMDatabase, tableName: String, columnNames: Array<String>, values: QueryArgs): ReadableMap? {
fun findByColumns(db: Database, tableName: String, columnNames: Array<String>, values: QueryArgs): ReadableMap? {
try {
val whereString = columnNames.joinToString(" AND ") { "$it = ?" }
db.rawQuery(
@ -124,7 +108,7 @@ fun findByColumns(db: WMDatabase, tableName: String, columnNames: Array<String>,
}
}
fun queryIds(db: WMDatabase, tableName: String, ids: Array<String>): List<String> {
fun queryIds(db: Database, tableName: String, ids: Array<String>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(ids).map { "?" }.toArray())
try {
@ -145,7 +129,7 @@ fun queryIds(db: WMDatabase, tableName: String, ids: Array<String>): List<String
return list
}
fun queryByColumn(db: WMDatabase, tableName: String, columnName: String, values: Array<Any?>): List<String> {
fun queryByColumn(db: Database, tableName: String, columnName: String, values: Array<Any?>): List<String> {
val list: MutableList<String> = ArrayList()
val args = TextUtils.join(",", Arrays.stream(values).map { "?" }.toArray())
try {
@ -165,7 +149,7 @@ fun queryByColumn(db: WMDatabase, tableName: String, columnName: String, values:
return list
}
fun countByColumn(db: WMDatabase, tableName: String, columnName: String, value: Any?): Int {
fun countByColumn(db: Database, tableName: String, columnName: String, value: Any?): Int {
try {
db.rawQuery(
"SELECT COUNT(*) FROM $tableName WHERE $columnName == ? LIMIT 1",

View file

@ -3,13 +3,13 @@ package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.ReadableMapUtils
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import kotlin.Exception
internal fun queryLastPostCreateAt(db: WMDatabase?, channelId: String): Double? {
internal fun queryLastPostCreateAt(db: Database?, channelId: String): Double? {
try {
if (db != null) {
val postsInChannelQuery = "SELECT earliest, latest FROM PostsInChannel WHERE channel_id=? ORDER BY latest DESC LIMIT 1"
@ -35,7 +35,7 @@ internal fun queryLastPostCreateAt(db: WMDatabase?, channelId: String): Double?
return null
}
fun queryPostSinceForChannel(db: WMDatabase?, channelId: String): Double? {
fun queryPostSinceForChannel(db: Database?, channelId: String): Double? {
try {
if (db != null) {
val postsInChannelQuery = "SELECT last_fetched_at FROM MyChannel WHERE id=? LIMIT 1"
@ -57,7 +57,7 @@ fun queryPostSinceForChannel(db: WMDatabase?, channelId: String): Double? {
return null
}
fun queryLastPostInThread(db: WMDatabase?, rootId: String): Double? {
fun queryLastPostInThread(db: Database?, rootId: String): Double? {
try {
if (db != null) {
val query = "SELECT create_at FROM Post WHERE root_id=? AND delete_at=0 ORDER BY create_at DESC LIMIT 1"
@ -75,7 +75,7 @@ fun queryLastPostInThread(db: WMDatabase?, rootId: String): Double? {
return null
}
internal fun insertPost(db: WMDatabase, post: JSONObject) {
internal fun insertPost(db: Database, post: JSONObject) {
try {
val id = try { post.getString("id") } catch (e: JSONException) { return }
val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return }
@ -129,7 +129,7 @@ internal fun insertPost(db: WMDatabase, post: JSONObject) {
}
}
internal fun updatePost(db: WMDatabase, post: JSONObject) {
internal fun updatePost(db: Database, post: JSONObject) {
try {
val id = try { post.getString("id") } catch (e: JSONException) { return }
val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return }
@ -182,7 +182,7 @@ internal fun updatePost(db: WMDatabase, post: JSONObject) {
}
}
fun DatabaseHelper.handlePosts(db: WMDatabase, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) {
fun DatabaseHelper.handlePosts(db: Database, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) {
// Posts, PostInChannel, PostInThread, Reactions, Files, CustomEmojis, Users
try {
if (postsData != null) {

View file

@ -4,23 +4,21 @@ import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.RandomId
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import com.nozbe.watermelondb.mapCursor
internal fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: Double): ReadableMap? {
for (i in 0 until chunks.size()) {
val chunk = chunks.getMap(i)
chunk?.let {
if (earliest >= it.getDouble("earliest") || latest <= it.getDouble("latest")) {
return it
}
if (earliest >= chunk.getDouble("earliest") || latest <= chunk.getDouble("latest")) {
return chunk
}
}
return null
}
internal fun insertPostInChannel(db: WMDatabase, channelId: String, earliest: Double, latest: Double): ReadableMap? {
internal fun insertPostInChannel(db: Database, channelId: String, earliest: Double, latest: Double): ReadableMap? {
return try {
val id = RandomId.generate()
db.execute(
@ -43,16 +41,14 @@ internal fun insertPostInChannel(db: WMDatabase, channelId: String, earliest: Do
}
}
internal fun mergePostsInChannel(db: WMDatabase, existingChunks: ReadableArray, newChunk: ReadableMap) {
internal fun mergePostsInChannel(db: Database, existingChunks: ReadableArray, newChunk: ReadableMap) {
for (i in 0 until existingChunks.size()) {
try {
val chunk = existingChunks.getMap(i)
chunk?.let {
if (newChunk.getDouble("earliest") <= it.getDouble("earliest") &&
newChunk.getDouble("latest") >= it.getDouble("latest")) {
db.execute("DELETE FROM PostsInChannel WHERE id = ?", arrayOf(it.getString("id")))
return
}
if (newChunk.getDouble("earliest") <= chunk.getDouble("earliest") &&
newChunk.getDouble("latest") >= chunk.getDouble("latest")) {
db.execute("DELETE FROM PostsInChannel WHERE id = ?", arrayOf(chunk.getString("id")))
break
}
} catch (e: Exception) {
e.printStackTrace()
@ -60,7 +56,7 @@ internal fun mergePostsInChannel(db: WMDatabase, existingChunks: ReadableArray,
}
}
internal fun handlePostsInChannel(db: WMDatabase, channelId: String, earliest: Double, latest: Double) {
internal fun handlePostsInChannel(db: Database, channelId: String, earliest: Double, latest: Double) {
try {
db.rawQuery(
"SELECT id, channel_id, earliest, latest FROM PostsInChannel WHERE channel_id = ?",

View file

@ -1,10 +1,10 @@
package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.Arguments
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import com.nozbe.watermelondb.mapCursor
fun getTeammateDisplayNameSetting(db: WMDatabase): String {
fun getTeammateDisplayNameSetting(db: Database): String {
val configSetting = queryConfigDisplayNameSetting(db)
if (configSetting != null) {
return configSetting

View file

@ -1,10 +1,10 @@
package com.mattermost.helpers.database_extension
import com.mattermost.helpers.RandomId
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import org.json.JSONArray
internal fun insertReactions(db: WMDatabase, reactions: JSONArray) {
internal fun insertReactions(db: Database, reactions: JSONArray) {
for (i in 0 until reactions.length()) {
try {
val reaction = reactions.getJSONObject(i)

View file

@ -1,19 +1,19 @@
package com.mattermost.helpers.database_extension
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import org.json.JSONObject
fun queryCurrentUserId(db: WMDatabase): String? {
fun queryCurrentUserId(db: Database): String? {
val result = find(db, "System", "currentUserId")
return result?.getString("value")?.removeSurrounding("\"")
}
fun queryCurrentTeamId(db: WMDatabase): String? {
fun queryCurrentTeamId(db: Database): String? {
val result = find(db, "System", "currentTeamId")
return result?.getString("value")?.removeSurrounding("\"")
}
fun queryConfigDisplayNameSetting(db: WMDatabase): String? {
fun queryConfigDisplayNameSetting(db: Database): String? {
val license = find(db, "System", "license")
val lockDisplayName = find(db, "Config", "LockTeammateNameDisplay")
val displayName = find(db, "Config", "TeammateNameDisplay")
@ -30,11 +30,3 @@ fun queryConfigDisplayNameSetting(db: WMDatabase): String? {
return null
}
fun queryConfigSigningKey(db: WMDatabase): String? {
return find(db, "Config", "AsymmetricSigningPublicKey")?.getString("value")
}
fun queryConfigServerVersion(db: WMDatabase): String? {
return find(db, "Config", "Version")?.getString("value")
}

View file

@ -3,10 +3,10 @@ package com.mattermost.helpers.database_extension
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import com.nozbe.watermelondb.mapCursor
fun findTeam(db: WMDatabase?, teamId: String): Boolean {
fun findTeam(db: Database?, teamId: String): Boolean {
if (db != null) {
val team = find(db, "Team", teamId)
return team != null
@ -14,7 +14,7 @@ fun findTeam(db: WMDatabase?, teamId: String): Boolean {
return false
}
fun findMyTeam(db: WMDatabase?, teamId: String): Boolean {
fun findMyTeam(db: Database?, teamId: String): Boolean {
if (db != null) {
val team = find(db, "MyTeam", teamId)
return team != null
@ -22,7 +22,7 @@ fun findMyTeam(db: WMDatabase?, teamId: String): Boolean {
return false
}
fun queryMyTeams(db: WMDatabase?): ArrayList<ReadableMap>? {
fun queryMyTeams(db: Database?): ArrayList<ReadableMap>? {
db?.rawQuery("SELECT * FROM MyTeam")?.use { cursor ->
val results = ArrayList<ReadableMap>()
if (cursor.count > 0) {
@ -38,7 +38,7 @@ fun queryMyTeams(db: WMDatabase?): ArrayList<ReadableMap>? {
return null
}
fun insertTeam(db: WMDatabase, team: ReadableMap): Boolean {
fun insertTeam(db: Database, team: ReadableMap): Boolean {
val id = try { team.getString("id") } catch (e: Exception) { return false }
val deleteAt = try {team.getDouble("delete_at") } catch (e: Exception) { 0 }
if (deleteAt.toInt() > 0) {
@ -78,7 +78,7 @@ fun insertTeam(db: WMDatabase, team: ReadableMap): Boolean {
}
}
fun insertMyTeam(db: WMDatabase, myTeam: ReadableMap): Boolean {
fun insertMyTeam(db: Database, myTeam: ReadableMap): Boolean {
val currentUserId = queryCurrentUserId(db) ?: return false
val id = try { myTeam.getString("id") } catch (e: NoSuchKeyException) { return false }
val roles = try { myTeam.getString("roles") } catch (e: NoSuchKeyException) { "" }

View file

@ -1,31 +1,15 @@
package com.mattermost.helpers.database_extension
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.RandomId
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import com.nozbe.watermelondb.mapCursor
import org.json.JSONObject
private fun getLastReplyAt(thread: ReadableMap): Double {
try {
var v = thread.getDouble("last_reply_at")
if (v == 0.0) {
val post = thread.getMap("post")
if (post != null) {
v = post.getDouble("create_at")
}
}
return v
} catch (e: NoSuchKeyException) {
return 0.0
}
}
internal fun insertThread(db: WMDatabase, thread: ReadableMap) {
internal fun insertThread(db: Database, thread: ReadableMap) {
// These fields are not present when we extract threads from posts
try {
val id = try { thread.getString("id") } catch (e: NoSuchKeyException) { return }
@ -33,7 +17,7 @@ internal fun insertThread(db: WMDatabase, thread: ReadableMap) {
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { 0 }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { 0 }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { 0 }
val lastReplyAt = getLastReplyAt(thread)
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
@ -52,7 +36,7 @@ internal fun insertThread(db: WMDatabase, thread: ReadableMap) {
}
}
internal fun updateThread(db: WMDatabase, thread: ReadableMap, existingRecord: ReadableMap) {
internal fun updateThread(db: Database, thread: ReadableMap, existingRecord: ReadableMap) {
try {
// These fields are not present when we extract threads from posts
val id = try { thread.getString("id") } catch (e: NoSuchKeyException) { return }
@ -60,7 +44,7 @@ internal fun updateThread(db: WMDatabase, thread: ReadableMap, existingRecord: R
val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { existingRecord.getDouble("last_viewed_at") }
val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_replies") }
val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_mentions") }
val lastReplyAt = getLastReplyAt(thread)
val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 }
val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 }
db.execute(
@ -79,28 +63,26 @@ internal fun updateThread(db: WMDatabase, thread: ReadableMap, existingRecord: R
}
}
internal fun insertThreadParticipants(db: WMDatabase, threadId: String, participants: ReadableArray) {
internal fun insertThreadParticipants(db: Database, threadId: String, participants: ReadableArray) {
for (i in 0 until participants.size()) {
try {
val participant = participants.getMap(i)
participant?.let {
val id = RandomId.generate()
db.execute(
"""
INSERT INTO ThreadParticipant
(id, thread_id, user_id, _changed, _status)
VALUES (?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, threadId, it.getString("id"))
)
}
val id = RandomId.generate()
db.execute(
"""
INSERT INTO ThreadParticipant
(id, thread_id, user_id, _changed, _status)
VALUES (?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(id, threadId, participant.getString("id"))
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun insertTeamThreadsSync(db: WMDatabase, teamId: String, earliest: Double, latest: Double) {
fun insertTeamThreadsSync(db: Database, teamId: String, earliest: Double, latest: Double) {
try {
val query = """
INSERT INTO TeamThreadsSync (id, _changed, _status, earliest, latest)
@ -112,7 +94,7 @@ fun insertTeamThreadsSync(db: WMDatabase, teamId: String, earliest: Double, late
}
}
fun updateTeamThreadsSync(db: WMDatabase, teamId: String, earliest: Double, latest: Double, existingRecord: ReadableMap) {
fun updateTeamThreadsSync(db: Database, teamId: String, earliest: Double, latest: Double, existingRecord: ReadableMap) {
try {
val storeEarliest = minOf(earliest, existingRecord.getDouble("earliest"))
val storeLatest = maxOf(latest, existingRecord.getDouble("latest"))
@ -123,7 +105,7 @@ fun updateTeamThreadsSync(db: WMDatabase, teamId: String, earliest: Double, late
}
}
fun syncParticipants(db: WMDatabase, thread: ReadableMap) {
fun syncParticipants(db: Database, thread: ReadableMap) {
try {
val threadId = thread.getString("id")
val participants = thread.getArray("participants")
@ -139,7 +121,7 @@ fun syncParticipants(db: WMDatabase, thread: ReadableMap) {
}
}
internal fun handlePostsInThread(db: WMDatabase, postsInThread: Map<String, List<JSONObject>>) {
internal fun handlePostsInThread(db: Database, postsInThread: Map<String, List<JSONObject>>) {
postsInThread.forEach { (key, list) ->
try {
val sorted = list.sortedBy { it.getDouble("create_at") }
@ -179,7 +161,7 @@ internal fun handlePostsInThread(db: WMDatabase, postsInThread: Map<String, List
}
}
fun handleThreads(db: WMDatabase, threads: ArrayList<ReadableMap>, teamId: String?) {
fun handleThreads(db: Database, threads: ArrayList<ReadableMap>, teamId: String?) {
val teamIds = ArrayList<String>()
if (teamId.isNullOrEmpty()) {
val myTeams = queryMyTeams(db)
@ -204,7 +186,7 @@ fun handleThreads(db: WMDatabase, threads: ArrayList<ReadableMap>, teamId: Strin
handleTeamThreadsSync(db, threads, teamIds)
}
fun handleThread(db: WMDatabase, thread: ReadableMap, teamIds: ArrayList<String>) {
fun handleThread(db: Database, thread: ReadableMap, teamIds: ArrayList<String>) {
// Insert/Update the thread
val threadId = thread.getString("id")
val isFollowing = thread.getBoolean("is_following")
@ -225,7 +207,7 @@ fun handleThread(db: WMDatabase, thread: ReadableMap, teamIds: ArrayList<String>
}
}
fun handleThreadInTeam(db: WMDatabase, thread: ReadableMap, teamId: String) {
fun handleThreadInTeam(db: Database, thread: ReadableMap, teamId: String) {
val threadId = thread.getString("id") ?: return
val existingRecord = findByColumns(
db,
@ -247,21 +229,10 @@ fun handleThreadInTeam(db: WMDatabase, thread: ReadableMap, teamId: String) {
}
}
fun handleTeamThreadsSync(db: WMDatabase, threadList: ArrayList<ReadableMap>, teamIds: ArrayList<String>) {
fun handleTeamThreadsSync(db: Database, threadList: ArrayList<ReadableMap>, teamIds: ArrayList<String>) {
val sortedList = threadList.filter{ it.getBoolean("is_following") }
.sortedBy {
var v = getLastReplyAt(it)
if (v == 0.0) {
Log.d("Database", "Trying to add a thread with no replies and no post")
}
v
}
.map {
getLastReplyAt(it)
}
if (sortedList.isEmpty()) {
return;
}
.sortedBy { it.getDouble("last_reply_at") }
.map { it.getDouble("last_reply_at") }
val earliest = sortedList.first()
val latest = sortedList.last()

View file

@ -4,9 +4,9 @@ import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.NoSuchKeyException
import com.facebook.react.bridge.ReadableArray
import com.mattermost.helpers.ReadableMapUtils
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
fun getLastPictureUpdate(db: WMDatabase?, userId: String): Double? {
fun getLastPictureUpdate(db: Database?, userId: String): Double? {
try {
if (db != null) {
var id = userId
@ -28,7 +28,7 @@ fun getLastPictureUpdate(db: WMDatabase?, userId: String): Double? {
return null
}
fun getCurrentUserLocale(db: WMDatabase): String {
fun getCurrentUserLocale(db: Database): String {
try {
val currentUserId = queryCurrentUserId(db) ?: return "en"
val userQuery = "SELECT locale FROM User WHERE id=?"
@ -45,43 +45,41 @@ fun getCurrentUserLocale(db: WMDatabase): String {
return "en"
}
fun handleUsers(db: WMDatabase, users: ReadableArray) {
fun handleUsers(db: Database, users: ReadableArray) {
for (i in 0 until users.size()) {
val user = users.getMap(i)
user?.let { u ->
val roles = u.getString("roles") ?: ""
val isBot = try {
u.getBoolean("is_bot")
} catch (e: NoSuchKeyException) {
false
}
val roles = user.getString("roles") ?: ""
val isBot = try {
user.getBoolean("is_bot")
} catch (e: NoSuchKeyException) {
false
}
val lastPictureUpdate = try { u.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 }
val lastPictureUpdate = try { user.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 }
try {
db.execute(
"""
INSERT INTO User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest,
last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props,
props, timezone, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
u.getString("id"),
u.getString("auth_service"), u.getDouble("update_at"), u.getDouble("delete_at"),
u.getString("email"), u.getString("first_name"), isBot,
roles.contains("system_guest"), u.getString("last_name"), lastPictureUpdate,
u.getString("locale"), u.getString("nickname"), u.getString("position"),
roles, "", u.getString("username"), "{}",
ReadableMapUtils.toJSONObject(u.getMap("props")
?: Arguments.createMap()).toString(),
ReadableMapUtils.toJSONObject(u.getMap("timezone")
?: Arguments.createMap()).toString(),
)
)
} catch (e: Exception) {
e.printStackTrace()
}
try {
db.execute(
"""
INSERT INTO User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest,
last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props,
props, timezone, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
user.getString("id"),
user.getString("auth_service"), user.getDouble("update_at"), user.getDouble("delete_at"),
user.getString("email"), user.getString("first_name"), isBot,
roles.contains("system_guest"), user.getString("last_name"), lastPictureUpdate,
user.getString("locale"), user.getString("nickname"), user.getString("position"),
roles, "", user.getString("username"), "{}",
ReadableMapUtils.toJSONObject(user.getMap("props")
?: Arguments.createMap()).toString(),
ReadableMapUtils.toJSONObject(user.getMap("timezone")
?: Arguments.createMap()).toString(),
)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View file

@ -7,9 +7,9 @@ import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.database_extension.findByColumns
import com.mattermost.helpers.database_extension.queryCurrentUserId
import com.mattermost.helpers.database_extension.queryMyTeams
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
suspend fun PushNotificationDataRunnable.Companion.fetchMyTeamCategories(db: WMDatabase, serverUrl: String, teamId: String): ReadableMap? {
suspend fun PushNotificationDataRunnable.Companion.fetchMyTeamCategories(db: Database, serverUrl: String, teamId: String): ReadableMap? {
return try {
val userId = queryCurrentUserId(db)
val categories = fetch(serverUrl, "/api/v4/users/$userId/teams/$teamId/channels/categories")
@ -20,7 +20,7 @@ suspend fun PushNotificationDataRunnable.Companion.fetchMyTeamCategories(db: WMD
}
}
fun PushNotificationDataRunnable.Companion.addToDefaultCategoryIfNeeded(db: WMDatabase, channel: ReadableMap): ReadableArray? {
fun PushNotificationDataRunnable.Companion.addToDefaultCategoryIfNeeded(db: Database, channel: ReadableMap): ReadableArray? {
val channelId = channel.getString("id") ?: return null
val channelType = channel.getString("type")
val categoryChannels = Arguments.createArray()
@ -44,7 +44,7 @@ fun PushNotificationDataRunnable.Companion.addToDefaultCategoryIfNeeded(db: WMDa
return categoryChannels
}
private fun categoryChannelForTeam(db: WMDatabase, channelId: String, teamId: String?, type: String): ReadableMap? {
private fun categoryChannelForTeam(db: Database, channelId: String, teamId: String?, type: String): ReadableMap? {
teamId?.let { id ->
val category = findByColumns(db, "Category", arrayOf("type", "team_id"), arrayOf(type, id))
val categoryId = category?.getString("id")

Some files were not shown because too many files have changed in this diff Show more