Compare commits

..

12 Commits

Author SHA1 Message Date
Daniel Espino García
15fd6925b3 Performance fixes and fix manual sort (#7190)
* Performance fixes and fix manual sort

* Fix test

* Use combineLatestWith

* Revert unread on top
2023-03-07 19:25:25 +01:00
Daniel Espino García
571070e284 Fix race condition when the same websocket gets initialized twice (#7185)
* Fix race condition when the same websocket gets initialized twice

* Bump network library
2023-03-07 19:13:19 +01:00
Elias Nahum
ab8a43032e Refactor category channels to react to setting changes and apply the correct order (#7170)
* Refactor category channels to react to setting changes and apply the correct order

* feedback review
2023-03-03 15:54:12 +02:00
Elias Nahum
6904be23da Fix push notification token registration race/missing (#7183) 2023-03-03 12:14:32 +02:00
Elias Nahum
6bc7c05ccb support WS connection over TLS1.3 (#7182)
* support WS connection over TLS1.3

* fix updateDraftMessage on unmount
2023-03-03 11:33:48 +02:00
Elias Nahum
4b142483a5 Fix display name when open own DM (#7181) 2023-03-02 16:58:31 +02:00
Elias Nahum
63674e2a43 fix entry for tablets (#7179) 2023-03-02 16:56:26 +02:00
Elias Nahum
cdaf1f50e7 use sourceScreen instead of location in post options (#7176) 2023-03-02 12:47:58 +02:00
Elias Nahum
10735dcbf1 trigger Search when hardware keyboard enter key is pressed (#7174) 2023-03-01 15:20:02 +02:00
Elias Nahum
619decd253 Fix potential reaction crash (#7172) 2023-03-01 15:19:55 +02:00
Elias Nahum
55f18bcfc3 ignore leading and trailing spaces when editing profile (#7173) 2023-03-01 15:19:47 +02:00
Elias Nahum
870336142a Fix iOS push notification when set as generic message with sender name (#7171) 2023-03-01 15:19:39 +02:00
2416 changed files with 96172 additions and 231282 deletions

617
.circleci/config.yml Normal file
View File

@@ -0,0 +1,617 @@
version: 2.1
orbs:
owasp: entur/owasp@0.0.10
node: circleci/node@5.0.3
executors:
android:
parameters:
resource_class:
default: xlarge
type: string
environment:
NODE_OPTIONS: --max_old_space_size=12000
NODE_ENV: production
BABEL_ENV: production
docker:
- image: cimg/android:2022.09.2-node
working_directory: ~/mattermost-mobile
resource_class: <<parameters.resource_class>>
ios:
parameters:
resource_class:
default: medium
type: string
environment:
NODE_OPTIONS: --max_old_space_size=12000
NODE_ENV: production
BABEL_ENV: production
macos:
xcode: "14.0.0"
working_directory: ~/mattermost-mobile
shell: /bin/bash --login -o pipefail
resource_class: <<parameters.resource_class>>
commands:
checkout-private:
description: "Checkout the private repo with build env vars"
steps:
- add_ssh_keys:
fingerprints:
- "03:1c:a7:07:35:bc:57:e4:1d:6c:e1:2c:4b:be:09:6d"
- run:
name: Clone the mobile private repo
command: git clone git@github.com:mattermost/mattermost-mobile-private.git ~/mattermost-mobile-private
fastlane-dependencies:
description: "Get Fastlane dependencies"
parameters:
for:
type: string
steps:
- restore_cache:
name: Restore Fastlane cache
key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }}
- run:
working_directory: fastlane
name: Download Fastlane dependencies
command: bundle install --path vendor/bundle
- save_cache:
name: Save Fastlane cache
key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }}
paths:
- fastlane/vendor/bundle
gradle-dependencies:
description: "Get Gradle dependencies"
steps:
- restore_cache:
name: Restore Gradle cache
key: v1-gradle-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }}
- run:
working_directory: android
name: Download Gradle dependencies
command: ./gradlew dependencies
- save_cache:
name: Save Gradle cache
paths:
- ~/.gradle
key: v1-gradle-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }}
assets:
description: "Generate app assets"
steps:
- restore_cache:
name: Restore assets cache
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
- run:
name: Generate assets
command: node ./scripts/generate-assets.js
- run:
name: Compass Icons
environment:
COMPASS_ICONS: "node_modules/@mattermost/compass-icons/font/compass-icons.ttf"
command: |
cp "$COMPASS_ICONS" "assets/fonts/"
cp "$COMPASS_ICONS" "android/app/src/main/assets/fonts"
- save_cache:
name: Save assets cache
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
paths:
- dist
npm-dependencies:
description: "Get JavaScript dependencies"
steps:
- node/install:
node-version: '18.7.0'
- restore_cache:
name: Restore npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
- run:
name: Getting JavaScript dependencies
command: |
NODE_ENV=development npm ci --ignore-scripts
node node_modules/\@sentry/cli/scripts/install.js
node node_modules/react-native-webrtc/tools/downloadWebRTC.js
- save_cache:
name: Save npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
paths:
- node_modules
- run:
name: "Patch dependencies"
command: npx patch-package
pods-dependencies:
description: "Get cocoapods dependencies"
steps:
- restore_cache:
name: Restore cocoapods specs and pods
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
- run:
name: iOS gems
command: npm run ios-gems
- run:
name: Getting cocoapods dependencies
command: npm run pod-install
- save_cache:
name: Save cocoapods specs and pods cache
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
paths:
- ios/Pods
- ~/.cocoapods
build-android:
description: "Build the android app"
steps:
- checkout:
path: ~/mattermost-mobile
- checkout-private
- npm-dependencies
- assets
- fastlane-dependencies:
for: android
- gradle-dependencies
- run:
name: Append Keystore to build Android
command: |
cp ~/mattermost-mobile-private/android/${STORE_FILE} android/app/${STORE_FILE}
echo "" | tee -a android/gradle.properties > /dev/null
echo MATTERMOST_RELEASE_STORE_FILE=${STORE_FILE} | tee -a android/gradle.properties > /dev/null
echo ${STORE_ALIAS} | tee -a android/gradle.properties > /dev/null
echo ${STORE_PASSWORD} | tee -a android/gradle.properties > /dev/null
- run:
name: Jetify android libraries
command: ./node_modules/.bin/jetify
- run:
working_directory: fastlane
name: Run fastlane to build android
no_output_timeout: 30m
command: export TERM=xterm && bundle exec fastlane android build
build-ios:
description: "Build the iOS app"
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build iOS
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
export TERM=xterm && bundle exec fastlane ios build
deploy-to-store:
description: "Deploy build to store"
parameters:
task:
type: string
target:
type: string
file:
type: string
env:
type: string
steps:
- attach_workspace:
at: ~/
- run:
name: <<parameters.task>>
working_directory: fastlane
command: <<parameters.env>> bundle exec fastlane <<parameters.target>> deploy file:$HOME/mattermost-mobile/<<parameters.file>>
persist:
description: "Persist mattermost-mobile directory"
steps:
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile*
save:
description: "Save binaries artifacts"
parameters:
filename:
type: string
steps:
- run:
name: Copying artifacts
command: |
mkdir /tmp/artifacts;
cp ~/mattermost-mobile/<<parameters.filename>> /tmp/artifacts;
- store_artifacts:
path: /tmp/artifacts
jobs:
test:
working_directory: ~/mattermost-mobile
docker:
- image: cimg/node:16.14.2
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- assets
- run:
name: Check styles
command: npm run check
- run:
name: Running Tests
command: npm test
- run:
name: Check i18n
command: ./scripts/precommit/i18n.sh
check-deps:
parameters:
cve_data_directory:
type: string
default: "~/.owasp/dependency-check-data"
working_directory: ~/mattermost-mobile
executor: owasp/default
environment:
version_url: "https://jeremylong.github.io/DependencyCheck/current.txt"
executable_url: "https://dl.bintray.com/jeremy-long/owasp/dependency-check-VERSION-release.zip"
steps:
- checkout
- restore_cache:
name: Restore npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
- run:
name: Checkout config
command: cd .. && git clone https://github.com/mattermost/security-automation-config
- run:
name: Install Go
command: sudo apt-get update && sudo apt-get install golang
- owasp/with_commandline:
steps:
# Taken from https://github.com/entur/owasp-orb/blob/master/src/%40orb.yml#L349-L361
- owasp/generate_cache_keys:
cache_key: commmandline-default-cache-key-v7
- owasp/restore_owasp_cache
- run:
name: Update OWASP Dependency-Check Database
command: |
if ! ~/.owasp/dependency-check/bin/dependency-check.sh --data << parameters.cve_data_directory >> --updateonly; then
# Update failed, probably due to a bad DB version; delete cached DB and try again
rm -rv ~/.owasp/dependency-check-data/*.db
~/.owasp/dependency-check/bin/dependency-check.sh --data << parameters.cve_data_directory >> --updateonly
fi
- owasp/store_owasp_cache:
cve_data_directory: <<parameters.cve_data_directory>>
- run:
name: Run OWASP Dependency-Check Analyzer
command: |
~/.owasp/dependency-check/bin/dependency-check.sh \
--data << parameters.cve_data_directory >> --format ALL --noupdate --enableExperimental \
--propertyfile ../security-automation-config/dependency-check/dependencycheck.properties \
--suppression ../security-automation-config/dependency-check/suppression.xml \
--suppression ../security-automation-config/dependency-check/suppression.$CIRCLE_PROJECT_REPONAME.xml \
--scan './**/*' || true
- owasp/collect_reports:
persist_to_workspace: false
- run:
name: Post results to Mattermost
command: go run ../security-automation-config/dependency-check/post_results.go
build-android-beta:
executor: android
steps:
- build-android
- persist
- save:
filename: "*.apk"
build-android-release:
executor: android
steps:
- build-android
- persist
- save:
filename: "*.apk"
build-android-pr:
executor: android
environment:
BRANCH_TO_BUILD: ${CIRCLE_BRANCH}
steps:
- build-android
- save:
filename: "*.apk"
build-android-unsigned:
executor: android
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- assets
- fastlane-dependencies:
for: android
- gradle-dependencies
- run:
name: Jetify Android libraries
command: ./node_modules/.bin/jetify
- run:
working_directory: fastlane
name: Run fastlane to build unsigned android
no_output_timeout: 30m
command: bundle exec fastlane android unsigned
- persist
- save:
filename: "*.apk"
build-ios-beta:
executor:
name: ios
resource_class: large
steps:
- build-ios
- persist
- save:
filename: "*.ipa"
build-ios-release:
executor: ios
steps:
- build-ios
- persist
- save:
filename: "*.ipa"
build-ios-pr:
executor: ios
environment:
BRANCH_TO_BUILD: ${CIRCLE_BRANCH}
steps:
- build-ios
- save:
filename: "*.ipa"
build-ios-unsigned:
executor: ios
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build unsigned iOS
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios unsigned
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile/*.ipa
- save:
filename: "*.ipa"
build-ios-simulator:
executor: ios
steps:
- checkout:
path: ~/mattermost-mobile
- npm-dependencies
- pods-dependencies
- assets
- fastlane-dependencies:
for: ios
- run:
working_directory: fastlane
name: Run fastlane to build unsigned x86_64 iOS app for iPhone simulator
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
bundle exec fastlane ios simulator
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile/Mattermost-simulator-x86_64.app.zip
- save:
filename: "Mattermost-simulator-x86_64.app.zip"
deploy-android-release:
executor:
name: android
resource_class: medium
steps:
- deploy-to-store:
task: "Deploy to Google Play"
target: android
file: "*.apk"
env: "SUPPLY_TRACK=beta"
deploy-android-beta:
executor:
name: android
resource_class: medium
steps:
- deploy-to-store:
task: "Deploy to Google Play"
target: android
file: "*.apk"
env: "SUPPLY_TRACK=alpha"
deploy-ios-release:
executor: ios
steps:
- deploy-to-store:
task: "Deploy to TestFlight"
target: ios
file: "*.ipa"
env: ""
deploy-ios-beta:
executor: ios
steps:
- deploy-to-store:
task: "Deploy to TestFlight"
target: ios
file: "*.ipa"
env: ""
github-release:
executor:
name: android
resource_class: medium
steps:
- attach_workspace:
at: ~/
- run:
name: Create GitHub release
working_directory: fastlane
command: bundle exec fastlane github
workflows:
version: 2
build:
jobs:
- test
# - check-deps:
# context: sast-webhook
# requires:
# - test
- build-android-release:
context: mattermost-mobile-android-release
requires:
- test
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-android-release-\d+$/
- deploy-android-release:
context: mattermost-mobile-android-release
requires:
- build-android-release
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-android-release-\d+$/
- build-android-beta:
context: mattermost-mobile-android-beta
filters:
branches:
only:
- /^build-\d+$/
- /^build-android-\d+$/
- /^build-android-beta-\d+$/
- deploy-android-beta:
context: mattermost-mobile-android-beta
requires:
- build-android-beta
filters:
branches:
only:
- /^build-\d+$/
- /^build-android-\d+$/
- /^build-android-beta-\d+$/
- build-ios-release:
context: mattermost-mobile-ios-release
requires:
- test
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-ios-release-\d+$/
- deploy-ios-release:
context: mattermost-mobile-ios-release
requires:
- build-ios-release
filters:
branches:
only:
- /^build-release-\d+$/
- /^build-ios-release-\d+$/
- build-ios-beta:
context: mattermost-mobile-ios-beta
filters:
branches:
only:
- /^build-\d+$/
- /^build-ios-\d+$/
- /^build-ios-beta-\d+$/
- deploy-ios-beta:
context: mattermost-mobile-ios-beta
requires:
- build-ios-beta
filters:
branches:
only:
- /^build-\d+$/
- /^build-ios-\d+$/
- /^build-ios-beta-\d+$/
- build-android-pr:
context: mattermost-mobile-android-pr
requires:
- test
filters:
branches:
only: /^(build|android)-pr-.*/
- build-ios-pr:
context: mattermost-mobile-ios-pr
requires:
- test
filters:
branches:
only: /^(build|ios)-pr-.*/
- build-android-unsigned:
context: mattermost-mobile-unsigned
requires:
- test
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned
- build-ios-unsigned:
context: mattermost-mobile-unsigned
requires:
- test
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned
- build-ios-simulator:
context: mattermost-mobile-unsigned
requires:
- test
filters:
branches:
only:
- /^build-\d+$/
- /^build-ios-sim-\d+$/
- github-release:
context: mattermost-mobile-unsigned
requires:
- build-android-unsigned
- build-ios-unsigned
filters:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned

View File

@@ -1,94 +0,0 @@
kind: pipeline
name: default
steps:
- name: private assets
image: alpine/git
settings:
username:
from_secret: repo_user
password:
from_secret: repo_pass
commands:
- chmod -R 777 .
- git clone https://$$PLUGIN_USERNAME:$$PLUGIN_PASSWORD@git.ivasoft.cz/Ivasoft/mattermost-mobile-private.git ./mattermost-mobile-private
- name: build-android-release_exprojekt
image: git.ivasoft.cz/sw/github-actions
settings:
#action_image: catthehacker/ubuntu:act-22.04 # based on ubuntu:22.04 as required by ruby installer
action_image: mingc/android-build-box:1.25.0
daemon_off: true
uses: ./.drone/build-android-release
with:
company: exprojekt
environment:
STORE_PASSWORD_INPUT:
from_secret: android_keystore_pass
AWS_ACCESS_KEY_ID:
from_secret: minio_build_access_key
AWS_SECRET_ACCESS_KEY:
from_secret: minio_build_secret_key
when:
target:
- production
- android
- name: build-ios-release_exprojekt
image: git.ivasoft.cz/sw/github-actions
settings:
action_image: osx:act
no_force_pull: true
daemon_off: true
kvm: true
#verbose: true
uses: ./.drone/build-ios-release
with:
company: exprojekt
teamId: SX6ZHT7MDU
environment:
AWS_ACCESS_KEY_ID:
from_secret: minio_build_access_key
AWS_SECRET_ACCESS_KEY:
from_secret: minio_build_secret_key
APPSTORE_APP_PASSWORD:
from_secret: appstore_app_password
when:
target:
- production
- ios
- name: build-ios-beta_exprojekt
image: git.ivasoft.cz/sw/github-actions
settings:
action_image: osx:act
no_force_pull: true
daemon_off: true
kvm: true
#verbose: true
uses: ./.drone/build-ios-beta
with:
company: exprojekt
teamId: SX6ZHT7MDU
environment:
AWS_ACCESS_KEY_ID:
from_secret: minio_build_access_key
AWS_SECRET_ACCESS_KEY:
from_secret: minio_build_secret_key
APPSTORE_APP_PASSWORD:
from_secret: appstore_app_password
when:
target:
- beta
- name: gitea_release
image: plugins/gitea-release
settings:
api_key:
from_secret: drone_release
base_url: https://git.ivasoft.cz
files:
- 'Mattermost.apk'
- '*.ipa'
when:
event: tag

View File

@@ -1,53 +0,0 @@
name: droid-build-android-release
description: Equivalent to build-android-release workflow on GitHub
inputs:
company:
description: Company whose release to release
default: "exprojekt"
title:
description: Company whose release to release
default: "EXprojekt Team"
#store-password:
# description: Password for the keystore
runs:
using: composite
steps:
- name: ci/prepare-android-company-overrides
env:
COMPANY_SOURCE_PATH: "mattermost-mobile-private/android/${{ inputs.company }}"
run: |
mkdir -p assets/override
cp -R -f ${COMPANY_SOURCE_PATH}/assets/* assets/override/
cp -f ${COMPANY_SOURCE_PATH}/android/app/google-services.json android/app/
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
env:
STORE_FILE: "${{ inputs.company }}.keystore"
STORE_ALIAS: "MATTERMOST_RELEASE_KEY_ALIAS=mattermost-google-key"
STORE_PASSWORD: "MATTERMOST_RELEASE_PASSWORD=${{ env.STORE_PASSWORD_INPUT }}"
- name: ci/prepare-android-build-fix
run: |
sed -i -E "s:^(SENTRY_ENABLED\=)(.*)$:\1false:" fastlane/.env.android.release
sed -i -E "s:^(APP_NAME\=)(.*)$:\1${{ inputs.title }}:" fastlane/.env.android.release
sed -i -E "s:^(APP_SCHEME\=)(.*)$:\1mm\-${{ inputs.company }}:" fastlane/.env.android.release
sed -i -E "s:^(MAIN_APP_IDENTIFIER\=)(.*)$:\1cz.${{ inputs.company }}.team:" fastlane/.env.android.release
sed -i -E "s:^(SUPPLY_PACKAGE_NAME\=)(.*)$:\1cz.${{ inputs.company }}.team:" fastlane/.env.android.release
sed -i -E "s:^(org\.gradle\.jvmargs\=)(.*)(\-Xmx)([[:digit:]]+m)(.*)(\-XX\:MaxMetaspaceSize\=)([[:digit:]]+m)(.*)$:\1\2\34096m\5\61024m\8:" android/gradle.properties
sed -i -E "s:^([[:space:]]*)(.*)(Aws\:\:S3\:\:Resource\.new)(.*)$:\1Aws.config.update\(endpoint\:'http\://storage.cluster\:81',force_path_style\:true\)\n\1\2\3\4:" fastlane/Fastfile
- name: ci/build-and-deploy-android-release
env:
AWS_ACCESS_KEY_ID: "${{ env.AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ env.AWS_SECRET_ACCESS_KEY }}"
run: |
echo "::group::Build"
bundle exec fastlane android build --env android.release
echo "::endgroup::"
echo "::group::Deploy to Play Store"
bundle exec fastlane android deploy file:"${{ github.workspace }}/*.apk" --env android.release
echo "::endgroup::"
working-directory: ./fastlane

View File

@@ -1,91 +0,0 @@
name: droid-build-ios-beta
description: Equivalent to build-ios-beta workflow on GitHub
inputs:
company:
description: Company whose beta to release
default: "exprojekt"
title:
description: App title whose beta to release
default: "EXprojekt Team"
teamId:
description: App Store Team ID
runs:
using: composite
steps:
- name: ci/create-keychain-beta
uses: ./.drone/create-keychain
with:
path: /tmp/beta-${{ inputs.company }}-keychain
- name: ci/add-certs
env:
KEYCHAIN_PATH: /tmp/beta-${{ inputs.company }}-keychain
shell: bash
run: |
security import ${{ github.workspace }}/mattermost-mobile-private/ios/AppleWWDRCAG3.cer -k $KEYCHAIN_PATH
security import ${{ github.workspace }}/mattermost-mobile-private/ios/AppleWWDRCAG4.cer -k $KEYCHAIN_PATH
security import ${{ github.workspace }}/mattermost-mobile-private/ios/${{ inputs.company }}/dev.cer -k $KEYCHAIN_PATH
security unlock-keychain -p a $KEYCHAIN_PATH
security import ${{ github.workspace }}/mattermost-mobile-private/ios/${{ inputs.company }}/dev.key -k $KEYCHAIN_PATH -A -T /usr/bin/codesign -T /usr/bin/security
security import ${{ github.workspace }}/mattermost-mobile-private/ios/${{ inputs.company }}/notify.cer -k $KEYCHAIN_PATH
security import ${{ github.workspace }}/mattermost-mobile-private/ios/${{ inputs.company }}/notify.key -k $KEYCHAIN_PATH -A -T /usr/bin/codesign -T /usr/bin/security
- name: ci/complete-keychain-beta
uses: ./.drone/complete-keychain
with:
path: /tmp/beta-${{ inputs.company }}-keychain
- name: ci/profile-app-beta
uses: ./.drone/install-ios-profile
with:
company: ${{ inputs.company }}
name: TeamCechTest.mobileprovision
- name: ci/profile-app-notify-beta
uses: ./.drone/install-ios-profile
with:
company: ${{ inputs.company }}
name: TeamNotifyCechTest.mobileprovision
- name: ci/profile-app-share-beta
uses: ./.drone/install-ios-profile
with:
company: ${{ inputs.company }}
name: TeamShareCechTest.mobileprovision
- name: ci/prepare-ios-beta-fix
uses: ./.drone/fix-ios-build
with:
company: ${{ inputs.company }}
title: ${{ inputs.title }}
envPath: fastlane/.env.ios.beta
provisioningProfileTeam: TeamCechTest
provisioningProfileNotify: TeamNotifyCechTest
provisioningProfileShare: TeamShareCechTest
- name: ci/prepare-ios-build-beta
env:
LC_ALL: en_US.UTF-8
uses: ./.github/actions/prepare-ios-build
- name: ci/build-and-deploy-ios-beta
env:
AWS_ACCESS_KEY_ID: "${{ env.AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ env.AWS_SECRET_ACCESS_KEY }}"
APPSTORE_APP_PASSWORD: "${{ env.APPSTORE_APP_PASSWORD }}"
FASTLANE_TEAM_ID: "${{ inputs.teamId }}"
# Assign provisioning profiles in Xcode project
sigh_cz.exprojekt.team_appstore_profile-path: mattermost-mobile-private/ios/${{ inputs.company }}/TeamCechTest.mobileprovision
sigh_cz.exprojekt.team.NotificationService_appstore_profile-path: mattermost-mobile-private/ios/${{ inputs.company }}/TeamNotifyCechTest.mobileprovision
sigh_cz.exprojekt.team.MattermostShare_appstore_profile-path: mattermost-mobile-private/ios/${{ inputs.company }}/TeamShareCechTest.mobileprovision
LC_ALL: en_US.UTF-8
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
KEYCHAIN_PATH: /tmp/beta-${{ inputs.company }}-keychain
run: |
echo "Unlocking keychain"
security unlock-keychain -p a $KEYCHAIN_PATH
echo "::group::Build"
bundle exec fastlane ios build --env ios.beta
echo "::endgroup::"
echo "::group::Deploy to TestFlight"
echo xcrun altool --upload-app -f "${{ github.workspace }}/EXprojekt_Team.ipa" -t ios -u apple@exprojekt.cz -p "$APPSTORE_APP_PASSWORD"
echo "::endgroup::"
working-directory: ./fastlane

View File

@@ -1,115 +0,0 @@
name: droid-build-ios-release
description: Equivalent to build-ios-release workflow on GitHub
inputs:
company:
description: Company whose release to release
default: "exprojekt"
title:
description: App title whose release to release
default: "EXprojekt Team"
teamId:
description: App Store Team ID
runs:
using: composite
steps:
- name: ci/prepare-ios-company-overrides
env:
COMPANY_SOURCE_PATH: "mattermost-mobile-private/ios/${{ inputs.company }}"
run: |
mkdir -p assets/override
cp -R -f ${COMPANY_SOURCE_PATH}/assets/* assets/override/
- name: ci/create-keychain-release
uses: ./.drone/create-keychain
with:
path: /tmp/release-${{ inputs.company }}-keychain
- name: ci/add-certs
env:
KEYCHAIN_PATH: /tmp/release-${{ inputs.company }}-keychain
shell: bash
run: |
security import ${{ github.workspace }}/mattermost-mobile-private/ios/AppleWWDRCAG3.cer -k $KEYCHAIN_PATH
security import ${{ github.workspace }}/mattermost-mobile-private/ios/AppleWWDRCAG4.cer -k $KEYCHAIN_PATH
security import ${{ github.workspace }}/mattermost-mobile-private/ios/${{ inputs.company }}/distr.cer -k $KEYCHAIN_PATH
security unlock-keychain -p a $KEYCHAIN_PATH
security import ${{ github.workspace }}/mattermost-mobile-private/ios/${{ inputs.company }}/distr.key -k $KEYCHAIN_PATH -A -T /usr/bin/codesign -T /usr/bin/security
security import ${{ github.workspace }}/mattermost-mobile-private/ios/${{ inputs.company }}/notify.cer -k $KEYCHAIN_PATH
security import ${{ github.workspace }}/mattermost-mobile-private/ios/${{ inputs.company }}/notify.key -k $KEYCHAIN_PATH -A -T /usr/bin/codesign -T /usr/bin/security
- name: ci/complete-keychain-release
uses: ./.drone/complete-keychain
with:
path: /tmp/release-${{ inputs.company }}-keychain
- name: ci/profile-app-release
uses: ./.drone/install-ios-profile
with:
company: ${{ inputs.company }}
name: TeamAppStore.mobileprovision
- name: ci/profile-app-notify-release
uses: ./.drone/install-ios-profile
with:
company: ${{ inputs.company }}
name: TeamNotifyAppStore.mobileprovision
- name: ci/profile-app-share-release
uses: ./.drone/install-ios-profile
with:
company: ${{ inputs.company }}
name: TeamShareAppStore.mobileprovision
- name: ci/prepare-ios-release-fix
uses: ./.drone/fix-ios-build
with:
company: ${{ inputs.company }}
title: ${{ inputs.title }}
envPath: fastlane/.env.ios.release
provisioningProfileTeam: TeamAppStore
provisioningProfileNotify: TeamNotifyAppStore
provisioningProfileShare: TeamShareAppStore
#- name: ci/set-build-number-ios-release
# run: |
# echo -e "\nBUILD_NUMBER=519\nINCREMENT_BUILD_NUMBER=true" >> fastlane/.env.ios.release
# HACK: build numbers are broken change it manually after build
# 1. extract .ipa (ie. using peazip)
# 2. codesign -d --entitlements Entitlements.plist EXprojekt_Team/Payload/Mattermost.app
# 3. plutil -replace CFBundleVersion -string 519 EXprojekt_Team/Payload/Mattermost.app/Info.plist
# 4. delete EXprojekt_Team/Payload/Mattermost.app/_CodeSignature
# 5. security import mattermost-mobile-private/ios/exprojekt/distr.pem
# 6. security import mattermost-mobile-private/ios/exprojekt/distr.key
# 7. security find-identity (and determine id of the Apple Distribution certificate)
# 8. codesign -s <id from previous step> -f --entitlements Entitlements.plist EXprojekt_Team/Payload/Mattermost.app
# 9. re-zip the directory to .ipa
- name: ci/prepare-ios-build-release
env:
LC_ALL: en_US.UTF-8
uses: ./.github/actions/prepare-ios-build
- name: ci/build-and-deploy-ios-release
env:
AWS_ACCESS_KEY_ID: "${{ env.AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ env.AWS_SECRET_ACCESS_KEY }}"
APPSTORE_APP_PASSWORD: "${{ env.APPSTORE_APP_PASSWORD }}"
FASTLANE_TEAM_ID: "${{ inputs.teamId }}"
# Assign provisioning profiles in Xcode project
sigh_cz.exprojekt.team_appstore_profile-path: mattermost-mobile-private/ios/${{ inputs.company }}/TeamAppStore.mobileprovision
sigh_cz.exprojekt.team.NotificationService_appstore_profile-path: mattermost-mobile-private/ios/${{ inputs.company }}/TeamNotifyAppStore.mobileprovision
sigh_cz.exprojekt.team.MattermostShare_appstore_profile-path: mattermost-mobile-private/ios/${{ inputs.company }}/TeamShareAppStore.mobileprovision
LC_ALL: en_US.UTF-8
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
KEYCHAIN_PATH: /tmp/release-${{ inputs.company }}-keychain
run: |
echo "Unlocking keychain"
security unlock-keychain -p a $KEYCHAIN_PATH
echo "Changing build number"
echo -e "\nBUILD_NUMBER=666\nINCREMENT_BUILD_NUMBER=true" >> .env.ios.release
bundle exec fastlane set_app_build_number --env ios.release
echo "::group::Build"
bundle exec fastlane ios build --env ios.release
echo "::endgroup::"
echo "::group::Deploy to TestFlight"
echo xcrun altool --upload-app -f "${{ github.workspace }}/EXprojekt_Team.ipa" -t ios -u apple@exprojekt.cz -p "$APPSTORE_APP_PASSWORD"
echo "::endgroup::"
working-directory: ./fastlane

View File

@@ -1,16 +0,0 @@
name: droid-complete-keychain
description: Completes preparation of a keychain for signing certificates
inputs:
path:
description: Path to keychain to complete
runs:
using: composite
steps:
- name: ci/complete-keychain
shell: bash
run: |
security set-key-partition-list -S apple-tool:,apple: -k a ${{ inputs.path }}
security list-keychain -d user -s ${{ inputs.path }}

View File

@@ -1,17 +0,0 @@
name: droid-create-keychain
description: Prepares a keychain for signing certificates
inputs:
path:
description: Path to keychain to create
runs:
using: composite
steps:
- name: ci/create-keychain
shell: bash
run: |
security create-keychain -p a ${{ inputs.path }}
security set-keychain-settings -lut 21600 ${{ inputs.path }}
security unlock-keychain -p a ${{ inputs.path }}

View File

@@ -1,42 +0,0 @@
name: droid-fix-ios-build
description: Makex fixes to the build environment
inputs:
company:
description: Company whose build to fix
title:
description: Company whose release to release
envPath:
description: Path to the fastlane environment file
provisioningProfileTeam:
description: Name of provisioning profile for the base app
provisioningProfileNotify:
description: Name of provisioning profile for the notification
provisioningProfileShare:
description: Name of provisioning profile for the sharing
runs:
using: composite
steps:
- name: ci/fix-ios-build
shell: bash
# WARNING: we are using BSD sed here and that requires -i ''
run: |
sudo mkdir /Users/runner
sudo chown mac /Users/runner
sudo mkdir /opt/hostedtoolcache
sudo chown mac /opt/hostedtoolcache
sed -i '' -E "s:^(SENTRY_ENABLED\=)(.*)$:\1false:" "${{ inputs.envPath }}"
sed -i '' -E "s:^(APP_NAME\=)(.*)$:\1${{ inputs.title }}:" "${{ inputs.envPath }}"
sed -i '' -E "s:^(APP_SCHEME\=)(.*)$:\1mm\-${{ inputs.company }}:" "${{ inputs.envPath }}"
sed -i '' -E "s:^(FASTLANE_TEAM_ID\=)(.*)$:\1:" "${{ inputs.envPath }}"
sed -i '' -E "s:^(MAIN_APP_IDENTIFIER\=)(.*)$:\1cz.${{ inputs.company }}.team:" "${{ inputs.envPath }}"
sed -i '' -E "s:^(NOTIFICATION_SERVICE_IDENTIFIER\=)(.*)$:\1cz.${{ inputs.company }}.team.NotificationService:" "${{ inputs.envPath }}"
sed -i '' -E "s:^(EXTENSION_APP_IDENTIFIER\=)(.*)$:\1cz.${{ inputs.company }}.team.MattermostShare:" "${{ inputs.envPath }}"
sed -i '' -E "s:^(IOS_APP_GROUP\=)(.*)$:\1group.cz.${{ inputs.company }}.team:" "${{ inputs.envPath }}"
sed -i '' -E "s:^(IOS_ICLOUD_CONTAINER\=)(.*)$:\1iCloud.cz.${{ inputs.company }}.team:" "${{ inputs.envPath }}"
sed -i '' -E "s:^(MATCH_APP_IDENTIFIER\=)(.*)$:\1cz.${{ inputs.company }}.team,cz.${{ inputs.company }}.team.NotificationService,cz.${{ inputs.company }}.team.MattermostShare:" "${{ inputs.envPath }}"
sed -i '' -E "s:^([[:space:]]*)(.*)(Aws\:\:S3\:\:Resource\.new)(.*)$:\1Aws.config.update\(endpoint\:'http\://storage.cluster\:81',force_path_style\:true\)\n\1\2\3\4:" fastlane/Fastfile
sed -i '' -E "s:^([[:space:]]*export_options\:[[:space:]]*\{)(.*)$:\1provisioningProfiles\:\{'cz.${{ inputs.company }}.team'\:'${{ inputs.provisioningProfileTeam }}','cz.${{ inputs.company }}.team.NotificationService'\:'${{ inputs.provisioningProfileNotify }}','cz.${{ inputs.company }}.team.MattermostShare'\:'${{ inputs.provisioningProfileShare }}'\},\2:" fastlane/Fastfile
echo Hack 'detected dubious ownership in repository' while aplying patches
HOME=/Users/mac git config --global --add safe.directory /Users/mac/drone/src

View File

@@ -1,26 +0,0 @@
name: droid-install-ios-profile
description: Installs a provisioning profile
inputs:
company:
description: Company whose profile to install
default: "exprojekt"
name:
description: Profile file name
runs:
using: composite
steps:
- name: ci/install-profile
shell: bash
env:
#PROFILES_PATH: "/private/var/root/Library/MobileDevice/Provisioning Profiles"
PROFILES_PATH: "/Users/mac/Library/MobileDevice/Provisioning Profiles"
run: |
mkdir -p "$PROFILES_PATH"
srcPath=${{ github.workspace }}/mattermost-mobile-private/ios/${{ inputs.company }}/${{ inputs.name }}
uuid=`grep UUID -A1 -a "$srcPath" | grep -io "[-A-F0-9]\{36\}"`
name=${{ inputs.name }}
extension="${name##*.}"
echo Installing provisioning profile $name -> $uuid
cp "$srcPath" "$PROFILES_PATH/${uuid}.${extension}"

114
.eslintrc.json Normal file
View File

@@ -0,0 +1,114 @@
{
"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,
"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

@@ -1,46 +0,0 @@
name: prepare-android-build
description: Action to prepare environment for android build
inputs:
sign:
description: Flag to enable android package signing
default: "true"
runs:
using: composite
steps:
- name: ci/prepare-mobile-build
uses: ./.github/actions/prepare-mobile-build
- name: ci/setup-java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: ci/jetify-android-libraries
shell: bash
run: |
echo "::group::jetify-android-libraries"
./node_modules/.bin/jetify
echo "::endgroup::"
- name: ci/checkout-private-repo
if: ${{ inputs.sign == 'neverMatches' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: mattermost/mattermost-mobile-private
token: ${{ env.MATTERMOST_BUILD_GH_TOKEN }}
path: ${{ github.workspace }}/mattermost-mobile-private
- name: ci/append-keystore-to-android-build-for-signing
if: ${{ inputs.sign == 'true' }}
shell: bash
run: |
echo "::group::append-keystore-to-android-build-for-signing"
cp ${{ github.workspace }}/mattermost-mobile-private/android/${STORE_FILE} android/app/${STORE_FILE}
echo "" | tee -a android/gradle.properties > /dev/null
echo MATTERMOST_RELEASE_STORE_FILE=${STORE_FILE} | tee -a android/gradle.properties > /dev/null
echo ${STORE_ALIAS} | tee -a android/gradle.properties > /dev/null
echo ${STORE_PASSWORD} | tee -a android/gradle.properties > /dev/null
echo "::endgroup::"

View File

@@ -1,29 +0,0 @@
name: prepare-ios-build
description: Action to prepare environment for ios build
runs:
using: composite
steps:
- name: ci/setup-xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: latest-stable
- name: ci/prepare-mobile-build
uses: ./.github/actions/prepare-mobile-build
- name: ci/install-pods-dependencies
shell: bash
run: |
echo "::group::install-pods-dependencies"
npm run ios-gems
npm run pod-install
echo "::endgroup::"
- name: Cache Pods
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: Pods
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-

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

@@ -1,27 +0,0 @@
name: prepare-mobile-build
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
- name: ci/setup-fastlane-dependencies
shell: bash
run: |
echo "::group::setup-fastlane-dependencies"
bundle install
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

@@ -1,54 +0,0 @@
name: deps
description: Common deps for mobile repo
runs:
using: composite
steps:
- name: ci/setup-node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: ".nvmrc"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/install-npm-dependencies
shell: bash
env:
NODE_ENV: development
run: |
echo "::group::install-npm-dependencies"
npm ci --ignore-scripts
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: |
echo "::group::patch-npm-dependencies"
npx patch-package
echo "::endgroup::"
- name: ci/generate-assets
shell: bash
run: |
echo "::group::generate-assets"
node ./scripts/generate-assets.js
echo "::endgroup::"
- name: ci/import-compass-icon
shell: bash
env:
COMPASS_ICONS: "node_modules/@mattermost/compass-icons/font/compass-icons.ttf"
run: |
echo "::group::import-compass-icon"
cp "$COMPASS_ICONS" "assets/fonts/"
cp "$COMPASS_ICONS" "android/app/src/main/assets/fonts"
echo "::endgroup::"

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

@@ -1,31 +0,0 @@
name: test
description: Common tests for mobile repo
runs:
using: composite
steps:
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
- name: ci/check-styles
shell: bash
run: |
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
echo "::endgroup::"
- name: ci/check-i18n
shell: bash
run: |
echo "::group::check-i18n"
./scripts/precommit/i18n.sh
echo "::endgroup::"

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

@@ -1,60 +0,0 @@
---
name: build-android-beta
on:
push:
branches:
- build-beta-[0-9]+
- build-beta-android-[0-9]+
env:
NODE_VERSION: 22.14.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/test
uses: ./.github/actions/test
build-and-deploy-android-beta:
runs-on: ubuntu-22.04
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/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: ci/build-and-deploy-android-beta
env:
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
SENTRY_AUTH_TOKEN: "${{ secrets.MM_MOBILE_SENTRY_AUTH_TOKEN }}"
SENTRY_DSN_ANDROID: ${{ secrets.MM_MOBILE_BETA_SENTRY_DSN_ANDROID }}
SUPPLY_JSON_KEY: ${{ github.workspace }}/mattermost-mobile-private/android/mattermost-credentials.json
run: |
echo "::group::Build"
bundle exec fastlane android build --env android.beta
echo "::endgroup::"
echo "::group::Deploy to Play Store"
bundle exec fastlane android deploy file:"${{ github.workspace }}/*.apk" --env android.beta
echo "::endgroup::"
working-directory: ./fastlane
- name: ci/upload-android-beta-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-build-beta-${{ github.run_id }}
path: "*.apk"

View File

@@ -1,60 +0,0 @@
---
name: build-android-release
on:
push:
branches:
- build-release-[0-9]+
- build-release-android-[0-9]+
env:
NODE_VERSION: 22.14.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/test
uses: ./.github/actions/test
build-and-deploy-android-release:
runs-on: ubuntu-22.04
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/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: ci/build-and-deploy-android-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 }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_RELEASE_MATTERMOST_WEBHOOK_URL }}"
SENTRY_AUTH_TOKEN: "${{ secrets.MM_MOBILE_SENTRY_AUTH_TOKEN }}"
SENTRY_DSN_ANDROID: ${{ secrets.MM_MOBILE_RELEASE_SENTRY_DSN_ANDROID }}
SUPPLY_JSON_KEY: ${{ github.workspace }}/mattermost-mobile-private/android/mattermost-credentials.json
run: |
echo "::group::Build"
bundle exec fastlane android build --env android.release
echo "::endgroup::"
echo "::group::Deploy to Play Store"
bundle exec fastlane android deploy file:"${{ github.workspace }}/*.apk" --env android.release
echo "::endgroup::"
working-directory: ./fastlane
- name: ci/upload-android-release-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-build-release-${{ github.run_id }}
path: "*.apk"

View File

@@ -1,99 +0,0 @@
---
name: build-ios-beta
on:
push:
branches:
- build-beta-[0-9]+
- build-beta-ios-[0-9]+
- build-beta-sim-[0-9]+
env:
NODE_VERSION: 22.14.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/test
uses: ./.github/actions/test
build-ios-simulator:
runs-on: macos-14-large
if: ${{ !contains(github.ref_name, 'beta-ios') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- name: ci/build-ios-simulator
env:
TAG: "${{ github.ref_name }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios simulator --env ios.simulator
working-directory: ./fastlane
- name: ci/upload-ios-pr-simulator
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
build-and-deploy-ios-beta:
runs-on: macos-14-large
if: ${{ !contains(github.ref_name, 'beta-sim') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- 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
- name: ci/build-and-deploy-ios-beta
env:
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
IOS_API_KEY: "${{ secrets.MM_MOBILE_IOS_API_KEY }}"
IOS_API_KEY_ID: "${{ secrets.MM_MOBILE_IOS_API_KEY_ID }}"
MATCH_GIT_URL: "${{ secrets.MM_MOBILE_MATCH_GIT_URL }}"
MATCH_PASSWORD: "${{ secrets.MM_MOBILE_MATCH_PASSWORD }}"
SENTRY_AUTH_TOKEN: "${{ secrets.MM_MOBILE_SENTRY_AUTH_TOKEN }}"
SENTRY_DSN_IOS: "${{ secrets.MM_MOBILE_BETA_SENTRY_DSN_IOS }}"
run: |
echo "::group::Build"
bundle exec fastlane ios build --env ios.beta
echo "::endgroup::"
echo "::group::Deploy to TestFlight"
bundle exec fastlane ios deploy file:"${{ github.workspace }}/*.ipa" --env ios.beta
echo "::endgroup::"
working-directory: ./fastlane
- name: ci/upload-ios-beta-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-beta-${{ github.run_id }}
path: "*.ipa"

View File

@@ -1,99 +0,0 @@
---
name: build-ios-release
on:
push:
branches:
- build-release-[0-9]+
- build-release-ios-[0-9]+
- build-release-sim-[0-9]+
env:
NODE_VERSION: 22.14.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/test
uses: ./.github/actions/test
build-and-deploy-ios-release:
runs-on: macos-14-large
if: ${{ !contains(github.ref_name, 'release-sim') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- 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 }}"
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 }}"
IOS_API_KEY: "${{ secrets.MM_MOBILE_IOS_API_KEY }}"
IOS_API_KEY_ID: "${{ secrets.MM_MOBILE_IOS_API_KEY_ID }}"
MATCH_GIT_URL: "${{ secrets.MM_MOBILE_MATCH_GIT_URL }}"
MATCH_PASSWORD: "${{ secrets.MM_MOBILE_MATCH_PASSWORD }}"
SENTRY_AUTH_TOKEN: "${{ secrets.MM_MOBILE_SENTRY_AUTH_TOKEN }}"
SENTRY_DSN_IOS: ${{ secrets.MM_MOBILE_RELEASE_SENTRY_DSN_IOS }}
run: |
echo "::group::Build"
bundle exec fastlane ios build --env ios.release
echo "::endgroup::"
echo "::group::Deploy to TestFlight"
bundle exec fastlane ios deploy file:"${{ github.workspace }}/*.ipa" --env ios.release
echo "::endgroup::"
working-directory: ./fastlane
- name: ci/upload-ios-release-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-release-${{ github.run_id }}
path: "*.ipa"
build-ios-simulator:
runs-on: macos-14-large
if: ${{ !contains(github.ref_name , 'release-ios') }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- name: ci/build-ios-simulator
env:
TAG: "${{ github.ref_name }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios simulator --env ios.simulator
working-directory: ./fastlane
- name: ci/upload-ios-pr-simulator
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip

View File

@@ -1,100 +0,0 @@
---
name: build-pr
on:
pull_request:
types:
- labeled
env:
NODE_VERSION: 22.14.0
TERM: xterm
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 }}
- name: ci/test
uses: ./.github/actions/test
build-ios-pr:
runs-on: macos-14-large
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- 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 }}"
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 }}"
IOS_API_ISSUER_ID: "${{ secrets.MM_MOBILE_IOS_API_ISSUER_ID }}"
IOS_API_KEY: "${{ secrets.MM_MOBILE_IOS_API_KEY }}"
IOS_API_KEY_ID: "${{ secrets.MM_MOBILE_IOS_API_KEY_ID }}"
MATCH_GIT_URL: "${{ secrets.MM_MOBILE_MATCH_GIT_URL }}"
MATCH_PASSWORD: "${{ secrets.MM_MOBILE_MATCH_PASSWORD }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_PR_MATTERMOST_WEBHOOK_URL }}"
run: bundle exec fastlane ios build --env ios.pr
working-directory: ./fastlane
- name: ci/upload-ios-pr-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.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' }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/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: ci/build-android-pr
env:
BRANCH_TO_BUILD: "${{ github.event.pull_request.head.ref }}"
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 }}"
run: bundle exec fastlane android build --env android.pr
working-directory: ./fastlane
- name: ci/upload-android-pr-build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-build-pr-${{ github.run_id }}
path: "*.apk"

View File

@@ -1,29 +0,0 @@
---
name: ci
on:
push:
branches:
- main
- 'release*'
pull_request:
permissions:
pull-requests: write
env:
NODE_VERSION: 22.14.0
TERM: xterm
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- 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: android-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ 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,217 +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: Detox E2E Tests PR
on:
pull_request:
branches:
- main
- feature_schedule_posts
types:
- labeled
concurrency:
group: "${{ github.workflow }}-${{ github.event.pull_request.number }}-${{ github.event.label.name }}"
cancel-in-progress: 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-14
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
- name: Build iOS Simulator
env:
TAG: "${{ github.event.pull_request.head.sha }}"
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
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 Mobile Tests on PR
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 Mobile Tests on PR
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,164 +0,0 @@
name: Detox E2E Tests Release
on:
push:
branches:
- release-*
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-14
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
- 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,163 +0,0 @@
name: Detox E2E Tests (Scheduled)
on:
schedule:
- cron: "0 0 * * 4,5" # Wednesday and Thursday midnight
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-14
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
- 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,342 +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 15 Pro"
ios_device_os_name:
description: "iPhone simulator OS version"
required: false
type: string
default: "iOS 17.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: ios-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
runs-on: macos-14
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: 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: |
cat > .env <<EOF
echo "RUNNING_E2E=true" >> .env
- name: Run Detox E2E Tests
continue-on-error: true # We want to run all the tests
run: |
# Start the server
npm run start &
sleep 120 # Wait for watchman to finish querying the files
cd detox
npm run clean-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: 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

@@ -1,234 +0,0 @@
---
name: github-release
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"
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/test
uses: ./.github/actions/test
build-ios-unsigned:
runs-on: macos-14-large
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
- 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 }}"
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
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
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
with:
sign: false
- name: ci/build-android-beta
env:
TAG: "${{ env.RELEASE_TAG }}"
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
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: ruby/setup-ruby@cb0fda56a307b8c78d38320cd40d9eb22a3bf04e # v1.242.0
- 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
- 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"

8
.gitignore vendored
View File

@@ -103,7 +103,6 @@ detox/detox_pixel_*
# Bundle artifact
*.jsbundle
.bundle
#editor-settings
.vscode
@@ -115,10 +114,3 @@ launch.json
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
libraries/**/**/build
libraries/**/**/.build
# Android sounds
android/app/src/main/res/raw/*
.aider*

View File

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

View File

@@ -1 +0,0 @@
22.14.0

1
.npmrc
View File

@@ -1,2 +1 @@
save-exact=true
engine-strict=true

1
.nvmrc
View File

@@ -1 +0,0 @@
22.14.0

View File

@@ -1 +0,0 @@
3.2.0

View File

@@ -4,11 +4,29 @@
"output" : "moderate"
},
"requirements": {
"Node": [
{
"rule": "cli",
"binary": "node",
"semver": ">=16.0.0",
"error": "install node using nvm https://github.com/nvm-sh/nvm#installing-and-updating"
},
{
"rule": "cli",
"binary": "npm",
"semver": ">=8.5.5 <9.0.0",
"error": "install npm 8.5.5 `npm i -g npm@8.5.5"
}
],
"Android": [
{
"rule": "cli",
"binary": "emulator"
},
{
"rule": "cli",
"binary": "android"
},
{
"rule": "env",
"variable": "ANDROID_HOME",
@@ -32,14 +50,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.11.3",
"platform": "darwin"
}
],

1364
NOTICE.txt

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
# Mattermost Mobile v2
- **Minimum Server versions:** Current ESR version (10.5.0+)
- **Supported iOS versions:** 15.1+
- **Minimum Server versions:** Current ESR version (7.1.0+)
- **Supported iOS versions:** 12.1+
- **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).
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 21 languages. Learn more at [https://about.mattermost.com](https://about.mattermost.com).
You can download our apps from the [App Store](https://mattermost.com/mattermost-ios-app/) or [Google Play Store](https://mattermost.com/mattermost-android-app/), or [build them yourself](https://developers.mattermost.com/contribute/mobile/build-your-own/).
You can download our apps from the [App Store](https://about.mattermost.com/mattermost-ios-app/) or [Google Play Store](https://about.mattermost.com/mattermost-android-app/), or [build them yourself](https://developers.mattermost.com/contribute/mobile/build-your-own/).
We plan on releasing monthly updates with new features - check the [changelog](https://github.com/mattermost/mattermost-mobile/blob/master/CHANGELOG.md) for what features are currently supported!
@@ -27,8 +27,8 @@ To help with testing app updates before they're released, you can:
- Repro steps
- Observed behavior (including screenshot / video when possible)
- Expected behavior
4. (Optional) [Sign up for our team site](https://community.mattermost.com/signup_user_complete/?id=codoy5s743rq5mk18i7u5ksz7e&md=link&sbr=su)
- 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
4. (Optional) [Sign up for our team site](https://pre-release.mattermost.com/signup_user_complete/?id=f1924a8db44ff3bb41c96424cdc20676)
- Join the [Native Mobile Apps channel](https://pre-release.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.mattermost.rnbeta) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
@@ -39,7 +39,7 @@ You can leave the Beta testing program at any time:
1. Look in [GitHub issues](https://mattermost.com/pl/help-wanted-mattermost-mobile) for issues marked as [Help Wanted]
2. Comment to let people know youre working on it
3. Follow [these instructions](https://developers.mattermost.com/contribute/mobile/developer-setup/) to set up your developer environment
4. Join the [Native Mobile Apps channel](https://community.mattermost.com/core/channels/native-mobile-apps) on our team site to ask questions
4. Join the [Native Mobile Apps channel](https://pre-release.mattermost.com/core/channels/native-mobile-apps) on our team site to ask questions
@@ -57,7 +57,7 @@ You can still access it! We have moved the code from master to the [v1 branch](h
### I keep getting a message "Cannot connect to the server. Please check your server URL and internet connection."
This sometimes appears when there is an issue with the SSL certificate configuration.
This sometimes appears when there is an issue with the SSL certitificate configuration.
To check that your SSL certificate is set up correctly, test the SSL certificate by visiting a site such as https://www.ssllabs.com/ssltest/index.html. If theres an error about the missing chain or certificate path, there is likely an intermediate certificate missing that needs to be included.

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,7 +98,6 @@ def reactNativeArchitectures() {
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdkVersion rootProject.ext.compileSdkVersion
namespace "com.mattermost.rnbeta"
@@ -115,8 +110,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 666
versionName "2.32.0"
versionCode 459
versionName "2.1.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)

Binary file not shown.

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

@@ -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,18 @@
<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 +32,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 +47,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"
@@ -90,12 +82,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"
@@ -108,22 +102,5 @@
<data android:mimeType="*/*" />
</intent-filter>
</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"
/>
</application>
</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;
@@ -29,41 +29,29 @@ import androidx.core.app.RemoteInput;
import androidx.core.graphics.drawable.IconCompat;
import com.mattermost.rnbeta.*;
import com.mattermost.rnutils.helpers.NotificationHelper;
import com.nozbe.watermelondb.WMDatabase;
import com.mattermost.turbolog.TurboLog;
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";
public static final String PUSH_TYPE_CLEAR = "clear";
public static final String PUSH_TYPE_SESSION = "session";
public static final String CATEGORY_CAN_REPLY = "CAN_REPLY";
private static NotificationChannel mHighImportanceChannel;
private static NotificationChannel mMinImportanceChannel;
@@ -92,7 +80,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,9 +132,8 @@ 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);
if (android.text.TextUtils.isEmpty(postId) || serverUrl == null || !canReply) {
if (android.text.TextUtils.isEmpty(postId) || serverUrl == null) {
return;
}
@@ -192,9 +179,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 +225,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 +273,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 +411,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 +433,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(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 = new ReactApplicationContext(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

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,7 +50,7 @@ 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)
@@ -64,7 +64,7 @@ fun insertCategoriesWithChannels(db: WMDatabase, orderCategories: ReadableMap) {
}
}
fun insertChannelToDefaultCategory(db: WMDatabase, categoryChannels: ReadableArray) {
fun insertChannelToDefaultCategory(db: Database, categoryChannels: ReadableArray) {
try {
for (i in 0 until categoryChannels.size()) {
val cc = categoryChannels.getMap(i)

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) }
@@ -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(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 }
@@ -86,7 +86,6 @@ internal fun insertPost(db: WMDatabase, post: JSONObject) {
val editAt = try { post.getDouble("edit_at") } catch (e: JSONException) { 0 }
val isPinned = try { post.getBoolean("is_pinned") } catch (e: JSONException) { false }
val message = try { post.getString("message") } catch (e: JSONException) { "" }
val messageSource = try { post.getString("message_source") } catch (e: JSONException) { "" }
val metadata = try { post.getJSONObject("metadata") } catch (e: JSONException) { JSONObject() }
val originalId = try { post.getString("original_id") } catch (e: JSONException) { "" }
val pendingId = try { post.getString("pending_post_id") } catch (e: JSONException) { "" }
@@ -101,13 +100,13 @@ internal fun insertPost(db: WMDatabase, post: JSONObject) {
db.execute(
"""
INSERT INTO Post
(id, channel_id, create_at, delete_at, update_at, edit_at, is_pinned, message, message_source, metadata, original_id, pending_post_id,
(id, channel_id, create_at, delete_at, update_at, edit_at, is_pinned, message, metadata, original_id, pending_post_id,
previous_post_id, root_id, type, user_id, props, _changed, _status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'created')
""".trimIndent(),
arrayOf(
id, channelId, createAt, deleteAt, updateAt, editAt,
isPinned, message, messageSource, metadata.toString(),
isPinned, message, metadata.toString(),
originalId, pendingId, prevId, rootId,
type, userId, props
)
@@ -129,7 +128,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 }
@@ -140,7 +139,6 @@ internal fun updatePost(db: WMDatabase, post: JSONObject) {
val editAt = try { post.getDouble("edit_at") } catch (e: JSONException) { 0 }
val isPinned = try { post.getBoolean("is_pinned") } catch (e: JSONException) { false }
val message = try { post.getString("message") } catch (e: JSONException) { "" }
val messageSource = try { post.getString("message_source") } catch (e: JSONException) { "" }
val metadata = try { post.getJSONObject("metadata") } catch (e: JSONException) { JSONObject() }
val originalId = try { post.getString("original_id") } catch (e: JSONException) { "" }
val pendingId = try { post.getString("pending_post_id") } catch (e: JSONException) { "" }
@@ -156,13 +154,13 @@ internal fun updatePost(db: WMDatabase, post: JSONObject) {
db.execute(
"""
UPDATE Post SET channel_id = ?, create_at = ?, delete_at = ?, update_at =?, edit_at =?,
is_pinned = ?, message = ?, message_source = ?, metadata = ?, original_id = ?, pending_post_id = ?, previous_post_id = ?,
is_pinned = ?, message = ?, metadata = ?, original_id = ?, pending_post_id = ?, previous_post_id = ?,
root_id = ?, type = ?, user_id = ?, props = ?, _status = 'updated'
WHERE id = ?
""".trimIndent(),
arrayOf(
channelId, createAt, deleteAt, updateAt, editAt,
isPinned, message, messageSource, metadata.toString(),
isPinned, message, metadata.toString(),
originalId, pendingId, prevId, rootId,
type, userId, props,
id,
@@ -182,7 +180,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,8 +4,8 @@ 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()) {
@@ -18,7 +18,7 @@ internal fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest:
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(
@@ -41,7 +41,7 @@ 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)
@@ -56,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,7 +63,7 @@ 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)
@@ -98,7 +82,7 @@ internal fun insertThreadParticipants(db: WMDatabase, threadId: String, particip
}
}
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)
@@ -110,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"))
@@ -121,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")
@@ -137,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") }
@@ -177,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)
@@ -202,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")
@@ -223,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,
@@ -245,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,7 +45,7 @@ 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)
val roles = user.getString("roles") ?: ""

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

View File

@@ -8,11 +8,12 @@ import com.mattermost.helpers.database_extension.findChannel
import com.mattermost.helpers.database_extension.getCurrentUserLocale
import com.mattermost.helpers.database_extension.getTeammateDisplayNameSetting
import com.mattermost.helpers.database_extension.queryCurrentUserId
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
import java.text.Collator
import java.util.Locale
import kotlin.math.max
suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: WMDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple<ReadableMap?, ReadableMap?, ReadableArray?> {
suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple<ReadableMap?, ReadableMap?, ReadableArray?> {
val channel = fetch(serverUrl, "/api/v4/channels/$channelId")
var channelData = channel?.getMap("data")
val myChannelData = channelData?.let { fetchMyChannelData(serverUrl, channelId, isCRTEnabled, it) }
@@ -108,7 +109,7 @@ private suspend fun PushNotificationDataRunnable.Companion.fetchMyChannelData(se
return null
}
private suspend fun PushNotificationDataRunnable.Companion.fetchProfileInChannel(db: WMDatabase, serverUrl: String, channelId: String): ReadableArray? {
private suspend fun PushNotificationDataRunnable.Companion.fetchProfileInChannel(db: Database, serverUrl: String, channelId: String): ReadableArray? {
return try {
val currentUserId = queryCurrentUserId(db)
val profilesInChannel = fetch(serverUrl, "/api/v4/users?in_channel=${channelId}&page=0&per_page=8&sort=")

View File

@@ -22,12 +22,12 @@ internal suspend fun PushNotificationDataRunnable.Companion.fetch(serverUrl: Str
}
}
override fun reject(code: String, message: String?) {
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(throwable: Throwable) {
cont.resumeWith(Result.failure(IOException("Unexpected code $throwable")))
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}
@@ -41,12 +41,12 @@ internal suspend fun PushNotificationDataRunnable.Companion.fetchWithPost(server
cont.resumeWith(Result.success(response))
}
override fun reject(code: String, message: String?) {
override fun reject(code: String, message: String) {
cont.resumeWith(Result.failure(IOException("Unexpected code $code $message")))
}
override fun reject(throwable: Throwable) {
cont.resumeWith(Result.failure(IOException("Unexpected code $throwable")))
override fun reject(reason: Throwable?) {
cont.resumeWith(Result.failure(IOException("Unexpected code $reason")))
}
})
}

View File

@@ -9,10 +9,10 @@ import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.ReadableArrayUtils
import com.mattermost.helpers.ReadableMapUtils
import com.mattermost.helpers.database_extension.*
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts(
db: WMDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean,
db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean,
rootId: String?, loadedProfiles: ReadableArray?
): ReadableMap? {
return try {
@@ -66,20 +66,6 @@ internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts(
}
}
fun findNeededUsernames(text: String?) {
if (text == null) {
return
}
val matchResults = regex.findAll(text)
matchResults.iterator().forEach {
val username = it.value.removePrefix("@")
if (!usernames.contains(username) && currentUsername != username && !specialMentions.contains(username)) {
usernames.add(username)
}
}
}
while (iterator.hasNextKey()) {
val key = iterator.nextKey()
val post = posts.getMap(key)
@@ -87,22 +73,17 @@ internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts(
if (userId != null && userId != currentUserId && !userIdsAlreadyLoaded.contains(userId) && !userIds.contains(userId)) {
userIds.add(userId)
}
val message = post?.getString("message")
findNeededUsernames(message)
val props = post?.getMap("props")
val attachments = props?.getArray("attachments")
if (attachments != null) {
for (i in 0 until attachments.size()) {
val attachment = attachments.getMap(i)
val pretext = attachment.getString("pretext")
val text = attachment.getString("text")
findNeededUsernames(pretext)
findNeededUsernames(text)
if (message != null) {
val matchResults = regex.findAll(message)
matchResults.iterator().forEach {
val username = it.value.removePrefix("@")
if (!usernames.contains(username) && currentUsername != username && !specialMentions.contains(username)) {
usernames.add(username)
}
}
}
if (isCRTEnabled) {
// Add root post as a thread
val threadId = post?.getString("root_id")

View File

@@ -4,9 +4,9 @@ import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.database_extension.findMyTeam
import com.mattermost.helpers.database_extension.findTeam
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
suspend fun PushNotificationDataRunnable.Companion.fetchTeamIfNeeded(db: WMDatabase, serverUrl: String, teamId: String): Pair<ReadableMap?, ReadableMap?> {
suspend fun PushNotificationDataRunnable.Companion.fetchTeamIfNeeded(db: Database, serverUrl: String, teamId: String): Pair<ReadableMap?, ReadableMap?> {
return try {
var team: ReadableMap? = null
var myTeam: ReadableMap? = null

View File

@@ -3,9 +3,9 @@ package com.mattermost.helpers.push_notification
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.PushNotificationDataRunnable
import com.mattermost.helpers.database_extension.*
import com.nozbe.watermelondb.WMDatabase
import com.nozbe.watermelondb.Database
internal suspend fun PushNotificationDataRunnable.Companion.fetchThread(db: WMDatabase, serverUrl: String, threadId: String, teamId: String?): ReadableMap? {
internal suspend fun PushNotificationDataRunnable.Companion.fetchThread(db: Database, serverUrl: String, threadId: String, teamId: String?): ReadableMap? {
val currentUserId = queryCurrentUserId(db) ?: return null
val threadTeamId = (if (teamId.isNullOrEmpty()) queryCurrentTeamId(db) else teamId) ?: return null

View File

@@ -0,0 +1,177 @@
package com.mattermost.rnbeta;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import java.util.Objects;
import com.facebook.react.bridge.ReadableMap;
import com.mattermost.helpers.CustomPushNotificationHelper;
import com.mattermost.helpers.DatabaseHelper;
import com.mattermost.helpers.Network;
import com.mattermost.helpers.NotificationHelper;
import com.mattermost.helpers.PushNotificationDataHelper;
import com.mattermost.helpers.ReadableMapUtils;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper;
import static com.mattermost.helpers.database_extension.GeneralKt.*;
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
public class CustomPushNotification extends PushNotification {
private final PushNotificationDataHelper dataHelper;
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
dataHelper = new PushNotificationDataHelper(context);
try {
Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).init(context);
Network.init(context);
NotificationHelper.cleanNotificationPreferencesIfNeeded(context);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void onReceived() {
final Bundle initialData = mNotificationProps.asBundle();
final String type = initialData.getString("type");
final String ackId = initialData.getString("ack_id");
final String postId = initialData.getString("post_id");
final String channelId = initialData.getString("channel_id");
final boolean isIdLoaded = initialData.getString("id_loaded") != null && initialData.getString("id_loaded").equals("true");
int notificationId = NotificationHelper.getNotificationId(initialData);
String serverUrl = addServerUrlToBundle(initialData);
if (ackId != null && serverUrl != null) {
Bundle response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded);
if (isIdLoaded && response != null) {
Bundle current = mNotificationProps.asBundle();
if (!current.containsKey("server_url")) {
response.putString("server_url", serverUrl);
}
current.putAll(response);
mNotificationProps = createProps(current);
}
}
finishProcessingNotification(serverUrl, type, channelId, notificationId);
}
@Override
public void onOpened() {
if (mNotificationProps != null) {
digestNotification();
Bundle data = mNotificationProps.asBundle();
NotificationHelper.clearChannelOrThreadNotifications(mContext, data);
}
}
private void finishProcessingNotification(final String serverUrl, @NonNull final String type, final String channelId, final int notificationId) {
final boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
switch (type) {
case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE:
case CustomPushNotificationHelper.PUSH_TYPE_SESSION:
ShareModule shareModule = ShareModule.getInstance();
String currentActivityName = shareModule != null ? shareModule.getCurrentActivityName() : "";
Log.i("ReactNative", currentActivityName);
if (!mAppLifecycleFacade.isAppVisible() || !currentActivityName.equals("MainActivity")) {
boolean createSummary = type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE);
if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
Bundle notificationBundle = mNotificationProps.asBundle();
if (serverUrl != null) {
// We will only fetch the data related to the notification on the native side
// as updating the data directly to the db removes the wal & shm files needed
// by watermelonDB, if the DB is updated while WDB is running it causes WDB to
// detect the database as malformed, thus the app stop working and a restart is required.
// Data will be fetch from within the JS context instead.
Bundle notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit);
if (notificationResult != null) {
notificationBundle.putBundle("data", notificationResult);
mNotificationProps = createProps(notificationBundle);
}
}
createSummary = NotificationHelper.addNotificationToPreferences(
mContext,
notificationId,
notificationBundle
);
}
}
buildNotification(notificationId, createSummary);
}
break;
case CustomPushNotificationHelper.PUSH_TYPE_CLEAR:
NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle());
break;
}
if (isReactInit) {
notifyReceivedToJS();
}
}
private void buildNotification(Integer notificationId, boolean createSummary) {
final PendingIntent pendingIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, mNotificationProps);
final Notification notification = buildNotification(pendingIntent);
if (createSummary) {
final Notification summary = getNotificationSummaryBuilder(pendingIntent).build();
super.postNotification(summary, notificationId + 1);
}
super.postNotification(notification, notificationId);
}
@Override
protected NotificationCompat.Builder getNotificationBuilder(PendingIntent intent) {
Bundle bundle = mNotificationProps.asBundle();
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, false);
}
protected NotificationCompat.Builder getNotificationSummaryBuilder(PendingIntent intent) {
Bundle bundle = mNotificationProps.asBundle();
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, true);
}
private void notifyReceivedToJS() {
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.getRunningReactContext());
}
private String addServerUrlToBundle(Bundle bundle) {
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
String serverId = bundle.getString("server_id");
String serverUrl = null;
if (dbHelper != null) {
if (serverId == null) {
serverUrl = dbHelper.getOnlyServerUrl();
} else {
serverUrl = getServerUrlForIdentifier(dbHelper, serverId);
}
if (!TextUtils.isEmpty(serverUrl)) {
bundle.putString("server_url", serverUrl);
mNotificationProps = createProps(bundle);
}
}
return serverUrl;
}
}

View File

@@ -1,177 +0,0 @@
package com.mattermost.rnbeta
import android.app.PendingIntent
import android.content.Context
import android.os.Bundle
import androidx.core.app.NotificationCompat
import com.mattermost.helpers.CustomPushNotificationHelper
import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.Network
import com.mattermost.helpers.PushNotificationDataHelper
import com.mattermost.helpers.database_extension.getServerUrlForIdentifier
import com.mattermost.rnutils.helpers.NotificationHelper
import com.mattermost.turbolog.TurboLog
import com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME
import com.wix.reactnativenotifications.core.AppLaunchHelper
import com.wix.reactnativenotifications.core.AppLifecycleFacade
import com.wix.reactnativenotifications.core.JsIOHelper
import com.wix.reactnativenotifications.core.NotificationIntentAdapter
import com.wix.reactnativenotifications.core.notification.PushNotification
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class CustomPushNotification(
context: Context,
bundle: Bundle,
appLifecycleFacade: AppLifecycleFacade,
appLaunchHelper: AppLaunchHelper,
jsIoHelper: JsIOHelper
) : PushNotification(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper) {
private val dataHelper = PushNotificationDataHelper(context)
init {
try {
DatabaseHelper.instance?.init(context)
NotificationHelper.cleanNotificationPreferencesIfNeeded(context)
} catch (e: Exception) {
e.printStackTrace()
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onReceived() {
val initialData = mNotificationProps.asBundle()
val type = initialData.getString("type")
val ackId = initialData.getString("ack_id")
val postId = initialData.getString("post_id")
val channelId = initialData.getString("channel_id")
val signature = initialData.getString("signature")
val isIdLoaded = initialData.getString("id_loaded") == "true"
val notificationId = NotificationHelper.getNotificationId(initialData)
val serverUrl = addServerUrlToBundle(initialData)
Network.init(mContext)
GlobalScope.launch {
try {
handlePushNotificationInCoroutine(serverUrl, type, channelId, ackId, isIdLoaded, notificationId, postId, signature)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private suspend fun handlePushNotificationInCoroutine(
serverUrl: String?,
type: String?,
channelId: String?,
ackId: String?,
isIdLoaded: Boolean,
notificationId: Int,
postId: String?,
signature: String?
) {
if (ackId != null && serverUrl != null) {
val response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded)
if (isIdLoaded && response != null) {
val current = mNotificationProps.asBundle()
if (!current.containsKey("server_url")) {
response.putString("server_url", serverUrl)
}
current.putAll(response)
mNotificationProps = createProps(current)
}
}
if (!CustomPushNotificationHelper.verifySignature(mContext, signature, serverUrl, ackId)) {
TurboLog.i("Mattermost Notifications Signature verification", "Notification skipped because we could not verify it.")
return
}
finishProcessingNotification(serverUrl, type, channelId, notificationId)
}
override fun onOpened() {
mNotificationProps?.let {
digestNotification()
NotificationHelper.clearChannelOrThreadNotifications(mContext, it.asBundle())
}
}
private suspend fun finishProcessingNotification(serverUrl: String?, type: String?, channelId: String?, notificationId: Int) {
val isReactInit = mAppLifecycleFacade.isReactInitialized()
when (type) {
CustomPushNotificationHelper.PUSH_TYPE_MESSAGE, CustomPushNotificationHelper.PUSH_TYPE_SESSION -> {
val currentActivityName = mAppLifecycleFacade.runningReactContext?.currentActivity?.componentName?.className ?: ""
TurboLog.i("ReactNative", currentActivityName)
if (!mAppLifecycleFacade.isAppVisible() || !currentActivityName.contains("MainActivity")) {
var createSummary = type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE
if (type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE) {
channelId?.let {
val notificationBundle = mNotificationProps.asBundle()
serverUrl?.let {
val notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit)
notificationResult?.let { result ->
notificationBundle.putBundle("data", result)
mNotificationProps = createProps(notificationBundle)
}
}
createSummary = NotificationHelper.addNotificationToPreferences(mContext, notificationId, notificationBundle)
}
}
buildNotification(notificationId, createSummary)
}
}
CustomPushNotificationHelper.PUSH_TYPE_CLEAR -> NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle())
}
if (isReactInit) {
notifyReceivedToJS()
}
}
private fun buildNotification(notificationId: Int, createSummary: Boolean) {
val pendingIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, mNotificationProps)
val notification = buildNotification(pendingIntent)
if (createSummary) {
val summary = getNotificationSummaryBuilder(pendingIntent).build()
super.postNotification(summary, notificationId + 1)
}
super.postNotification(notification, notificationId)
}
override fun getNotificationBuilder(intent: PendingIntent): NotificationCompat.Builder {
val bundle = mNotificationProps.asBundle()
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, false)
}
private fun getNotificationSummaryBuilder(intent: PendingIntent): NotificationCompat.Builder {
val bundle = mNotificationProps.asBundle()
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, true)
}
private fun notifyReceivedToJS() {
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.runningReactContext)
}
private fun addServerUrlToBundle(bundle: Bundle): String? {
val dbHelper = DatabaseHelper.instance
val serverId = bundle.getString("server_id")
var serverUrl: String? = null
dbHelper?.let {
serverUrl = if (serverId == null) {
it.onlyServerUrl
} else {
it.getServerUrlForIdentifier(serverId)
}
if (!serverUrl.isNullOrEmpty()) {
bundle.putString("server_url", serverUrl)
mNotificationProps = createProps(bundle)
}
}
return serverUrl
}
}

View File

@@ -0,0 +1,58 @@
package com.mattermost.rnbeta
import android.app.Activity
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowLayoutInfo
import androidx.window.rxjava3.layout.windowLayoutInfoObservable
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
class FoldableObserver(private val activity: Activity) {
private var disposable: Disposable? = null
private lateinit var observable: Observable<WindowLayoutInfo>
fun onCreate() {
observable = WindowInfoTracker.getOrCreate(activity)
.windowLayoutInfoObservable(activity)
}
fun onStart() {
if (disposable?.isDisposed == true) {
onCreate()
}
disposable = observable.observeOn(AndroidSchedulers.mainThread())
.subscribe { layoutInfo ->
val splitViewModule = SplitViewModule.getInstance()
val foldingFeature = layoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>()
.firstOrNull()
when {
foldingFeature?.state === FoldingFeature.State.FLAT ->
splitViewModule?.setDeviceFolded(false)
isTableTopPosture(foldingFeature) ->
splitViewModule?.setDeviceFolded(false)
isBookPosture(foldingFeature) ->
splitViewModule?.setDeviceFolded(false)
else -> {
splitViewModule?.setDeviceFolded(true)
}
}
}
}
fun onStop() {
disposable?.dispose()
}
private fun isTableTopPosture(foldFeature : FoldingFeature?) : Boolean {
return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
}
private fun isBookPosture(foldFeature : FoldingFeature?) : Boolean {
return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
}
}

View File

@@ -0,0 +1,109 @@
package com.mattermost.rnbeta;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.KeyEvent;
import android.content.res.Configuration;
import com.facebook.react.ReactActivityDelegate;
import com.reactnativenavigation.NavigationActivity;
import com.github.emilioicai.hwkeyboardevent.HWKeyboardEventModule;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
import java.util.Objects;
public class MainActivity extends NavigationActivity {
private boolean HWKeyboardConnected = false;
private final FoldableObserver foldableObserver = new FoldableObserver(this);
@Override
protected String getMainComponentName() {
return "Mattermost";
}
/**
* Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link
* DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React
* (aka React 18) with two boolean flags.
*/
@Override
protected ReactActivityDelegate createReactActivityDelegate() {
return new DefaultReactActivityDelegate(
this,
Objects.requireNonNull(getMainComponentName()),
// If you opted-in for the New Architecture, we enable the Fabric Renderer.
DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled
// If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18).
DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled
);
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(null);
setContentView(R.layout.launch_screen);
setHWKeyboardConnected();
foldableObserver.onCreate();
}
@Override
protected void onStart() {
super.onStart();
foldableObserver.onStart();
}
@Override
protected void onStop() {
super.onStop();
foldableObserver.onStop();
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) {
HWKeyboardConnected = true;
} else if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES) {
HWKeyboardConnected = false;
}
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
getReactGateway().onWindowFocusChanged(hasFocus);
}
/*
https://mattermost.atlassian.net/browse/MM-10601
Required by react-native-hw-keyboard-event
(https://github.com/emilioicai/react-native-hw-keyboard-event)
*/
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (HWKeyboardConnected) {
int keyCode = event.getKeyCode();
int keyAction = event.getAction();
if (keyAction == KeyEvent.ACTION_UP) {
if (keyCode == KeyEvent.KEYCODE_ENTER) {
String keyPressed = event.isShiftPressed() ? "shift-enter" : "enter";
HWKeyboardEventModule.getInstance().keyPressed(keyPressed);
return true;
} else if (keyCode == KeyEvent.KEYCODE_K && event.isCtrlPressed()) {
HWKeyboardEventModule.getInstance().keyPressed("find-channels");
return true;
}
}
}
return super.dispatchKeyEvent(event);
}
private void setHWKeyboardConnected() {
HWKeyboardConnected = getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY;
}
}

View File

@@ -1,89 +0,0 @@
package com.mattermost.rnbeta
import android.content.res.Configuration
import android.os.Bundle
import android.view.KeyEvent
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.mattermost.hardware.keyboard.MattermostHardwareKeyboardImpl
import com.mattermost.rnutils.helpers.FoldableObserver
import com.reactnativenavigation.NavigationActivity
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : NavigationActivity() {
private var HWKeyboardConnected = false
private val foldableObserver = FoldableObserver.getInstance(this)
private var lastOrientation: Int = Configuration.ORIENTATION_UNDEFINED
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "Mattermost"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
DefaultReactActivityDelegate(this, mainComponentName, DefaultNewArchitectureEntryPoint.fabricEnabled))
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(null)
setContentView(R.layout.launch_screen)
setHWKeyboardConnected()
lastOrientation = this.resources.configuration.orientation
foldableObserver.onCreate()
}
override fun onStart() {
super.onStart()
foldableObserver.onStart()
}
override fun onStop() {
super.onStop()
foldableObserver.onStop()
}
override fun onDestroy() {
super.onDestroy()
foldableObserver.onDestroy()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val newOrientation = newConfig.orientation
if (newOrientation != lastOrientation) {
lastOrientation = newOrientation
foldableObserver.handleWindowLayoutInfo()
}
if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) {
HWKeyboardConnected = true
} else if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES) {
HWKeyboardConnected = false
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
reactGateway.onWindowFocusChanged(hasFocus)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (HWKeyboardConnected) {
val ok = MattermostHardwareKeyboardImpl.dispatchKeyEvent(event)
if (ok) {
return true
}
}
return super.dispatchKeyEvent(event)
}
private fun setHWKeyboardConnected() {
HWKeyboardConnected = getResources().configuration.keyboard == Configuration.KEYBOARD_QWERTY
}
}

View File

@@ -0,0 +1,161 @@
package com.mattermost.rnbeta;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import java.io.File;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.mattermost.helpers.RealPathUtil;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.RNNotificationsPackage;
import com.reactnativenavigation.NavigationApplication;
import com.wix.reactnativenotifications.core.notification.INotificationsApplication;
import com.wix.reactnativenotifications.core.notification.IPushNotification;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper;
import com.facebook.react.PackageList;
import com.facebook.react.ReactPackage;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactNativeHost;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.TurboReactPackage;
import com.facebook.react.bridge.JSIModuleSpec;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.JSIModulePackage;
import com.facebook.react.module.model.ReactModuleInfo;
import com.facebook.react.module.model.ReactModuleInfoProvider;
import com.facebook.react.modules.network.OkHttpClientProvider;
import com.facebook.soloader.SoLoader;
import com.mattermost.flipper.ReactNativeFlipper;
import com.mattermost.networkclient.RCTOkHttpClientFactory;
import com.nozbe.watermelondb.jsi.WatermelonDBJSIPackage;
public class MainApplication extends NavigationApplication implements INotificationsApplication {
public static MainApplication instance;
public Boolean sharedExtensionIsOpened = false;
private final ReactNativeHost mReactNativeHost =
new DefaultReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}
@Override
protected List<ReactPackage> getPackages() {
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new RNNotificationsPackage(MainApplication.this));
packages.add(
new TurboReactPackage() {
@Override
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
switch (name) {
case "MattermostManaged":
return MattermostManagedModule.getInstance(reactContext);
case "MattermostShare":
return ShareModule.getInstance(reactContext);
case "Notifications":
return NotificationsModule.getInstance(instance, reactContext);
case "SplitView":
return SplitViewModule.Companion.getInstance(reactContext);
default:
throw new IllegalArgumentException("Could not find module " + name);
}
}
@Override
public ReactModuleInfoProvider getReactModuleInfoProvider() {
return () -> {
Map<String, ReactModuleInfo> map = new HashMap<>();
map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false));
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
map.put("Notifications", new ReactModuleInfo("Notifications", "com.mattermost.rnbeta.NotificationsModule", false, false, false, false, false));
map.put("SplitView", new ReactModuleInfo("SplitView", "com.mattermost.rnbeta.SplitViewModule", false, false, false, false, false));
return map;
};
}
}
);
return packages;
}
@Override
protected JSIModulePackage getJSIModulePackage() {
return (reactApplicationContext, jsContext) -> {
List<JSIModuleSpec> modules = Collections.emptyList();
modules.addAll(new WatermelonDBJSIPackage().getJSIModules(reactApplicationContext, jsContext));
return modules;
};
}
@Override
protected String getJSMainModuleName() {
return "index";
}
@Override
protected boolean isNewArchEnabled() {
return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
}
@Override
protected Boolean isHermesEnabled() {
return BuildConfig.IS_HERMES_ENABLED;
}
};
@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
}
@Override
public void onCreate() {
super.onCreate();
instance = this;
Context context = getApplicationContext();
// Delete any previous temp files created by the app
File tempFolder = new File(context.getCacheDir(), RealPathUtil.CACHE_DIR_NAME);
RealPathUtil.deleteTempFiles(tempFolder);
Log.i("ReactNative", "Cleaning temp cache " + tempFolder.getAbsolutePath());
// Tells React Native to use our RCTOkHttpClientFactory which builds an OKHttpClient
// with a cookie jar defined in APIClientModule and an interceptor to intercept all
// requests that originate from React Native's OKHttpClient
OkHttpClientProvider.setOkHttpClientFactory(new RCTOkHttpClientFactory());
SoLoader.init(this, /* native exopackage */ false);
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
DefaultNewArchitectureEntryPoint.load();
}
ReactNativeFlipper.initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
}
@Override
public IPushNotification getPushNotification(Context context, Bundle bundle, AppLifecycleFacade defaultFacade, AppLaunchHelper defaultAppLaunchHelper) {
return new CustomPushNotification(
context,
bundle,
defaultFacade,
defaultAppLaunchHelper,
new JsIOHelper()
);
}
}

