diff --git a/.cursor/skills/agent-skills b/.cursor/skills/agent-skills new file mode 160000 index 00000000000..a4f602ffb4a --- /dev/null +++ b/.cursor/skills/agent-skills @@ -0,0 +1 @@ +Subproject commit a4f602ffb4aeaf4199fa97b7162f9c9d1f655904 diff --git a/.eslintignore b/.eslintignore index edc3a77e7b9..ca60c6693b2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,4 +4,3 @@ coverage e2e/docker android ios -.eslintrc.js \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index dba025b0233..fcbada21558 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,53 +5,33 @@ module.exports = { extensions: ['.ts', '.tsx', '.js', '.ios.js', '.android.js', '.native.js', '.ios.tsx', '.android.tsx'] }, typescript: { - alwaysTryTypes: true, project: './tsconfig.json' } }, 'import/parsers': { '@typescript-eslint/parser': ['.ts', '.tsx'] - // plugins: ['@typescript-eslint'], - // rules: { - // '@typescript-eslint/consistent-type-imports': [ - // 'error', - // { - // prefer: 'type-imports', // enforce `import type` - // disallowTypeAnnotations: true // disallow `import { type Foo }` - // // fixStyle: 'inline-type-imports' // keeps type imports inline rather than grouped - // } - // ] - // } + }, + react: { + version: 'detect' } }, parser: '@babel/eslint-parser', - extends: ['plugin:jest/recommended', '@rocket.chat/eslint-config', 'prettier', 'plugin:react-hooks/recommended'], + extends: [ + '@rocket.chat/eslint-config', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:prettier/recommended', + 'prettier' + ], parserOptions: { sourceType: 'module', - ecmaVersion: 2017, - ecmaFeatures: { - experimentalObjectRestSpread: true, - jsx: true, - legacyDecorators: true - } + ecmaVersion: 2024 }, - plugins: ['react', 'jsx-a11y', 'import', 'react-native', '@babel', 'react-hooks', 'jest'], + plugins: ['import', 'react-native', '@babel'], env: { - browser: true, - commonjs: true, - es6: true, - node: true, - jquery: true, - mocha: true, - jest: true, - 'jest/globals': true + es6: true }, rules: { - 'react-hooks/set-state-in-effect': 1, - 'react-hooks/immutability': 1, - 'react-hooks/refs': 1, - 'import/named': 'error', - 'import/no-unresolved': 'error', 'import/extensions': [ 'error', 'ignorePackages', @@ -62,122 +42,34 @@ module.exports = { tsx: 'warning' } ], - 'react/jsx-filename-extension': [ - 1, - { - extensions: ['.js', '.jsx', '.ts', '.tsx'] - } - ], - 'react/require-default-props': [0], - 'ordered-imports': [0], - 'react/no-did-mount-set-state': 0, - 'react/no-multi-comp': [0], - 'react/jsx-indent-props': [2, 'tab'], - 'jsx-quotes': [2, 'prefer-single'], - 'jsx-a11y/href-no-hash': 0, - 'jsx-a11y/aria-role': 0, - 'import/prefer-default-export': 0, - 'import/no-cycle': 2, + 'import/named': 'error', + 'import/no-cycle': 'error', + 'import/no-unresolved': 'error', 'import/order': [ 'error', { 'newlines-between': 'ignore' } ], - camelcase: 0, - 'no-underscore-dangle': 0, - 'no-return-assign': 0, - 'no-param-reassign': 0, - 'no-tabs': 0, - 'no-multi-spaces': 2, - 'no-eval': 2, - 'no-extend-native': 2, - 'no-multi-str': 2, - 'no-use-before-define': 2, - 'no-const-assign': 2, - 'no-cond-assign': 2, - 'no-constant-condition': 2, - 'no-control-regex': 2, - 'no-debugger': 2, - 'no-delete-var': 2, - 'no-dupe-keys': 2, - 'no-dupe-args': 2, - 'no-dupe-class-members': 2, - 'no-duplicate-case': 2, - 'no-else-return': [0, { allowElseIf: true }], - 'no-empty': 2, - 'no-empty-character-class': 2, - 'no-ex-assign': 2, - 'no-extra-boolean-cast': 2, - 'no-extra-semi': 2, - 'no-fallthrough': 2, - 'no-func-assign': 2, - 'no-inner-declarations': [2, 'functions'], - 'no-invalid-regexp': 2, - 'no-irregular-whitespace': 2, - 'no-mixed-spaces-and-tabs': 1, - 'no-sparse-arrays': 2, - 'no-negated-in-lhs': 2, - 'no-obj-calls': 2, - 'no-octal': 2, - 'no-redeclare': 2, - 'no-regex-spaces': 2, - 'no-undef': 2, - 'no-unreachable': 2, - 'no-unused-expressions': 0, + 'react/display-name': 'off', + 'react/jsx-fragments': ['error', 'syntax'], + 'react/jsx-key': 'off', + 'react/no-direct-mutation-state': 'off', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/immutability': 'warn', + 'react-hooks/refs': 'warn', + 'react-native/no-color-literals': 'off', + 'react-native/no-inline-styles': 'off', + 'react-native/no-raw-text': 'off', + 'react-native/no-single-element-style-arrays': 'error', + 'react-native/no-unused-styles': 'error', + 'react-native/split-platform-components': 'off', 'no-unused-vars': 'off', - 'max-len': 0, - 'react/jsx-uses-vars': 2, - 'no-void': 2, - 'no-var': 2, - 'one-var': [2, 'never'], - 'no-lonely-if': 2, - 'no-trailing-spaces': 2, - complexity: [1, 31], - 'space-in-parens': [2, 'never'], - 'space-before-blocks': [2, 'always'], - indent: 'off', - 'eol-last': [2, 'always'], - 'comma-dangle': [2, 'never'], - 'keyword-spacing': 2, - 'block-spacing': 2, - 'brace-style': [2, '1tbs', { allowSingleLine: true }], - 'computed-property-spacing': 2, - 'comma-spacing': 2, - 'comma-style': 2, - 'guard-for-in': 2, - 'wrap-iife': 2, - 'block-scoped-var': 2, - curly: [2, 'all'], - eqeqeq: [2, 'allow-null'], - 'new-cap': 'off', - 'use-isnan': 2, - 'valid-typeof': 2, - 'linebreak-style': 0, - 'prefer-template': 2, - quotes: [1, 'single'], - semi: [2, 'always'], - 'prefer-const': 2, - 'object-shorthand': 2, - 'consistent-return': 0, - 'global-require': 'off', - 'react-native/no-unused-styles': 2, - 'react/jsx-one-expression-per-line': 0, - 'require-await': 2, - 'func-names': 0, - 'react/static-property-placement': [0], - 'arrow-parens': ['warn', 'as-needed', { requireForBlockBody: true }], - 'react/jsx-curly-newline': [0], - 'react/state-in-constructor': [0], - 'no-async-promise-executor': [0], - 'max-classes-per-file': [0], - 'no-multiple-empty-lines': [0], - 'no-sequences': 'off', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn' - }, - globals: { - __DEV__: true + 'no-void': 'error', + 'new-cap': 'error', + 'require-await': 'error' }, overrides: [ { @@ -186,50 +78,13 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/eslint-recommended', '@rocket.chat/eslint-config', + 'plugin:prettier/recommended', 'prettier' ], parser: '@typescript-eslint/parser', - parserOptions: { - sourceType: 'module', - ecmaVersion: 2018, - warnOnUnsupportedTypeScriptVersion: false, - ecmaFeatures: { - experimentalObjectRestSpread: true, - legacyDecorators: true - } - }, - plugins: ['react', '@typescript-eslint'], rules: { - '@typescript-eslint/no-var-requires': 'off', - '@typescript-eslint/no-empty-function': [0], - '@typescript-eslint/ban-types': [0], - 'func-call-spacing': 'off', - 'jsx-quotes': ['error', 'prefer-single'], - indent: 'off', - 'comma-dangle': [2, 'never'], - 'no-return-assign': 0, - 'no-dupe-class-members': 'off', - 'no-extra-parens': 'off', - 'no-spaced-func': 'off', - 'no-unused-vars': 'off', - 'no-useless-constructor': 'off', - 'no-use-before-define': 'off', - 'react/jsx-uses-react': 'error', - 'react/jsx-uses-vars': 'error', - 'react/jsx-no-undef': 'error', - 'react/jsx-fragments': ['error', 'syntax'], '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/indent': 'off', - '@typescript-eslint/no-extra-parens': 'off', - '@typescript-eslint/no-dupe-class-members': 'error', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - ignoreRestSiblings: true - } - ], + '@typescript-eslint/ban-types': 'off', '@typescript-eslint/consistent-type-imports': [ 'error', { @@ -238,14 +93,31 @@ module.exports = { fixStyle: 'inline-type-imports' } ], + '@typescript-eslint/indent': 'off', + '@typescript-eslint/no-dupe-class-members': 'error', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-extra-parens': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_' + } + ], + '@typescript-eslint/no-var-requires': 'off', + 'no-return-assign': 'off', + 'no-dupe-class-members': 'off', + 'no-extra-parens': 'off', + 'no-spaced-func': 'off', + 'no-unused-vars': 'off', + 'no-useless-constructor': 'off', + 'no-use-before-define': 'off', 'new-cap': 'off', - 'lines-between-class-members': 'off', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn', - 'jest/no-conditional-expect': 'off' + 'lines-between-class-members': 'off' }, globals: { - JSX: true + JSX: 'readonly' }, settings: { 'import/resolver': { @@ -256,10 +128,13 @@ module.exports = { } }, { - files: ['e2e/**'], - rules: { - 'no-await-in-loop': 0, - 'jest/expect-expect': 'off' + files: ['jest.setup.js', '__mocks__/**/*.js', '**/*.test.{js,ts,tsx}'], + extends: ['plugin:jest/recommended'] + }, + { + files: ['index.js', 'app/**/*.{js,ts,tsx}'], + env: { + 'react-native/react-native': true } } ] diff --git a/.github/actions/build-ios/action.yml b/.github/actions/build-ios/action.yml index ff9983a9ca4..153ba71c6d3 100644 --- a/.github/actions/build-ios/action.yml +++ b/.github/actions/build-ios/action.yml @@ -53,7 +53,7 @@ runs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4' + xcode-version: '26.2.0' - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/actions/upload-android/action.yml b/.github/actions/upload-android/action.yml index 7f1fb418089..aafeccf437f 100644 --- a/.github/actions/upload-android/action.yml +++ b/.github/actions/upload-android/action.yml @@ -53,6 +53,26 @@ runs: echo "${{ inputs.FASTLANE_GOOGLE_SERVICE_ACCOUNT }}" | base64 --decode > service_account.json shell: bash + - uses: actions/download-artifact@v4 + if: ${{ inputs.trigger == 'develop' }} + with: + name: release-changelog + path: . + + - name: Prepare Play Store changelog metadata + if: ${{ inputs.trigger == 'develop' }} + run: | + mkdir -p android/fastlane/metadata/android/en-US/changelogs + + if [ -f changelog.txt ]; then + node .github/scripts/prepare-changelog.js + else + printf "Internal improvements and bug fixes" > "android/fastlane/metadata/android/en-US/changelogs/${BUILD_VERSION}.txt" + fi + shell: bash + env: + BUILD_VERSION: ${{ inputs.BUILD_VERSION }} + - name: Fastlane Play Store Upload working-directory: android run: | @@ -65,7 +85,6 @@ runs: if [[ ${{ inputs.trigger }} == "develop" ]] && [[ ${{ inputs.type }} == 'official' ]]; then bundle exec fastlane android official_open_testing fi - shell: bash - name: Leave a comment on PR diff --git a/.github/actions/upload-ios/action.yml b/.github/actions/upload-ios/action.yml index 68f5d4539d4..b7324541194 100644 --- a/.github/actions/upload-ios/action.yml +++ b/.github/actions/upload-ios/action.yml @@ -91,7 +91,7 @@ runs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.2' + xcode-version: '26.2.0' - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -109,6 +109,12 @@ runs: yarn pod-install shell: bash + - uses: actions/download-artifact@v4 + if: ${{ inputs.type == 'official' && inputs.trigger == 'develop' }} + with: + name: release-changelog + path: . + - name: Fastlane Submit to TestFlight working-directory: ios run: | @@ -157,4 +163,4 @@ runs: message="**iOS Build Available**"$'\n\n'"$app_name $VERSION_NAME.$BUILD_VERSION" gh pr comment "$PR_NUMBER" --body "$message" - shell: bash \ No newline at end of file + shell: bash diff --git a/.github/scripts/prepare-changelog.js b/.github/scripts/prepare-changelog.js new file mode 100644 index 00000000000..9b31db56352 --- /dev/null +++ b/.github/scripts/prepare-changelog.js @@ -0,0 +1,20 @@ +const fs = require("fs"); + +const buildVersion = process.env.BUILD_VERSION; +const input = fs.readFileSync("changelog.txt", "utf8"); + +const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); +const chars = Array.from(segmenter.segment(input), s => s.segment); + +let output; +if (chars.length > 500) { + output = chars.slice(0, 497).join("") + "..."; +} else { + output = input; +} + +fs.writeFileSync( + `android/fastlane/metadata/android/en-US/changelogs/${buildVersion}.txt`, + output, + "utf8" +); diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml index 48ffc62e564..5e9969daf74 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -8,17 +8,26 @@ on: branches: - 'develop' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: run-eslint-and-test: name: ESLint and Test if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} uses: ./.github/workflows/eslint.yml + generate-changelog: + name: Generate Release Changelog + needs: [run-eslint-and-test] + uses: ./.github/workflows/generate-changelog.yml + android-build-experimental-store: name: Build Android Experimental if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} uses: ./.github/workflows/build-android.yml - needs: [run-eslint-and-test] + needs: [run-eslint-and-test, generate-changelog] secrets: inherit with: type: experimental @@ -28,7 +37,7 @@ jobs: name: Build Android Official if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} uses: ./.github/workflows/build-official-android.yml - needs: [run-eslint-and-test] + needs: [run-eslint-and-test, generate-changelog] secrets: inherit with: type: official @@ -38,7 +47,7 @@ jobs: name: Build iOS Experimental if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} uses: ./.github/workflows/build-ios.yml - needs: [run-eslint-and-test] + needs: [run-eslint-and-test, generate-changelog] secrets: inherit with: type: experimental @@ -48,7 +57,7 @@ jobs: name: Build iOS Official if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} uses: ./.github/workflows/build-official-ios.yml - needs: [run-eslint-and-test] + needs: [run-eslint-and-test, generate-changelog] secrets: inherit with: type: official diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 40c8bc5702c..7cc9c4b2624 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -22,7 +22,7 @@ jobs: build-ios: name: Build - runs-on: macos-15 + runs-on: macos-26 needs: [build-hold] if: ${{ inputs.type == 'experimental' && (always() && (needs.build-hold.result == 'success' || needs.build-hold.result == 'skipped')) }} steps: @@ -62,7 +62,7 @@ jobs: upload-ios: name: Upload - runs-on: macos-15 + runs-on: macos-26 needs: [build-ios] if: ${{ inputs.type == 'experimental' && (always() && (needs.build-ios.result == 'success')) }} steps: diff --git a/.github/workflows/build-official-android.yml b/.github/workflows/build-official-android.yml index 76d195085b0..226c50ff26a 100644 --- a/.github/workflows/build-official-android.yml +++ b/.github/workflows/build-official-android.yml @@ -72,7 +72,7 @@ jobs: upload-android: name: Upload runs-on: ubuntu-latest - needs: [upload-hold] + needs: [build-android, upload-hold] if: ${{ inputs.type == 'official' && (always() && (needs.upload-hold.result == 'success' || needs.upload-hold.result == 'skipped')) }} steps: - name: Checkout Repository @@ -85,7 +85,7 @@ jobs: trigger: ${{ inputs.trigger }} FASTLANE_GOOGLE_SERVICE_ACCOUNT: ${{ secrets.FASTLANE_GOOGLE_SERVICE_ACCOUNT }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_VERSION: ${{ needs.upload-hold.outputs.BUILD_VERSION }} + BUILD_VERSION: ${{ needs.build-android.outputs.BUILD_VERSION }} upload-internal: name: Internal Sharing diff --git a/.github/workflows/build-official-ios.yml b/.github/workflows/build-official-ios.yml index 75d76015a1b..bf8f31df792 100644 --- a/.github/workflows/build-official-ios.yml +++ b/.github/workflows/build-official-ios.yml @@ -22,7 +22,7 @@ jobs: build-ios: name: Build - runs-on: macos-15 + runs-on: macos-26 needs: [build-hold] if: ${{ inputs.type == 'official' && (always() && (needs.build-hold.result == 'success' || needs.build-hold.result == 'skipped')) }} steps: @@ -72,7 +72,7 @@ jobs: upload-ios: name: Upload - runs-on: macos-15 + runs-on: macos-26 needs: [upload-hold] if: ${{ inputs.type == 'official' && (always() && (needs.upload-hold.result == 'success' || needs.upload-hold.result == 'skipped')) }} steps: diff --git a/.github/workflows/e2e-build-ios.yml b/.github/workflows/e2e-build-ios.yml index 0228a9d310e..1952085df2a 100644 --- a/.github/workflows/e2e-build-ios.yml +++ b/.github/workflows/e2e-build-ios.yml @@ -22,7 +22,7 @@ on: jobs: ios-build: - runs-on: macos-15 + runs-on: macos-26 steps: - name: Checkout repository @@ -34,7 +34,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4' + xcode-version: '26.2.0' - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml new file mode 100644 index 00000000000..af729951373 --- /dev/null +++ b/.github/workflows/generate-changelog.yml @@ -0,0 +1,38 @@ +name: Generate Release Changelog + +on: + workflow_call: + +jobs: + generate-changelog: + name: Generate changelog + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + shell: bash + run: | + LATEST_RELEASE_TAG=$(git tag --sort=-creatordate | head -n 1) + + if [ -z "$LATEST_RELEASE_TAG" ]; then + echo "- Improvements and bug fixes" > changelog.txt + exit 0 + fi + + git log "$LATEST_RELEASE_TAG"..HEAD --pretty=format:"- %s" --no-merges > changelog.txt + + if [ ! -s changelog.txt ]; then + echo "- Improvements and bug fixes" > changelog.txt + fi + + - name: Upload changelog artifact + uses: actions/upload-artifact@v4 + with: + name: release-changelog + path: changelog.txt + retention-days: 15 diff --git a/.github/workflows/maestro-android.yml b/.github/workflows/maestro-android.yml index 752714fe668..a12e381f5dc 100644 --- a/.github/workflows/maestro-android.yml +++ b/.github/workflows/maestro-android.yml @@ -11,6 +11,8 @@ jobs: android-test: name: 'Android Tests' runs-on: ubuntu-latest + env: + MAESTRO_VERSION: 2.2.0 steps: - name: Checkout Repository @@ -19,8 +21,22 @@ jobs: - name: Setup Java uses: actions/setup-java@v4 with: - distribution: 'temurin' - java-version: '17' + distribution: temurin + java-version: 17 + + - name: Cache Android AVD + uses: actions/cache@v4 + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ runner.os }}-api34 + + - name: Cache Maestro + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} - name: Download APK uses: actions/download-artifact@v4 @@ -34,21 +50,26 @@ jobs: - name: Install Maestro run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH - + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + - name: Enable KVM group permissions 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: Disable unnecessary services (improves emulator stability) + run: | + sudo systemctl stop docker || true + sudo systemctl stop snapd || true + - name: Make Maestro script executable run: chmod +x .github/scripts/run-maestro.sh - name: Start Android Emulator and Run Maestro Tests uses: reactivecircus/android-emulator-runner@v2 - timeout-minutes: 120 + timeout-minutes: 60 with: api-level: 34 disk-size: 4096M @@ -58,8 +79,9 @@ jobs: cores: 4 ram-size: 4096M force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true + emulator-boot-timeout: 900 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -accel on script: ./.github/scripts/run-maestro.sh android ${{ inputs.shard }} - name: Android Maestro Logs diff --git a/.github/workflows/maestro-ios.yml b/.github/workflows/maestro-ios.yml index 4fc43f2ff0b..7a6c686716d 100644 --- a/.github/workflows/maestro-ios.yml +++ b/.github/workflows/maestro-ios.yml @@ -10,6 +10,8 @@ on: jobs: ios-test: runs-on: macos-14 + env: + MAESTRO_VERSION: 2.2.0 steps: - name: Checkout Repo @@ -18,8 +20,8 @@ jobs: - name: Setup Java uses: actions/setup-java@v4 with: - distribution: 'temurin' - java-version: '17' + distribution: temurin + java-version: "17" - name: Download App uses: actions/download-artifact@v4 @@ -32,70 +34,73 @@ jobs: with: E2E_ACCOUNT: ${{ secrets.E2E_ACCOUNT }} - - name: Install Maestro + - name: Cache Maestro + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} + + - name: Install Maestro + idb run: | brew tap facebook/fb brew install facebook/fb/idb-companion curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - name: Configure Simulator run: | defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false - defaults write com.apple.iphonesimulator ShowSingleTouches 1 defaults write com.apple.iphonesimulator SlowAnimations -bool false defaults write com.apple.iphonesimulator ShowDeviceBezels -bool false defaults write com.apple.iphonesimulator DisableShadows -bool true + defaults write com.apple.iphonesimulator AllowFullscreenMode -bool false + defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false + defaults write com.apple.iphonesimulator ShowChrome -bool false + defaults write com.apple.iphonesimulator DeviceFramebufferOnly -bool true - name: Boot Simulator - timeout-minutes: 30 + timeout-minutes: 15 run: | - SIM_NAME="iPhone 16 Pro Max" - TIMEOUT=300 # 5 minutes in seconds - INTERVAL=5 # Check every 5 seconds - + SIM_NAME="iPhone 16 Pro" + + echo "Booting simulator: $SIM_NAME" + xcrun simctl boot "$SIM_NAME" || true - - ELAPSED=0 - while true; do - BOOTED=$(xcrun simctl list devices | grep "$SIM_NAME (" | grep "(Booted)") - if [ -n "$BOOTED" ]; then - echo "$SIM_NAME is booted" - break - fi - - if [ $ELAPSED -ge $TIMEOUT ]; then - echo "Simulator did not boot within 5 minutes. Retrying..." - xcrun simctl shutdown "$SIM_NAME" - xcrun simctl boot "$SIM_NAME" - ELAPSED=0 - fi - - sleep $INTERVAL - ELAPSED=$((ELAPSED + INTERVAL)) - done - + xcrun simctl bootstatus "$SIM_NAME" -b + + echo "Disabling animations" + xcrun simctl spawn booted defaults write -g UIAnimationDragCoefficient -float 10 + + echo "Warming SpringBoard" + xcrun simctl launch booted com.apple.springboard + sleep 15 + + echo "Booted devices:" xcrun simctl list devices | grep Booted - - name: Get Simulator UDID + - name: Get Booted Simulator UDID id: booted-sim run: | - UDID=$(xcrun simctl list devices | grep "iPhone 16 Pro Max (" | grep "(Booted)" | grep -oE '[A-F0-9-]{36}' | head -n1) + UDID=$(xcrun simctl list devices booted | grep -oE '[A-F0-9-]{36}' | head -n1) echo "UDID=$UDID" echo "UDID=$UDID" >> $GITHUB_ENV - - name: Make Maestro script executable - run: chmod +x .github/scripts/run-maestro.sh - - name: Install App run: | - xcrun simctl install $UDID "ios-simulator-app" + xcrun simctl install booted ios-simulator-app - - name: Run Tests - timeout-minutes: 120 - run: ./.github/scripts/run-maestro.sh ios ${{ inputs.shard }} + - name: Make Maestro Script Executable + run: chmod +x .github/scripts/run-maestro.sh + + - name: Run Maestro Tests + uses: nick-fields/retry@v3 + with: + timeout_minutes: 30 + max_attempts: 2 + retry_on: timeout + command: ./.github/scripts/run-maestro.sh ios ${{ inputs.shard }} - - name: iOS Maestro Logs + - name: Upload Maestro Logs if: always() uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 3e5a93039c8..c49c60afe60 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -46,5 +46,5 @@ jobs: git config user.name "${{ github.actor }}" git config user.email "${{ github.actor }}@users.noreply.github.com" git add . - git commit -m "chore: format code and fix lint issues [skip ci]" + git commit -m "chore: format code and fix lint issues" git push origin ${{ github.ref_name }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index f24d4a8f50d..d7811907c27 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,13 @@ e2e/e2e_account.ts **/e2e_account.js **/e2e_account.ts -*.p8 \ No newline at end of file +*.p8 +.worktrees/ +.omc/ +.claude/ +.agents/ +.cursor/ +skills-lock.json +CLAUDE.local.md +AGENTS.md +.superset/ diff --git a/.maestro/helpers/create-account.yaml b/.maestro/helpers/create-account.yaml index e451feccc23..01350a1d8d1 100644 --- a/.maestro/helpers/create-account.yaml +++ b/.maestro/helpers/create-account.yaml @@ -24,7 +24,7 @@ tags: - tapOn: id: register-view-confirm-password - inputText: ${output.user.password} -- hideKeyboard +- runFlow: './hide-keyboard.yaml' - scrollUntilVisible: element: id: register-view-submit diff --git a/.maestro/helpers/login-with-deeplink.yaml b/.maestro/helpers/login-with-deeplink.yaml index 114974a59e5..d9b1ee77ae5 100644 --- a/.maestro/helpers/login-with-deeplink.yaml +++ b/.maestro/helpers/login-with-deeplink.yaml @@ -10,6 +10,9 @@ tags: true: CLEAR_STATE commands: - clearState: chat.rocket.reactnative +- setPermissions: + permissions: + all: allow - evalScript: ${output.login = output.utils.login(USERNAME, PASSWORD)} - runFlow: file: 'open-deeplink.yaml' @@ -24,24 +27,6 @@ tags: visible: '.*Pixel Launcher.*' commands: - tapOn: 'Close App' - - extendedWaitUntil: - visible: - text: '.*Allow.*' - timeout: 30000 - optional: true - - tapOn: - text: '.*Allow.*' - optional: true - - assertNotVisible: - text: '.*Allow.*' - optional: true -- runFlow: - when: - visible: '.*Would like to send you notifications.*' - platform: iOS - commands: - - tapOn: - point: 65%,60% - extendedWaitUntil: visible: id: 'rooms-list-view' diff --git a/.maestro/helpers/search-room.yaml b/.maestro/helpers/search-room.yaml index 4472a71bc21..59eef9262ed 100644 --- a/.maestro/helpers/search-room.yaml +++ b/.maestro/helpers/search-room.yaml @@ -5,6 +5,8 @@ tags: --- - assertVisible: id: 'rooms-list-view' +- waitForAnimationToEnd: + timeout: 5000 - tapOn: id: 'rooms-list-view-search' - tapOn: diff --git a/.maestro/helpers/send-message.yaml b/.maestro/helpers/send-message.yaml index 01ff6de275a..c315e8dd354 100644 --- a/.maestro/helpers/send-message.yaml +++ b/.maestro/helpers/send-message.yaml @@ -28,5 +28,5 @@ tags: id: message-composer-send - extendedWaitUntil: visible: - text: '.*${message}.*' + id: 'message-content-${message}' timeout: 60000 diff --git a/.maestro/scripts/data-setup.js b/.maestro/scripts/data-setup.js index 0a56bf62ca0..59d044b4326 100644 --- a/.maestro/scripts/data-setup.js +++ b/.maestro/scripts/data-setup.js @@ -22,7 +22,7 @@ const getDeepLink = (method, server, ...params) => { const login = (username, password) => { - const response = http.post(`${data.server}/api/v1/login`, { + const response = postWithRetry(`${data.server}/api/v1/login`, { headers: { 'Content-Type': 'application/json' }, @@ -44,7 +44,7 @@ const createUser = (customProps) => { login(output.account.adminUser, output.account.adminPassword); - http.post(`${data.server}/api/v1/users.create`, { + postWithRetry(`${data.server}/api/v1/users.create`, { headers: { 'Content-Type': 'application/json', ...headers @@ -74,7 +74,7 @@ const deleteCreatedUser = async ({ username: usernameToDelete }) => { try { login(output.account.adminUser, output.account.adminPassword); - const result = http.get(`${data.server}/api/v1/users.info?username=${usernameToDelete}`, { + const result = getWithRetry(`${data.server}/api/v1/users.info?username=${usernameToDelete}`, { headers: { 'Content-Type': 'application/json', ...headers @@ -82,11 +82,12 @@ const deleteCreatedUser = async ({ username: usernameToDelete }) => { }); const userId = json(result.body)?.data?.user?._id; - http.post(`${data.server}/api/v1/users.delete`, { userId, confirmRelinquish: true }, { + postWithRetry(`${data.server}/api/v1/users.delete`, { headers: { 'Content-Type': 'application/json', ...headers - } + }, + body: JSON.stringify({ userId, confirmRelinquish: true }) }); } catch (error) { console.log(JSON.stringify(error)); @@ -98,7 +99,7 @@ const createRandomTeam = (username, password) => { const teamName = output.randomTeamName(); - http.post(`${data.server}/api/v1/teams.create`, { + postWithRetry(`${data.server}/api/v1/teams.create`, { headers: { 'Content-Type': 'application/json', ...headers @@ -113,7 +114,7 @@ const createRandomRoom = (username, password, type = 'c') => { login(username, password); const room = `room${output.random()}`; - const response = http.post(`${data.server}/api/v1/${type === 'c' ? 'channels.create' : 'groups.create'}`, { + const response = postWithRetry(`${data.server}/api/v1/${type === 'c' ? 'channels.create' : 'groups.create'}`, { headers: { 'Content-Type': 'application/json', ...headers @@ -133,7 +134,7 @@ const sendMessage = (username, password, channel, msg, tmid) => { login(username, password); const channelParam = tmid ? { roomId: channel } : { channel }; - const response = http.post(`${data.server}/api/v1/chat.postMessage`, { + const response = postWithRetry(`${data.server}/api/v1/chat.postMessage`, { headers: { 'Content-Type': 'application/json', ...headers @@ -153,7 +154,7 @@ const sendMessage = (username, password, channel, msg, tmid) => { const getProfileInfo = (userId) => { login(output.account.adminUser, output.account.adminPassword); - const result = http.get(`${data.server}/api/v1/users.info?userId=${userId}`, { + const result = getWithRetry(`${data.server}/api/v1/users.info?userId=${userId}`, { headers: { 'Content-Type': 'application/json', ...headers @@ -168,7 +169,7 @@ const getProfileInfo = (userId) => { const post = (endpoint, username, password, body) => { login(username, password); - const response = http.post(`${data.server}/api/v1/${endpoint}`, { + const response = postWithRetry(`${data.server}/api/v1/${endpoint}`, { headers: { 'Content-Type': 'application/json', ...headers @@ -182,7 +183,7 @@ const post = (endpoint, username, password, body) => { const createDM = (username, password, otherUsername) => { login(username, password); - const result = http.post(`${data.server}/api/v1/im.create`, { + const result = postWithRetry(`${data.server}/api/v1/im.create`, { headers: { 'Content-Type': 'application/json', ...headers @@ -208,6 +209,48 @@ function logAccounts() { console.log(JSON.stringify(data.accounts)); } +const sleep = (ms) => { + const start = Date.now(); + while (Date.now() - start < ms) { } +} + +const retryRequest = (fn, { + retries = 3, + delay = 1000, + factor = 2 +} = {}) => { + let lastError; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const response = fn(); + + if (response && response.status >= 200 && response.status < 300) { + return response; + } + + if (response && response.status >= 400 && response.status < 500) { + throw new Error(`Non-retryable error ${response.status}`); + } + + lastError = new Error(`HTTP ${response ? response.status : 'unknown'}`); + } catch (err) { + lastError = err; + } + + if (attempt < retries) { + const wait = delay * Math.pow(factor, attempt - 1); + console.log(`Retry ${attempt}/${retries} after ${wait}ms`); + sleep(wait); + } + } + + throw lastError; +}; + +const postWithRetry = (url, options) => retryRequest(() => http.post(url, options)); + +const getWithRetry = (url, options) => retryRequest(() => http.get(url, options)); + output.utils = { createUser, createUserWithPasswordChange, diff --git a/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml b/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml index 63ff917ab40..973e6f430f3 100644 --- a/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml +++ b/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml @@ -18,10 +18,13 @@ tags: # Show alerts as Toasts - tapOn: 'Menu' - tapOn: 'Accessibility & appearance' -- tapOn: 'Show alerts as. Toasts' +- tapOn: '.*Show alerts as.*' - assertVisible: 'Toasts. Dismissed automatically. Checked' - assertVisible: 'Dialogs. Require manual dismissal. Unchecked' -- tapOn: 'Bottom Sheet backdrop' +- tapOn: + id: 'action-sheet-handle' +- waitForAnimationToEnd: + timeout: 5000 - tapOn: 'Menu' - tapOn: 'Edit status' - tapOn: @@ -34,7 +37,7 @@ tags: # Show alerts as Dialogs - tapOn: 'Accessibility & appearance' -- tapOn: 'Show alerts as. Toasts' +- tapOn: '.*Show alerts as.*' - assertVisible: 'Toasts. Dismissed automatically. Checked' - tapOn: 'Dialogs. Require manual dismissal. Unchecked' - tapOn: 'Menu' diff --git a/.maestro/tests/assorted/broadcast.yaml b/.maestro/tests/assorted/broadcast.yaml index 1ee8df14453..f8bca168bc4 100644 --- a/.maestro/tests/assorted/broadcast.yaml +++ b/.maestro/tests/assorted/broadcast.yaml @@ -65,7 +65,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: ${output.room} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - assertVisible: id: 'create-channel-broadcast' - tapOn: diff --git a/.maestro/tests/assorted/change-avatar.yaml b/.maestro/tests/assorted/change-avatar.yaml index da1ab5c351e..385d04df75c 100644 --- a/.maestro/tests/assorted/change-avatar.yaml +++ b/.maestro/tests/assorted/change-avatar.yaml @@ -173,7 +173,7 @@ tags: timeout: 60000 - tapOn: text: 'Fetch image from URL' -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'change-avatar-view-submit' diff --git a/.maestro/tests/assorted/join-from-directory.yaml b/.maestro/tests/assorted/join-from-directory.yaml index 5e8de558c70..d536cb874bb 100644 --- a/.maestro/tests/assorted/join-from-directory.yaml +++ b/.maestro/tests/assorted/join-from-directory.yaml @@ -97,10 +97,10 @@ tags: id: 'directory-view-filter' - extendedWaitUntil: visible: - text: 'Users unselected' + id: 'directory-switch-users' timeout: 60000 - tapOn: - text: 'Users unselected' + id: 'directory-switch-users' - tapOn: id: 'directory-view-search' - inputText: ${output.otherUser.username} @@ -145,10 +145,10 @@ tags: id: 'directory-view-filter' - extendedWaitUntil: visible: - text: 'Teams unselected' + id: 'directory-switch-teams' timeout: 60000 - tapOn: - text: 'Teams unselected' + id: 'directory-switch-teams' - tapOn: id: 'directory-view-search' - inputText: ${output.team} diff --git a/.maestro/tests/assorted/profile.yaml b/.maestro/tests/assorted/profile.yaml index 43f673d16ee..e6741045fc4 100644 --- a/.maestro/tests/assorted/profile.yaml +++ b/.maestro/tests/assorted/profile.yaml @@ -68,7 +68,6 @@ tags: # submit button should be disabled because of no changes - assertVisible: id: 'profile-view-submit' - enabled: false # should have new password - scrollUntilVisible: @@ -97,7 +96,8 @@ tags: env: id: 'profile-view-username' - inputText: ${output.user.username + 'username'} -- tapOn: '.*Username.*' #hidekeyboard on iOS +- runFlow: + file: '../../helpers/hide-keyboard.yaml' - scrollUntilVisible: element: id: 'profile-view-submit' @@ -112,9 +112,8 @@ tags: - tapOn: id: 'profile-view-nickname' - inputText: ${output.user.username + 'newnickname'} -- tapOn: - text: '.*Nickname.*' - index: 0 +- runFlow: + file: '../../helpers/hide-keyboard.yaml' - assertVisible: id: 'profile-view-bio' - tapOn: diff --git a/.maestro/tests/e2ee/flows/create-e2ee-room.yaml b/.maestro/tests/e2ee/flows/create-e2ee-room.yaml index 07dbebd8c8b..551a94362c6 100644 --- a/.maestro/tests/e2ee/flows/create-e2ee-room.yaml +++ b/.maestro/tests/e2ee/flows/create-e2ee-room.yaml @@ -44,7 +44,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: ${ROOM} -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'create-channel-encrypted' diff --git a/.maestro/tests/e2ee/utils/change-e2ee-key.yaml b/.maestro/tests/e2ee/utils/change-e2ee-key.yaml index 125cc968a6e..de401fc5c96 100644 --- a/.maestro/tests/e2ee/utils/change-e2ee-key.yaml +++ b/.maestro/tests/e2ee/utils/change-e2ee-key.yaml @@ -7,10 +7,12 @@ tags: - tapOn: Enter manually - tapOn: New password - inputText: ${output.data.e2eePassword} -- hideKeyboard -- swipe: - direction: DOWN - duration: 100 +- runFlow: '../../../helpers/hide-keyboard.yaml' +- scrollUntilVisible: + element: + text: 'Save Changes' + timeout: 60000 + centerElement: true - tapOn: Save Changes - extendedWaitUntil: visible: diff --git a/.maestro/tests/onboarding/change-password.yaml b/.maestro/tests/onboarding/change-password.yaml index cc3f0e2ee66..5c5276aa5e5 100644 --- a/.maestro/tests/onboarding/change-password.yaml +++ b/.maestro/tests/onboarding/change-password.yaml @@ -26,13 +26,13 @@ tags: - tapOn: id: change-password-view-new-password - inputText: 123456 -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - assertVisible: id: change-password-view-confirm-new-password - tapOn: id: change-password-view-confirm-new-password - inputText: 123456 -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - assertVisible: id: change-password-view-set-new-password-button - tapOn: diff --git a/.maestro/tests/onboarding/login/invalid-credentials.yaml b/.maestro/tests/onboarding/login/invalid-credentials.yaml index f896d60d03d..6068f2761b1 100644 --- a/.maestro/tests/onboarding/login/invalid-credentials.yaml +++ b/.maestro/tests/onboarding/login/invalid-credentials.yaml @@ -17,7 +17,7 @@ tags: - tapOn: id: 'login-view-password' - inputText: 'NotMyActualPassword' -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - tapOn: id: login-view-submit - assertVisible: diff --git a/.maestro/tests/onboarding/login/login.yaml b/.maestro/tests/onboarding/login/login.yaml index 3d1df9a6f01..133daf00888 100644 --- a/.maestro/tests/onboarding/login/login.yaml +++ b/.maestro/tests/onboarding/login/login.yaml @@ -18,7 +18,7 @@ tags: - tapOn: id: 'login-view-password' - inputText: ${output.createdUser.password} -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - tapOn: id: login-view-submit - extendedWaitUntil: diff --git a/.maestro/tests/onboarding/register/create-account.yaml b/.maestro/tests/onboarding/register/create-account.yaml index 58fc4ed842c..79364d8e509 100644 --- a/.maestro/tests/onboarding/register/create-account.yaml +++ b/.maestro/tests/onboarding/register/create-account.yaml @@ -28,7 +28,7 @@ tags: - tapOn: id: register-view-confirm-password - inputText: ${output.user.password} -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - scrollUntilVisible: element: id: register-view-submit diff --git a/.maestro/tests/room/create-room.yaml b/.maestro/tests/room/create-room.yaml index 5e92836ad0c..5cbdf51a5b9 100644 --- a/.maestro/tests/room/create-room.yaml +++ b/.maestro/tests/room/create-room.yaml @@ -171,7 +171,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: 'general' -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'create-channel-submit' @@ -190,7 +190,7 @@ tags: id: 'create-channel-name' - eraseText: 100 - inputText: ${output.publicRoomName} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - tapOn: id: 'create-channel-type' - extendedWaitUntil: @@ -258,7 +258,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: ${output.privateRoomName} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'create-channel-submit' @@ -322,7 +322,7 @@ tags: - tapOn: id: 'create-channel-name' - inputText: ${output.emptyRoomName} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'create-channel-submit' @@ -346,101 +346,3 @@ tags: visible: id: ${'rooms-list-view-item-' + output.emptyRoomName} timeout: 60000 - -# should create a room with non-latin alphabet and do a case insensitive search for it -- runFlow: - when: - platform: iOS - commands: - - evalScript: ${output.nonLatinRoomName = 'ПРОВЕРКА' + output.random()} - - evalScript: ${output.nonLatinRoomNameLower = output.nonLatinRoomName.toLowerCase()} - - - extendedWaitUntil: - visible: - id: 'rooms-list-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 - - tapOn: - id: 'rooms-list-view-create-channel' - - extendedWaitUntil: - visible: - id: 'new-message-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: 'new-message-view-create-channel' - timeout: 60000 - - tapOn: - id: 'new-message-view-create-channel' - - extendedWaitUntil: - visible: - id: 'select-users-view' - timeout: 60000 - - tapOn: - id: 'selected-users-view-submit' - - extendedWaitUntil: - visible: - id: 'create-channel-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: 'create-channel-name' - timeout: 60000 - - tapOn: - id: 'create-channel-name' - - inputText: ${output.nonLatinRoomName} - - hideKeyboard - - extendedWaitUntil: - visible: - id: 'create-channel-submit' - timeout: 60000 - - tapOn: - id: 'create-channel-submit' - - extendedWaitUntil: - visible: - id: 'room-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: ${'room-view-title-' + output.nonLatinRoomName} - timeout: 60000 - - runFlow: '../../helpers/go-back.yaml' - - extendedWaitUntil: - visible: - id: 'rooms-list-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: ${'rooms-list-view-item-' + output.nonLatinRoomName} - timeout: 60000 - - extendedWaitUntil: - visible: - id: 'rooms-list-view-search' - timeout: 60000 - - tapOn: - id: 'rooms-list-view-search' - - extendedWaitUntil: - visible: - id: 'rooms-list-view-search-input' - timeout: 60000 - - tapOn: - id: 'rooms-list-view-search-input' - - inputText: ${output.nonLatinRoomNameLower} - - hideKeyboard - - extendedWaitUntil: - visible: - id: ${'rooms-list-view-item-' + output.nonLatinRoomName} - timeout: 60000 - - tapOn: - id: ${'rooms-list-view-item-' + output.nonLatinRoomName} - - extendedWaitUntil: - visible: - id: 'room-view' - timeout: 60000 - - extendedWaitUntil: - visible: - id: ${'room-view-title-' + output.nonLatinRoomName} - timeout: 60000 diff --git a/.maestro/tests/room/ignoreuser.yaml b/.maestro/tests/room/ignoreuser.yaml index 9eaff66e266..93b4df7c309 100644 --- a/.maestro/tests/room/ignoreuser.yaml +++ b/.maestro/tests/room/ignoreuser.yaml @@ -88,9 +88,20 @@ tags: visible: id: 'username-header-${output.otherUser.username}' timeout: 60000 -- tapOn: - id: 'username-header-${output.otherUser.username}' - retryTapIfNoChange: true +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: .*${output.otherUser.username}.* + retryTapIfNoChange: true +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: 'username-header-${output.otherUser.username}' + retryTapIfNoChange: true - extendedWaitUntil: visible: text: 'Ignore' @@ -139,9 +150,20 @@ tags: visible: id: 'username-header-${output.otherUser.username}' timeout: 60000 -- tapOn: - id: 'username-header-${output.otherUser.username}' - retryTapIfNoChange: true +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: .*${output.otherUser.username}.* + retryTapIfNoChange: true +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: 'username-header-${output.otherUser.username}' + retryTapIfNoChange: true - extendedWaitUntil: visible: text: 'Unignore' @@ -187,14 +209,13 @@ tags: - tapOn: id: 'report-user-view-input' - inputText: 'e2e test' +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'report-user-view-submit' timeout: 60000 -- hideKeyboard - tapOn: id: 'report-user-view-submit' - retryTapIfNoChange: true - extendedWaitUntil: visible: id: 'room-view-title-${output.otherUser.username}' @@ -208,9 +229,22 @@ tags: ROOM: ${output.room.name} - extendedWaitUntil: visible: - text: ${output.otherUser.username} + id: username-header-${output.otherUser.username} timeout: 60000 -- tapOn: ${output.otherUser.username} +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: username-header-${output.otherUser.username} + retryTapIfNoChange: true +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: .*${output.otherUser.username}.* + retryTapIfNoChange: true - extendedWaitUntil: visible: id: 'room-info-view' @@ -232,14 +266,13 @@ tags: - tapOn: id: 'report-user-view-input' - inputText: 'e2e test' -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: 'report-user-view-submit' timeout: 60000 - tapOn: id: 'report-user-view-submit' - retryTapIfNoChange: true - extendedWaitUntil: visible: id: 'room-view-title-${output.room.name}' diff --git a/.maestro/tests/room/jump-to-message.yaml b/.maestro/tests/room/jump-to-message.yaml index 2d330c4217b..3ac33d7041c 100644 --- a/.maestro/tests/room/jump-to-message.yaml +++ b/.maestro/tests/room/jump-to-message.yaml @@ -153,6 +153,7 @@ tags: text: '.*Load newer.*' direction: DOWN timeout: 60000 + centerElement: true - tapOn: text: 'Load newer' retryTapIfNoChange: true diff --git a/.maestro/tests/room/message-markdown-click.yaml b/.maestro/tests/room/message-markdown-click.yaml new file mode 100644 index 00000000000..3b6bab80b63 --- /dev/null +++ b/.maestro/tests/room/message-markdown-click.yaml @@ -0,0 +1,197 @@ +appId: chat.rocket.reactnative +name: Message Markdown +jsEngine: graaljs +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +onFlowComplete: + - evalScript: ${output.utils.deleteCreatedUsers()} +tags: + - test-12 + +--- +- evalScript: ${output.user = output.utils.createUser()} + +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} +- runFlow: + file: '../../helpers/search-and-navigate-room.yaml' + env: + ROOM: 'maestro-message-clickable-test' + +- extendedWaitUntil: + visible: + text: 'Link with text https://www.rocket.chat' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*detox-public*.' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*e2e_admin*.' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*Message with thread*.' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'message-thread-button-message with thread' + timeout: 60000 + +# Tap on detox-public mention to open room info +- tapOn: + text: '.*detox-public*.' + +# Verify room info view is shown +- extendedWaitUntil: + visible: + id: 'room-info-view' + timeout: 60000 + +# Verify room name is visible in room info +- assertVisible: + text: '.*detox-public.*' + +# Go back to room +- tapOn: + id: custom-header-back + +# Wait for room view to be visible again +- extendedWaitUntil: + visible: + id: 'room-view-title-maestro-message-clickable-test' + timeout: 60000 + +# Tap on the URL link to open alert +- runFlow: + when: + platform: Android + commands: + - tapOn: + text: '.*https://www.rocket.chat.*' +- runFlow: + when: + platform: iOS + commands: + - tapOn: + point: 66%,66% + +# Verify alert is shown with the link +- extendedWaitUntil: + visible: + text: '.*Link Pressed*.' + timeout: 10000 + +- assertVisible: + text: '.*https://www.rocket.chat*.' + +# Dismiss the alert +- tapOn: + text: 'OK' + optional: true + +# Long press on the link to copy to clipboard +- runFlow: + when: + platform: Android + commands: + - longPressOn: + text: '.*https://www.rocket.chat.*' +- runFlow: + when: + platform: iOS + commands: + - longPressOn: + point: 66%,66% + +# Verify clipboard has the link alert +- extendedWaitUntil: + visible: + text: '.*Link Long Pressed*.' + timeout: 10000 + +- assertVisible: + text: '.*https://www.rocket.chat*.' + +# Dismiss the alert +- tapOn: + text: 'OK' + optional: true + +# Tap on e2e_admin mention to open user info +- tapOn: + text: '.*e2e_admin*.' + +# Verify user info view is shown +- extendedWaitUntil: + visible: + text: '.*User info*.' + timeout: 60000 + +# Verify username is visible in user info +- assertVisible: + text: '.*e2e_admin.*' + +# Go back to room +- tapOn: + id: custom-header-back + +# Wait for room view to be visible again +- extendedWaitUntil: + visible: + id: 'room-view-title-maestro-message-clickable-test' + timeout: 60000 + +# Tap on message with thread to open thread room +- tapOn: + text: '.*Message with thread*.' + +# Verify thread room view is opened with the correct id +- extendedWaitUntil: + visible: + id: 'room-view-title-message with thread' + timeout: 60000 + +# Go back to main room +- tapOn: + id: header-back + +# Wait for room view to be visible again +- extendedWaitUntil: + visible: + id: 'room-view-title-maestro-message-clickable-test' + timeout: 60000 + +# Tap on "View thread" button +- tapOn: + id: 'message-thread-button-message with thread' + +# Verify thread room view is opened +- extendedWaitUntil: + visible: + id: 'room-view-title-message with thread' + timeout: 60000 + +# Go back to main room +- tapOn: + id: header-back + +# Wait for room view to be visible again +- extendedWaitUntil: + visible: + id: 'room-view-title-maestro-message-clickable-test' + timeout: 60000 + +# Now tap on a message within the thread +- tapOn: + text: '.*a message in thread*.' + +# Verify thread room view remains (clicking message in thread stays in thread) +- extendedWaitUntil: + visible: + id: 'room-view-title-message with thread' + timeout: 60000 diff --git a/.maestro/tests/room/room-actions.yaml b/.maestro/tests/room/room-actions.yaml index 1073f819ea9..df2b95a9a76 100644 --- a/.maestro/tests/room/room-actions.yaml +++ b/.maestro/tests/room/room-actions.yaml @@ -121,8 +121,8 @@ tags: from: id: action-sheet-handle direction: UP -- extendedWaitUntil: - visible: +- scrollUntilVisible: + element: text: 'Star' timeout: 60000 - tapOn: diff --git a/.maestro/tests/room/room-info.yaml b/.maestro/tests/room/room-info.yaml index c1ba9dae806..7360de99c59 100644 --- a/.maestro/tests/room/room-info.yaml +++ b/.maestro/tests/room/room-info.yaml @@ -290,7 +290,7 @@ tags: env: id: 'room-info-edit-view-name' - inputText: ${output.newroomname} -- pressKey: Enter +- runFlow: '../../helpers/hide-keyboard.yaml' - scrollUntilVisible: element: id: 'room-info-edit-view-submit' @@ -321,6 +321,14 @@ tags: timeout: 60000 # should change room description, topic, announcement +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + notVisible: + text: '.*Settings succesfully changed.*' + timeout: 60000 - scrollUntilVisible: element: id: 'room-info-edit-view-topic' diff --git a/.maestro/tests/room/room.yaml b/.maestro/tests/room/room.yaml index fa6c6f983b8..b29ad58be49 100644 --- a/.maestro/tests/room/room.yaml +++ b/.maestro/tests/room/room.yaml @@ -253,10 +253,8 @@ tags: visible: id: 'action-sheet-handle' timeout: 60000 -- swipe: - from: - id: action-sheet-handle - direction: DOWN +- tapOn: + id: 'action-sheet-handle' - extendedWaitUntil: notVisible: id: 'reactionsList' @@ -318,22 +316,23 @@ tags: visible: text: '.*edit.*' timeout: 60000 +- hideKeyboard - longPressOn: id: 'message-content-edit' +- waitForAnimationToEnd: + timeout: 1000 - extendedWaitUntil: visible: id: action-sheet timeout: 60000 - extendedWaitUntil: visible: - id: action-sheet-handle - timeout: 60000 -- extendedWaitUntil: - visible: - text: 'Edit' + id: message-actions-edit timeout: 60000 +- waitForAnimationToEnd: + timeout: 1000 - tapOn: - text: 'Edit' + id: message-actions-edit - extendedWaitUntil: visible: id: message-composer-input @@ -364,10 +363,10 @@ tags: timeout: 60000 - extendedWaitUntil: visible: - text: 'Quote' + id: message-actions-quote timeout: 60000 - tapOn: - text: 'Quote' + id: message-actions-quote - extendedWaitUntil: visible: id: message-composer-input @@ -420,9 +419,9 @@ tags: direction: UP - scrollUntilVisible: element: - text: 'Delete' + id: message-actions-delete - tapOn: - text: 'Delete' + id: message-actions-delete - extendedWaitUntil: visible: text: '.*You will not be able to recover this message.*' @@ -472,10 +471,10 @@ tags: timeout: 60000 - extendedWaitUntil: visible: - text: 'Reply in direct message' + id: message-actions-reply-in-dm timeout: 60000 - tapOn: - text: 'Reply in direct message' + id: message-actions-reply-in-dm - extendedWaitUntil: visible: id: room-view-title-${output.replyUser.username} @@ -487,7 +486,7 @@ tags: - tapOn: id: message-composer-input - inputText: ${output.replyMessage} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - tapOn: id: message-composer-send - extendedWaitUntil: @@ -510,7 +509,7 @@ tags: - tapOn: id: message-composer-input - inputText: 'draft' -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - runFlow: '../../helpers/go-back.yaml' - runFlow: file: '../../helpers/navigate-to-room.yaml' @@ -565,7 +564,7 @@ tags: - tapOn: id: message-composer-input - inputText: ${output.draftMessage} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - runFlow: '../../helpers/go-back.yaml' - runFlow: file: '../../helpers/navigate-to-room.yaml' @@ -586,7 +585,7 @@ tags: id: action-sheet timeout: 60000 - tapOn: - text: 'Quote' + id: message-actions-quote - extendedWaitUntil: visible: id: 'markdown-preview-${output.originalMessage}' diff --git a/.maestro/tests/room/search-member.yaml b/.maestro/tests/room/search-member.yaml new file mode 100644 index 00000000000..6d6bf982b37 --- /dev/null +++ b/.maestro/tests/room/search-member.yaml @@ -0,0 +1,91 @@ +appId: chat.rocket.reactnative +name: Search Member +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +tags: + - test-13 + +--- +- evalScript: ${output.user = output.utils.createUser()} + +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} + +- runFlow: + file: '../../helpers/navigate-to-room.yaml' + env: + ROOM: 'general' +- tapOn: + id: room-header +- extendedWaitUntil: + visible: + id: 'room-actions-view' + timeout: 60000 +- tapOn: + id: 'room-actions-members' +- extendedWaitUntil: + visible: + id: 'room-members-view-search' + timeout: 60000 + +# should search in all users +- tapOn: + id: room-members-view-search +- inputText: rohit.bansal +- extendedWaitUntil: + visible: + id: 'room-members-view-item-rohit.bansal' + timeout: 60000 + +# use online status and it should use the text filter +- tapOn: + id: room-members-view-filter +- extendedWaitUntil: + visible: + id: 'room-members-view-toggle-status-online' + timeout: 60000 +- tapOn: + id: room-members-view-toggle-status-online +- extendedWaitUntil: + visible: + text: 'No members found' + timeout: 60000 + +# use all status again and it should use text filter +- tapOn: + id: room-members-view-filter +- extendedWaitUntil: + visible: + id: 'room-members-view-toggle-status-all' + timeout: 60000 +- tapOn: + id: room-members-view-toggle-status-all +- extendedWaitUntil: + visible: + id: 'room-members-view-item-rohit.bansal' + timeout: 60000 +- tapOn: + id: clear-text-input + +- evalScript: ${output.secondUser = output.utils.createUser()} + +# should search for new user in all list +- tapOn: + id: room-members-view-search +- inputText: ${output.secondUser.username} +- extendedWaitUntil: + visible: + id: 'room-members-view-item-${output.secondUser.username}' + timeout: 60000 + +# Verify "No members found" message appears correctly when search returns no results +- tapOn: + id: room-members-view-search +- inputText: nonexistentuser12345 +- extendedWaitUntil: + visible: + text: 'No members found' + timeout: 60000 diff --git a/.maestro/tests/room/share-message.yaml b/.maestro/tests/room/share-message.yaml index 4bbf18fff3e..d7a339a9030 100644 --- a/.maestro/tests/room/share-message.yaml +++ b/.maestro/tests/room/share-message.yaml @@ -68,17 +68,15 @@ tags: - tapOn: id: 'multi-select-search' - inputText: ${output.otherUser.username} -- hideKeyboard - extendedWaitUntil: visible: id: multi-select-item-${output.otherUser.username.toLowerCase()} timeout: 60000 - tapOn: id: multi-select-item-${output.otherUser.username.toLowerCase()} -- swipe: - from: - id: 'action-sheet-handle' - direction: DOWN +# this is to hide bottom sheet and keyboard together +- tapOn: + point: 5%,10% - extendedWaitUntil: notVisible: id: 'multi-select-search' diff --git a/.maestro/tests/teams/team.yaml b/.maestro/tests/teams/team.yaml index 67ba897ffb1..4207534d68c 100644 --- a/.maestro/tests/teams/team.yaml +++ b/.maestro/tests/teams/team.yaml @@ -106,12 +106,12 @@ tags: - tapOn: id: create-channel-name - inputText: ${output.privateRoomName} -- hideKeyboard -- extendedWaitUntil: - visible: - id: create-channel-submit +- runFlow: '../../helpers/hide-keyboard.yaml' +- scrollUntilVisible: + element: + id: 'create-channel-submit' timeout: 60000 - label: should have submit button + centerElement: true - tapOn: id: create-channel-submit - extendedWaitUntil: @@ -297,7 +297,7 @@ tags: - tapOn: id: select-users-view-search - inputText: rocket.cat -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: select-users-view-item-rocket.cat @@ -316,7 +316,7 @@ tags: id: select-users-view-search - eraseText - inputText: ${output.secondUser.username} -- hideKeyboard +- runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: visible: id: select-users-view-item-${output.secondUser.username} diff --git a/.maestro/tests/teams/utils/close-action-sheet.yaml b/.maestro/tests/teams/utils/close-action-sheet.yaml index bd6d32cf6a8..8a19f95eebc 100644 --- a/.maestro/tests/teams/utils/close-action-sheet.yaml +++ b/.maestro/tests/teams/utils/close-action-sheet.yaml @@ -3,10 +3,8 @@ tags: - 'util' --- -- swipe: - from: - id: action-sheet-handle - direction: DOWN +- tapOn: + id: action-sheet-handle - extendedWaitUntil: notVisible: id: action-sheet-handle diff --git a/.maestro/tests/teams/utils/create-channel.yaml b/.maestro/tests/teams/utils/create-channel.yaml index 679c0c2c8c8..e8f9d91fcb2 100644 --- a/.maestro/tests/teams/utils/create-channel.yaml +++ b/.maestro/tests/teams/utils/create-channel.yaml @@ -34,7 +34,7 @@ tags: - tapOn: id: create-channel-name - inputText: ${channelName} -- hideKeyboard +- runFlow: '../../../helpers/hide-keyboard.yaml' - runFlow: when: diff --git a/.maestro/tests/teams/utils/open-action-sheet.yaml b/.maestro/tests/teams/utils/open-action-sheet.yaml index 5b6083c0515..b3bbd9808aa 100644 --- a/.maestro/tests/teams/utils/open-action-sheet.yaml +++ b/.maestro/tests/teams/utils/open-action-sheet.yaml @@ -7,16 +7,16 @@ tags: visible: id: room-members-view-item-${username} timeout: 60000 +- waitForAnimationToEnd: + timeout: 2000 - tapOn: id: room-members-view-item-${username} +- waitForAnimationToEnd: + timeout: 2000 - extendedWaitUntil: visible: id: action-sheet timeout: 60000 -- assertVisible: - id: action-sheet-handle -- tapOn: - id: action-sheet-handle - extendedWaitUntil: visible: id: action-sheet diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000000..681311eb9cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..ae66fc99245 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Rocket.Chat React Native mobile client. Single-package React Native app (not a monorepo) using Yarn 1.22.22 (npm won't work). Supports iOS 13.4+ and Android 6.0+. + +- React 19.1, React Native 0.81, Expo 54 +- TypeScript with strict mode, baseUrl set to `app/` (imports resolve from there) +- Node: engines `>=18`, volta pins 24.13.1 +- Read UBIQUITOUS_LANGUAGE.md + +## Commands + +```bash +# Install & setup +yarn # Install dependencies (postinstall runs patch-package) +yarn pod-install # Install iOS CocoaPods (required before iOS builds) + +# Run +yarn start # Start Metro bundler +yarn ios # Build and run on iOS +yarn android # Build and run on Android + +# Test +TZ=UTC yarn test # Run Jest unit tests (TZ=UTC is set in script) +yarn test -- --testPathPattern='path/to/test' # Run a single test file +yarn test-update # Update snapshots + +# Lint & format +yarn lint # ESLint + TypeScript compiler check +yarn prettier-lint # Prettier auto-fix + lint + +# Storybook +yarn storybook:start # Start Metro with Storybook UI +yarn storybook-generate # Generate story snapshots +``` + +## Code Style + +- **Prettier**: tabs, single quotes, 130 char width, no trailing commas, arrow parens avoid, bracket same line +- **ESLint**: `@rocket.chat/eslint-config` base with React, React Native, TypeScript, Jest plugins +- **Before committing**: Run `yarn prettier-lint` and `TZ=UTC yarn test` for modified files +- Pre-commit hooks enforce these checks + +## Architecture + +### State Management: Redux + Redux-Saga + +- **Actions** (`app/actions/`) — plain action creators +- **Reducers** (`app/reducers/`) — state shape (app, login, connect, rooms, encryption, etc.) +- **Sagas** (`app/sagas/`) — side effects (init, login, rooms, messages, encryption, deepLinking, videoConf) +- **Selectors** (`app/selectors/`) — memoized with reselect +- **Store** (`app/lib/store/`) — configures middleware (saga, app state, internet state) + +### Navigation: React Navigation 7 + +- **Stacks** (`app/stacks/`) — InsideStack (authenticated), OutsideStack (login/register), MasterDetailStack (tablets), ShareExtensionStack +- **Root** (`app/AppContainer.tsx`) — switches between auth states +- **Responsive layout** (`app/lib/hooks/useResponsiveLayout/`) — master-detail on tablets vs single stack on phones + +### Database: WatermelonDB (offline-first SQLite) + +- **Models** (`app/lib/database/model/`) — Message, Room, Subscription, User, Thread, Upload, Server, CustomEmoji, Permission, Role, etc. +- **Schema** (`app/lib/database/schema/`) +- Local-first: UI reads from DB, sagas sync with server + +### API Layer + +- **SDK** (`app/lib/services/sdk.ts`) — Rocket.Chat JS SDK for WebSocket real-time subscriptions +- **REST** (`app/lib/services/restApi.ts`) — HTTP via fetch +- **Connect** (`app/lib/services/connect.ts`) — server connection management + +### Views & Components + +- **Views** (`app/views/`) — 70+ screen components +- **Containers** (`app/containers/`) — reusable UI components +- **Theme** (`app/theme.tsx`) — theming context + +### Other Key Systems + +- **i18n** (`app/i18n/`) — i18n-js with 40+ locales, RTL support +- **Encryption** (`app/lib/encryption/`) — E2E encryption via @rocket.chat/mobile-crypto +- **Enterprise** (`app/ee/`) — Omnichannel/livechat features +- **Definitions** (`app/definitions/`) — shared TypeScript types +- **VideoConf** (`app/sagas/videoConf.ts`, `app/lib/methods/videoConf.ts`) — server-managed video conferencing (Jitsi); uses Redux actions/reducers/sagas. May be replaced or removed in the future. +- **VoIP** (`app/lib/services/voip/`) — new WebRTC peer-to-peer audio calls with native CallKit (iOS) and Telecom (Android) integration; uses Zustand stores, not Redux. VoIP and VideoConf are entirely separate features — do not conflate them. + +### Entry Points + +- `index.js` — registers app, conditionally loads Storybook +- `app/index.tsx` — Redux provider, theme, navigation, notifications setup +- `app/AppContainer.tsx` — root navigation container diff --git a/MERGE_NOTES.md b/MERGE_NOTES.md new file mode 100644 index 00000000000..47bde13c00e --- /dev/null +++ b/MERGE_NOTES.md @@ -0,0 +1,244 @@ +**BASE_TIP:** 58e91f1b7 + +# Merge develop → feat.voip-lib-new (v6) + +Executed from `.worktrees/merge-develop` on branch `merge/develop-into-voip-lib-new`. + +## Slice 1 — Branch hygiene + worktree + preflight + +- **Started:** 2026-04-08 +- Archived prior aborted branch: `archive/merge-develop-v5-aborted-2026-04-08` → `ea118b952` +- Reset `merge/develop-into-voip-lib-new` to `58e91f1b7` (origin/feat.voip-lib-new) +- Created worktree at `.worktrees/merge-develop` +- `git fsck --no-dangling`: clean +- `df -h`: 23Gi free (user explicitly waived >100 GB precheck — "Don't worry about git or storage") +- Extracted `MERGE_NOTES.md.v5-baseline` from `archive/merge-develop-v5-aborted-2026-04-08:MERGE_NOTES.md` +- Primary checkout at `/Users/diegomello/Development/Work/Rocket.Chat.ReactNative` untouched + +## Slice 2 — Initial baseline `yarn install` + +- `yarn install` exit 0 +- `node_modules/` populated +- `patch-package` applied all 15 patches cleanly (zero "Hunk failed"): + `@discord/bottom-sheet@4.6.1`, `@rocket.chat/message-parser@0.31.31`, `@rocket.chat/sdk@1.3.3-mobile`, + `@types/ejson@2.2.2`, `expo-file-system@18.1.7`, `expo-image@2.3.2`, `react-native@0.79.4`, + `react-native-callkeep@4.3.16`, `react-native-easy-toast@2.3.0`, `react-native-mmkv@3.3.3`, + `react-native-modal@13.0.1`, `react-native-notifier@1.6.1`, `react-native-picker-select@9.0.1`, + `react-native-webview@13.15.0`, `remove-markdown@0.3.0` + +## Slice 3 — Cherry-pick 2a: iOS 26 deployment target (#6974) + +- `git cherry-pick -x 09ec94dac` → commit `79a987603` (clean, no conflicts) + - Auto-merged `.github/actions/upload-ios/action.yml` and `ios/RocketChatRN/Info.plist` + - 6 files changed, 10 insertions, 8 deletions +- Podfile: no VoIP pod entries to preserve (VoIP libs autolink via package.json; Podfile has no explicit `callkeep`/`media-signaling` refs, pre- or post-cherry-pick) +- Gates: + - `yarn install` exit 0 (cached, 0.8s) + - `yarn lint` exit 0 (184 warnings, 0 errors) + - `yarn test` exit 0 (127 suites, 1022 tests, 317 snapshots, 18.2s) + - `grep -rE "^<<<<<<< "` → 0 +- Commit count `git log feat.voip-lib-new..HEAD --oneline | wc -l` == 1 +- No `pod install` (deferred Phase 4) +- **Adapt:** created `.worktrees/.eslintrc.js` barrier (`module.exports = { root: true }`) to stop ESLint cascading from the worktree into the primary checkout's config (worktree is nested inside primary). Not tracked, not part of any commit. + +## Slice 4 — Cherry-pick 2b: react-native-true-sheet (#6970) + +- `git cherry-pick -x ec27a7c4c` → commit `4eba633eb` +- Conflicts resolved (`git checkout --theirs` on all 3): + - `app/containers/ActionSheet/ActionSheet.tsx` → theirs (develop's TrueSheet usage) + - `app/containers/TextInput/FormTextInput.tsx` → theirs (dropped `BottomSheetTextInput` import) + - `jest.setup.js` → theirs (dropped bottom-sheet mock, gained TrueSheet mock) +- **Fallout from `--theirs` on `jest.setup.js`**: wiped VoIP-only mocks (`react-native-incall-manager`, `expo-haptics` object form). Restored via adapt commit below. +- `git grep '@discord/bottom-sheet'` in `app/views/CallView/**` → 0 hits +- Other VoIP-side `@discord/bottom-sheet` imports found in NewMediaCall stories + `bottomSheet` prop usages on `FormTextInput` in Dialpad.tsx + FilterHeader.tsx → migrated via adapt commit +- Gates: + - `yarn install` exit 0 (patch-package: 15/15 applied, `@lodev09/react-native-true-sheet@3.7.3` replaces `@discord/bottom-sheet`) + - `yarn lint` exit 0 (181 warnings, 0 errors) + - `yarn test` exit 0 (128 suites, 1027 tests, 317 snapshots) + - `grep -rE "^<<<<<<< "` → 0 +- Snapshot regeneration (per-file, not blanket): + - `FilterHeader.test.tsx.snap` (VoIP; justification: BottomSheet decorator removed → shallower render tree) + - `Dialpad.test.tsx.snap` (VoIP; justification: `bottomSheet` prop removed from Dialpad.tsx + upstream RN TextInput `textAlign: "auto"` default) + - `TextInput.test.tsx.snap`, `ServerItem.test.tsx.snap`, `Markdown.test.tsx.snap` (non-VoIP; justification: true-sheet render tree style churn from upstream components) +- **Adapt commit `021f3d664`**: `adapt: migrate VoIP screens off @discord/bottom-sheet (post true-sheet #6970)` +- Commit count: `git log feat.voip-lib-new..HEAD --oneline | wc -l` == 3 (2 cherry-picks + 1 adapt) + +## Slice 5 — Cherry-pick 2c: reanimated v4 (#6720) + +- `git cherry-pick -x 75d866b88` → commit `d8a2c8f06` +- Conflicts (4): + - `package.json`: manual union — kept VoIP's `react-native-prompt-android: 1.1.0`, took develop's `react-native-reanimated: ^4.1.3` and `react-native-worklets: ^0.6.1`; other VoIP-exclusive deps (`react-native-platform-touchable`, `react-native-slowlog`, `react-native-webrtc`) were already on non-conflicted context lines and preserved automatically + - `app/containers/AudioPlayer/Seek.tsx` → theirs (develop migrated from `useAnimatedGestureHandler` to `Gesture.Pan()` API; VoIP did not touch this file) + - `app/containers/message/__snapshots__/Message.test.tsx.snap` → theirs, then regenerated post-install (see adapt below) + - `yarn.lock`: **attempted regeneration from scratch failed** — patch-package bombed on `@rocket.chat/message-parser` (0.31.31 → 0.31.35) and `react-native-webview` (13.15.0 → 13.16.1) because yarn floated to newer compatible versions with an empty lock. Recovered by `git checkout 75d866b88 -- yarn.lock` (develop's reanimated-PR lock), then `yarn install` to reconcile VoIP-only entries. All 15 patches applied cleanly afterwards. **Recipe for future cherry-picks: prefer `git checkout -- yarn.lock` over `rm yarn.lock && yarn install`.** +- `babel.config.js` → byte-identical to `git show origin/develop:babel.config.js` (not conflicted; auto-merged) +- Gates: + - `yarn install` exit 0 (all 15 patches applied) + - `yarn lint` exit 0 (176 warnings, 0 errors) + - `yarn test` exit 0 (128 suites, 1027 tests, 317 snapshots) + - `grep -rE "^<<<<<<< "` → 0 +- Snapshot regeneration (per-file, not blanket): + - `Message.test.tsx.snap` (non-VoIP; 98 updated, 5 obsolete removed; justification: reanimated 4 worklets runtime + Seek.tsx gesture API migration alter the render tree of message components that embed the audio player) +- **Adapt commit `95fbb9669`**: `adapt: regenerate Message.test.tsx.snap for reanimated 4 (#6720)` +- Commit count: `git log feat.voip-lib-new..HEAD --oneline | wc -l` == 5 (3 cherry-picks + 2 adapts) + +## Slice 6 — Cherry-pick 2d: RN 81 + Expo 54 (#6875) + +- `git cherry-pick -x 91b223410` → commit `d8b48adba` +- Highest-risk single slice. Recipes applied per v6 plan. +- Conflicts: + - **modify/delete (VoIP deleted, develop modified)** — resolved with `git rm`: + - `app/containers/InAppNotification/__snapshots__/NotifierComponent.test.tsx.snap` + - `app/containers/message/Touch.tsx` + - **content (`--theirs`, develop's version)**: 16 snapshot files + `ios/Podfile.lock` + `ios/RocketChatRN.xcodeproj/project.pbxproj` + - **content (`--ours`, VoIP's version)**: + - `app/containers/Button/index.tsx` (VoIP migrated from `RectButton` → `Touchable`) + - `app/containers/UIKit/Overflow.tsx` (VoIP uses `Touchable` + `touchable[blockId]` ref pattern; develop's version imports a `Touch` helper that VoIP removed) + - **`package.json`**: took theirs then spliced 12 VoIP-exclusive deps back alphabetically: + - dependencies: `@rocket.chat/media-signaling`, `react-native-callkeep`, `react-native-incall-manager`, `react-native-platform-touchable`, `react-native-prompt-android`, `react-native-slowlog`, `react-native-webrtc`, `zustand` + - devDependencies: `@types/react-native-platform-touchable`, `eslint-plugin-jsx-a11y`, `lint-staged` + - Dropped VoIP-side `prop-types` (unused in app/) + - **`yarn.lock`**: `git checkout 91b223410 -- yarn.lock` then `yarn install` to reconcile VoIP entries (recipe from slice 5) +- patch-package post-install: 14/14 applied (down from 15 — `expo-image+2.3.2.patch` correctly died on expo-image 3.0.x bump) +- Native config: `ios/Podfile.lock`, `ios/RocketChatRN.xcodeproj/project.pbxproj`, `android/` files — all took develop's RN 81 / AGP / Kotlin / Gradle bumps (auto-merged or `--theirs`) +- Package.json AC checks: + - `react-native` == `0.81.5` ✓ + - `expo` == `^54.0.0` ✓ + - `@rocket.chat/media-signaling` == `file:./packages/rocket.chat-media-signaling-0.1.3.tgz` ✓ + - `react-native-callkeep` == `4.3.16` ✓ +- Patches AC checks: + - `patches/react-native-callkeep+4.3.16.patch` ✓ exists + - `patches/expo-image+2.3.2.patch` ✓ absent + - `patches/react-native+0.79.4.patch` ✓ absent + - `patches/react-native+0.81.5.patch` ✓ exists +- Gates: + - `yarn install` exit 0 + - `npx patch-package` (via postinstall): 14/14 applied, zero "Hunk failed" + - `yarn lint` exit 0 (176 warnings, 0 errors after adapt fixes) + - `yarn test` exit 0 (128 suites, 1027 tests, 317 snapshots after regen) + - `grep -rE "^<<<<<<< "` → 0 +- **Adapt commit `4dbc1185b`**: `adapt: RN 81 + Expo 54 render-tree churn and type tightening (#6875)` — 200 snapshots updated + 8 obsolete removed across 26 suites; 2 TS tightening fixes (`ForwardMessageView` dead `?? true`, `RoomView` @ts-ignore for screen name generic); `jest.setup.js` eslint --fix; `yarn.lock` reconciled for VoIP-only deps. +- Commit count: `git log feat.voip-lib-new..HEAD --oneline | wc -l` == 7 (4 cherry-picks + 3 adapts) + +## SHA mapping table (cherry-picks 2a–2d) + +| # | Source (origin/develop) | Applied (merge branch) | PR | Purpose | +| --- | ----------------------- | ---------------------- | ----- | ---------------------------------- | +| 2a | `09ec94dac` | `79a987603` | #6974 | iOS 26 deployment target | +| 2b | `ec27a7c4c` | `4eba633eb` | #6970 | Migrate to react-native-true-sheet | +| 2c | `75d866b88` | `d8a2c8f06` | #6720 | Upgrade reanimated to v4 | +| 2d | `91b223410` | `d8b48adba` | #6875 | Upgrade to RN 81 + Expo 54 | + +## Slice 7 — Bulk merge + per-file recipes + Kotlin compile gate + +- **Started:** 2026-04-08 +- `git merge origin/develop --no-ff --no-commit` → 23 conflicts +- Merge base: `58e91f1b7` (feat.voip-lib-new tip = `4dbc1185b` after slice 6) +- **NOT YET COMMITTED** — Slice 8 (`NotificationIntentHandler.kt` sanity pause) must run first. + +### Per-file resolutions + +| File | Strategy | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `package.json` | manual union — take theirs, splice VoIP-only deps | Re-added `react-native-platform-touchable`, `react-native-slowlog`, `@types/react-native-platform-touchable`, `react-native-incall-manager`, `react-native-prompt-android`, `react-native-webrtc`, `@rocket.chat/media-signaling` (already present), `zustand` (already present). devDeps: `eslint-plugin-jsx-a11y`, `lint-staged`. | +| `yarn.lock` | `git checkout MERGE_HEAD -- yarn.lock` + `yarn install` reconcile | Recipe from slice 5/6. Develop lock became base; yarn install added VoIP-only entries. 14/14 patches applied clean. Develop bumped patch targets: `@rocket.chat/message-parser+0.31.32`, `expo-file-system+19.0.21`, `react-native-webview+13.16.1`. | +| `jest.setup.js` | manual — take theirs style on 3 formatting conflicts, preserve VoIP mocks | Conflicts were pure prettier body-style (arrow concise vs braced + parens). VoIP mocks `react-native-incall-manager` + `expo-haptics` (object form with `ImpactFeedbackStyle`) preserved from slice 4 adapt. | +| `android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt` | union — keep both imports + both `add()` calls | VoIP's `VoipTurboPackage` + develop's `InvertedScrollPackage`. Class body auto-merged cleanly (only the import block needed manual resolution). | +| `android/app/build.gradle` | union — keep both deps | `testImplementation 'junit:junit:4.13.2'` (VoIP) + `implementation 'androidx.lifecycle:lifecycle-process:2.8.7'` (develop). | +| `app/sagas/login.js` | manual — base ours (VoIP), layer develop's new logic | Kept VoIP's `disconnect` import (develop's unused `connect` import dropped). Added develop's `setUserPresenceAway` restApi import, `checkBackgroundAndSetAway` function, and `yield fork(checkBackgroundAndSetAway)` call. VoIP's `startVoipFork`, `getUserPresence(user.id)`, and removal of `fetchEnterpriseModulesFork` preserved. | +| `app/containers/message/Touch.tsx` | re-deleted (`git rm -f`) | VoIP intentionally removed this file; merge re-added it from develop. Re-deleted since VoIP code no longer references it (0 importers). | +| `app/containers/InAppNotification/NotifierComponent.{test.tsx,stories.tsx}` + snapshot | re-deleted (`git rm -f`) | VoIP removed the component; merge re-added test/stories from develop. Test file has no component to target. | +| `app/containers/CustomIcon/selection.json`, `ios/custom.ttf`, `android/app/src/main/assets/fonts/custom.ttf` | `--theirs` | VoIP didn't touch icon font assets (empty log); take develop's bump. | +| 15 non-VoIP snapshots (Avatar, DirectoryItem, List, LoginServices, RoomItem, ServerItem, TextInput, UIKitMessage, UIKitModal, Message, DiscussionsView/Item, ServersHistoryItem, LoadMore, ThreadMessagesView/Item) | `--theirs` then regenerate per-file | All 15 failures were pure theme color diffs (`#E4E7EA` → `#C1C7D0`). No logic changes. | +| 9 regenerated snaps after install (Avatar, List, InAppNotification/NotifierComponent, CallView/index, DiscussionsView/Item, ThreadMessagesView/Item, RoomItem, UIKit/UiKitMessage, LoadMore) | `yarn jest -u ` | 15 snapshots updated across 9 suites. CallView/index is VoIP-touched — its snapshot matches VoIP's current component output. No blanket `-u`. | + +### Post-merge eslint --fix + +`yarn eslint . --fix` cleared 7 autofixable prettier errors (`(error)` → `error` arrow-paren rule) across index.js + sagas/login.js + sagas/deepLinking.js. 0 errors, 172 warnings remain (same warning surface as post-slice-6). + +### Gates + +- `grep -rE "^<<<<<<< " -- android/app/src/main app android ios` → 0 ✓ +- `yarn install` exit 0; 14/14 patches applied clean +- `yarn lint` exit 0 (0 errors, 172 warnings) +- `yarn test` exit 0 (129 suites, 1056 tests, 331 snapshots) +- **`cd android && ./gradlew compileDebugKotlin` BUILD SUCCESSFUL** in 1m 29s (340 tasks; only warnings were from `react-native-screens` upstream, none from VoIP code) ✓ +- `grep VoipTurboPackage android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt` → 2 ✓ +- `grep media-signaling android/app/build.gradle` → 0 (VoIP's baseline build.gradle also had 0; media-signaling is autolinked from package.json `file:./packages/rocket.chat-media-signaling-0.1.3.tgz`, not declared in build.gradle). **Plan AC line is informational only for this repo.** +- `packages/rocket.chat-media-signaling-0.1.3.tgz` present ✓ (not a conflict) +- `NotificationIntentHandler.kt` auto-merged cleanly (no conflict markers); `grep VoipNotification` == 2. Slice 8 code-reviewer sanity pause still required. + +## Slice 8 — NotificationIntentHandler.kt sanity pause + +- **Started:** 2026-04-08 +- The file auto-merged cleanly during Slice 7's `git merge origin/develop --no-ff` (no conflict markers). +- Sanity-pause review performed via the `oh-my-claudecode:code-reviewer` subagent on the resolved file, with explicit instruction to verify the three VoIP invariants (VoipPayload parsing, MediaCallEvents emissions, no VoIP branch dropped). + +### Resolved diff vs HEAD (VoIP baseline) + +```diff +@@ -98,6 +98,20 @@ class NotificationIntentHandler { + } + + try { ++ val notId = extras.getString("notId") ++ ++ // Clear the notification messages from the static map to prevent stacking ++ if (!notId.isNullOrEmpty()) { ++ try { ++ val notIdInt = notId.toIntOrNull() ++ if (notIdInt != null) { ++ CustomPushNotification.clearMessages(notIdInt) ++ } ++ } catch (e: Exception) { ++ Log.e(TAG, "Error clearing notification messages for ID $notId: ${e.message}", e) ++ } ++ } ++ + // Extract all notification data from Intent extras + // Only include serializable types to avoid JSON serialization errors + val notificationData = mutableMapOf() +``` + +### NotificationIntentHandler.kt review + +**Verdict: PASS** + +**Invariant 1 — VoIP early-return intact (line 25-27)** + +```kotlin +if (VoipNotification.handleMainActivityVoipIntent(context, intent)) { + return +} +``` + +First statement in `handleIntent()`. If the intent is a VoIP payload, it is parsed into `VoipPayload`, handled, and control returns immediately. VoipPayload parsing path preserved. + +**Invariant 2 — `clearMessages` block positioned on non-VoIP path only** +The new block (lines 101-113) lives inside `handleNotificationIntent()` (private method, line 91), which is only reached via line 35 — AFTER both the VoIP early-return (line 25) and the videoconf early-return (line 30). A VoIP intent cannot reach `CustomPushNotification.clearMessages()`. The block additionally sits inside the `ejson`-guard (line 96), so it only runs for real push notifications with payload data. + +**Invariant 3 — `caller` key rename preserved (line 53, 69-72)** +VoIP branch's `callerName` → `caller` rename survived the merge in the videoconf handler. No regression to the old key. + +**MediaCallEvents code paths** — `MediaCallEvents` is not referenced in this file directly; emissions happen inside `VoipNotification.handleMainActivityVoipIntent()`, called unchanged at line 25. No VoIP branch was dropped. + +**No VoIP branch dropped** — three-branch dispatch structure (VoIP → VideoConf → Regular Notification) fully intact at lines 25-35. + +**Merge semantics summary** — The `origin/develop` addition (`notId`/`clearMessages` cleanup) was semantically independent from VoIP's additions (early-return + `caller` key rename). Auto-merge placed the develop-side change inside `handleNotificationIntent()` — the correct non-VoIP, non-videoconf code path — and left both VoIP modifications untouched. All three invariants hold. Slice 8 merge is safe to keep. + +### Invariants verified manually after subagent review + +- [x] VoIP push payloads still parsed into `VoipPayload` (via `VoipNotification.handleMainActivityVoipIntent` at line 25) +- [x] `MediaCallEvents` emissions still fire on same paths (indirectly via `VoipNotification`, unchanged call site) +- [x] No VoIP-specific branch dropped (three-branch dispatch intact) + +## Slice 9 — Native install gates (`yarn install` + `pod install`) + +- **Started:** 2026-04-08 +- `yarn install` (post-merge sanity) → exit 0 (0.93s, "Already up-to-date"), 14/14 patches applied clean. **`yarn.lock` byte-stable** (no diff against the merge commit). +- `rm -rf ios/Pods ios/Podfile.lock ios/build` → clean (user executed from shell due to sandbox rm restriction). +- `bundle install` (first run on this machine/ruby — required to fetch `rake-13.2.1` and 124 other gems into `vendor/bundle`). +- `yarn pod-install` → **first attempt failed** downloading `JitsiWebRTC v124.0.2` xcframework from `release-assets.githubusercontent.com` (curl exit 7, connect refused in 20ms — transient network/DNS blip). **Retry succeeded** in 83.22s: "Pod installation complete! 132 dependencies from the Podfile and 156 total pods installed." Codegen ran cleanly for all 39 RN modules (including `RNCallKeep`, `ReactNativeIncallManager`, `react-native-webrtc`). No errors mentioning `iOS deployment target`, `media-signaling`, or `callkeep`. +- `ios/Podfile.lock` regenerated: +30/-8 lines (expected from RN 81 + Expo 54 + Jitsi WebRTC refresh after the iOS 26 deployment target bump). +- Pre-existing CocoaPods warnings (not introduced by this merge): `ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES` and `CLANG_CXX_LANGUAGE_STANDARD` overrides on RocketChatRN/Rocket.Chat/NotificationService targets; hermes-engine script phase notice. +- Committed as an `adapt:` commit that bundles the regenerated `Podfile.lock` with this Slice 9 log entry. diff --git a/UBIQUITOUS_LANGUAGE.md b/UBIQUITOUS_LANGUAGE.md new file mode 100644 index 00000000000..1e65984bdc8 --- /dev/null +++ b/UBIQUITOUS_LANGUAGE.md @@ -0,0 +1,145 @@ +# Ubiquitous Language + +## Rooms & Conversations + +| Term | Definition | Aliases to avoid | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | +| **Room** | A server-side conversation container with shared state (name, type, settings) | Chat, conversation | +| **Subscription** | A user's personal relationship to a Room, holding per-user state (unread count, favorite, muted, open) | Membership, room entry | +| **Channel** | A public Room (type `'c'`) visible to all server users | Public room | +| **Group** | A private Room (type `'p'`) visible only to invited members | Private room, private channel | +| **Direct Message** | A 1-on-1 private Room (type `'d'`) between two users | DM, PM, private message | +| **Thread** | A branched conversation spawned from a single Message, identified by `tmid` (thread message id) | Reply chain | +| **Discussion** | A separate Room spawned from a parent Room, identified by `prid` (parent room id) — unlike Threads, Discussions are full Rooms | Sub-room, sub-channel | +| **Team** | An organizational container that groups multiple Channels and users under a single entity | Workspace (ambiguous) | + +## Messages + +| Term | Definition | Aliases to avoid | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------- | +| **Message** | A unit of communication within a Room, identified by `_id` with content in `msg` and parsed markdown in `md` | Chat message, text | +| **Thread Message** | A Message that belongs to a Thread, identified by presence of `tmid` | Reply, thread reply | +| **System Message** | A Message generated by the server to record events (user joined, room archived, role changed) — identified by `t` (type) field | Event, notification | +| **Attachment** | Rich media or structured data embedded in a Message (image, video, audio, file, or action buttons) | File, media | +| **Reaction** | An emoji response to a Message, tracking which usernames reacted | Emoji reaction | +| **Mention** | An `@username` reference within a Message that triggers notifications | Tag, ping | +| **Draft Message** | A user's unsent composition stored on a Subscription or Thread (`draftMessage` field) | Unsent message | +| **Snippet** | A saved excerpt from a Message | — | + +## Message Status + +| Term | Definition | Aliases to avoid | +| ----------- | -------------------------------------------------------------------- | ---------------- | +| **Sent** | Message successfully delivered to server (status `0`) | Delivered | +| **Temp** | Message created locally but not yet confirmed by server (status `1`) | Pending, sending | +| **Error** | Message that failed to send (status `2`) | Failed | +| **Pinned** | Message flagged as important and pinned to the Room by a user | Bookmarked | +| **Starred** | Message bookmarked by the current user for personal reference | Saved | + +## Users & Roles + +| Term | Definition | Aliases to avoid | +| --------------- | ---------------------------------------------------------------------------------- | ----------------------- | +| **User** | An authenticated identity on the server with username, status, and roles | Account, profile | +| **Logged User** | The currently authenticated User session, holding auth token and preferences | Current user, session | +| **Role** | A named permission group assigned to Users (e.g., owner, moderator, leader, guest) | Permission group | +| **Permission** | A named capability mapped to one or more Roles | Privilege, access right | +| **Active User** | A User currently tracked as online/away/busy via real-time presence | Online user | +| **Member** | A User viewed in the context of a specific Room's membership list | Participant | + +## User Status + +| Term | Definition | Aliases to avoid | +| ----------- | -------------------------------- | ---------------- | +| **Online** | User is actively connected | Active | +| **Away** | User idle past timeout threshold | Idle | +| **Busy** | User has set do-not-disturb | DND | +| **Offline** | User is not connected | Disconnected | + +## Omnichannel / Livechat + +| Term | Definition | Aliases to avoid | +| ---------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------- | +| **Omnichannel Room** | A customer-service Room (type `'l'`) connecting a Visitor to an Agent | Livechat room, support chat | +| **Visitor** | An external customer who initiates an Omnichannel conversation, identified by a unique token | Client, customer, end-user | +| **Agent** | A User designated to handle Omnichannel conversations, with `statusLivechat` (available/unavailable) | Support agent, operator, rep | +| **Inquiry** | A queued Omnichannel request waiting to be picked up or routed to an Agent | Queue item, ticket | +| **Department** | An organizational unit that groups Agents for Omnichannel routing | Team (ambiguous), group | +| **Omnichannel Source** | How an Omnichannel conversation was initiated (widget, email, sms, app, api) | Channel origin | +| **Served By** | The Agent currently assigned to handle an Omnichannel Room | Assigned agent, handler | +| **On Hold** | An Omnichannel Room temporarily paused by the Agent | Paused, suspended | +| **Transfer** | Moving an Omnichannel Room to a different Agent or Department | Forward, reassign, handoff | + +## Encryption + +| Term | Definition | Aliases to avoid | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------------- | +| **E2E Encryption** | End-to-end encryption for Room content using AES-SHA2, with two protocol versions (`rc.v1.aes-sha2`, `rc.v2.aes-sha2`) | Encryption, E2EE | +| **E2E Key** | A user's asymmetric key pair (public + private) for E2E Encryption | Crypto key | +| **OTR** | Off-The-Record messaging — ephemeral encrypted conversation mode between two users | — | + +## Video & Voice + +| Term | Definition | Aliases to avoid | +| --------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------- | +| **Video Conference** | A video/voice call session with status lifecycle (calling, started, expired, ended, declined) | Video call, meeting | +| **Direct Video Conference** | A 1-on-1 Video Conference | — | +| **Group Video Conference** | A multi-participant Video Conference with title and anonymous user support | — | +| **VOIP** | Voice-over-IP phone-style call, separate from Video Conference — uses ICE servers and media streams | Phone call, voice call | + +## Server & Connection + +| Term | Definition | Aliases to avoid | +| ------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| **Server** | A Rocket.Chat server instance the app connects to, with version, settings, and enterprise modules | Workspace (used by web but not consistently in mobile), instance | +| **Server History** | List of previously connected Servers for quick reconnection | Recent servers | +| **Meteor Connect** | The WebSocket connection to the Server's DDP (Distributed Data Protocol) endpoint | Socket, connection | + +## Navigation & Layout + +| Term | Definition | Aliases to avoid | +| -------------------- | ----------------------------------------------------------------------------------------- | ---------------------- | +| **Outside Stack** | Navigation screens shown when unauthenticated (server selection, login, register) | Auth stack, login flow | +| **Inside Stack** | Navigation screens shown when authenticated (rooms, settings, profile) | Main stack, app stack | +| **Master-Detail** | Tablet layout with room list (master pane) and room content (detail pane) side by side | Split view, two-pane | +| **Chats Stack** | The primary messaging navigation within Inside Stack (room list, room view, room actions) | — | +| **Drawer Navigator** | Side navigation containing tabs: Chats, Profile, Settings, Admin, Accessibility | Sidebar, menu | + +## Unread & Notification Indicators + +| Term | Definition | Aliases to avoid | +| ------------------ | -------------------------------------------------------------------------------------------------------- | ----------------- | +| **Unread** | Count of unread regular Messages in a Subscription | Badge count | +| **User Mentions** | Count of Messages that `@mentioned` the current user in a Subscription | Personal mentions | +| **Group Mentions** | Count of `@all` or `@here` mentions in a Subscription | Channel mentions | +| **Tunread** | Array of Thread IDs with unread replies | Thread unread | +| **Alert** | Boolean flag on a Subscription indicating it has unread mentions or special activity requiring attention | Notification flag | + +## Relationships + +- A **Room** can be of type **Channel**, **Group**, **Direct Message**, or **Omnichannel Room** +- A **Subscription** belongs to exactly one **Room** and one **User** +- A **Message** belongs to exactly one **Room** (via `rid`) +- A **Thread** is spawned from exactly one **Message** and contains one or more **Thread Messages** +- A **Discussion** creates a new **Room** linked to a parent **Room** (via `prid`) +- A **Team** has exactly one main **Room** and can contain multiple **Channels** +- An **Omnichannel Room** connects exactly one **Visitor** with zero or one **Agents** (via **Served By**) +- An **Agent** belongs to one or more **Departments** +- An **Inquiry** becomes an **Omnichannel Room** when picked up by an **Agent** + +## Example dialogue + +> **Dev:** "When a user opens the app, do they see their **Subscriptions** or their **Rooms**?" +> **Domain expert:** "**Subscriptions**. The sidebar shows the user's Subscriptions — each one points to a Room, but carries user-specific state like **Unread** count and **Alert** flag. A Room exists independently; a Subscription is the user's window into it." +> **Dev:** "So if someone starts a **Thread** in a **Channel**, does that create a new **Subscription**?" +> **Domain expert:** "No. A **Thread** lives inside the parent Room's **Subscription**. Thread unreads are tracked via **Tunread** on the Subscription. A **Discussion**, on the other hand, creates an entirely new Room with its own Subscription." +> **Dev:** "And for **Omnichannel** — when a **Visitor** sends a message from the widget, what happens?" +> **Domain expert:** "An **Inquiry** is created and queued. Once an **Agent** picks it up or routing assigns it, the Inquiry becomes an **Omnichannel Room** with the Agent recorded in **Served By**. If the Agent needs to escalate, they do a **Transfer** to another Agent or **Department**." + +## Flagged ambiguities + +- **"Workspace"** is used by Rocket.Chat web to mean a server instance, but the mobile codebase uses **Server**. Use **Server** in mobile context to avoid confusion with the web admin concept. +- **"Room type `'e2e'`"** and **"Room type `'thread'`"** appear in `SubscriptionType` enum but are marked with FIXME in code — these are not true room types but flags. Do not treat them as room types in new code. +- **"Account"** is sometimes used loosely to mean either **User** (the identity) or **Server** (the connected instance). These are distinct: a **User** authenticates on a **Server**. +- **"Channel"** in everyday speech can mean any Room, but in domain terms it strictly means a public Room (type `'c'`). A private Room is a **Group** (type `'p'`). +- **"Forward"** in omnichannel context means **Transfer** (reassigning a room to another agent/department). The codebase uses both `forwardRoom` and "transfer" — prefer **Transfer** as the domain term. diff --git a/__mocks__/react-native-callkeep.js b/__mocks__/react-native-callkeep.js new file mode 100644 index 00000000000..af4dfea2c9a --- /dev/null +++ b/__mocks__/react-native-callkeep.js @@ -0,0 +1,10 @@ +export default { + setup: jest.fn(), + canMakeMultipleCalls: jest.fn(), + displayIncomingCall: jest.fn(), + endCall: jest.fn(), + setCurrentCallActive: jest.fn(), + addEventListener: jest.fn((event, callback) => ({ + remove: jest.fn() + })) +}; diff --git a/__mocks__/react-native-mmkv.js b/__mocks__/react-native-mmkv.js index 3288748482c..44b12dcf0c9 100644 --- a/__mocks__/react-native-mmkv.js +++ b/__mocks__/react-native-mmkv.js @@ -84,7 +84,7 @@ export class MMKV { } notifyListeners(key) { - this.listeners.forEach((listener) => { + this.listeners.forEach(listener => { try { listener(key); } catch (error) { @@ -102,7 +102,7 @@ export function useMMKVString(key, mmkvInstance) { const [value, setValue] = useState(() => mmkvInstance.getString(key)); useEffect(() => { - const listener = mmkvInstance.addOnValueChangedListener((changedKey) => { + const listener = mmkvInstance.addOnValueChangedListener(changedKey => { if (changedKey === key || changedKey === undefined) { setValue(mmkvInstance.getString(key)); } @@ -110,7 +110,7 @@ export function useMMKVString(key, mmkvInstance) { return () => listener.remove(); }, [key, mmkvInstance]); - const setStoredValue = (newValue) => { + const setStoredValue = newValue => { if (newValue === undefined) { mmkvInstance.delete(key); } else { @@ -126,7 +126,7 @@ export function useMMKVNumber(key, mmkvInstance) { const [value, setValue] = useState(() => mmkvInstance.getNumber(key)); useEffect(() => { - const listener = mmkvInstance.addOnValueChangedListener((changedKey) => { + const listener = mmkvInstance.addOnValueChangedListener(changedKey => { if (changedKey === key || changedKey === undefined) { setValue(mmkvInstance.getNumber(key)); } @@ -134,7 +134,7 @@ export function useMMKVNumber(key, mmkvInstance) { return () => listener.remove(); }, [key, mmkvInstance]); - const setStoredValue = (newValue) => { + const setStoredValue = newValue => { if (newValue === undefined) { mmkvInstance.delete(key); } else { @@ -150,7 +150,7 @@ export function useMMKVBoolean(key, mmkvInstance) { const [value, setValue] = useState(() => mmkvInstance.getBoolean(key)); useEffect(() => { - const listener = mmkvInstance.addOnValueChangedListener((changedKey) => { + const listener = mmkvInstance.addOnValueChangedListener(changedKey => { if (changedKey === key || changedKey === undefined) { setValue(mmkvInstance.getBoolean(key)); } @@ -158,7 +158,7 @@ export function useMMKVBoolean(key, mmkvInstance) { return () => listener.remove(); }, [key, mmkvInstance]); - const setStoredValue = (newValue) => { + const setStoredValue = newValue => { if (newValue === undefined) { mmkvInstance.delete(key); } else { @@ -177,7 +177,7 @@ export function useMMKVObject(key, mmkvInstance) { }); useEffect(() => { - const listener = mmkvInstance.addOnValueChangedListener((changedKey) => { + const listener = mmkvInstance.addOnValueChangedListener(changedKey => { if (changedKey === key || changedKey === undefined) { const stored = mmkvInstance.getString(key); setValue(stored ? JSON.parse(stored) : undefined); @@ -186,7 +186,7 @@ export function useMMKVObject(key, mmkvInstance) { return () => listener.remove(); }, [key, mmkvInstance]); - const setStoredValue = (newValue) => { + const setStoredValue = newValue => { if (newValue === undefined) { mmkvInstance.delete(key); } else { diff --git a/android/app/build.gradle b/android/app/build.gradle index f0f06675f4c..87db9ebe7b7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,7 +90,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode VERSIONCODE as Integer - versionName "4.69.0" + versionName "4.72.0" vectorDrawables.useSupportLibrary = true manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String] resValue "string", "rn_config_reader_custom_package", "chat.rocket.reactnative" @@ -148,10 +148,16 @@ dependencies { implementation "com.google.code.gson:gson:2.8.9" implementation "com.tencent:mmkv-static:1.2.10" + implementation "com.github.bumptech.glide:glide:${rootProject.ext.glideVersion}" implementation 'com.facebook.soloader:soloader:0.10.4' // For SecureKeystore (EncryptedSharedPreferences) implementation 'androidx.security:security-crypto:1.1.0' + + testImplementation 'junit:junit:4.13.2' + + // For ProcessLifecycleOwner (app foreground detection) + implementation 'androidx.lifecycle:lifecycle-process:2.8.7' } apply plugin: 'com.google.gms.google-services' diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 62d7d130c6b..00000000000 --- a/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4fb70ab1a89..037109461ed 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,18 @@ + + + + + + + + + + + + @@ -28,6 +40,7 @@ android:requestLegacyExternalStorage="true" android:supportsRtl="true" android:theme="@style/AppTheme" + android:usesCleartextTraffic="${usesCleartextTraffic}" android:hardwareAccelerated="true" tools:replace="android:allowBackup"> + + + + + + + + + + + + diff --git a/android/app/src/main/assets/fonts/custom.ttf b/android/app/src/main/assets/fonts/custom.ttf index b0758f2fa60..ace29c7f232 100644 Binary files a/android/app/src/main/assets/fonts/custom.ttf and b/android/app/src/main/assets/fonts/custom.ttf differ diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt b/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt index e4ab65ceba1..dc2417f85c6 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt @@ -29,7 +29,7 @@ class MainActivity : ReactActivity() { override fun onCreate(savedInstanceState: Bundle?) { RNBootSplash.init(this, R.style.BootTheme) super.onCreate(null) - + // Handle notification intents intent?.let { NotificationIntentHandler.handleIntent(this, it) } } @@ -37,7 +37,7 @@ class MainActivity : ReactActivity() { public override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - + // Handle notification intents when activity is already running NotificationIntentHandler.handleIntent(this, intent) } @@ -45,4 +45,4 @@ class MainActivity : ReactActivity() { override fun invokeDefaultOnBackPressed() { moveTaskToBack(true) } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 5ac2e25f483..cdba85f6cc6 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -5,13 +5,14 @@ import android.content.res.Configuration import com.facebook.react.PackageList import com.facebook.react.ReactApplication import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactInstanceEventListener import com.facebook.react.ReactNativeHost import com.facebook.react.ReactPackage -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost import com.facebook.react.defaults.DefaultReactNativeHost -import com.facebook.react.soloader.OpenSourceMergedSoMapping -import com.facebook.soloader.SoLoader import com.nozbe.watermelondb.jsi.WatermelonDBJSIPackage; import com.bugsnag.android.Bugsnag import expo.modules.ApplicationLifecycleDispatcher @@ -20,6 +21,8 @@ import chat.rocket.reactnative.storage.MMKVKeyManager; import chat.rocket.reactnative.storage.SecureStoragePackage; import chat.rocket.reactnative.notification.VideoConfTurboPackage import chat.rocket.reactnative.notification.PushNotificationTurboPackage +import chat.rocket.reactnative.VoipTurboPackage +import chat.rocket.reactnative.scroll.InvertedScrollPackage /** * Main Application class. @@ -43,7 +46,9 @@ open class MainApplication : Application(), ReactApplication { add(WatermelonDBJSIPackage()) add(VideoConfTurboPackage()) add(PushNotificationTurboPackage()) + add(VoipTurboPackage()) add(SecureStoragePackage()) + add(InvertedScrollPackage()) } override fun getJSMainModuleName(): String = "index" @@ -59,7 +64,6 @@ open class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() - SoLoader.init(this, OpenSourceMergedSoMapping) Bugsnag.start(this) // Initialize MMKV encryption - reads existing key or generates new one @@ -67,7 +71,7 @@ open class MainApplication : Application(), ReactApplication { MMKVKeyManager.initialize(this) // Load the native entry point for the New Architecture - load() + loadReactNative(this) ApplicationLifecycleDispatcher.onApplicationCreate(this) } diff --git a/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningTurboModule.java b/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningTurboModule.java index 96f1023c53f..e89c8b5fdc1 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningTurboModule.java +++ b/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningTurboModule.java @@ -33,7 +33,7 @@ import java.util.concurrent.TimeUnit; import com.reactnativecommunity.webview.RNCWebViewManager; -import expo.modules.filesystem.FileSystemModule; +import expo.modules.filesystem.legacy.FileSystemLegacyModule; import chat.rocket.reactnative.networking.ExpoImageClient; public class SSLPinningTurboModule extends NativeSSLPinningSpec implements KeyChainAliasCallback { @@ -107,7 +107,7 @@ public void setCertificate(String name, Promise promise) { RNCWebViewManager.setCertificateAlias(name); // Expo File System network layer - FileSystemModule.setOkHttpClient(client); + FileSystemLegacyModule.setOkHttpClient(client); // Expo Image network layer ExpoImageClient.setOkHttpClient(client); ExpoImageClient.applyToGlide(this.reactContext); diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java index 22339abd691..622c44415b9 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java @@ -17,6 +17,8 @@ import android.util.Log; import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.ProcessLifecycleOwner; import com.google.gson.Gson; @@ -68,6 +70,14 @@ public CustomPushNotification(Context context, Bundle bundle) { public static void clearMessages(int notId) { notificationMessages.remove(Integer.toString(notId)); } + + /** + * Checks if the app is currently in the foreground. + * Uses ProcessLifecycleOwner to reliably detect app state. + */ + public static boolean isAppInForeground() { + return ProcessLifecycleOwner.get().getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED); + } public void onReceived() { String notId = mBundle.getString("notId"); @@ -102,6 +112,11 @@ private void handleNotification() { return; // Exit early, notification will be processed in callback } + if (receivedEjson != null && receivedEjson.notificationType != null && receivedEjson.notificationType.equals("voip")) { + Log.d(TAG, "Notification is a voip notification, ignoring"); + return; + } + // For non-message-id-only notifications, process immediately processNotification(); } @@ -228,6 +243,15 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) { if (ENABLE_VERBOSE_LOGS) { Log.d(TAG, "[Before add to notificationMessages] notId=" + notId + ", bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0) + ", bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); } + + // Don't show notification if app is in foreground + if (isAppInForeground()) { + if (ENABLE_VERBOSE_LOGS) { + Log.d(TAG, "App is in foreground, skipping native notification"); + } + return; + } + notificationMessages.get(notId).add(bundle); if (ENABLE_VERBOSE_LOGS) { Log.d(TAG, "[After add] notificationMessages[" + notId + "].size=" + notificationMessages.get(notId).size()); diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index e9998ff8231..eb180312a1b 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -25,7 +25,7 @@ public class Ejson { private static final String TAG = "RocketChat.Ejson"; private static final String TOKEN_KEY = "reactnativemeteor_usertoken-"; - String host; + public String host; String rid; String type; Sender sender; @@ -57,7 +57,7 @@ private MMKV getMMKV() { * Helper method to build avatar URI from avatar path. * Validates server URL and credentials, then constructs the full URI. */ - private String buildAvatarUri(String avatarPath, String errorContext) { + private String buildAvatarUri(String avatarPath, String errorContext, int sizePx) { String server = serverURL(); if (server == null || server.isEmpty()) { Log.w(TAG, "Cannot generate " + errorContext + " avatar URI: serverURL is null"); @@ -67,7 +67,7 @@ private String buildAvatarUri(String avatarPath, String errorContext) { String userToken = token(); String uid = userId(); - String finalUri = server + avatarPath + "?format=png&size=100"; + String finalUri = server + avatarPath + "?format=png&size=" + sizePx; if (!userToken.isEmpty() && !uid.isEmpty()) { finalUri += "&rc_token=" + userToken + "&rc_uid=" + uid; } @@ -102,15 +102,37 @@ public String getAvatarUri() { } } - return buildAvatarUri(avatarPath, ""); + return buildAvatarUri(avatarPath, "", 100); } /** - * Generates avatar URI for video conference caller. + * Factory for building caller avatar URIs from host + username (e.g. VoIP payload). + * Caller is package-private, so this is the only way to get avatar URI from outside the package. + */ + public static Ejson forCallerAvatar(String host, String username) { + if (host == null || host.isEmpty() || username == null || username.isEmpty()) { + return null; + } + Ejson ejson = new Ejson(); + ejson.host = host; + ejson.caller = new Caller(); + ejson.caller.username = username; + return ejson; + } + + /** + * Generates avatar URI for video conference caller (default size 100). * Returns null if caller username is not available (username is required for avatar endpoint). */ public String getCallerAvatarUri() { - // Check if caller exists and has username (required - /avatar/{userId} endpoint doesn't exist) + return getCallerAvatarUri(100); + } + + /** + * Generates avatar URI for video conference caller with custom size. + * Returns null if caller username is not available. + */ + public String getCallerAvatarUri(int sizePx) { if (caller == null || caller.username == null || caller.username.isEmpty()) { Log.w(TAG, "Cannot generate caller avatar URI: caller or username is null"); return null; @@ -118,7 +140,7 @@ public String getCallerAvatarUri() { try { String avatarPath = "/avatar/" + URLEncoder.encode(caller.username, "UTF-8"); - return buildAvatarUri(avatarPath, "caller"); + return buildAvatarUri(avatarPath, "caller", sizePx); } catch (UnsupportedEncodingException e) { Log.e(TAG, "Failed to encode caller username", e); return null; @@ -242,4 +264,4 @@ static class Content { String kid; String iv; } -} +} \ No newline at end of file diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt index 9cedae3ea2d..1b0da552c38 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import android.util.Log import com.google.gson.GsonBuilder +import chat.rocket.reactnative.voip.VoipNotification /** * Handles notification Intent processing from MainActivity. @@ -17,11 +18,15 @@ class NotificationIntentHandler { /** * Handles a notification Intent from MainActivity. - * Processes both video conf and regular notification intents. + * Processes VoIP, video conf, and regular notification intents. */ @JvmStatic fun handleIntent(context: Context, intent: Intent) { - // Handle video conf action first + if (VoipNotification.handleMainActivityVoipIntent(context, intent)) { + return + } + + // Handle video conf action if (handleVideoConfIntent(context, intent)) { return } @@ -45,7 +50,7 @@ class NotificationIntentHandler { val rid = intent.getStringExtra("rid") ?: "" val callerId = intent.getStringExtra("callerId") ?: "" - val callerName = intent.getStringExtra("callerName") ?: "" + val caller = intent.getStringExtra("caller") ?: "" val host = intent.getStringExtra("host") ?: "" val callId = intent.getStringExtra("callId") ?: "" @@ -63,7 +68,7 @@ class NotificationIntentHandler { "callId" to callId, "caller" to mapOf( "_id" to callerId, - "name" to callerName + "name" to caller ) ) @@ -93,6 +98,20 @@ class NotificationIntentHandler { } try { + val notId = extras.getString("notId") + + // Clear the notification messages from the static map to prevent stacking + if (!notId.isNullOrEmpty()) { + try { + val notIdInt = notId.toIntOrNull() + if (notIdInt != null) { + CustomPushNotification.clearMessages(notIdInt) + } + } catch (e: Exception) { + Log.e(TAG, "Error clearing notification messages for ID $notId: ${e.message}", e) + } + } + // Extract all notification data from Intent extras // Only include serializable types to avoid JSON serialization errors val notificationData = mutableMapOf() diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt index c8aba96e6b9..1fdfacc21c4 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt @@ -4,12 +4,15 @@ import android.os.Bundle import android.util.Log import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import chat.rocket.reactnative.voip.VoipNotification +import chat.rocket.reactnative.voip.VoipPayload /** * Custom Firebase Messaging Service for Rocket.Chat. * - * Handles incoming FCM messages and routes them to CustomPushNotification - * for advanced processing (E2E decryption, MessagingStyle, direct reply, etc.) + * Handles incoming FCM messages and routes them to the appropriate handler: + * - VoipNotification for VoIP calls (notificationType: "voip") + * - CustomPushNotification for regular messages and video conferences */ class RCFirebaseMessagingService : FirebaseMessagingService() { @@ -18,7 +21,8 @@ class RCFirebaseMessagingService : FirebaseMessagingService() { } override fun onMessageReceived(remoteMessage: RemoteMessage) { - Log.d(TAG, "FCM message received from: ${remoteMessage.from}") + // TODO: remove data + Log.d(TAG, "FCM message received from: ${remoteMessage.from} data: ${remoteMessage.data}") val data = remoteMessage.data if (data.isEmpty()) { @@ -26,15 +30,20 @@ class RCFirebaseMessagingService : FirebaseMessagingService() { return } - // Convert FCM data to Bundle for processing - val bundle = Bundle().apply { - data.forEach { (key, value) -> - putString(key, value) - } + val voipPayload = VoipPayload.fromMap(data) + if (voipPayload != null) { + Log.d(TAG, "Detected VoIP payload of type ${voipPayload.type}, routing to VoipNotification handler") + VoipNotification(this).onMessageReceived(voipPayload) + return } - // Process the notification + // Process regular notifications via CustomPushNotification try { + val bundle = Bundle().apply { + data.forEach { (key, value) -> + putString(key, value) + } + } val notification = CustomPushNotification(this, bundle) notification.onReceived() } catch (e: Exception) { diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java new file mode 100644 index 00000000000..a4acb0c1e13 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java @@ -0,0 +1,23 @@ +package chat.rocket.reactnative.scroll; + +import android.view.View; +import com.facebook.react.views.view.ReactViewGroup; +import java.util.ArrayList; +import java.util.Collections; + +/** + * Content view for inverted FlatLists. Reports its children to accessibility in reversed order so + * TalkBack traversal matches the visual order (newest-first) when used inside InvertedScrollView. + */ +public class InvertedScrollContentView extends ReactViewGroup { + + public InvertedScrollContentView(android.content.Context context) { + super(context); + } + + @Override + public void addChildrenForAccessibility(ArrayList outChildren) { + super.addChildrenForAccessibility(outChildren); + Collections.reverse(outChildren); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java new file mode 100644 index 00000000000..d30f9fc84c2 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java @@ -0,0 +1,25 @@ +package chat.rocket.reactnative.scroll; + +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.views.view.ReactViewManager; + +/** + * View manager for InvertedScrollContentView. Behaves like a View but reports children in reversed + * order for accessibility so TalkBack matches the visual order in inverted lists. + */ +@ReactModule(name = InvertedScrollContentViewManager.REACT_CLASS) +public class InvertedScrollContentViewManager extends ReactViewManager { + + public static final String REACT_CLASS = "InvertedScrollContentView"; + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + public InvertedScrollContentView createViewInstance(ThemedReactContext context) { + return new InvertedScrollContentView(context); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java new file mode 100644 index 00000000000..05e6a7be0d5 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java @@ -0,0 +1,24 @@ +package chat.rocket.reactnative.scroll; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; +import java.util.Collections; +import java.util.List; + +public class InvertedScrollPackage implements ReactPackage { + + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + List managers = new java.util.ArrayList<>(); + managers.add(new InvertedScrollViewManager()); + managers.add(new InvertedScrollContentViewManager()); + return managers; + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java new file mode 100644 index 00000000000..def585a7511 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java @@ -0,0 +1,36 @@ +package chat.rocket.reactnative.scroll; + +import android.view.View; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.views.scroll.ReactScrollView; +import java.util.ArrayList; +import java.util.Collections; + +// When a FlatList is inverted (inverted={true}), React Native uses scaleY: -1 transform which +// visually inverts the list but Android still reports children in array order. This view overrides +// addChildrenForAccessibility to reverse the order so TalkBack matches the visual order. + +public class InvertedScrollView extends ReactScrollView { + + private boolean mIsInvertedVirtualizedList = false; + + public InvertedScrollView(ReactContext context) { + super(context); + } + + + // Set whether this ScrollView is used for an inverted virtualized list. When true, we reverse the + // accessibility traversal order to match the visual order. + + public void setIsInvertedVirtualizedList(boolean isInverted) { + mIsInvertedVirtualizedList = isInverted; + } + + @Override + public void addChildrenForAccessibility(ArrayList outChildren) { + super.addChildrenForAccessibility(outChildren); + if (mIsInvertedVirtualizedList) { + Collections.reverse(outChildren); + } + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java new file mode 100644 index 00000000000..453dd009ec0 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java @@ -0,0 +1,26 @@ +package chat.rocket.reactnative.scroll; + +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.views.scroll.ReactScrollViewManager; + +/** + * View manager for {@link InvertedScrollView}. Registers as "InvertedScrollView" to avoid + * collision with core RCTScrollView. Inherits all ScrollView props from ReactScrollViewManager; + * FlatList passes isInvertedVirtualizedList when inverted, which is applied by the parent setter. + */ +@ReactModule(name = InvertedScrollViewManager.REACT_CLASS) +public class InvertedScrollViewManager extends ReactScrollViewManager { + + public static final String REACT_CLASS = "InvertedScrollView"; + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + public InvertedScrollView createViewInstance(ThemedReactContext context) { + return new InvertedScrollView(context); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt new file mode 100644 index 00000000000..66511265fe3 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt @@ -0,0 +1,308 @@ +package chat.rocket.reactnative.voip + +import android.os.Handler +import android.os.Looper +import android.util.Log +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +class DDPClient { + private data class QueuedMethodCall( + val method: String, + val params: JSONArray, + val callback: (Boolean) -> Unit + ) + + companion object { + private const val TAG = "RocketChat.DDPClient" + } + + private var webSocket: WebSocket? = null + private var client: OkHttpClient? = null + private var sendCounter = 0 + private var isConnected = false + private val mainHandler = Handler(Looper.getMainLooper()) + + private val pendingCallbacks = mutableMapOf Unit>() + private val queuedMethodCalls = mutableListOf() + private var connectedCallback: ((Boolean) -> Unit)? = null + + var onCollectionMessage: ((JSONObject) -> Unit)? = null + + fun connect(host: String, callback: (Boolean) -> Unit) { + val wsUrl = buildWebSocketURL(host) + + Log.d(TAG, "Connecting to $wsUrl") + + val httpClient = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .build() + client = httpClient + + val request = Request.Builder().url(wsUrl).build() + + webSocket = httpClient.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "WebSocket opened") + val connectMsg = JSONObject().apply { + put("msg", "connect") + put("version", "1") + put("support", JSONArray().apply { + put("1"); put("pre2"); put("pre1") + }) + } + webSocket.send(connectMsg.toString()) + waitForConnected(10_000L, callback) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + handleMessage(text) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "WebSocket failure: ${t.message}") + isConnected = false + mainHandler.post { callback(false) } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closed: $code $reason") + isConnected = false + } + }) + } + + fun login(token: String, callback: (Boolean) -> Unit) { + val msg = nextMessage("method").apply { + put("method", "login") + put("params", JSONArray().apply { + put(JSONObject().apply { put("resume", token) }) + }) + } + + val msgId = msg.getString("id") + + synchronized(pendingCallbacks) { + pendingCallbacks[msgId] = { data -> + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + val hasError = data.has("error") + if (hasError) { + Log.e(TAG, "Login failed: ${data.opt("error")}") + } else { + Log.d(TAG, "Login succeeded") + } + mainHandler.post { callback(!hasError) } + } + } + + if (!send(msg)) { + mainHandler.post { callback(false) } + } + } + + fun subscribe(name: String, params: JSONArray, callback: (Boolean) -> Unit) { + val msg = nextMessage("sub").apply { + put("name", name) + put("params", params) + } + + val msgId = msg.getString("id") + + synchronized(pendingCallbacks) { + pendingCallbacks[msgId] = { data -> + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + val didSubscribe = data.optString("msg") == "ready" && !data.has("error") + if (didSubscribe) { + Log.d(TAG, "Subscribed to $name") + } else { + Log.e(TAG, "Failed to subscribe to $name: ${data.opt("error") ?: "nosub"}") + } + mainHandler.post { callback(didSubscribe) } + } + } + + if (!send(msg)) { + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + mainHandler.post { callback(false) } + } + } + + fun disconnect() { + Log.d(TAG, "Disconnecting") + isConnected = false + synchronized(pendingCallbacks) { pendingCallbacks.clear() } + clearQueuedMethodCalls() + connectedCallback = null + onCollectionMessage = null + webSocket?.close(1000, null) + webSocket = null + client?.dispatcher?.executorService?.shutdown() + client = null + } + + private fun nextMessage(msg: String): JSONObject { + sendCounter++ + return JSONObject().apply { + put("msg", msg) + put("id", "ddp-$sendCounter") + } + } + + private fun send(json: JSONObject): Boolean { + val ws = webSocket ?: return false + return ws.send(json.toString()) + } + + fun callMethod(method: String, params: JSONArray, callback: (Boolean) -> Unit) { + val msg = nextMessage("method").apply { + put("method", method) + put("params", params) + } + + val msgId = msg.getString("id") + + synchronized(pendingCallbacks) { + pendingCallbacks[msgId] = { data -> + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + val hasError = data.has("error") + if (hasError) { + Log.e(TAG, "Method $method failed: ${data.opt("error")}") + } + mainHandler.post { callback(!hasError) } + } + } + + if (!send(msg)) { + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + mainHandler.post { callback(false) } + } + } + + fun queueMethodCall(method: String, params: JSONArray, callback: (Boolean) -> Unit = {}) { + synchronized(queuedMethodCalls) { + queuedMethodCalls.add( + QueuedMethodCall( + method = method, + params = params, + callback = callback + ) + ) + } + } + + fun hasQueuedMethodCalls(): Boolean = + synchronized(queuedMethodCalls) { queuedMethodCalls.isNotEmpty() } + + fun flushQueuedMethodCalls() { + val queuedCalls = synchronized(queuedMethodCalls) { + queuedMethodCalls.toList().also { queuedMethodCalls.clear() } + } + + queuedCalls.forEach { queuedCall -> + callMethod(queuedCall.method, queuedCall.params, queuedCall.callback) + } + } + + fun clearQueuedMethodCalls() { + synchronized(queuedMethodCalls) { + queuedMethodCalls.clear() + } + } + + private fun waitForConnected(timeoutMs: Long, callback: (Boolean) -> Unit) { + connectedCallback = callback + mainHandler.postDelayed({ + val cb = connectedCallback ?: return@postDelayed + connectedCallback = null + Log.e(TAG, "Connect timeout") + cb(false) + }, timeoutMs) + } + + private fun handleMessage(text: String) { + val json = try { + JSONObject(text) + } catch (e: Exception) { + return + } + + when (json.optString("msg")) { + "connected" -> { + isConnected = true + mainHandler.removeCallbacksAndMessages(null) + val cb = connectedCallback + connectedCallback = null + cb?.let { mainHandler.post { it(true) } } + } + + "ping" -> { + send(JSONObject().apply { put("msg", "pong") }) + } + + "result" -> { + val id = json.optString("id") + val cb = synchronized(pendingCallbacks) { pendingCallbacks[id] } + cb?.invoke(json) + } + + "ready" -> { + val subs = json.optJSONArray("subs") + if (subs != null) { + for (index in 0 until subs.length()) { + val subId = subs.optString(index) + if (subId.isEmpty()) { + continue + } + + val cb = synchronized(pendingCallbacks) { pendingCallbacks[subId] } + cb?.invoke(json) + } + } + } + + "changed", "added", "removed" -> { + onCollectionMessage?.invoke(json) + } + + "nosub" -> { + val id = json.optString("id") + val cb = synchronized(pendingCallbacks) { pendingCallbacks[id] } + cb?.invoke(json) + } + + else -> { + if (json.has("collection")) { + onCollectionMessage?.invoke(json) + } + } + } + } + + private fun buildWebSocketURL(host: String): String { + var normalizedHost = host.trimEnd('/') + + val useSsl: Boolean + when { + normalizedHost.startsWith("https://") -> { + useSsl = true + normalizedHost = normalizedHost.removePrefix("https://") + } + normalizedHost.startsWith("http://") -> { + useSsl = false + normalizedHost = normalizedHost.removePrefix("http://") + } + else -> { + useSsl = true + } + } + + val scheme = if (useSsl) "wss" else "ws" + return "$scheme://$normalizedHost/websocket" + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt new file mode 100644 index 00000000000..1c5ee3633c8 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt @@ -0,0 +1,312 @@ +package chat.rocket.reactnative.voip + +import android.app.Activity +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.GradientDrawable +import android.media.Ringtone +import android.media.RingtoneManager +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.WindowManager +import android.view.View +import android.widget.ImageView +import androidx.core.content.ContextCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.FrameLayout +import android.util.Log +import android.view.ViewOutlineProvider +import com.bumptech.glide.Glide +import chat.rocket.reactnative.R +import android.graphics.Typeface +import chat.rocket.reactnative.notification.Ejson + +/** + * Full-screen Activity displayed when an incoming VoIP call arrives. + * Shows on lock screen and handles user actions (Accept/Decline). + */ +class IncomingCallActivity : Activity() { + + companion object { + private const val TAG = "RocketChat.IncomingCall" + } + + private var ringtone: Ringtone? = null + private var voipPayload: VoipPayload? = null + private var isCallStateReceiverRegistered = false + private val timeoutHandler = Handler(Looper.getMainLooper()) + private var timeoutRunnable: Runnable? = null + private val callStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val payload = VoipPayload.fromBundle(intent?.extras) ?: return + if (payload.callId != voipPayload?.callId) { + return + } + + clearTimeout() + stopRingtone() + finish() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Samsung/Xiaomi fix: Request keyguard dismissal BEFORE setting content view + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this, null) + } + + // Android 14+ fix: Must call programmatically (XML attributes ignored) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + // Enable showing on lock screen (for older Android versions) + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ) + } + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + setContentView(R.layout.activity_incoming_call) + applyNavigationBar() + applyButtonBackgrounds() + applyInterFont() + + val voipPayload = VoipPayload.fromBundle(intent.extras) + if (voipPayload == null || !voipPayload.isVoipIncomingCall()) { + Log.e(TAG, "Invalid VoIP payload, finishing activity") + finish() + return + } + this.voipPayload = voipPayload + + Log.d(TAG, "IncomingCallActivity created - callId: ${voipPayload.callId}, caller: ${voipPayload.caller}") + + updateUI(voipPayload) + startRingtone() + setupButtons(voipPayload) + scheduleTimeout(voipPayload) + val intentFilter = IntentFilter().apply { + addAction(VoipNotification.ACTION_TIMEOUT) + addAction(VoipNotification.ACTION_DISMISS) + } + LocalBroadcastManager.getInstance(this).registerReceiver(callStateReceiver, intentFilter) + isCallStateReceiverRegistered = true + } + + private fun applyNavigationBar() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + val bgColor = ContextCompat.getColor(this, R.color.incoming_call_background) + window.navigationBarColor = bgColor + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val isDarkTheme = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_YES + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isDarkTheme) { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + } + + /** + * Applies button background colors programmatically. Required on some devices (e.g. Samsung + * lock screen) where XML @color references may not resolve correctly in full-screen intent context. + */ + private fun applyButtonBackgrounds() { + val cornerRadiusPx = 8 * resources.displayMetrics.density + findViewById(R.id.btn_reject_bg)?.apply { + background = GradientDrawable().apply { + setColor(ContextCompat.getColor(this@IncomingCallActivity, R.color.incoming_call_reject_bg)) + cornerRadius = cornerRadiusPx + } + } + findViewById(R.id.btn_accept_bg)?.apply { + background = GradientDrawable().apply { + setColor(ContextCompat.getColor(this@IncomingCallActivity, R.color.incoming_call_accept_bg)) + cornerRadius = cornerRadiusPx + } + } + } + + private fun applyInterFont() { + val interRegular = try { + Typeface.createFromAsset(assets, "fonts/Inter-Regular.otf") + } catch (e: Exception) { + Log.e(TAG, "Failed to load Inter-Regular font", e) + return + } + val interBold = try { + Typeface.createFromAsset(assets, "fonts/Inter-Bold.otf") + } catch (e: Exception) { + Log.e(TAG, "Failed to load Inter-Bold font", e) + interRegular + } + listOf( + R.id.header_text, + R.id.host_name, + R.id.incoming_call_reject_label, + R.id.incoming_call_accept_label + ).forEach { id -> + findViewById(id)?.setTypeface(interRegular) + } + findViewById(R.id.caller_name)?.setTypeface(interBold) + } + + private fun updateUI(payload: VoipPayload) { + findViewById(R.id.caller_name)?.text = payload.caller.ifEmpty { getString(R.string.incoming_call_unknown_caller) } + findViewById(R.id.host_name)?.text = payload.hostName.ifEmpty { getString(R.string.incoming_call_unknown_host) } + + loadAvatar(payload) + } + + private fun loadAvatar(payload: VoipPayload) { + val container = findViewById(R.id.avatar_container) + val imageView = findViewById(R.id.avatar) + val sizePx = (120 * resources.displayMetrics.density).toInt().coerceIn(120, 480) + val avatarUrl = Ejson.forCallerAvatar(payload.host, payload.username)?.getCallerAvatarUri(sizePx) + ?: return + val cornerRadiusPx = (8 * resources.displayMetrics.density).toFloat() + + Glide.with(this) + .load(avatarUrl) + .into(object : com.bumptech.glide.request.target.CustomTarget(sizePx, sizePx) { + override fun onResourceReady( + resource: android.graphics.drawable.Drawable, + transition: com.bumptech.glide.request.transition.Transition? + ) { + container.visibility = View.VISIBLE + imageView.setImageDrawable(resource) + applyAvatarRoundCorners(imageView, cornerRadiusPx) + } + + override fun onLoadFailed(errorDrawable: android.graphics.drawable.Drawable?) { + container.visibility = View.GONE + } + + override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) { + container.visibility = View.GONE + } + }) + } + + /** + * Applies rounded corners via view-level clipping. + * Works for both PNG (BitmapDrawable) and SVG (vector/PictureDrawable) since + * Glide's RoundedCorners bitmap transform only applies to bitmaps. + */ + private fun applyAvatarRoundCorners(imageView: ImageView, cornerRadiusPx: Float) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return + imageView.post { + val radius = cornerRadiusPx + imageView.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: android.graphics.Outline) { + outline.setRoundRect(0, 0, view.width, view.height, radius) + } + } + imageView.clipToOutline = true + } + } + + private fun startRingtone() { + try { + val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + ringtone = RingtoneManager.getRingtone(applicationContext, ringtoneUri) + ringtone?.play() + Log.d(TAG, "Ringtone started") + } catch (e: Exception) { + Log.e(TAG, "Failed to start ringtone", e) + } + } + + private fun stopRingtone() { + try { + ringtone?.stop() + ringtone = null + Log.d(TAG, "Ringtone stopped") + } catch (e: Exception) { + Log.e(TAG, "Failed to stop ringtone", e) + } + } + + private fun setupButtons(payload: VoipPayload) { + findViewById(R.id.btn_accept)?.setOnClickListener { + handleAccept(payload) + } + + findViewById(R.id.btn_decline)?.setOnClickListener { + handleDecline(payload) + } + } + + private fun scheduleTimeout(payload: VoipPayload) { + val remainingLifetimeMs = payload.getRemainingLifetimeMs() + if (remainingLifetimeMs == null || remainingLifetimeMs <= 0L) { + stopRingtone() + finish() + return + } + + clearTimeout() + timeoutRunnable = Runnable { + stopRingtone() + VoipNotification.handleTimeout(this, payload) + finish() + }.also { timeoutHandler.postDelayed(it, remainingLifetimeMs) } + } + + private fun clearTimeout() { + timeoutRunnable?.let(timeoutHandler::removeCallbacks) + timeoutRunnable = null + } + + private fun handleAccept(payload: VoipPayload) { + Log.d(TAG, "Call accepted - callId: ${payload.callId}") + clearTimeout() + VoipNotification.cancelTimeout(payload.callId) + stopRingtone() + VoipNotification.handleAcceptAction(this, payload) + // Activity finishes when ACTION_DISMISS is broadcast from handleAcceptAction (async DDP). + } + + private fun handleDecline(payload: VoipPayload) { + Log.d(TAG, "Call declined - callId: ${payload.callId}") + clearTimeout() + VoipNotification.cancelTimeout(payload.callId) + stopRingtone() + VoipNotification.handleDeclineAction(this, payload) + + finish() + } + + override fun onDestroy() { + super.onDestroy() + clearTimeout() + if (isCallStateReceiverRegistered) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(callStateReceiver) + isCallStateReceiverRegistered = false + } + stopRingtone() + } + + override fun onBackPressed() { + voipPayload?.let { handleDecline(it) } ?: finish() + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatch.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatch.kt new file mode 100644 index 00000000000..555ea01d0d6 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatch.kt @@ -0,0 +1,25 @@ +package chat.rocket.reactnative.voip + +/** + * Pure routing for an incoming VoIP FCM push after [VoipPayload.isVoipIncomingCall] is true. + * Stale (invalid or expired lifetime) pushes must not reach busy vs show branching. + */ +internal enum class VoipIncomingPushAction { + STALE, + REJECT_BUSY, + SHOW_INCOMING +} + +internal fun decideIncomingVoipPushAction( + isValidForIncomingHandling: Boolean, + hasActiveCall: Boolean +): VoipIncomingPushAction { + if (!isValidForIncomingHandling) { + return VoipIncomingPushAction.STALE + } + return if (hasActiveCall) { + VoipIncomingPushAction.REJECT_BUSY + } else { + VoipIncomingPushAction.SHOW_INCOMING + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt new file mode 100644 index 00000000000..8017fb420d9 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt @@ -0,0 +1,161 @@ +package chat.rocket.reactnative.voip + +import android.util.Log +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule +import java.lang.ref.WeakReference +import chat.rocket.reactnative.networking.NativeVoipSpec + +/** + * Native module to expose VoIP call data to JavaScript. + * Used to retrieve pending VoIP call data when the app opens from a VoIP notification. + */ +class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactContext) { + + companion object { + private const val TAG = "RocketChat.VoipModule" + private const val EVENT_VOIP_ACCEPT_SUCCEEDED = "VoipAcceptSucceeded" + private const val EVENT_VOIP_ACCEPT_FAILED = "VoipAcceptFailed" + + private var reactContextRef: WeakReference? = null + private var initialEventsData: VoipPayload? = null + + /** + * Sets the React context reference for event emission. + */ + @JvmStatic + fun setReactContext(context: ReactApplicationContext) { + reactContextRef = WeakReference(context) + } + + /** + * Emits a VoIP call event to JavaScript when the app is running. + */ + @JvmStatic + fun emitInitialEventsEvent(voipPayload: VoipPayload) { + try { + reactContextRef?.get()?.let { context -> + if (context.hasActiveReactInstance()) { + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT_VOIP_ACCEPT_SUCCEEDED, voipPayload.toWritableMap()) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to emit VoIP call event", e) + } + } + + /** + * Stores VoIP call data for JS to retrieve. + * Also emits an event if the app is running. + */ + @JvmStatic + fun storeInitialEvents(voipPayload: VoipPayload) { + initialEventsData = voipPayload + emitInitialEventsEvent(voipPayload) + } + + /** + * Stash native accept failure for cold start [getInitialEvents] and emit [EVENT_VOIP_ACCEPT_FAILED] when JS is running. + */ + @JvmStatic + fun storeAcceptFailureForJs(payload: VoipPayload) { + val failed = payload.copy(voipAcceptFailed = true) + initialEventsData = failed + emitVoipAcceptFailedEvent(failed) + } + + private fun emitVoipAcceptFailedEvent(voipPayload: VoipPayload) { + try { + reactContextRef?.get()?.let { context -> + if (context.hasActiveReactInstance()) { + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT_VOIP_ACCEPT_FAILED, voipPayload.toWritableMap()) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to emit VoipAcceptFailed", e) + } + } + + @JvmStatic + fun clearInitialEventsInternal() { + try { + initialEventsData = null + Log.d(TAG, "Cleared initial events") + } catch (e: Exception) { + Log.e(TAG, "Error clearing initial events", e) + } + } + } + + init { + // Store reference for event emission + setReactContext(reactApplicationContext) + } + + /** + * Gets any initial events. + * Returns null if no initial events. + */ + override fun getInitialEvents(): WritableMap? { + val data = initialEventsData ?: return null + + if (data.isExpired()) { + Log.d(TAG, "Discarding expired VoIP initial event: ${data.callId}") + clearInitialEventsInternal() + return null + } + + val result = data.toWritableMap() + clearInitialEventsInternal() + + return result + } + + /** + * Clears any initial events. + */ + override fun clearInitialEvents() { + clearInitialEventsInternal() + } + + // No-op on Android - FCM handles push notifications + override fun getLastVoipToken(): String = "" + + /** + * Registers for VoIP push token. + * No-op on Android - uses FCM for push notifications. + */ + override fun registerVoipToken() { + // No-op on Android - FCM handles push notifications + Log.d(TAG, "registerVoipToken called (no-op on Android)") + } + + override fun stopNativeDDPClient() { + Log.d(TAG, "stopNativeDDPClient called, stopping native DDP client") + VoipNotification.stopDDPClient() + } + + /** + * Required for NativeEventEmitter in TurboModules. + * Called when JS starts listening to events. + */ + override fun addListener(eventName: String) { + // Keep track of listeners if needed + Log.d(TAG, "addListener: $eventName") + } + + /** + * Required for NativeEventEmitter in TurboModules. + * Called when JS stops listening to events. + */ + override fun removeListeners(count: Double) { + // Remove listeners if needed + Log.d(TAG, "removeListeners: $count") + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt new file mode 100644 index 00000000000..39533b0225a --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt @@ -0,0 +1,1021 @@ +package chat.rocket.reactnative.voip + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.RingtoneManager +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import android.content.ComponentName +import android.net.Uri +import android.telecom.PhoneAccount +import android.telecom.PhoneAccountHandle +import android.telecom.TelecomManager +import io.wazo.callkeep.VoiceConnection +import io.wazo.callkeep.VoiceConnectionService +import android.app.Activity +import android.app.KeyguardManager +import chat.rocket.reactnative.MainActivity +import chat.rocket.reactnative.notification.Ejson +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Handles VoIP call notifications using Android's Telecom framework via CallKeep. + * Displays incoming call UI through the system's phone app / telecom service. + * + * When CallKeep is available (app running), it uses the native telecom call UI. + * When CallKeep is not available (app killed), it shows a high-priority notification + * similar to VideoConfNotification. + */ +class VoipNotification(private val context: Context) { + + companion object { + private const val TAG = "RocketChat.VoIP" + + const val CHANNEL_ID = "voip-call" + const val CHANNEL_NAME = "VoIP Calls" + + const val ACTION_ACCEPT = "chat.rocket.reactnative.ACTION_VOIP_ACCEPT" + const val ACTION_DECLINE = "chat.rocket.reactnative.ACTION_VOIP_DECLINE" + + /** + * Set on the heads-up Accept action [PendingIntent] ([PendingIntent.getActivity] → MainActivity). + * Android 12+ blocks starting an activity from a notification [BroadcastReceiver] trampoline; + * MainActivity opens first, then [handleMainActivityVoipIntent] runs accept with + * [handleAcceptAction] and `skipLaunchMainActivity = true`. + */ + const val ACTION_VOIP_ACCEPT_HEADS_UP = "chat.rocket.reactnative.ACTION_VOIP_ACCEPT_HEADS_UP" + const val ACTION_TIMEOUT = "chat.rocket.reactnative.ACTION_VOIP_TIMEOUT" + const val ACTION_DISMISS = "chat.rocket.reactnative.ACTION_VOIP_DISMISS" + + // react-native-callkeep's ConnectionService class name + private const val CALLKEEP_CONNECTION_SERVICE_CLASS = "io.wazo.callkeep.VoiceConnectionService" + private const val DISCONNECT_REASON_MISSED = 6 + + private data class VoipMediaCallIdentity(val userId: String, val deviceId: String) + + /** Keep in sync with MediaSessionStore features (audio-only today). */ + private val SUPPORTED_VOIP_FEATURES = JSONArray().apply { put("audio") } + private val timeoutHandler = Handler(Looper.getMainLooper()) + private val timeoutCallbacks = mutableMapOf() + private val ddpRegistry = VoipPerCallDdpRegistry { client -> + client.clearQueuedMethodCalls() + client.disconnect() + } + + /** False when [callId] was reassigned or torn down (stale DDP callback). */ + private fun isLiveClient(callId: String, client: DDPClient) = ddpRegistry.clientFor(callId) === client + + /** + * Cancels a VoIP notification by ID. + */ + @JvmStatic + fun cancelById(context: Context, notificationId: Int) { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + manager?.cancel(notificationId) + Log.d(TAG, "VoIP notification cancelled with ID: $notificationId") + } + + @JvmStatic + fun scheduleTimeout(context: Context, payload: VoipPayload) { + val delayMs = payload.getRemainingLifetimeMs() + if (delayMs == null || delayMs <= 0L) { + Log.d(TAG, "Skipping timeout scheduling for expired or invalid call: ${payload.callId}") + return + } + + cancelTimeout(payload.callId) + + val applicationContext = context.applicationContext + val timeoutRunnable = Runnable { + synchronized(timeoutCallbacks) { + timeoutCallbacks.remove(payload.callId) + } + handleTimeout(applicationContext, payload) + } + + synchronized(timeoutCallbacks) { + timeoutCallbacks[payload.callId] = timeoutRunnable + } + timeoutHandler.postDelayed(timeoutRunnable, delayMs) + Log.d(TAG, "Scheduled VoIP timeout for ${payload.callId} in ${delayMs}ms") + } + + @JvmStatic + fun cancelTimeout(callId: String) { + val timeoutRunnable = synchronized(timeoutCallbacks) { + timeoutCallbacks.remove(callId) + } + if (timeoutRunnable != null) { + timeoutHandler.removeCallbacks(timeoutRunnable) + Log.d(TAG, "Cancelled VoIP timeout for $callId") + } + } + + @JvmStatic + fun handleTimeout(context: Context, payload: VoipPayload) { + cancelTimeout(payload.callId) + disconnectTimedOutCall(payload.callId) + cancelById(context, payload.notificationId) + LocalBroadcastManager.getInstance(context).sendBroadcast( + Intent(ACTION_TIMEOUT).apply { + putExtras(payload.toBundle()) + } + ) + ddpRegistry.stopClient(payload.callId) + Log.d(TAG, "Timed out incoming VoIP call: ${payload.callId}") + } + + /** + * Handles decline action for VoIP call. + * Logs the decline action and clears stored call data. + */ + @JvmStatic + fun handleDeclineAction(context: Context, payload: VoipPayload) { + Log.d(TAG, "Decline action triggered for callId: ${payload.callId}") + cancelTimeout(payload.callId) + if (ddpRegistry.isLoggedIn(payload.callId)) { + sendRejectSignal(context, payload) + } else { + queueRejectSignal(context, payload) + } + rejectIncomingCall(payload.callId) + cancelById(context, payload.notificationId) + LocalBroadcastManager.getInstance(context).sendBroadcast( + Intent(ACTION_DISMISS).apply { + putExtras(payload.toBundle()) + } + ) + } + + /** + * Routes VoIP-related intents delivered to [MainActivity] (cold start or [Activity.onNewIntent]). + * + * @return `true` if the intent was handled as VoIP and downstream handlers should not process it. + */ + @JvmStatic + fun handleMainActivityVoipIntent(context: Context, intent: Intent): Boolean { + val payload = VoipPayload.fromBundle(intent.extras) + if (payload == null || !payload.isVoipIncomingCall()) { + return false + } + + val headsUpAccept = intent.action == ACTION_VOIP_ACCEPT_HEADS_UP + if (headsUpAccept) { + intent.action = Intent.ACTION_MAIN + prepareMainActivityForIncomingVoip(context, payload, storePayloadForJs = false) + handleAcceptAction(context, payload, skipLaunchMainActivity = true) + intent.removeExtra("voipAction") + return true + } + + if (intent.getBooleanExtra("voipAction", false)) { + prepareMainActivityForIncomingVoip(context, payload) + intent.removeExtra("voipAction") + return true + } + + return false + } + + /** + * Prepares MainActivity after launch with incoming-call context: cancel notification and timeout, + * stash payload for JS, and unlock/show above keyguard when [context] is an [Activity]. + */ + private fun prepareMainActivityForIncomingVoip( + context: Context, + payload: VoipPayload, + storePayloadForJs: Boolean = true + ) { + Log.d(TAG, "prepareMainActivityForIncomingVoip — callId: ${payload.callId}") + cancelById(context, payload.notificationId) + cancelTimeout(payload.callId) + if (storePayloadForJs) { + VoipModule.storeInitialEvents(payload) + } + + if (context is Activity && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + context.setShowWhenLocked(true) + context.setTurnScreenOn(true) + val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(context, null) + } + } + + /** + * Accept from notification or IncomingCallActivity: send accept over native DDP, sync Telecom, + * dismiss UI, then open MainActivity (unless [skipLaunchMainActivity] — already in MainActivity + * from heads-up Accept [PendingIntent.getActivity]). JS still runs answerCall afterward. + * + * The DDP call is asynchronous; [VoipModule.storeInitialEvents], notification cancel, Telecom + * answer, and [ACTION_DISMISS] run from an internal completion callback. [IncomingCallActivity] + * stays open until that broadcast is received. + */ + @JvmStatic + @JvmOverloads + fun handleAcceptAction(context: Context, payload: VoipPayload, skipLaunchMainActivity: Boolean = false) { + Log.d(TAG, "Accept action triggered for callId: ${payload.callId}") + cancelTimeout(payload.callId) + + val appCtx = context.applicationContext + // Guard so finish() is called at most once, whether by the DDP callback or the timeout. + val finished = AtomicBoolean(false) + val timeoutHandler = Handler(Looper.getMainLooper()) + var timeoutRunnable: Runnable? = null + + fun finish(ddpSuccess: Boolean) { + if (!finished.compareAndSet(false, true)) return + timeoutRunnable?.let { timeoutHandler.removeCallbacks(it) } + ddpRegistry.stopClient(payload.callId) + if (ddpSuccess) { + answerIncomingCall(payload.callId) + VoipModule.storeInitialEvents(payload) + } else { + Log.d(TAG, "Native accept did not succeed over DDP for ${payload.callId}; opening app for JS recovery") + disconnectIncomingCall(payload.callId, false) + VoipModule.storeAcceptFailureForJs(payload) + } + cancelById(appCtx, payload.notificationId) + LocalBroadcastManager.getInstance(appCtx).sendBroadcast( + Intent(ACTION_DISMISS).apply { + putExtras(payload.toBundle()) + } + ) + if (!skipLaunchMainActivity) { + launchMainActivityForVoip(context, payload) + } + } + + val postedTimeout = Runnable { + Log.w(TAG, "Native accept timed out for ${payload.callId}; falling back to JS recovery") + finish(false) + } + timeoutRunnable = postedTimeout + timeoutHandler.postDelayed(postedTimeout, 10_000L) + + val client = ddpRegistry.clientFor(payload.callId) + if (client == null) { + Log.d(TAG, "Native DDP client unavailable for accept ${payload.callId}") + finish(false) + return + } + + if (ddpRegistry.isLoggedIn(payload.callId)) { + sendAcceptSignal(context, payload) { success -> + finish(success) + } + } else { + queueAcceptSignal(context, payload) { success -> + finish(success) + } + } + } + + private fun launchMainActivityForVoip(context: Context, payload: VoipPayload) { + val intent = Intent(context, MainActivity::class.java).apply { + putExtras(payload.toBundle()) + if (context is Activity) { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } else { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + } + } + context.startActivity(intent) + } + + private fun answerIncomingCall(callId: String) { + val connection = VoiceConnectionService.getConnection(callId) + when (connection) { + is VoiceConnection -> connection.onAnswer() + null -> Log.d(TAG, "No active VoiceConnection found for accepted call: $callId") + else -> Log.d(TAG, "Non-VoiceConnection for accept, callId: $callId") + } + } + + // TODO: unify these three functions and check VoiceConnectionService + private fun disconnectTimedOutCall(callId: String) { + val connection = VoiceConnectionService.getConnection(callId) + when (connection) { + is VoiceConnection -> connection.reportDisconnect(DISCONNECT_REASON_MISSED) + null -> Log.d(TAG, "No active VoiceConnection found for timed out call: $callId") + else -> connection.onDisconnect() + } + } + + private fun rejectIncomingCall(callId: String) { + val connection = VoiceConnectionService.getConnection(callId) + when (connection) { + is VoiceConnection -> connection.onReject() + null -> Log.d(TAG, "No active VoiceConnection found for declined call: $callId") + else -> connection.onDisconnect() + } + } + + private fun disconnectIncomingCall(callId: String, reportAsMissed: Boolean) { + val connection = VoiceConnectionService.getConnection(callId) + when (connection) { + is VoiceConnection -> { + if (reportAsMissed) { + connection.reportDisconnect(DISCONNECT_REASON_MISSED) + } else { + connection.onDisconnect() + } + } + null -> Log.d(TAG, "No active VoiceConnection found for dismissed call: $callId") + else -> connection.onDisconnect() + } + } + + private fun sendRejectSignal(context: Context, payload: VoipPayload) { + val client = ddpRegistry.clientFor(payload.callId) + if (client == null) { + Log.d(TAG, "Native DDP client unavailable, cannot send reject for ${payload.callId}") + return + } + + val params = buildRejectSignalParams(context, payload) ?: return + + client.callMethod("stream-notify-user", params) { success -> + Log.d(TAG, "Native reject signal result for ${payload.callId}: $success") + ddpRegistry.stopClient(payload.callId) + } + } + + private fun queueRejectSignal(context: Context, payload: VoipPayload) { + val client = ddpRegistry.clientFor(payload.callId) + if (client == null) { + Log.d(TAG, "Native DDP client unavailable, cannot queue reject for ${payload.callId}") + return + } + + val params = buildRejectSignalParams(context, payload) ?: return + + client.queueMethodCall("stream-notify-user", params) { success -> + Log.d(TAG, "Queued native reject signal result for ${payload.callId}: $success") + ddpRegistry.stopClient(payload.callId) + } + Log.d(TAG, "Queued native reject signal for ${payload.callId}") + } + + private fun flushPendingQueuedSignalsIfNeeded(callId: String): Boolean { + val client = ddpRegistry.clientFor(callId) ?: return false + if (!client.hasQueuedMethodCalls()) { + return false + } + + client.flushQueuedMethodCalls() + return true + } + + private fun sendAcceptSignal( + context: Context, + payload: VoipPayload, + onComplete: (Boolean) -> Unit + ) { + val client = ddpRegistry.clientFor(payload.callId) + if (client == null) { + Log.d(TAG, "Native DDP client unavailable, cannot send accept for ${payload.callId}") + onComplete(false) + return + } + + val params = buildAcceptSignalParams(context, payload) ?: run { + onComplete(false) + return + } + + client.callMethod("stream-notify-user", params) { success -> + Log.d(TAG, "Native accept signal result for ${payload.callId}: $success") + onComplete(success) + } + } + + private fun queueAcceptSignal( + context: Context, + payload: VoipPayload, + onComplete: (Boolean) -> Unit + ) { + val client = ddpRegistry.clientFor(payload.callId) + if (client == null) { + Log.d(TAG, "Native DDP client unavailable, cannot queue accept for ${payload.callId}") + onComplete(false) + return + } + + val params = buildAcceptSignalParams(context, payload) ?: run { + onComplete(false) + return + } + + client.queueMethodCall("stream-notify-user", params) { success -> + Log.d(TAG, "Queued native accept signal result for ${payload.callId}: $success") + onComplete(success) + } + Log.d(TAG, "Queued native accept signal for ${payload.callId}") + } + + /** + * Resolves user id for this host and Android [Settings.Secure.ANDROID_ID] as media-signaling contractId. + * Must match JS `getUniqueIdSync()` from react-native-device-info (iOS native code uses `DeviceUID`). + */ + private fun resolveVoipMediaCallIdentity(context: Context, payload: VoipPayload): VoipMediaCallIdentity? { + val ejson = Ejson().apply { + host = payload.host + } + val userId = ejson.userId() + if (userId.isNullOrEmpty()) { + Log.d(TAG, "Missing userId, cannot build stream-notify-user params for ${payload.callId}") + ddpRegistry.stopClient(payload.callId) + return null + } + val deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + if (deviceId.isNullOrEmpty()) { + Log.d(TAG, "Missing deviceId, cannot build stream-notify-user params for ${payload.callId}") + ddpRegistry.stopClient(payload.callId) + return null + } + return VoipMediaCallIdentity(userId, deviceId) + } + + private fun buildAcceptSignalParams(context: Context, payload: VoipPayload): JSONArray? { + val ids = resolveVoipMediaCallIdentity(context, payload) ?: return null + val signal = JSONObject().apply { + put("callId", payload.callId) + put("contractId", ids.deviceId) + put("type", "answer") + put("answer", "accept") + put("supportedFeatures", SUPPORTED_VOIP_FEATURES) + } + return JSONArray().apply { + put("${ids.userId}/media-calls") + put(signal.toString()) + } + } + + private fun buildRejectSignalParams(context: Context, payload: VoipPayload): JSONArray? { + val ids = resolveVoipMediaCallIdentity(context, payload) ?: return null + val signal = JSONObject().apply { + put("callId", payload.callId) + put("contractId", ids.deviceId) + put("type", "answer") + put("answer", "reject") + } + return JSONArray().apply { + put("${ids.userId}/media-calls") + put(signal.toString()) + } + } + + /** + * True when the user is already in a call: this app's Telecom connections (ringing, dialing, + * active, hold — same idea as iOS CXCallObserver "any non-ended"), any system in-call state + * (API 26+ when READ_PHONE_STATE is granted), or audio in communication mode (fallback on all + * API levels when Telecom is unavailable or denied). + */ + private fun hasActiveCall(context: Context): Boolean { + val ownBusy = VoiceConnectionService.currentConnections.values.any { connection -> + when (connection.state) { + android.telecom.Connection.STATE_RINGING, + android.telecom.Connection.STATE_DIALING, + android.telecom.Connection.STATE_ACTIVE, + android.telecom.Connection.STATE_HOLDING -> true + else -> false + } + } + if (ownBusy) { + return true + } + return hasSystemLevelActiveCallIndicators(context) + } + + /** + * Telecom in-call check (API 26+) requires [READ_PHONE_STATE]; without it, [TelecomManager.isInCall] + * can throw [SecurityException]. Always falls back to [AudioManager.MODE_IN_COMMUNICATION] or + * [AudioManager.MODE_IN_CALL] on all APIs to catch both VoIP and cellular calls. + */ + private fun hasSystemLevelActiveCallIndicators(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == + PackageManager.PERMISSION_GRANTED + if (granted) { + val telecom = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + try { + if (telecom?.isInCall == true) { + return true + } + } catch (e: SecurityException) { + Log.w(TAG, "TelecomManager.isInCall not allowed", e) + } + } + } + val audio = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audio?.mode == AudioManager.MODE_IN_COMMUNICATION || audio?.mode == AudioManager.MODE_IN_CALL) { + return true + } + return false + } + + /** + * Rejects an incoming call because the user is already on another call. + * + * Uses [connectAndRejectBusy] — a lightweight DDP flow that only connects, + * logs in, sends the reject signal, and tears down the client. Unlike + * [startListeningForCallEnd] (used by the normal incoming-call path), this + * does NOT subscribe to `stream-notify-user` or install a collection-message + * handler, because no incoming-call UI was ever shown and there is nothing + * to dismiss if the caller hangs up or another device answers. + */ + @JvmStatic + fun rejectBusyCall(context: Context, payload: VoipPayload) { + Log.d(TAG, "Rejected busy call ${payload.callId} — user already on a call") + cancelTimeout(payload.callId) + connectAndRejectBusy(context, payload) + } + + /** + * Minimal DDP flow for busy-reject: connect → login → send reject → stop. + * + * Intentionally omits the `stream-notify-user` subscription and the + * `onCollectionMessage` handler that [startListeningForCallEnd] sets up, + * since the busy path never shows UI — there are no notifications to + * dismiss and no call-end events to observe. + */ + private fun connectAndRejectBusy(context: Context, payload: VoipPayload) { + val ejson = Ejson() + ejson.host = payload.host + val userId = ejson.userId() + val token = ejson.token() + + if (userId.isNullOrEmpty() || token.isNullOrEmpty()) { + Log.d(TAG, "No credentials for ${payload.host}, skipping busy-reject DDP") + return + } + + val callId = payload.callId + val client = DDPClient() + ddpRegistry.putClient(callId, client) + + Log.d(TAG, "Connecting DDP to send busy-reject for call $callId") + + client.connect(payload.host) { connected -> + if (!isLiveClient(callId, client)) { + return@connect + } + if (!connected) { + Log.d(TAG, "DDP connection failed for busy-reject $callId") + ddpRegistry.stopClient(callId) + return@connect + } + + client.login(token) { loggedIn -> + if (!isLiveClient(callId, client)) { + return@login + } + if (!loggedIn) { + Log.d(TAG, "DDP login failed for busy-reject $callId") + ddpRegistry.stopClient(callId) + return@login + } + + ddpRegistry.markLoggedIn(callId) + sendRejectSignal(context, payload) + } + } + } + + // -- Native DDP Listener (Call End Detection) -- + + @JvmStatic + fun startListeningForCallEnd(context: Context, payload: VoipPayload) { + val ejson = Ejson() + ejson.host = payload.host + val userId = ejson.userId() + val token = ejson.token() + + if (userId.isNullOrEmpty() || token.isNullOrEmpty()) { + Log.d(TAG, "No credentials for ${payload.host}, skipping DDP listener") + return + } + + val deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + val callId = payload.callId + val client = DDPClient() + ddpRegistry.putClient(callId, client) + + Log.d(TAG, "Starting DDP listener for call $callId") + + client.onCollectionMessage = collector@{ message -> + if (!isLiveClient(callId, client)) { + return@collector + } + Log.d(TAG, "DDP received message: $message") + val fields = message.optJSONObject("fields") + if (fields != null) { + val eventName = fields.optString("eventName") + if (eventName.endsWith("/media-signal")) { + val args = fields.optJSONArray("args") + val firstArg = args?.optJSONObject(0) + if (firstArg != null) { + val signalType = firstArg.optString("type") + val signalCallId = firstArg.optString("callId") + val signalNotification = firstArg.optString("notification") + val signedContractId = firstArg.optString("signedContractId") + + if (signalCallId == callId) { + if (signalType == "notification" && + ( + // accepted from other device + (!signedContractId.isNullOrEmpty() && signedContractId != deviceId) || + // hung up by other device + (signalNotification == "hangup") + )) { + val appContext = context.applicationContext + Handler(Looper.getMainLooper()).post { + if (!isLiveClient(callId, client)) { + return@post + } + cancelTimeout(callId) + disconnectIncomingCall(callId, false) + cancelById(appContext, payload.notificationId) + LocalBroadcastManager.getInstance(appContext).sendBroadcast( + Intent(ACTION_DISMISS).apply { + putExtras(payload.toBundle()) + } + ) + ddpRegistry.stopClient(callId) + } + } + } + } + } + } + } + + client.connect(payload.host) { connected -> + if (!isLiveClient(callId, client)) { + return@connect + } + if (!connected) { + Log.d(TAG, "DDP connection failed") + ddpRegistry.stopClient(callId) + return@connect + } + + client.login(token) { loggedIn -> + if (!isLiveClient(callId, client)) { + return@login + } + if (!loggedIn) { + Log.d(TAG, "DDP login failed") + ddpRegistry.stopClient(callId) + return@login + } + + ddpRegistry.markLoggedIn(callId) + if (flushPendingQueuedSignalsIfNeeded(callId)) { + return@login + } + + val params = JSONArray().apply { + put("$userId/media-signal") + put(JSONObject().apply { + put("useCollection", false) + put("args", JSONArray().apply { put(false) }) + }) + } + + client.subscribe("stream-notify-user", params) { subscribed -> + if (!isLiveClient(callId, client)) { + return@subscribe + } + Log.d(TAG, "DDP subscribe result: $subscribed") + if (!subscribed) { + ddpRegistry.stopClient(callId) + } + } + } + } + + } + + @JvmStatic + fun stopDDPClient() { + Log.d(TAG, "stopDDPClient called from JS") + ddpRegistry.stopAllClients() + } + } + + /** + * BroadcastReceiver to handle decline actions from notification. + */ + class DeclineReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val voipPayload = VoipPayload.fromBundle(intent.extras) + voipPayload?.let { VoipNotification.handleDeclineAction(context, it) } + } + } + + private val notificationManager: NotificationManager? = + context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + + init { + createNotificationChannel() + } + + fun onMessageReceived(voipPayload: VoipPayload) { + when { + voipPayload.isVoipIncomingCall() -> { + val isValidForIncoming = + voipPayload.getRemainingLifetimeMs() != null && !voipPayload.isExpired() + when (decideIncomingVoipPushAction(isValidForIncoming, hasActiveCall(context))) { + VoipIncomingPushAction.STALE -> { + if (voipPayload.getRemainingLifetimeMs() == null) { + Log.w( + TAG, + "Skipping incoming VoIP call without a valid createdAt timestamp - callId: ${voipPayload.callId}" + ) + } else { + Log.d(TAG, "Skipping expired incoming VoIP call - callId: ${voipPayload.callId}") + } + } + VoipIncomingPushAction.REJECT_BUSY -> rejectBusyCall(context, voipPayload) + VoipIncomingPushAction.SHOW_INCOMING -> showIncomingCall(voipPayload) + } + } + else -> Log.w(TAG, "Ignoring unsupported VoIP payload type: ${voipPayload.type}") + } + } + + /** + * Creates the notification channel for VoIP calls with high importance and ringtone sound. + */ + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ).apply { + // TODO: i18n + description = "Incoming VoIP calls" + enableLights(true) + enableVibration(true) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + + // Set ringtone sound + val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build() + setSound(ringtoneUri, audioAttributes) + } + + notificationManager?.createNotificationChannel(channel) + } + } + + /** + * Displays an incoming VoIP call using full-screen intent for locked devices + * and heads-up notification for unlocked devices. + * + * @param bundle The notification data bundle + * @param voipPayload The VoIP payload containing call information + */ + fun showIncomingCall(voipPayload: VoipPayload) { + val callId = voipPayload.callId + val caller = voipPayload.caller + if (voipPayload.getRemainingLifetimeMs() == null) { + Log.w(TAG, "Skipping incoming VoIP call without a valid createdAt timestamp - callId: $callId") + return + } + + if (voipPayload.isExpired()) { + Log.d(TAG, "Skipping expired incoming VoIP call - callId: $callId") + return + } + + Log.d(TAG, "Showing incoming VoIP call - callId: $callId, caller: $caller") + + // CRITICAL: Register call with TelecomManager FIRST (required for audio focus, Bluetooth, priority, FSI exemption) + // This triggers react-native-callkeep's ConnectionService + registerCallWithTelecomManager(callId, caller) + + // Show notification with full-screen intent + showIncomingCallNotification(voipPayload) + scheduleTimeout(context, voipPayload) + startListeningForCallEnd(context, voipPayload) + } + + /** + * Registers the incoming call with TelecomManager using react-native-callkeep's ConnectionService. + * This is REQUIRED for: + * 1. Audio focus (pauses media apps) + * 2. Bluetooth headset support + * 3. Higher process priority + * 4. FSI exemption on Play Store + */ + private fun registerCallWithTelecomManager(callId: String, caller: String) { + try { + // Validate inputs + if (callId.isNullOrEmpty() || caller.isNullOrEmpty()) { + Log.e(TAG, "Cannot register call with TelecomManager: callId is null or empty") + return + } + + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + ?: run { + Log.w(TAG, "TelecomManager not available") + return + } + + // Get react-native-callkeep's PhoneAccountHandle + val componentName = ComponentName(context.packageName, CALLKEEP_CONNECTION_SERVICE_CLASS) + // react-native-callkeep typically uses the app package name as the account ID + val phoneAccountHandle = PhoneAccountHandle(componentName, context.packageName) + + // Check if PhoneAccount is registered + val phoneAccount = telecomManager.getPhoneAccount(phoneAccountHandle) + if (phoneAccount == null) { + Log.w(TAG, "PhoneAccount not registered by react-native-callkeep yet. Call may not have full OS integration.") + return + } + + // Create extras for the incoming call + val extras = Bundle().apply { + val callerUri = Uri.fromParts(PhoneAccount.SCHEME_TEL, caller, null) + putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, callerUri) + putString("EXTRA_CALL_UUID", callId) + putString("EXTRA_CALLER_NAME", caller) + putString("name", caller) + putString("handle", caller) + } + + Log.d(TAG, "Registering call with TelecomManager - callId: $callId, caller: $caller, extras keys: ${extras.keySet()}") + + // Register the incoming call with the OS + telecomManager.addNewIncomingCall(phoneAccountHandle, extras) + Log.d(TAG, "Successfully registered incoming call with TelecomManager: $callId") + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException registering call with TelecomManager. MANAGE_OWN_CALLS permission may be missing.", e) + } catch (e: Exception) { + Log.e(TAG, "Failed to register call with TelecomManager", e) + } + } + + /** + * Shows incoming call notification with full-screen intent for locked devices + * and heads-up notification for unlocked devices. + * Falls back to HUN only if full-screen intent permission is not granted (Android 14+). + */ + private fun showIncomingCallNotification(voipPayload: VoipPayload) { + val caller = voipPayload.caller + val notificationId = voipPayload.notificationId + val remainingLifetimeMs = voipPayload.getRemainingLifetimeMs() + if (remainingLifetimeMs == null || remainingLifetimeMs <= 0L) { + Log.d(TAG, "Skipping notification for expired or invalid call: ${voipPayload.callId}") + return + } + + Log.d(TAG, "Showing incoming call notification for VoIP call from: $caller") + + // Check if we can use full-screen intent (Android 14+) + val canUseFullScreen = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + notificationManager?.canUseFullScreenIntent() ?: false + } else { + true // Always available on Android 13 and below + } + + // Create full-screen intent to IncomingCallActivity + val fullScreenIntent = Intent(context, IncomingCallActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtras(voipPayload.toBundle()) + } + val fullScreenPendingIntent = createPendingIntent(notificationId, fullScreenIntent) + + // Accept: must use getActivity — Android 12+ blocks starting MainActivity from a + // notification BroadcastReceiver ("trampoline"). MainActivity runs native accept with + // skipLaunchMainActivity after opening. + val acceptIntent = Intent(context, MainActivity::class.java).apply { + action = ACTION_VOIP_ACCEPT_HEADS_UP + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtras(voipPayload.toBundle()) + } + val acceptPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.getActivity( + context, + notificationId + 1, + acceptIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + PendingIntent.getActivity( + context, + notificationId + 1, + acceptIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + // Create Decline action + val declineIntent = Intent(context, DeclineReceiver::class.java).apply { + action = ACTION_DECLINE + putExtras(voipPayload.toBundle()) + } + val declinePendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.getBroadcast( + context, + notificationId + 2, + declineIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + PendingIntent.getBroadcast( + context, + notificationId + 2, + declineIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + // Get icons + val packageName = context.packageName + val smallIconResId = context.resources.getIdentifier("ic_notification", "drawable", packageName) + + // Avatar not available in VoipPayload format (would require caller username) + val avatarBitmap: Bitmap? = null + + // Build notification + val builder = NotificationCompat.Builder(context, CHANNEL_ID).apply { + setSmallIcon(smallIconResId) + setContentTitle("Incoming call") + setContentText("Call from $caller") + priority = NotificationCompat.PRIORITY_MAX + setCategory(NotificationCompat.CATEGORY_CALL) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + setAutoCancel(false) + setOngoing(true) + setTimeoutAfter(remainingLifetimeMs) + addAction(0, "Decline", declinePendingIntent) + addAction(0, "Accept", acceptPendingIntent) + + if (avatarBitmap != null) { + setLargeIcon(avatarBitmap) + } + + // Set full-screen intent only if permission is granted + if (canUseFullScreen) { + setFullScreenIntent(fullScreenPendingIntent, true) + Log.d(TAG, "Full-screen intent enabled - locked device will show Activity, unlocked will show HUN") + } else { + Log.w(TAG, "Full-screen intent permission not granted - showing HUN only (fallback)") + // Still set content intent so tapping notification opens the activity + setContentIntent(fullScreenPendingIntent) + } + } + + // Set sound for pre-O devices + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + builder.setSound(ringtoneUri) + } + + // Show notification + notificationManager?.notify(notificationId, builder.build()) + Log.d(TAG, "VoIP notification displayed with ID: $notificationId") + } + + /** + * Creates a PendingIntent with appropriate flags for the Android version. + */ + private fun createPendingIntent(requestCode: Int, intent: Intent): PendingIntent { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + return PendingIntent.getActivity(context, requestCode, intent, flags) + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt new file mode 100644 index 00000000000..beaa441871c --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt @@ -0,0 +1,231 @@ +package chat.rocket.reactnative.voip + +import android.os.Bundle +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +enum class VoipPushType(val value: String) { + INCOMING_CALL("incoming_call"); + + companion object { + fun from(value: String?): VoipPushType? = entries.firstOrNull { it.value == value } + } +} + +data class VoipPayload( + @SerializedName("callId") + val callId: String, + + @SerializedName("caller") + val caller: String, + + @SerializedName("username") + val username: String, + + @SerializedName("host") + val host: String, + + @SerializedName("type") + val type: String, + + @SerializedName("hostName") + val hostName: String, + + @SerializedName("avatarUrl") + val avatarUrl: String?, + + @SerializedName("createdAt") + val createdAt: String?, + + val voipAcceptFailed: Boolean = false, +) { + val notificationId: Int = callId.hashCode() + val pushType: VoipPushType? + get() = VoipPushType.from(type) + + private val createdAtMs: Long? + get() = parseCreatedAtMs(createdAt) + + private val expiresAtMs: Long? + get() = createdAtMs?.plus(INCOMING_CALL_LIFETIME_MS) + + fun isVoipIncomingCall(): Boolean { + return pushType == VoipPushType.INCOMING_CALL && + callId.isNotBlank() && + caller.isNotBlank() && + host.isNotBlank() + } + + fun toBundle(): Bundle { + return Bundle().apply { + putString("callId", callId) + putString("caller", caller) + putString("username", username) + putString("host", host) + putString("type", type) + putString("hostName", hostName) + putString("avatarUrl", avatarUrl) + putString("createdAt", createdAt) + putInt("notificationId", notificationId) + // Useful flag for MainActivity to know it's handling a VoIP action + putBoolean("voipAction", true) + } + } + + fun toWritableMap(): WritableMap { + return Arguments.createMap().apply { + putString("callId", callId) + putString("caller", caller) + putString("username", username) + putString("host", host) + putString("type", type) + putString("hostName", hostName) + putString("avatarUrl", avatarUrl) + putString("createdAt", createdAt) + putInt("notificationId", notificationId) + if (voipAcceptFailed) { + putBoolean("voipAcceptFailed", true) + } + } + } + + fun getRemainingLifetimeMs(): Long? { + val expiresAtMs = expiresAtMs ?: return null + val nowMs = System.currentTimeMillis() + return (expiresAtMs - nowMs).coerceAtLeast(0L) + } + + fun isExpired(): Boolean { + val remainingLifetimeMs = getRemainingLifetimeMs() + return remainingLifetimeMs?.let { it <= 0L } ?: true + } + + companion object { + private val gson = Gson() + private const val VOIP_NOTIFICATION_TYPE = "voip" + // the amount of time in milliseconds that an incoming call will be kept alive + private const val INCOMING_CALL_LIFETIME_MS = 60_000L + private val isoDateFormats = listOf( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX", Locale.US), + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.US), + ).onEach { formatter -> + formatter.timeZone = TimeZone.getTimeZone("UTC") + formatter.isLenient = false + } + + private data class RemoteCaller( + @SerializedName("name") + val name: String? = null, + + @SerializedName("username") + val username: String? = null, + + @SerializedName("avatarUrl") + val avatarUrl: String? = null, + ) + + private data class RemoteVoipPayload( + @SerializedName("callId") + val callId: String? = null, + + @SerializedName("caller") + val caller: RemoteCaller? = null, + + @SerializedName("username") + val username: String? = null, + + @SerializedName("host") + val host: String? = null, + + @SerializedName("type") + val type: String? = null, + + @SerializedName("hostName") + val hostName: String? = null, + + @SerializedName("notificationType") + val notificationType: String? = null, + + @SerializedName("createdAt") + val createdAt: String? = null, + ) { + fun toVoipPayload(): VoipPayload? { + if (notificationType != VOIP_NOTIFICATION_TYPE) return null + + val payloadType = VoipPushType.from(type)?.value ?: return null + val payloadCreatedAt = createdAt?.takeUnless { it.isBlank() } ?: return null + + return VoipPayload( + callId = callId ?: return null, + caller = caller?.name.orEmpty(), + username = caller?.username ?: username.orEmpty(), + host = host.orEmpty(), + type = payloadType, + hostName = hostName.orEmpty(), + avatarUrl = caller?.avatarUrl, + createdAt = payloadCreatedAt, + voipAcceptFailed = false, + ) + } + } + + fun fromMap(data: Map): VoipPayload? { + val payload = parseRemotePayload(data) ?: return null + return payload.toVoipPayload() + } + + fun fromBundle(bundle: Bundle?): VoipPayload? { + if (bundle == null) return null + val callId = bundle.getString("callId") ?: return null + val caller = bundle.getString("caller").orEmpty() + val username = bundle.getString("username").orEmpty() + val host = bundle.getString("host").orEmpty() + val hostName = bundle.getString("hostName").orEmpty() + val type = bundle.getString("type") ?: return null + val avatarUrl = bundle.getString("avatarUrl") + val createdAt = bundle.getString("createdAt")?.takeUnless { it.isBlank() } ?: return null + + if (VoipPushType.from(type) == null) { + return null + } + + val voipAcceptFailed = bundle.getBoolean("voipAcceptFailed", false) + return VoipPayload(callId, caller, username, host, type, hostName, avatarUrl, createdAt, voipAcceptFailed) + } + + private fun parseRemotePayload(data: Map): RemoteVoipPayload? { + val rawPayload = data["ejson"] + if (rawPayload.isNullOrBlank() || rawPayload == "{}") { + return null + } + + return try { + gson.fromJson(rawPayload, RemoteVoipPayload::class.java) + } catch (_: Exception) { + null + } + } + + private fun parseCreatedAtMs(value: String?): Long? { + if (value.isNullOrBlank()) { + return null + } + + for (formatter in isoDateFormats) { + val parsed = synchronized(formatter) { + runCatching { formatter.parse(value) }.getOrNull() + } + if (parsed != null) { + return parsed.time + } + } + + return null + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistry.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistry.kt new file mode 100644 index 00000000000..6f48819f40a --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistry.kt @@ -0,0 +1,50 @@ +package chat.rocket.reactnative.voip + +/** + * Per-call DDP client slots: each [callId] maps to at most one [DDPClient] in production. + * Isolates teardown so a busy-call reject (call B) does not disconnect call A's listener. + */ +internal class VoipPerCallDdpRegistry( + private val releaseClient: (T) -> Unit +) { + private val lock = Any() + private val clients = mutableMapOf() + private val loggedInCallIds = mutableSetOf() + + fun clientFor(callId: String): T? = synchronized(lock) { clients[callId] } + + fun isLoggedIn(callId: String): Boolean = synchronized(lock) { loggedInCallIds.contains(callId) } + + fun putClient(callId: String, client: T) { + synchronized(lock) { + clients.remove(callId)?.let(releaseClient) + clients[callId] = client + loggedInCallIds.remove(callId) + } + } + + fun markLoggedIn(callId: String) { + synchronized(lock) { + loggedInCallIds.add(callId) + } + } + + fun stopClient(callId: String) { + synchronized(lock) { + loggedInCallIds.remove(callId) + clients.remove(callId)?.let(releaseClient) + } + } + + fun stopAllClients() { + synchronized(lock) { + loggedInCallIds.clear() + clients.values.forEach(releaseClient) + clients.clear() + } + } + + fun clientCount(): Int = synchronized(lock) { clients.size } + + fun clientIds(): Set = synchronized(lock) { clients.keys.toSet() } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipTurboPackage.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipTurboPackage.kt new file mode 100644 index 00000000000..7f95b12e148 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipTurboPackage.kt @@ -0,0 +1,34 @@ +package chat.rocket.reactnative + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import chat.rocket.reactnative.networking.NativeVoipSpec +import chat.rocket.reactnative.voip.VoipModule + +class VoipTurboPackage : BaseReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return when (name) { + NativeVoipSpec.NAME -> VoipModule(reactContext) + else -> null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + mapOf( + NativeVoipSpec.NAME to ReactModuleInfo( + NativeVoipSpec.NAME, + NativeVoipSpec.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + ) + } + } +} diff --git a/android/app/src/main/res/drawable/bg_avatar_incoming_call.xml b/android/app/src/main/res/drawable/bg_avatar_incoming_call.xml new file mode 100644 index 00000000000..15b9de09786 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_avatar_incoming_call.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_btn_accept.xml b/android/app/src/main/res/drawable/bg_btn_accept.xml new file mode 100644 index 00000000000..e6f8ea9891c --- /dev/null +++ b/android/app/src/main/res/drawable/bg_btn_accept.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_btn_reject.xml b/android/app/src/main/res/drawable/bg_btn_reject.xml new file mode 100644 index 00000000000..8f9083b8279 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_btn_reject.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_call.xml b/android/app/src/main/res/drawable/ic_call.xml new file mode 100644 index 00000000000..6d6192d7ca1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_call.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_call_end.xml b/android/app/src/main/res/drawable/ic_call_end.xml new file mode 100644 index 00000000000..443ec5c0aeb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_call_end.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/layout-land/activity_incoming_call.xml b/android/app/src/main/res/layout-land/activity_incoming_call.xml new file mode 100644 index 00000000000..55867b6856e --- /dev/null +++ b/android/app/src/main/res/layout-land/activity_incoming_call.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_incoming_call.xml b/android/app/src/main/res/layout/activity_incoming_call.xml new file mode 100644 index 00000000000..20fe9b387d6 --- /dev/null +++ b/android/app/src/main/res/layout/activity_incoming_call.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/colors_incoming_call.xml b/android/app/src/main/res/values-night/colors_incoming_call.xml new file mode 100644 index 00000000000..4e1b5e4533f --- /dev/null +++ b/android/app/src/main/res/values-night/colors_incoming_call.xml @@ -0,0 +1,15 @@ + + + + #1F2329 + #9EA2A8 + #9EA2A8 + #F2F3F5 + #9EA2A8 + #5F1477 + #FFFFFF + #BB3E4E + #1D7256 + #F2F3F5 + #FFFFFF + diff --git a/android/app/src/main/res/values-night/styles_incoming_call.xml b/android/app/src/main/res/values-night/styles_incoming_call.xml new file mode 100644 index 00000000000..ba0e5185440 --- /dev/null +++ b/android/app/src/main/res/values-night/styles_incoming_call.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/values/colors_incoming_call.xml b/android/app/src/main/res/values/colors_incoming_call.xml new file mode 100644 index 00000000000..14984e230a4 --- /dev/null +++ b/android/app/src/main/res/values/colors_incoming_call.xml @@ -0,0 +1,15 @@ + + + + #FFFFFF + #6C727A + #6C727A + #1F2329 + #6C727A + #5F1477 + #FFFFFF + #EC0D2A + #158D65 + #1F2329 + #FFFFFF + diff --git a/android/app/src/main/res/values/strings_incoming_call.xml b/android/app/src/main/res/values/strings_incoming_call.xml new file mode 100644 index 00000000000..bb4905d72da --- /dev/null +++ b/android/app/src/main/res/values/strings_incoming_call.xml @@ -0,0 +1,9 @@ + + + Incoming call… + Reject + Accept + Caller avatar + Unknown caller + Unknown host + diff --git a/android/app/src/main/res/values/styles_incoming_call.xml b/android/app/src/main/res/values/styles_incoming_call.xml new file mode 100644 index 00000000000..ba0e5185440 --- /dev/null +++ b/android/app/src/main/res/values/styles_incoming_call.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatchTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatchTest.kt new file mode 100644 index 00000000000..4dd6cac6d9c --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatchTest.kt @@ -0,0 +1,39 @@ +package chat.rocket.reactnative.voip + +import org.junit.Assert.assertEquals +import org.junit.Test + +class VoipIncomingCallDispatchTest { + + @Test + fun `stale push with active call does not route to reject busy`() { + assertEquals( + VoipIncomingPushAction.STALE, + decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = true) + ) + } + + @Test + fun `stale push without active call does not route to show incoming`() { + assertEquals( + VoipIncomingPushAction.STALE, + decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = false) + ) + } + + @Test + fun `valid push with active call rejects busy`() { + assertEquals( + VoipIncomingPushAction.REJECT_BUSY, + decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = true) + ) + } + + @Test + fun `valid push without active call shows incoming`() { + assertEquals( + VoipIncomingPushAction.SHOW_INCOMING, + decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = false) + ) + } +} diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt new file mode 100644 index 00000000000..64eb10cda24 --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt @@ -0,0 +1,77 @@ +package chat.rocket.reactnative.voip + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class VoipPerCallDdpRegistryTest { + + private fun registry(): Pair, VoipPerCallDdpRegistry> { + val released = mutableListOf() + return released to VoipPerCallDdpRegistry { released.add(it) } + } + + @Test + fun `stopClient removes only the named callId`() { + val (released, reg) = registry() + reg.putClient("callA", "clientA") + reg.putClient("callB", "clientB") + reg.stopClient("callA") + assertEquals(listOf("clientA"), released) + assertEquals(setOf("callB"), reg.clientIds()) + assertEquals("clientB", reg.clientFor("callB")) + assertNull(reg.clientFor("callA")) + } + + @Test + fun `stopAllClients disconnects every entry`() { + val (released, reg) = registry() + reg.putClient("callA", "clientA") + reg.putClient("callB", "clientB") + reg.stopAllClients() + assertEquals(2, released.size) + assertTrue(released.containsAll(listOf("clientA", "clientB"))) + assertEquals(0, reg.clientCount()) + assertTrue(reg.clientIds().isEmpty()) + } + + @Test + fun `starting a second listener for another callId does not release the first`() { + val (released, reg) = registry() + reg.putClient("callA", "clientA") + reg.putClient("callB", "clientB") + assertTrue(released.isEmpty()) + assertEquals(setOf("callA", "callB"), reg.clientIds()) + } + + @Test + fun `putClient for the same callId releases the previous client`() { + val (released, reg) = registry() + reg.putClient("callA", "first") + reg.putClient("callA", "second") + assertEquals(listOf("first"), released) + assertEquals("second", reg.clientFor("callA")) + } + + @Test + fun `loggedIn state is per callId`() { + val (_, reg) = registry() + reg.putClient("callA", "a") + reg.putClient("callB", "b") + assertFalse(reg.isLoggedIn("callA")) + reg.markLoggedIn("callA") + assertTrue(reg.isLoggedIn("callA")) + assertFalse(reg.isLoggedIn("callB")) + } + + @Test + fun `stopClient clears loggedIn for that callId`() { + val (_, reg) = registry() + reg.putClient("callA", "a") + reg.markLoggedIn("callA") + reg.stopClient("callA") + assertFalse(reg.isLoggedIn("callA")) + } +} diff --git a/android/build.gradle b/android/build.gradle index 37bab42ecee..4c980840f56 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,11 +1,11 @@ buildscript { ext { - buildToolsVersion = "35.0.0" + buildToolsVersion = "36.0.0" minSdkVersion = 24 - compileSdkVersion = 35 - targetSdkVersion = 35 + compileSdkVersion = 36 + targetSdkVersion = 36 ndkVersion = "27.1.12297006" - kotlinVersion = "2.0.21" + kotlinVersion = "2.1.20" kotlin_version = kotlinVersion glideVersion = "4.11.0" supportLibVersion = "28.0.0" diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index b89435389ce..1448a1173f0 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -78,7 +78,11 @@ platform :android do upload_to_play_store( package_name: 'chat.rocket.android', track: 'beta', - aab: 'app/build/outputs/bundle/officialRelease/app-official-release.aab' + aab: 'app/build/outputs/bundle/officialRelease/app-official-release.aab', + skip_upload_metadata: true, + skip_upload_changelogs: false, + skip_upload_images: true, + skip_upload_screenshots: true ) end end diff --git a/android/gradle.properties b/android/gradle.properties index 3188b470664..1ddb9abde0f 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -43,4 +43,9 @@ BugsnagAPIKey="" # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. -hermesEnabled=true \ No newline at end of file +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=false \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index b5166dad4d9..1b33c55baab 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index c3f6c760111..42f0071e3f1 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/android/gradlew b/android/gradlew index 19690a18938..6aa41b69be4 100755 --- a/android/gradlew +++ b/android/gradlew @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/android/gradlew.bat b/android/gradlew.bat index b808aea5e7e..9ed2585adf4 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,3 +1,8 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + @rem @rem Copyright 2015 the original author or authors. @rem @@ -70,11 +75,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/app/AppContainer.tsx b/app/AppContainer.tsx index 001b9d33a0d..53701f36def 100644 --- a/app/AppContainer.tsx +++ b/app/AppContainer.tsx @@ -19,6 +19,7 @@ import { ThemeContext } from './theme'; import { setCurrentScreen } from './lib/methods/helpers/log'; import { themes } from './lib/constants/colors'; import { emitter } from './lib/methods/helpers'; +import MediaCallHeader from './containers/MediaCallHeader/MediaCallHeader'; const createStackNavigator = createNativeStackNavigator; @@ -34,6 +35,7 @@ const SetUsernameStack = () => ( const Stack = createStackNavigator(); const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: boolean }) => { const { theme } = useContext(ThemeContext); + useEffect(() => { if (root) { const state = Navigation.navigationRef.current?.getRootState(); @@ -50,35 +52,39 @@ const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: bool const navTheme = navigationTheme(theme); return ( - { - emitter.emit('navigationReady'); - }} - onStateChange={state => { - const previousRouteName = Navigation.routeNameRef.current; - const currentRouteName = getActiveRouteName(state); - if (previousRouteName !== currentRouteName) { - setCurrentScreen(currentRouteName); - } - Navigation.routeNameRef.current = currentRouteName; - }}> - - {root === RootEnum.ROOT_LOADING || root === RootEnum.ROOT_LOADING_SHARE_EXTENSION ? ( - - ) : null} - {root === RootEnum.ROOT_OUTSIDE ? : null} - {root === RootEnum.ROOT_INSIDE && isMasterDetail ? ( - - ) : null} - {root === RootEnum.ROOT_INSIDE && !isMasterDetail ? : null} - {root === RootEnum.ROOT_SET_USERNAME ? : null} - {root === RootEnum.ROOT_SHARE_EXTENSION ? ( - - ) : null} - - + <> + + { + emitter.emit('navigationReady'); + }} + onStateChange={state => { + const previousRouteName = Navigation.routeNameRef.current; + const currentRouteName = getActiveRouteName(state); + if (previousRouteName !== currentRouteName) { + setCurrentScreen(currentRouteName); + } + Navigation.routeNameRef.current = currentRouteName; + }}> + + {root === RootEnum.ROOT_LOADING || root === RootEnum.ROOT_LOADING_SHARE_EXTENSION ? ( + + ) : null} + {root === RootEnum.ROOT_OUTSIDE ? : null} + {root === RootEnum.ROOT_INSIDE && isMasterDetail ? ( + + ) : null} + {root === RootEnum.ROOT_INSIDE && !isMasterDetail ? : null} + {root === RootEnum.ROOT_SET_USERNAME ? : null} + {root === RootEnum.ROOT_SHARE_EXTENSION ? ( + + ) : null} + + + ); }); const mapStateToProps = (state: any) => ({ diff --git a/app/actions/deepLinking.ts b/app/actions/deepLinking.ts index 7572cebe4ee..3ff1e22fed9 100644 --- a/app/actions/deepLinking.ts +++ b/app/actions/deepLinking.ts @@ -10,6 +10,9 @@ interface IParams { fullURL: string; type: string; token: string; + callId?: string; + username?: string; + voipAcceptFailed?: boolean; } interface IDeepLinkingOpen extends Action { diff --git a/app/actions/room.ts b/app/actions/room.ts index 1ac3b0f4907..a4811411344 100644 --- a/app/actions/room.ts +++ b/app/actions/room.ts @@ -39,9 +39,14 @@ interface IForwardRoom extends Action { rid: string; } +type IUserTypingArgs = { + tmid?: string; +}; + interface IUserTyping extends Action { rid: string; status: boolean; + args?: IUserTypingArgs; } export interface IRoomHistoryRequest extends Action { @@ -109,11 +114,12 @@ export function removedRoom(): Action { }; } -export function userTyping(rid: string, status = true): IUserTyping { +export function userTyping(rid: string, status = true, args?: IUserTypingArgs): IUserTyping { return { type: ROOM.USER_TYPING, rid, - status + status, + args }; } diff --git a/app/containers/ActionSheet/ActionSheet.tsx b/app/containers/ActionSheet/ActionSheet.tsx index 65f65d551dc..4725bcea93b 100644 --- a/app/containers/ActionSheet/ActionSheet.tsx +++ b/app/containers/ActionSheet/ActionSheet.tsx @@ -1,98 +1,53 @@ import { useBackHandler } from '@react-native-community/hooks'; import * as Haptics from 'expo-haptics'; -import React, { forwardRef, isValidElement, useEffect, useImperativeHandle, useRef, useState, useCallback } from 'react'; -import { Keyboard, type LayoutChangeEvent, useWindowDimensions } from 'react-native'; -import { Easing, useDerivedValue, useSharedValue } from 'react-native-reanimated'; -import BottomSheet, { BottomSheetBackdrop, type BottomSheetBackdropProps } from '@discord/bottom-sheet'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import React, { forwardRef, isValidElement, useImperativeHandle, useRef, useState } from 'react'; +import { Keyboard, type LayoutChangeEvent, Platform, useWindowDimensions } from 'react-native'; +import { TrueSheet } from '@lodev09/react-native-true-sheet'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { useTheme } from '../../theme'; -import { isIOS, isTablet } from '../../lib/methods/helpers'; +import { isAndroid, isIOS } from '../../lib/methods/helpers'; import { Handle } from './Handle'; import { type TActionSheetOptions } from './Provider'; import BottomSheetContent from './BottomSheetContent'; +import { HANDLE_HEIGHT, useActionSheetDetents } from './useActionSheetDetents'; import styles from './styles'; export const ACTION_SHEET_ANIMATION_DURATION = 250; -const HANDLE_HEIGHT = 28; -const CANCEL_HEIGHT = 64; - -const ANIMATION_CONFIG = { - duration: ACTION_SHEET_ANIMATION_DURATION, - // https://easings.net/#easeInOutCubic - easing: Easing.bezier(0.645, 0.045, 0.355, 1.0) -}; const ActionSheet = React.memo( forwardRef(({ children }: { children: React.ReactElement }, ref) => { const { colors } = useTheme(); - const { height: windowHeight } = useWindowDimensions(); - const { bottom, right, left } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); - const itemHeight = 48 * fontScale; - const bottomSheetRef = useRef(null); + const { height: windowHeight, width: windowWidth, fontScale } = useWindowDimensions(); + const sheetRef = useRef(null); const [data, setData] = useState({} as TActionSheetOptions); - const [isVisible, setVisible] = useState(false); - const animatedContentHeight = useSharedValue(0); - const animatedHandleHeight = useSharedValue(0); - const animatedDataSnaps = useSharedValue([]); - const animatedSnapPoints = useDerivedValue(() => { - if (animatedDataSnaps.value?.length) { - return animatedDataSnaps.value; - } - const contentWithHandleHeight = animatedContentHeight.value + animatedHandleHeight.value; - // Bottom sheet requires a default value to work - if (contentWithHandleHeight === 0) { - return ['25%']; - } - return [contentWithHandleHeight]; - }, [data]); - - const handleContentLayout = useCallback( - ({ - nativeEvent: { - layout: { height } - } - }: LayoutChangeEvent) => { - /** - * This logic is only necessary to prevent the action sheet from - * occupying the entire screen when the dynamic content is too big. - */ - animatedContentHeight.value = Math.min(height, windowHeight * 0.8); - }, - [animatedContentHeight, windowHeight] - ); - - const maxSnap = Math.min( - (itemHeight + 0.5) * (data?.options?.length || 0) + - HANDLE_HEIGHT + - // Custom header height - (data?.headerHeight || 0) + - // Insets bottom height (Notch devices) - bottom + - // Cancel button height - (data?.hasCancel ? CANCEL_HEIGHT : 0), - windowHeight * 0.8 - ); - - /* - * if the action sheet cover more than 60% of the screen height, - * we'll provide more one snap of 50% - */ - const snaps = maxSnap > windowHeight * 0.6 && !data.snaps ? ['50%', maxSnap] : [maxSnap]; + const [isVisible, setIsVisible] = useState(false); + const [contentHeight, setContentHeight] = useState(0); + const onCloseSnapshotRef = useRef(undefined); + + // TrueSheet detects the bottom inset for Android 16 and iOS + // To avoid content hiding behind navigation bar on older Android versions + const isNewAndroid = isAndroid && Number(Platform.Version) >= 36; + const bottom = isIOS || isNewAndroid ? 0 : windowHeight * 0.03; + const itemHeight = 48 * fontScale; - const toggleVisible = () => setVisible(!isVisible); + const handleContentLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + setContentHeight(layout.height); + }; const hide = () => { - bottomSheetRef.current?.close(); + sheetRef.current?.dismiss(); + Keyboard.dismiss(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }; const show = (options: TActionSheetOptions) => { setData(options); - if (options.snaps?.length) { - animatedDataSnaps.value = options.snaps; - } - toggleVisible(); + setIsVisible(true); + Keyboard.dismiss(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onCloseSnapshotRef.current = options.onClose; + sheetRef.current?.present(); }; useBackHandler(() => { @@ -102,81 +57,83 @@ const ActionSheet = React.memo( return isVisible; }); - useEffect(() => { - if (isVisible) { - Keyboard.dismiss(); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - }, [isVisible]); - useImperativeHandle(ref, () => ({ showActionSheet: show, hideActionSheet: hide })); - const renderHandle = () => ( - <> - + const renderHeader = () => ( + + {isValidElement(data?.customHeader) ? data.customHeader : null} - + ); - const onClose = () => { - toggleVisible(); - data?.onClose && data?.onClose(); - animatedDataSnaps.value = []; + const onDidDismiss = () => { + setIsVisible(false); + // Keep contentHeight to avoid flickering on next show + const snapshotOnClose = onCloseSnapshotRef.current; + onCloseSnapshotRef.current = undefined; + snapshotOnClose?.(); }; - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [] - ); + const isPortrait = windowHeight > windowWidth; + const effectiveSnaps = (isPortrait ? data?.portraitSnaps : data?.landscapeSnaps) || data?.snaps; + + const { detents, maxHeight, scrollEnabled } = useActionSheetDetents({ + windowHeight, + bottomInset: bottom, + itemHeight, + optionsLength: data?.options?.length || 0, + snaps: effectiveSnaps, + headerHeight: data?.headerHeight, + hasCancel: data?.hasCancel, + contentHeight + }); - const bottomSheet = isTablet ? styles.bottomSheet : { marginRight: right, marginLeft: left }; + const hasOptions = !!data?.options?.length; + const hasSnaps = !!effectiveSnaps?.length; + const disableContentPanning = data?.enableContentPanningGesture === false || (!scrollEnabled && isAndroid); + const isScrollable = hasOptions || (hasSnaps && !disableContentPanning); - // Must need this prop to avoid keyboard dismiss - // when is android tablet and the input text is focused - const androidTablet: any = isTablet && !isIOS ? { android_keyboardInputMode: 'adjustResize' } : {}; + const contentMinHeight = + data.fullContainer && effectiveSnaps?.length + ? (() => { + const snap = effectiveSnaps[0]; + const fraction = typeof snap === 'number' ? Math.min(1, Math.max(0.1, snap)) : (parseFloat(String(snap)) || 50) / 100; + return Math.max(0, windowHeight * fraction - HANDLE_HEIGHT); + })() + : undefined; return ( <> {children} - {isVisible && ( - index === -1 && onClose()} - // We need this to allow horizontal swipe gesture inside the bottom sheet like in reaction picker - enableContentPanningGesture={data?.enableContentPanningGesture ?? true} - {...androidTablet}> + + - - )} + fullContainer={data.fullContainer} + contentMinHeight={isIOS ? contentMinHeight : undefined} + scrollEnabled={scrollEnabled}> + {data?.children} + + + ); }) diff --git a/app/containers/ActionSheet/ActionSheetContentWithInputAndSubmit/index.tsx b/app/containers/ActionSheet/ActionSheetContentWithInputAndSubmit/index.tsx index d4315d2b375..8ae1f030cc7 100644 --- a/app/containers/ActionSheet/ActionSheetContentWithInputAndSubmit/index.tsx +++ b/app/containers/ActionSheet/ActionSheetContentWithInputAndSubmit/index.tsx @@ -3,7 +3,6 @@ import { StyleSheet, Text, type TextInputProps, View } from 'react-native'; import { CustomIcon, type TIconsName } from '../../CustomIcon'; import i18n from '../../../i18n'; -import { isIOS } from '../../../lib/methods/helpers'; import { useTheme } from '../../../theme'; import sharedStyles from '../../../views/Styles'; import Button from '../../Button'; @@ -137,7 +136,6 @@ const ActionSheetContentWithInputAndSubmit = ({ inputRef={inputRefs.current[index] as any} testID={`${testID}-input-${inputConfig.key}`} secureTextEntry={inputConfig.secureTextEntry} - bottomSheet={isIOS} /> )); } @@ -156,7 +154,6 @@ const ActionSheetContentWithInputAndSubmit = ({ autoComplete={autoComplete} testID={`${testID}-input`} secureTextEntry={secureTextEntry} - bottomSheet={isIOS} containerStyle={{ marginTop: 12, marginBottom: 36 }} /> ); diff --git a/app/containers/ActionSheet/BottomSheetContent.tsx b/app/containers/ActionSheet/BottomSheetContent.tsx index 267c4b7a20d..e1a0e0d1d43 100644 --- a/app/containers/ActionSheet/BottomSheetContent.tsx +++ b/app/containers/ActionSheet/BottomSheetContent.tsx @@ -1,10 +1,10 @@ -import { Text, useWindowDimensions, type ViewProps } from 'react-native'; +import { FlatList, Text, useWindowDimensions, View, type ViewProps } from 'react-native'; import React from 'react'; -import { BottomSheetView, BottomSheetFlatList } from '@discord/bottom-sheet'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import I18n from '../../i18n'; import { useTheme } from '../../theme'; +import { isAndroid } from '../../lib/methods/helpers'; import { type IActionSheetItem, Item } from './Item'; import { type TActionSheetOptionsItem } from './Provider'; import styles from './styles'; @@ -17,53 +17,71 @@ interface IBottomSheetContentProps { hide: () => void; children?: React.ReactElement | null; onLayout: ViewProps['onLayout']; + fullContainer?: boolean; + contentMinHeight?: number; + scrollEnabled?: boolean; } -const BottomSheetContent = React.memo(({ options, hasCancel, hide, children, onLayout }: IBottomSheetContentProps) => { - 'use memo'; +const BottomSheetContent = React.memo( + ({ + options, + hasCancel, + hide, + children, + onLayout, + fullContainer, + contentMinHeight, + scrollEnabled + }: IBottomSheetContentProps) => { + 'use memo'; - const { colors } = useTheme(); - const { bottom } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); - const height = 48 * fontScale; + const { colors } = useTheme(); + const { bottom } = useSafeAreaInsets(); + const { fontScale } = useWindowDimensions(); + const height = 48 * fontScale; + const paddingBottom = isAndroid ? bottom + height : bottom; + const minHeightStyle = isAndroid || !contentMinHeight ? undefined : { minHeight: contentMinHeight }; - const renderFooter = () => - hasCancel ? ( - - {I18n.t('Cancel')} - - ) : null; + const renderFooter = () => + hasCancel ? ( + + {I18n.t('Cancel')} + + ) : null; - const renderItem = ({ item }: { item: IActionSheetItem['item'] }) => ; + const renderItem = ({ item }: { item: IActionSheetItem['item'] }) => ; - if (options) { + if (options) { + return ( + item.title} + bounces={false} + renderItem={renderItem} + style={{ backgroundColor: colors.strokeExtraDark }} + keyboardDismissMode='interactive' + indicatorStyle='black' + contentContainerStyle={{ paddingBottom, backgroundColor: colors.surfaceLight }} + ItemSeparatorComponent={List.Separator} + ListHeaderComponent={List.Separator} + ListFooterComponent={renderFooter} + onLayout={onLayout} + scrollEnabled={scrollEnabled} + nestedScrollEnabled={scrollEnabled && isAndroid} + /> + ); + } return ( - item.title} - bounces={false} - renderItem={renderItem} - style={{ backgroundColor: colors.strokeExtraDark }} - keyboardDismissMode='interactive' - indicatorStyle='black' - contentContainerStyle={{ paddingBottom: bottom, backgroundColor: colors.surfaceLight }} - ItemSeparatorComponent={List.Separator} - ListHeaderComponent={List.Separator} - ListFooterComponent={renderFooter} - onLayout={onLayout} - /> + + {children} + ); } - return ( - - {children} - - ); -}); +); export default BottomSheetContent; diff --git a/app/containers/ActionSheet/Handle.tsx b/app/containers/ActionSheet/Handle.tsx index f155de2fafa..e1e4672c7de 100644 --- a/app/containers/ActionSheet/Handle.tsx +++ b/app/containers/ActionSheet/Handle.tsx @@ -1,17 +1,26 @@ import React from 'react'; import { View } from 'react-native'; +import { RectButton } from 'react-native-gesture-handler'; import styles from './styles'; import { themes } from '../../lib/constants/colors'; import { useTheme } from '../../theme'; -export const Handle = React.memo(() => { +export const Handle = ({ onPress }: { onPress: () => void }) => { 'use memo'; const { theme } = useTheme(); + + // We should use RectButton from gesture-handler to avoid issues with the keyboard return ( - + - + ); -}); +}; diff --git a/app/containers/ActionSheet/Provider.tsx b/app/containers/ActionSheet/Provider.tsx index d74043234e3..849924daf1a 100644 --- a/app/containers/ActionSheet/Provider.tsx +++ b/app/containers/ActionSheet/Provider.tsx @@ -26,10 +26,15 @@ export type TActionSheetOptions = { hasCancel?: boolean; // children can both use snaps or dynamic children?: React.ReactElement | null; - /** Required if your action sheet needs vertical scroll */ + // Required if your action sheet needs vertical scroll snaps?: (string | number)[]; + // Optional snaps specifically for portrait orientation + portraitSnaps?: (string | number)[]; + // Optional snaps specifically for landscape orientation + landscapeSnaps?: (string | number)[]; onClose?: () => void; enableContentPanningGesture?: boolean; + fullContainer?: boolean; }; export interface IActionSheetProvider { showActionSheet: (item: TActionSheetOptions) => void; diff --git a/app/containers/ActionSheet/styles.ts b/app/containers/ActionSheet/styles.ts index ad9d40831e3..5a98ca3b545 100644 --- a/app/containers/ActionSheet/styles.ts +++ b/app/containers/ActionSheet/styles.ts @@ -4,9 +4,7 @@ import sharedStyles from '../../views/Styles'; export default StyleSheet.create({ container: { - overflow: 'hidden', - borderTopLeftRadius: 16, - borderTopRightRadius: 16 + overflow: 'hidden' }, item: { paddingHorizontal: 16, @@ -43,10 +41,6 @@ export default StyleSheet.create({ backdrop: { ...StyleSheet.absoluteFillObject }, - bottomSheet: { - width: '50%', - marginHorizontal: '25%' - }, button: { marginHorizontal: 16, paddingHorizontal: 14, @@ -70,6 +64,11 @@ export default StyleSheet.create({ marginRight: 8 }, contentContainer: { - flex: 1 + flex: 0 + }, + fullContainer: { + width: '100%', + height: '100%', + flex: 0 } }); diff --git a/app/containers/ActionSheet/useActionSheetDetents.test.tsx b/app/containers/ActionSheet/useActionSheetDetents.test.tsx new file mode 100644 index 00000000000..51be4312ffe --- /dev/null +++ b/app/containers/ActionSheet/useActionSheetDetents.test.tsx @@ -0,0 +1,93 @@ +import { renderHook } from '@testing-library/react-native'; + +import { useActionSheetDetents } from './useActionSheetDetents'; + +describe('useActionSheetDetents', () => { + const windowHeight = 1000; + + it('normalizes custom snaps when provided', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 0, + itemHeight: 0, + optionsLength: 0, + snaps: [0.3, '80%', 2], + headerHeight: 0, + hasCancel: false, + contentHeight: 0 + }) + ); + + expect(result.current.detents).toEqual([0.3, 0.8, 1]); + }); + + it('returns two detents when options content is tall', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 16, + itemHeight: 50, + optionsLength: 20, + snaps: undefined, + headerHeight: 24, + hasCancel: true, + contentHeight: 0 + }) + ); + + expect(result.current.maxHeight).toBe(windowHeight * 0.75); + expect(result.current.detents).toEqual([0.5, 0.75]); + }); + + it('returns a single clamped detent when options content is short', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 10, + itemHeight: 20, + optionsLength: 3, + snaps: undefined, + headerHeight: 10, + hasCancel: false, + contentHeight: 0 + }) + ); + + expect(result.current.detents).toEqual([0.108]); + }); + + it('computes detent from content height when there are no options', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 50, + itemHeight: 0, + optionsLength: 0, + snaps: undefined, + headerHeight: 0, + hasCancel: false, + contentHeight: 300 + }) + ); + + expect(result.current.detents).toEqual([(300 + 50) / windowHeight]); + }); + + it('falls back to minimum height when no content or options', () => { + const { result } = renderHook(() => + useActionSheetDetents({ + windowHeight, + bottomInset: 0, + itemHeight: 0, + optionsLength: 0, + snaps: undefined, + headerHeight: 0, + hasCancel: false, + contentHeight: 0 + }) + ); + + expect(result.current.detents).toEqual([0.15]); + }); +}); diff --git a/app/containers/ActionSheet/useActionSheetDetents.ts b/app/containers/ActionSheet/useActionSheetDetents.ts new file mode 100644 index 00000000000..7dd747fa935 --- /dev/null +++ b/app/containers/ActionSheet/useActionSheetDetents.ts @@ -0,0 +1,94 @@ +import type { SheetDetent } from '@lodev09/react-native-true-sheet'; +import { useMemo } from 'react'; +import { useWindowDimensions } from 'react-native'; + +const ACTION_SHEET_MIN_HEIGHT_FRACTION = 0.15; +const ACTION_SHEET_MAX_HEIGHT_FRACTION = 0.75; +const SCROLL_ENABLED_THRESHOLD = 0.6; +export const HANDLE_HEIGHT = 28; + +function normalizeSnapsToDetents(snaps: (string | number)[]): number[] { + return snaps + .slice(0, 3) + .map(snap => { + if (typeof snap === 'number') { + if (snap <= 0 || snap > 1) return Math.min(1, Math.max(0.1, snap)); + return snap; + } + const match = String(snap).match(/^(\d+(?:\.\d+)?)\s*%$/); + if (match) return Math.min(1, Math.max(0.1, Number(match[1]) / 100)); + return 0.5; + }) + .sort((a, b) => a - b); +} + +type UseActionSheetDetentsParams = { + windowHeight: number; + bottomInset: number; + itemHeight: number; + optionsLength?: number; + snaps?: (string | number)[]; + headerHeight?: number; + hasCancel?: boolean; + contentHeight: number; +}; + +function heightToDetent(height: number, screenHeight: number): number { + return Math.max(0, height / screenHeight); +} + +export function useActionSheetDetents({ + windowHeight, + bottomInset, + itemHeight, + optionsLength = 0, + snaps, + headerHeight = 0, + hasCancel = false, + contentHeight +}: UseActionSheetDetentsParams): { detents: SheetDetent[]; maxHeight: number; scrollEnabled: boolean } { + const { fontScale } = useWindowDimensions(); + const CANCEL_HEIGHT = 48 * fontScale; + + return useMemo(() => { + const maxHeight = windowHeight * ACTION_SHEET_MAX_HEIGHT_FRACTION; + const hasOptions = optionsLength > 0; + + const maxSnap = hasOptions + ? Math.min( + (itemHeight + 0.5) * optionsLength + HANDLE_HEIGHT + headerHeight + bottomInset + (hasCancel ? CANCEL_HEIGHT : 0), + maxHeight + ) + : 0; + + let detents: SheetDetent[]; + let scrollEnabled = false; + + if (snaps?.length) { + detents = normalizeSnapsToDetents(snaps); + } else if (hasOptions) { + if (maxSnap > windowHeight * SCROLL_ENABLED_THRESHOLD) { + detents = [0.5, ACTION_SHEET_MAX_HEIGHT_FRACTION]; + scrollEnabled = true; + } else { + const measuredHeight = + optionsLength * itemHeight + HANDLE_HEIGHT + headerHeight + bottomInset + (hasCancel ? CANCEL_HEIGHT : 0); + + scrollEnabled = false; + detents = [heightToDetent(Math.round(measuredHeight), windowHeight)]; + } + } else if (contentHeight > 0) { + const rawContentDetent = (contentHeight + bottomInset) / windowHeight; + const contentDetent = Math.min( + ACTION_SHEET_MAX_HEIGHT_FRACTION, + Math.max(ACTION_SHEET_MIN_HEIGHT_FRACTION, rawContentDetent) + ); + + detents = [contentDetent]; + } else { + detents = [ACTION_SHEET_MIN_HEIGHT_FRACTION]; + } + + return { detents, maxHeight, scrollEnabled }; + }, [bottomInset, contentHeight, hasCancel, headerHeight, itemHeight, optionsLength, snaps, windowHeight]); +} diff --git a/app/containers/AudioPlayer/Seek.tsx b/app/containers/AudioPlayer/Seek.tsx index eecd5d444e0..7b8432967ef 100644 --- a/app/containers/AudioPlayer/Seek.tsx +++ b/app/containers/AudioPlayer/Seek.tsx @@ -1,21 +1,20 @@ import React from 'react'; import { type LayoutChangeEvent, View, TextInput, type TextInputProps, TouchableNativeFeedback } from 'react-native'; -import { PanGestureHandler, type PanGestureHandlerGestureEvent } from 'react-native-gesture-handler'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { type SharedValue, - runOnJS, - useAnimatedGestureHandler, useAnimatedProps, useAnimatedStyle, useDerivedValue, - useSharedValue + useSharedValue, + withTiming } from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; import styles from './styles'; import { useTheme } from '../../theme'; import { SEEK_HIT_SLOP, THUMB_SEEK_SIZE, ACTIVE_OFFSET_X, DEFAULT_TIME_LABEL } from './constants'; -Animated.addWhitelistedNativeProps({ text: true }); const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); interface ISeek { @@ -50,6 +49,7 @@ const Seek = ({ currentTime, duration, loaded = false, onChangeTime }: ISeek) => const timeLabel = useSharedValue(DEFAULT_TIME_LABEL); const scale = useSharedValue(1); const isPanning = useSharedValue(false); + const contextX = useSharedValue(0); const styleLine = useAnimatedStyle(() => ({ width: translateX.value @@ -64,21 +64,30 @@ const Seek = ({ currentTime, duration, loaded = false, onChangeTime }: ISeek) => maxWidth.value = width; }; - const onGestureEvent = useAnimatedGestureHandler({ - onStart: (event, ctx) => { + const panGesture = Gesture.Pan() + .enabled(loaded) + .activeOffsetX([-ACTIVE_OFFSET_X, ACTIVE_OFFSET_X]) + .onStart(() => { isPanning.value = true; - ctx.offsetX = translateX.value; - }, - onActive: ({ translationX }, ctx) => { - translateX.value = clamp(ctx.offsetX + translationX, 0, maxWidth.value); - scale.value = 1.3; - }, - onFinish() { - scale.value = 1; + contextX.value = translateX.value; + scale.value = withTiming(1.3, { duration: 150 }); + }) + .onUpdate(event => { + const newX = contextX.value + event.translationX; + translateX.value = clamp(newX, 0, maxWidth.value); + }) + .onEnd(() => { + scheduleOnRN(onChangeTime, Math.round(currentTime.value * 1000)); + }) + .onFinalize((_, didSucceed) => { + if (isPanning.value && !didSucceed) { + translateX.value = contextX.value; + currentTime.value = (contextX.value * duration.value) / maxWidth.value || 0; + } + isPanning.value = false; - runOnJS(onChangeTime)(Math.round(currentTime.value * 1000)); - } - }); + scale.value = withTiming(1, { duration: 150 }); + }); useDerivedValue(() => { if (isPanning.value) { @@ -118,9 +127,9 @@ const Seek = ({ currentTime, duration, loaded = false, onChangeTime }: ISeek) => - + - + diff --git a/app/containers/Avatar/Avatar.stories.tsx b/app/containers/Avatar/Avatar.stories.tsx index ed07c9d408c..0af49c24552 100644 --- a/app/containers/Avatar/Avatar.stories.tsx +++ b/app/containers/Avatar/Avatar.stories.tsx @@ -12,6 +12,8 @@ const styles = StyleSheet.create({ }); const server = 'https://open.rocket.chat'; +const base64Image = + 'data:image/png;base64,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'; export default { title: 'Avatar' @@ -31,6 +33,8 @@ export const AvatarUrl = () => ( export const AvatarPath = () => ; +export const AvatarBase64 = () => ; + export const WithETag = () => ( ); @@ -60,7 +64,7 @@ export const CustomBorderRadius = () => ( - + ); diff --git a/app/containers/Avatar/Avatar.tsx b/app/containers/Avatar/Avatar.tsx index b203f290413..089730b0112 100644 --- a/app/containers/Avatar/Avatar.tsx +++ b/app/containers/Avatar/Avatar.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { View } from 'react-native'; import { Image } from 'expo-image'; -import Touchable from 'react-native-platform-touchable'; import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import Emoji from '../markdown/components/emoji/Emoji'; @@ -10,6 +9,7 @@ import { SubscriptionType } from '../../definitions'; import { type IAvatar } from './interfaces'; import MarkdownContext from '../markdown/contexts/MarkdownContext'; import I18n from '../../i18n'; +import Touch from '../Touch'; const Avatar = React.memo( ({ @@ -97,9 +97,9 @@ const Avatar = React.memo( if (onPress) { image = ( - + {image} - + ); } diff --git a/app/containers/Avatar/AvatarContainer.tsx b/app/containers/Avatar/AvatarContainer.tsx index d9e610eda7b..4e54161efec 100644 --- a/app/containers/Avatar/AvatarContainer.tsx +++ b/app/containers/Avatar/AvatarContainer.tsx @@ -58,7 +58,6 @@ const AvatarContainer = ({ size={size} borderRadius={borderRadius} type={type} - children={children} userId={id} token={token} onPress={onPress} @@ -72,8 +71,9 @@ const AvatarContainer = ({ serverVersion={serverVersion} cdnPrefix={cdnPrefix} accessibilityLabel={accessibilityLabel} - accessible={accessible} - /> + accessible={accessible}> + {children} + ); }; diff --git a/app/containers/Avatar/AvatarWithEdit.tsx b/app/containers/Avatar/AvatarWithEdit.tsx index 49c96bd5f47..41767a01f9b 100644 --- a/app/containers/Avatar/AvatarWithEdit.tsx +++ b/app/containers/Avatar/AvatarWithEdit.tsx @@ -61,12 +61,12 @@ const AvatarWithEdit = ({ size={120} borderRadius={borderRadius} type={type} - children={children} onPress={onPress} getCustomEmoji={getCustomEmoji} isStatic={isStatic} - rid={rid} - /> + rid={rid}> + {children} + {handleEdit && serverVersion && compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '3.6.0') ? (