View File

@@ -1,120 +0,0 @@
package com.mattermost.rnbeta
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import com.facebook.react.PackageList
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.modules.network.OkHttpClientProvider
import com.facebook.soloader.SoLoader
import com.mattermost.networkclient.RCTOkHttpClientFactory
import com.mattermost.rnshare.helpers.RealPathUtil
import com.mattermost.turbolog.TurboLog
import com.mattermost.turbolog.ConfigureOptions
import com.nozbe.watermelondb.jsi.JSIInstaller
import com.nozbe.watermelondb.jsi.WatermelonDBJSIPackage
import com.reactnativenavigation.NavigationApplication
import com.wix.reactnativenotifications.RNNotificationsPackage
import com.wix.reactnativenotifications.core.AppLaunchHelper
import com.wix.reactnativenotifications.core.AppLifecycleFacade
import com.wix.reactnativenotifications.core.JsIOHelper
import com.wix.reactnativenotifications.core.notification.INotificationsApplication
import com.wix.reactnativenotifications.core.notification.IPushNotification
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
import expo.modules.image.okhttp.ExpoImageOkHttpClientGlideModule
import java.io.File
class MainApplication : NavigationApplication(), INotificationsApplication {
private var listenerAdded = false
override val reactNativeHost: ReactNativeHost =
ReactNativeHostWrapper(this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(RNNotificationsPackage(this@MainApplication))
add(WatermelonDBJSIPackage())
}
override fun getJSMainModuleName(): String = "index"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
})
override val reactHost: ReactHost
get() = getDefaultReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
// Delete any previous temp files created by the app
val tempFolder = File(applicationContext.cacheDir, RealPathUtil.CACHE_DIR_NAME)
RealPathUtil.deleteTempFiles(tempFolder)
TurboLog.configure(options = ConfigureOptions(logsDirectory = applicationContext.cacheDir.absolutePath + "/logs", logPrefix = applicationContext.packageName))
TurboLog.i("ReactNative", "Cleaning temp cache " + tempFolder.absolutePath)
// Tells React Native to use our RCTOkHttpClientFactory which builds an OKHttpClient
// with a cookie jar defined in APIClientModule and an interceptor to intercept all
// requests that originate from React Native's OKHttpClient
// Tells React Native to use our RCTOkHttpClientFactory which builds an OKHttpClient
// with a cookie jar defined in APIClientModule and an interceptor to intercept all
// requests that originate from React Native's OKHttpClient
OkHttpClientProvider.setOkHttpClientFactory(RCTOkHttpClientFactory())
ExpoImageOkHttpClientGlideModule.okHttpClient = RCTOkHttpClientFactory().createNewNetworkModuleClient()
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load(bridgelessEnabled = false)
}
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
override fun getPushNotification(
context: Context?,
bundle: Bundle?,
defaultFacade: AppLifecycleFacade?,
defaultAppLaunchHelper: AppLaunchHelper?
): IPushNotification {
return CustomPushNotification(
context!!,
bundle!!,
defaultFacade!!,
defaultAppLaunchHelper!!,
JsIOHelper()
)
}
@SuppressLint("VisibleForTests")
private fun runOnJSQueueThread(action: () -> Unit) {
reactNativeHost.reactInstanceManager.currentReactContext?.runOnJSQueueThread {
action()
} ?: UiThreadUtil.runOnUiThread {
reactNativeHost.reactInstanceManager.currentReactContext?.runOnJSQueueThread {
action()
}
}
}
}

View File

@@ -0,0 +1,263 @@
package com.mattermost.rnbeta;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.text.TextUtils;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import com.facebook.react.bridge.ActivityEventListener;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.BaseActivityEventListener;
import com.facebook.react.bridge.GuardedResultAsyncTask;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.mattermost.helpers.Credentials;
import com.reactlibrary.createthumbnail.CreateThumbnailModule;
import com.mattermost.helpers.RealPathUtil;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.lang.ref.WeakReference;
import java.net.URL;
import java.nio.channels.FileChannel;
import java.util.Objects;
public class MattermostManagedModule extends ReactContextBaseJavaModule {
private static final String SAVE_EVENT = "MattermostManagedSaveFile";
private static final Integer SAVE_REQUEST = 38641;
private static MattermostManagedModule instance;
private ReactApplicationContext reactContext;
private Promise mPickerPromise;
private String fileContent;
private MattermostManagedModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
// Let the document provider know you're done by closing the stream.
ActivityEventListener mActivityEventListener = new BaseActivityEventListener() {
@Override
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent intent) {
if (requestCode == SAVE_REQUEST) {
if (mPickerPromise != null) {
if (resultCode == Activity.RESULT_CANCELED) {
mPickerPromise.reject(SAVE_EVENT, "Save operation cancelled");
} else if (resultCode == Activity.RESULT_OK) {
Uri uri = intent.getData();
if (uri == null) {
mPickerPromise.reject(SAVE_EVENT, "No data found");
} else {
try {
new SaveDataTask(reactContext, fileContent, uri).execute();
mPickerPromise.resolve(uri.toString());
} catch (Exception e) {
mPickerPromise.reject(SAVE_EVENT, e.getMessage());
}
}
}
mPickerPromise = null;
} else if (resultCode == Activity.RESULT_OK) {
try {
Uri uri = intent.getData();
if (uri != null)
new SaveDataTask(reactContext, fileContent, uri).execute();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
};
reactContext.addActivityEventListener(mActivityEventListener);
}
public static MattermostManagedModule getInstance(ReactApplicationContext reactContext) {
if (instance == null) {
instance = new MattermostManagedModule(reactContext);
} else {
instance.reactContext = reactContext;
}
return instance;
}
public static MattermostManagedModule getInstance() {
return instance;
}
public void sendEvent(String eventName,
@Nullable WritableMap params) {
this.reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(eventName, params);
}
@Override
@NonNull
public String getName() {
return "MattermostManaged";
}
@ReactMethod
public void getFilePath(String filePath, Promise promise) {
Activity currentActivity = getCurrentActivity();
WritableMap map = Arguments.createMap();
if (currentActivity != null) {
Uri uri = Uri.parse(filePath);
String path = RealPathUtil.getRealPathFromURI(currentActivity, uri);
if (path != null) {
String text = "file://" + path;
map.putString("filePath", text);
}
}
promise.resolve(map);
}
@ReactMethod
public void saveFile(String path, final Promise promise) {
Uri contentUri;
String filename = "";
if(path.startsWith("content://")) {
contentUri = Uri.parse(path);
} else {
File newFile = new File(path);
filename = newFile.getName();
Activity currentActivity = getCurrentActivity();
if(currentActivity == null) {
promise.reject(SAVE_EVENT, "Activity doesn't exist");
return;
}
try {
final String packageName = currentActivity.getPackageName();
final String authority = packageName + ".provider";
contentUri = FileProvider.getUriForFile(currentActivity, authority, newFile);
}
catch(IllegalArgumentException e) {
promise.reject(SAVE_EVENT, e.getMessage());
return;
}
}
if(contentUri == null) {
promise.reject(SAVE_EVENT, "Invalid file");
return;
}
String extension = MimeTypeMap.getFileExtensionFromUrl(path).toLowerCase();
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (mimeType == null) {
mimeType = RealPathUtil.getMimeType(path);
}
Intent intent = new Intent();
intent.setAction(Intent.ACTION_CREATE_DOCUMENT);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_TITLE, filename);
PackageManager pm = Objects.requireNonNull(getCurrentActivity()).getPackageManager();
if (intent.resolveActivity(pm) != null) {
try {
getCurrentActivity().startActivityForResult(intent, SAVE_REQUEST);
mPickerPromise = promise;
fileContent = path;
}
catch(Exception e) {
promise.reject(SAVE_EVENT, e.getMessage());
}
} else {
try {
if(mimeType == null) {
throw new Exception("It wasn't possible to detect the type of the file");
}
throw new Exception("No app associated with this mime type");
}
catch(Exception e) {
promise.reject(SAVE_EVENT, e.getMessage());
}
}
}
@ReactMethod
public void createThumbnail(ReadableMap options, Promise promise) {
try {
WritableMap optionsMap = Arguments.createMap();
optionsMap.merge(options);
String url = options.hasKey("url") ? options.getString("url") : "";
URL videoUrl = new URL(url);
String serverUrl = videoUrl.getProtocol() + "://" + videoUrl.getHost() + ":" + videoUrl.getPort();
String token = Credentials.getCredentialsForServerSync(this.reactContext, serverUrl);
if (!TextUtils.isEmpty(token)) {
WritableMap headers = Arguments.createMap();
if (optionsMap.hasKey("headers")) {
headers.merge(Objects.requireNonNull(optionsMap.getMap("headers")));
}
headers.putString("Authorization", "Bearer " + token);
optionsMap.putMap("headers", headers);
}
CreateThumbnailModule thumb = new CreateThumbnailModule(this.reactContext);
thumb.create(optionsMap.copy(), promise);
} catch (Exception e) {
promise.reject("CreateThumbnail_ERROR", e);
}
}
private static class SaveDataTask extends GuardedResultAsyncTask<Object> {
private final WeakReference<Context> weakContext;
private final String fromFile;
private final Uri toFile;
protected SaveDataTask(ReactApplicationContext reactContext, String path, Uri destination) {
super(reactContext.getExceptionHandler());
weakContext = new WeakReference<>(reactContext.getApplicationContext());
fromFile = path;
toFile = destination;
}
@Override
protected Object doInBackgroundGuarded() {
try {
ParcelFileDescriptor pfd = weakContext.get().getContentResolver().openFileDescriptor(toFile, "w");
File input = new File(this.fromFile);
try (FileInputStream fileInputStream = new FileInputStream(input)) {
try (FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor())) {
FileChannel source = fileInputStream.getChannel();
FileChannel dest = fileOutputStream.getChannel();
dest.transferFrom(source, 0, source.size());
source.close();
dest.close();
}
}
pfd.close();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecuteGuarded(Object o) {
}
}
}

View File

@@ -4,9 +4,9 @@ import android.content.Context;
import android.content.Intent;
import android.app.IntentService;
import android.os.Bundle;
import android.util.Log;
import com.mattermost.rnutils.helpers.NotificationHelper;
import com.mattermost.turbolog.TurboLog;
import com.mattermost.helpers.NotificationHelper;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
public class NotificationDismissService extends IntentService {
@@ -19,7 +19,7 @@ public class NotificationDismissService extends IntentService {
final Context context = getApplicationContext();
final Bundle bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent);
NotificationHelper.INSTANCE.dismissNotification(context, bundle);
TurboLog.Companion.i("ReactNative", "Dismiss notification");
NotificationHelper.dismissNotification(context, bundle);
Log.i("ReactNative", "Dismiss notification");
}
}

View File

@@ -8,18 +8,16 @@ import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.app.Person;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.mattermost.helpers.*;
import com.mattermost.turbolog.TurboLog;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.notification.PushNotificationProps;
@@ -42,7 +40,6 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
final int notificationId = intent.getIntExtra(CustomPushNotificationHelper.NOTIFICATION_ID, -1);
final String serverUrl = bundle.getString("server_url");
Network.init(context);
if (serverUrl != null) {
replyToMessage(serverUrl, notificationId, message);
} else {
@@ -69,6 +66,7 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
WritableMap headers = Arguments.createMap();
headers.putString("Content-Type", "application/json");
WritableMap body = Arguments.createMap();
body.putString("channel_id", channelId);
body.putString("message", message.toString());
@@ -80,36 +78,26 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
String postsEndpoint = "/api/v4/posts?set_online=false";
Network.post(serverUrl, postsEndpoint, options, new ResolvePromise() {
private boolean isSuccessful(int statusCode) {
return statusCode >= 200 && statusCode < 300;
}
@Override
public void resolve(@Nullable Object value) {
if (value != null) {
ReadableMap response = (ReadableMap)value;
ReadableMap data = response.getMap("data");
if (data != null && data.hasKey("status_code") && !isSuccessful(data.getInt("status_code"))) {
TurboLog.Companion.i("ReactNative", String.format("Reply FAILED exception %s", data.getString("message")));
onReplyFailed(notificationId);
return;
}
onReplySuccess(notificationId, message);
TurboLog.Companion.i("ReactNative", "Reply SUCCESS");
Log.i("ReactNative", "Reply SUCCESS");
} else {
TurboLog.Companion.i("ReactNative", "Reply FAILED resolved without value");
Log.i("ReactNative", "Reply FAILED resolved without value");
onReplyFailed(notificationId);
}
}
@Override
public void reject(@NonNull Throwable reason) {
TurboLog.Companion.i("ReactNative", String.format("Reply FAILED exception %s", reason.getMessage()));
public void reject(Throwable reason) {
Log.i("ReactNative", String.format("Reply FAILED exception %s", reason.getMessage()));
onReplyFailed(notificationId);
}
@Override
public void reject(@NonNull String code, String message) {
TurboLog.Companion.i("ReactNative",
public void reject(String code, String message) {
Log.i("ReactNative",
String.format("Reply FAILED status %s BODY %s", code, message)
);
onReplyFailed(notificationId);

View File

@@ -0,0 +1,79 @@
package com.mattermost.rnbeta;
import android.app.Notification;
import android.content.Context;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.mattermost.helpers.NotificationHelper;
import java.util.Set;
public class NotificationsModule extends ReactContextBaseJavaModule {
private static NotificationsModule instance;
private final MainApplication mApplication;
private NotificationsModule(MainApplication application, ReactApplicationContext reactContext) {
super(reactContext);
mApplication = application;
}
public static NotificationsModule getInstance(MainApplication application, ReactApplicationContext reactContext) {
if (instance == null) {
instance = new NotificationsModule(application, reactContext);
}
return instance;
}
@NonNull
@Override
public String getName() {
return "Notifications";
}
@ReactMethod
public void getDeliveredNotifications(final Promise promise) {
Context context = mApplication.getApplicationContext();
StatusBarNotification[] notifications = NotificationHelper.getDeliveredNotifications(context);
WritableArray result = Arguments.createArray();
for (StatusBarNotification sbn:notifications) {
WritableMap map = Arguments.createMap();
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
Set<String> keys = bundle.keySet();
for (String key: keys) {
map.putString(key, bundle.getString(key));
}
result.pushMap(map);
}
promise.resolve(result);
}
@ReactMethod
public void removeChannelNotifications(String serverUrl, String channelId) {
Context context = mApplication.getApplicationContext();
NotificationHelper.removeChannelNotifications(context, serverUrl, channelId);
}
@ReactMethod
public void removeThreadNotifications(String serverUrl, String threadId) {
Context context = mApplication.getApplicationContext();
NotificationHelper.removeThreadNotifications(context, serverUrl, threadId);
}
@ReactMethod
public void removeServerNotifications(String serverUrl) {
Context context = mApplication.getApplicationContext();
NotificationHelper.removeServerNotifications(context, serverUrl);
}
}

View File

@@ -1,6 +1,7 @@
package com.mattermost.rnbeta;
import android.os.Bundle;
import android.util.Log;
import java.lang.System;
import java.util.Objects;
@@ -11,7 +12,6 @@ import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.WritableMap;
import com.mattermost.helpers.*;
import com.mattermost.turbolog.TurboLog;
import okhttp3.Response;
@@ -19,7 +19,7 @@ public class ReceiptDelivery {
private static final String[] ackKeys = new String[]{"post_id", "root_id", "category", "message", "team_id", "channel_id", "channel_name", "type", "sender_id", "sender_name", "version"};
public static Bundle send(final String ackId, final String serverUrl, final String postId, final String type, final boolean isIdLoaded) {
TurboLog.Companion.i("ReactNative", String.format("Send receipt delivery ACK=%s TYPE=%s to URL=%s with ID-LOADED=%s", ackId, type, serverUrl, isIdLoaded));
Log.i("ReactNative", String.format("Send receipt delivery ACK=%s TYPE=%s to URL=%s with ID-LOADED=%s", ackId, type, serverUrl, isIdLoaded));
WritableMap options = Arguments.createMap();
WritableMap headers = Arguments.createMap();
WritableMap body = Arguments.createMap();
@@ -38,7 +38,6 @@ public class ReceiptDelivery {
JSONObject jsonResponse = new JSONObject(responseBody);
return parseAckResponse(jsonResponse);
} catch (Exception e) {
TurboLog.Companion.e("ReactNative", "Send receipt delivery failed " + e.getMessage());
e.printStackTrace();
return null;
}

View File

@@ -0,0 +1,72 @@
package com.mattermost.rnbeta
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import com.learnium.RNDeviceInfo.resolver.DeviceTypeResolver
class SplitViewModule(private var reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
private var isDeviceFolded: Boolean = false
private var listenerCount = 0
companion object {
private var instance: SplitViewModule? = null
fun getInstance(reactContext: ReactApplicationContext): SplitViewModule {
if (instance == null) {
instance = SplitViewModule(reactContext)
} else {
instance!!.reactContext = reactContext
}
return instance!!
}
fun getInstance(): SplitViewModule? {
return instance
}
}
override fun getName() = "SplitView"
private fun sendEvent(params: WritableMap?) {
reactContext
.getJSModule(RCTDeviceEventEmitter::class.java)
.emit("SplitViewChanged", params)
}
private fun getSplitViewResults(folded: Boolean) : WritableMap? {
if (currentActivity != null) {
val deviceResolver = DeviceTypeResolver(this.reactContext)
val map = Arguments.createMap()
map.putBoolean("isSplitView", currentActivity!!.isInMultiWindowMode || folded)
map.putBoolean("isTablet", deviceResolver.isTablet)
return map
}
return null
}
fun setDeviceFolded(folded: Boolean) {
val map = getSplitViewResults(folded)
if (listenerCount > 0 && isDeviceFolded != folded) {
sendEvent(map)
}
isDeviceFolded = folded
}
@ReactMethod
fun isRunningInSplitView(promise: Promise) {
promise.resolve(getSplitViewResults(isDeviceFolded))
}
@ReactMethod
fun addListener(eventName: String) {
listenerCount += 1
}
@ReactMethod
fun removeListeners(count: Int) {
listenerCount -= count
}
}

View File

@@ -0,0 +1,20 @@
package com.mattermost.share;
import android.os.Bundle;
import com.facebook.react.ReactActivity;
import com.mattermost.rnbeta.MainApplication;
public class ShareActivity extends ReactActivity {
@Override
protected String getMainComponentName() {
return "MattermostShare";
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
MainApplication app = (MainApplication) this.getApplication();
app.sharedExtensionIsOpened = true;
}
}

View File

@@ -0,0 +1,259 @@
package com.mattermost.share;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.Arguments;
import com.mattermost.helpers.Credentials;
import com.mattermost.rnbeta.MainApplication;
import com.mattermost.helpers.RealPathUtil;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.File;
import java.util.ArrayList;
import org.json.JSONArray;
import org.json.JSONObject;
import org.json.JSONException;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.Response;
public class ShareModule extends ReactContextBaseJavaModule {
private final OkHttpClient client = new OkHttpClient();
public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static ShareModule instance;
private final MainApplication mApplication;
private ReactApplicationContext mReactContext;
private File tempFolder;
private ShareModule(ReactApplicationContext reactContext) {
super(reactContext);
mReactContext = reactContext;
mApplication = (MainApplication)reactContext.getApplicationContext();
}
public static ShareModule getInstance(ReactApplicationContext reactContext) {
if (instance == null) {
instance = new ShareModule(reactContext);
} else {
instance.mReactContext = reactContext;
}
return instance;
}
public static ShareModule getInstance() {
return instance;
}
@NonNull
@Override
public String getName() {
return "MattermostShare";
}
@ReactMethod(isBlockingSynchronousMethod = true)
public String getCurrentActivityName() {
Activity currentActivity = getCurrentActivity();
if (currentActivity != null) {
String activityName = currentActivity.getComponentName().getClassName();
String[] components = activityName.split("\\.");
return components[components.length - 1];
}
return "";
}
@ReactMethod
public void clear() {
Activity currentActivity = getCurrentActivity();
if (currentActivity != null && this.getCurrentActivityName().equals("ShareActivity")) {
Intent intent = currentActivity.getIntent();
intent.setAction("");
intent.removeExtra(Intent.EXTRA_TEXT);
intent.removeExtra(Intent.EXTRA_STREAM);
}
}
@Nullable
@Override
public Map<String, Object> getConstants() {
HashMap<String, Object> constants = new HashMap<>(1);
constants.put("cacheDirName", RealPathUtil.CACHE_DIR_NAME);
constants.put("isOpened", mApplication.sharedExtensionIsOpened);
return constants;
}
@ReactMethod
public void close(ReadableMap data) {
this.clear();
Activity currentActivity = getCurrentActivity();
if (currentActivity == null || !this.getCurrentActivityName().equals("ShareActivity")) {
return;
}
currentActivity.finishAndRemoveTask();
if (data != null && data.hasKey("serverUrl")) {
ReadableArray files = data.getArray("files");
String serverUrl = data.getString("serverUrl");
final String token = Credentials.getCredentialsForServerSync(mReactContext, serverUrl);
JSONObject postData = buildPostObject(data);
if (files != null && files.size() > 0) {
uploadFiles(serverUrl, token, files, postData);
} else {
try {
post(serverUrl, token, postData);
} catch (IOException e) {
e.printStackTrace();
}
}
}
mApplication.sharedExtensionIsOpened = false;
RealPathUtil.deleteTempFiles(this.tempFolder);
}
@ReactMethod
public void getSharedData(Promise promise) {
promise.resolve(processIntent());
}
public WritableArray processIntent() {
String type, action, extra;
WritableArray items = Arguments.createArray();
Activity currentActivity = getCurrentActivity();
if (currentActivity != null) {
this.tempFolder = new File(currentActivity.getCacheDir(), RealPathUtil.CACHE_DIR_NAME);
Intent intent = currentActivity.getIntent();
action = intent.getAction();
type = intent.getType();
extra = intent.getStringExtra(Intent.EXTRA_TEXT);
if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(type) && extra != null) {
items.pushMap(ShareUtils.getTextItem(extra));
} else if (Intent.ACTION_SEND.equals(action)) {
if (extra != null) {
items.pushMap(ShareUtils.getTextItem(extra));
}
Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri != null) {
ReadableMap fileInfo = ShareUtils.getFileItem(currentActivity, uri);
if (fileInfo != null) {
items.pushMap(fileInfo);
}
}
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
if (extra != null) {
items.pushMap(ShareUtils.getTextItem(extra));
}
ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
for (Uri uri : uris) {
ReadableMap fileInfo = ShareUtils.getFileItem(currentActivity, uri);
if (fileInfo != null) {
items.pushMap(fileInfo);
}
}
}
}
return items;
}
private JSONObject buildPostObject(ReadableMap data) {
JSONObject json = new JSONObject();
try {
json.put("user_id", data.getString("userId"));
if (data.hasKey("channelId")) {
json.put("channel_id", data.getString("channelId"));
}
if (data.hasKey("message")) {
json.put("message", data.getString("message"));
}
} catch (JSONException e) {
e.printStackTrace();
}
return json;
}
private void post(String serverUrl, String token, JSONObject postData) throws IOException {
RequestBody body = RequestBody.create(postData.toString(), JSON);
Request request = new Request.Builder()
.header("Authorization", "BEARER " + token)
.url(serverUrl + "/api/v4/posts")
.post(body)
.build();
client.newCall(request).execute();
}
private void uploadFiles(String serverUrl, String token, ReadableArray files, JSONObject postData) {
try {
MultipartBody.Builder builder = new MultipartBody.Builder()
.setType(MultipartBody.FORM);
for(int i = 0 ; i < files.size() ; i++) {
ReadableMap file = files.getMap(i);
String mime = file.getString("type");
String fullPath = file.getString("value");
if (fullPath != null) {
String filePath = fullPath.replaceFirst("file://", "");
File fileInfo = new File(filePath);
if (fileInfo.exists() && mime != null) {
final MediaType MEDIA_TYPE = MediaType.parse(mime);
builder.addFormDataPart("files", file.getString("filename"), RequestBody.create(fileInfo, MEDIA_TYPE));
}
}
}
builder.addFormDataPart("channel_id", postData.getString("channel_id"));
RequestBody body = builder.build();
Request request = new Request.Builder()
.header("Authorization", "BEARER " + token)
.url(serverUrl + "/api/v4/files")
.post(body)
.build();
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful()) {
String responseData = Objects.requireNonNull(response.body()).string();
JSONObject responseJson = new JSONObject(responseData);
JSONArray fileInfoArray = responseJson.getJSONArray("file_infos");
JSONArray file_ids = new JSONArray();
for(int i = 0 ; i < fileInfoArray.length() ; i++) {
JSONObject fileInfo = fileInfoArray.getJSONObject(i);
file_ids.put(fileInfo.getString("id"));
}
postData.put("file_ids", file_ids);
post(serverUrl, token, postData);
}
} catch (IOException e) {
e.printStackTrace();
}
} catch (JSONException e) {
e.printStackTrace();
}
}
}

View File

@@ -0,0 +1,115 @@
package com.mattermost.share;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.webkit.URLUtil;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.mattermost.helpers.RealPathUtil;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.UUID;
public class ShareUtils {
public static ReadableMap getTextItem(String text) {
WritableMap map = Arguments.createMap();
map.putString("value", text);
map.putString("type", "");
map.putBoolean("isString", true);
return map;
}
public static ReadableMap getFileItem(Activity activity, Uri uri) {
WritableMap map = Arguments.createMap();
String filePath = RealPathUtil.getRealPathFromURI(activity, uri);
if (filePath == null) {
return null;
}
File file = new File(filePath);
String type = RealPathUtil.getMimeTypeFromUri(activity, uri);
if (type != null) {
if (type.startsWith("image/")) {
BitmapFactory.Options bitMapOption = ShareUtils.getImageDimensions(filePath);
map.putInt("height", bitMapOption.outHeight);
map.putInt("width", bitMapOption.outWidth);
} else if (type.startsWith("video/")) {
File cacheDir = new File(activity.getCacheDir(), RealPathUtil.CACHE_DIR_NAME);
addVideoThumbnailToMap(cacheDir, activity.getApplicationContext(), map, "file://" + filePath);
}
} else {
type = "application/octet-stream";
}
map.putString("value", "file://" + filePath);
map.putDouble("size", (double) file.length());
map.putString("filename", file.getName());
map.putString("type", type);
map.putString("extension", RealPathUtil.getExtension(filePath).replaceFirst(".", ""));
map.putBoolean("isString", false);
return map;
}
public static BitmapFactory.Options getImageDimensions(String filePath) {
BitmapFactory.Options bitMapOption = new BitmapFactory.Options();
bitMapOption.inJustDecodeBounds=true;
BitmapFactory.decodeFile(filePath, bitMapOption);
return bitMapOption;
}
private static void addVideoThumbnailToMap(File cacheDir, Context context, WritableMap map, String filePath) {
String fileName = ("thumb-" + UUID.randomUUID().toString()) + ".png";
OutputStream fOut = null;
try {
File file = new File(cacheDir, fileName);
Bitmap image = getBitmapAtTime(context, filePath, 1);
if (file.createNewFile()) {
fOut = new FileOutputStream(file);
image.compress(Bitmap.CompressFormat.PNG, 100, fOut);
fOut.flush();
fOut.close();
map.putString("videoThumb", "file://" + file.getAbsolutePath());
map.putInt("width", image.getWidth());
map.putInt("height", image.getHeight());
}
} catch (Exception ignored) {
}
}
private static Bitmap getBitmapAtTime(Context context, String filePath, int time) {
try {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
if (URLUtil.isFileUrl(filePath)) {
String decodedPath;
try {
decodedPath = URLDecoder.decode(filePath, "UTF-8");
} catch (UnsupportedEncodingException e) {
decodedPath = filePath;
}
retriever.setDataSource(decodedPath.replace("file://", ""));
} else if (filePath.contains("content://")) {
retriever.setDataSource(context, Uri.parse(filePath));
}
Bitmap image = retriever.getFrameAtTime(time * 1000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC);
retriever.release();
return image;
} catch (Exception e) {
throw new IllegalStateException("File doesn't exist or not supported");
}
}
}

View File

@@ -14,10 +14,9 @@
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material">
<selector>
<!--
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref"/>
<exclude domain="file" path="./databases"/>
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -4,4 +4,5 @@
<external-files-path name="external_files" path="." />
<external-path name="external" path="." />
<cache-path name="cache" path="." />
<root-path name="root" path="." />
</paths>

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