commit 9f4b09d1357630261f970e42b7deb44bb03c8fe0 Author: Jeff Emmett Date: Thu Jan 8 19:27:14 2026 +0100 Initial commit: Jeffsi Meet - forked from Jitsi Meet diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5224197 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +{ + "name": "Jitsi Meet Dev Container", + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "20" + } + }, + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "32gb" + }, + "postCreateCommand": "bash -i -c 'nvm use && npm install && cp tsconfig.web.json tsconfig.json'" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4b23962 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +max_line_length = 120 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab + +[*.{java,kt}] +indent_size = 4 + +[*.xml] +indent_size = 2 + +[*.{swift,m,mm,h}] +indent_size = 4 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..e37ebe8 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,24 @@ +# The build artifacts of the jitsi-meet project. +build/* + +doc/* + +# Third-party source code which we (1) do not want to modify or (2) try to +# modify as little as possible. +libs/* +resources/* +react/features/stream-effects/virtual-background/vendor/* +react/features/face-landmarks/resources/* + +# ESLint will by default ignore its own configuration file. However, there does +# not seem to be a reason why we will want to risk being inconsistent with our +# remaining JavaScript source code. +!.eslintrc.js + +# Not worth it. +actionTypes.ts + +# It's not complete until all files are copied at build time. +react-native-sdk/ + +*.d.ts diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..9401eb3 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + extends: [ + '@jitsi/eslint-config' + ] +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c52c733 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.bundle.js -text -diff +*.pbxproj -text +lib-jitsi-meet.js -text -diff diff --git a/.github/ISSUE_TEMPLATE/1-bug.yml b/.github/ISSUE_TEMPLATE/1-bug.yml new file mode 100644 index 0000000..f1591f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug.yml @@ -0,0 +1,53 @@ +name: Bug report +description: File a bug report and help us improve +body: + - type: markdown + attributes: + value: | + This issue tracker is only for reporting bugs and tracking issues related to the source code. + + **Before posting, please make sure to check if the same or similar bugs have already been reported.** + + ⚠️ General questions regarding usage, installation, etc. should be posted in our [community forum](https://community.jitsi.org). + - type: textarea + attributes: + label: What happened? + description: Please describe the problem. Be as detailed as possible. + validations: + required: true + - type: checkboxes + attributes: + label: Platform + description: On what platforms can you reproduce the problem? + options: + - label: Chrome (or Chromium based) + - label: Firefox + - label: Safari + - label: Other desktop browser + - label: Android browser + - label: iOS browser + - label: Electron app + - label: Android mobile app + - label: iOS mobile app + - label: Custom app using a mobile SDK + - type: input + attributes: + label: Browser / app / sdk version + description: Please provide the version of the browser / app / sdk where the problem manifests. + validations: + required: true + - type: textarea + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. The browser console JS logs (if applicable) is a good start. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: checkboxes + attributes: + label: Reproducibility + description: Does the problem reproduce on meet.jit.si using Chrome, Firefox or the official mobile apps? + options: + - label: The problem is reproducible on meet.jit.si + - type: textarea + attributes: + label: More details? + description: Please provide more details in case they apply (such as the Jitsi Meet version you are running, if you are hosting your own server). diff --git a/.github/ISSUE_TEMPLATE/2-feature.yml b/.github/ISSUE_TEMPLATE/2-feature.yml new file mode 100644 index 0000000..ec67ba1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature.yml @@ -0,0 +1,22 @@ +name: Feature request +description: Suggest an idea for Jitsi Meet +labels: ["feature-request"] +body: + - type: markdown + attributes: + value: | + Thank you for suggesting an idea to make Jitsi Meet better. + + **Note**: the ultimate decision for implementing features lies on the Jitsi team, not all feature requests shall be accepted. + - type: textarea + attributes: + label: What problem are you trying to solve? + description: Tell us what problem your feature request would solve. + - type: textarea + attributes: + label: What solution would you like to see? + description: Please describe the desired behavior or feature. + - type: textarea + attributes: + label: Is there an alternative? + description: Please describe alternative solutions or features you have considered. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..35d5e69 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Need help with your Jitsi Meet installation? + url: https://community.jitsi.org + about: Please ask it in our community forum. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6ef3510 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,5 @@ + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ccfd06d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + # Monitor GitHub Actions versions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "chore(ci)" diff --git a/.github/workflows/ci-lua.yml b/.github/workflows/ci-lua.yml new file mode 100644 index 0000000..cb5dea8 --- /dev/null +++ b/.github/workflows/ci-lua.yml @@ -0,0 +1,26 @@ +name: Lua CI + +on: [pull_request] + +jobs: + luacheck: + name: Luacheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install luarocks + run: sudo apt-get --install-recommends -y install luarocks + + - name: Install luacheck + run: sudo luarocks install luacheck + + - name: Check lua codes + run: | + set -o pipefail && luacheck . \ + --exclude-files=resources/prosody-plugins/mod_firewall/mod_firewall.lua | awk -F: ' + { + print $0 + printf "::warning file=%s,line=%s,col=%s::%s\n", $1, $2, $3, $4 + } + ' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a007cf5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,203 @@ +name: Simple CI + +on: [pull_request] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Check Node / npm versions + run: | + node -v + npm -v + - name: Get changed files + id: changed-files + uses: jitsi/changed-files@main + - name: Get changed lang files + id: lang-files + run: echo "all=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | grep -oE 'lang\/\S+' | tr '\n' ' ')" >> "$GITHUB_OUTPUT" + - run: npm install + - name: Check git status + run: git status + - name: Normalize lang files to ensure sorted + if: steps.lang-files.outputs.all + run: npm run lang-sort + - name: Check lang files are formatted correctly + if: steps.lang-files.outputs.all + run: npm run lint:lang + - name: Check if the git repository is clean + run: $(exit $(git status --porcelain --untracked-files=no | head -255 | wc -l)) || (echo "Dirty git tree"; git diff; exit 1) + - run: npm run lint:ci && npm run tsc:ci + frontend: + name: Build Frontend + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Check Node / npm versions + run: | + node -v + npm -v + - run: npm install + - run: make + - name: Check config.js syntax + run: node config.js + android-rn-bundle-build: + name: Build mobile bundle (Android) + runs-on: macos-15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Check Node / npm versions + run: | + node -v + npm -v + - run: npm install + - run: npx react-native bundle --entry-file react/index.native.js --platform android --bundle-output /tmp/android.bundle --reset-cache + ios-rn-bundle-build: + name: Build mobile bundle (iOS) + runs-on: macos-15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Check Node / npm versions + run: | + node -v + npm -v + - run: npm install + - name: setup Xcode + run: | + uname -a + xcode-select -p + sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + xcodebuild -version + - name: setup-cocoapods + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: npx react-native info + - name: Install Pods + working-directory: ./ios + run: bundle exec pod install --repo-update --deployment + - run: npx react-native bundle --entry-file react/index.native.js --platform ios --bundle-output /tmp/ios.bundle --reset-cache + android-sdk-build: + name: Build mobile SDK (Android) + runs-on: ubuntu-latest + container: + image: reactnativecommunity/react-native-android:v15.0 + volumes: + - /usr:/host/usr + steps: + - name: Make space in image by removing preinstalled, but unused SDKs + run: | + df -h / + rm -rf /host/usr/local/lib/android + rm -rf /host/usr/local/.ghcup + rm -rf /host/usr/share/dotnet + rm -rf /host/usr/share/swift + df -h / + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Check Node / npm versions + run: | + node -v + npm -v + - run: npm install + - run: | + cd android + ./gradlew :sdk:clean + ./gradlew :sdk:assembleRelease + - run: | + git config --global --add safe.directory /__w/jitsi-meet/jitsi-meet + git clean -dfx + ios-sdk-build: + name: Build mobile SDK (iOS) + runs-on: macos-15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Check Node / npm versions + run: | + node -v + npm -v + - run: npm install + - name: setup Xcode + run: | + uname -a + xcode-select -p + sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + xcodebuild -version + - name: clean Xcode + run: | + rm -rf ios/sdk/out + xcodebuild clean \ + -workspace ios/jitsi-meet.xcworkspace \ + -scheme JitsiMeetSDK + - name: setup-cocoapods + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: npx react-native info + - name: Install Pods + working-directory: ./ios + run: bundle exec pod install --repo-update --deployment + - run: | + xcodebuild -downloadPlatform iOS -buildVersion 18.2 + xcodebuild archive \ + -workspace ios/jitsi-meet.xcworkspace \ + -scheme JitsiMeetSDK \ + -configuration Release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + -archivePath ios/sdk/out/ios-device \ + SKIP_INSTALL=NO \ + BUILD_LIBRARY_FOR_DISTRIBUTION=YES + xcodebuild -create-xcframework \ + -framework ios/sdk/out/ios-device.xcarchive/Products/Library/Frameworks/JitsiMeetSDK.framework \ + -output ios/sdk/out/JitsiMeetSDK.xcframework + - run: ls -lR ios/sdk/out + debian-build: + name: Test Debian packages build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Check Node / npm versions + run: | + node -v + npm -v + - run: npm install + - run: make + - run: sudo apt-get install -y debhelper + - run: dpkg-buildpackage -A -rfakeroot -us -uc -d + - run: make source-package diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..037a37b --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,21 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v8 + with: + stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' + stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' + stale-issue-label: 'stale' + stale-pr-label: 'stale' + exempt-issue-labels: 'confirmed,help-needed' + exempt-pr-labels: 'confirmed' + days-before-issue-stale: 60 + days-before-pr-stale: 90 + days-before-issue-close: 10 + days-before-pr-close: 10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0628149 --- /dev/null +++ b/.gitignore @@ -0,0 +1,120 @@ +*.swp +.*.tmp +deploy-local.sh +libs/ +all.css +*css.map +.remote-sync.json +.sync-config.cson + +# CocoaPods +Pods/ + +# The following are automatically generated by the react-native command line +# utility (either with the init or upgrade option which pull in the latest +# template files recommended by Facebook for React Native). + +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.dSYM.zip +*.xcuserstate +project.xcworkspace +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# BUCK +# +buck-out/ +\.buckd/ +*.keystore + +# fastlane +# +.bundle/ +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/test_output + +# Build artifacts +*.jsbundle +*.framework +android/app/debug +android/app/release +ios/sdk/out + +# precommit-hook +.jshintignore +.jshintrc + +# VSCode files +android/.project +android/.settings/org.eclipse.buildship.core.prefs + +# Secrets +android/app/dropbox.key +android/app/google-services.json +ios/app/dropbox.key +ios/app/GoogleService-Info.plist + +.vscode + +# TWA +twa/*.apk +twa/*.aab +twa/assetlinks.json + +tsconfig.json + +# React Native SDK +# +react-native-sdk/*.tgz +react-native-sdk/android/src +!react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetReactNativePackage.java +!react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java +!react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JMOngoingConferenceModule.java +!react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/RNOngoingNotification.java +react-native-sdk/images +react-native-sdk/ios +react-native-sdk/lang +react-native-sdk/modules +react-native-sdk/node_modules +react-native-sdk/react +react-native-sdk/service +react-native-sdk/sounds + +# tests +tests/.env +test-results + diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..148df09 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,8 @@ +global = false +unused = false +redefined = false +ignore = { "581" } +max_line_length = false +color = false +formatter = "plain" +quiet = 1 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6bc0e13 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +package-lock=true +; FIXME Set legacy-peer-deps=false when we upgrade RN. +legacy-peer-deps=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.watchmanconfig b/.watchmanconfig new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.watchmanconfig @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..57992a6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,267 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Building and Development +- `npm run lint-fix` - Automatically fix linting issues +- `npm run tsc:ci` - Run TypeScript checks for both web and native platforms +- `npm run tsc:web` - TypeScript check for web platform only +- `npm run tsc:native` - TypeScript check for native platform only +- `npm run lint:ci` - Run ESLint without type checking +- `make dev` - Start development server with webpack-dev-server +- `make compile` - Build production bundles +- `make clean` - Clean build directory +- `make all` - Full build (compile + deploy) + +### Testing +- `npm test` - Run full test suite using WebDriverIO +- `npm run test-single -- ` - Run single test file +- `npm run test-dev` - Run tests against development environment +- `npm run test-dev-single -- ` - Run single test in dev mode + + +### Language Tools +- `npm run lang-sort` - Sort language files +- `npm run lint:lang` - Validate JSON language files + +### Platform-Specific TypeScript +TypeScript configuration is split between web and native platforms with separate tsconfig files. + +## Architecture Overview + +### Multi-Platform Structure +Jitsi Meet supports both web and React Native platforms with platform-specific file extensions and directories: +- `.web.ts/.web.tsx` - Web-specific implementations +- `.native.ts/.native.tsx` - React Native-specific implementations +- `.any.ts/.any.tsx` - Shared cross-platform code +- `.android.ts/.android.tsx` - Android-specific code +- `.ios.ts/.ios.tsx` - iOS-specific code +- `web/` directories - Web-specific components and modules +- `native/` directories - React Native-specific components and modules +- `react/features/mobile/` - Native-only features + +### Core Directories +- `react/features/` - Main application features organized by domain (83+ feature modules) +- `modules/` - Legacy JavaScript modules and APIs +- `css/` - SCSS stylesheets compiled to CSS +- `libs/` - Compiled output directory for JavaScript bundles +- `static/` - Static assets and HTML files +- `tests/` - WebDriverIO end-to-end tests + +### Feature-Driven Architecture +The application is organized under `react/features/` with each feature containing: + +- **`actionTypes.ts`** - Redux action type constants +- **`actions.ts`** - Redux action creators (platform-specific variants with `.any.ts`, `.web.ts`, `.native.ts`) +- **`reducer.ts`** - Redux reducer functions +- **`middleware.ts`** - Redux middleware for side effects +- **`functions.ts`** - Utility functions and selectors +- **`constants.ts`** - Feature-specific constants +- **`logger.ts`** - Feature-specific logger instance +- **`types.ts`** - TypeScript type definitions + +### Key Application Files +- `app.js` - Main web application entry point +- `webpack.config.js` - Multi-bundle Webpack configuration +- `Makefile` - Build system for development and production +- `package.json` - Dependencies and scripts with version requirements + +### Bundle Architecture +The application builds multiple bundles: +- `app.bundle.js` / `app.bundle.min.js` - Main application bundle (entry: `./app.js`) +- `external_api.js` / `external_api.min.js` - External API for embedders (entry: `./modules/API/external/index.js`) +- `alwaysontop.js` / `alwaysontop.min.js` - Always-on-top window functionality (entry: `./react/features/always-on-top/index.tsx`) +- `close3.js` / `close3.min.js` - Close3 functionality (entry: `./static/close3.js`) +- `face-landmarks-worker.js` / `face-landmarks-worker.min.js` - Face landmarks detection worker (entry: `./react/features/face-landmarks/faceLandmarksWorker.ts`) +- `noise-suppressor-worklet.js` / `noise-suppressor-worklet.min.js` - Audio noise suppression worklet (entry: `./react/features/stream-effects/noise-suppression/NoiseSuppressorWorklet.ts`) +- `screenshot-capture-worker.js` / `screenshot-capture-worker.min.js` - Screenshot capture worker (entry: `./react/features/screenshot-capture/worker.ts`) + +### Redux Architecture +Features follow a Redux-based architecture with: +- Actions, reducers, and middleware in each feature directory +- Cross-platform state management +- Modular feature organization with clear boundaries + +The codebase uses a registry-based Redux architecture: +- **ReducerRegistry** - Features register their reducers independently +- **MiddlewareRegistry** - Features register middleware without cross-dependencies +- **IReduxState** - Global state is strongly typed with 80+ feature states + +### Dependencies +- Uses `lib-jitsi-meet` as the core WebRTC library +- React with TypeScript support +- React Native for mobile applications +- Webpack for bundling with development server + +### TypeScript Configuration +- `tsconfig.web.json` - Web platform TypeScript config (excludes native files) +- `tsconfig.native.json` - React Native TypeScript config (excludes web files) +- Strict TypeScript settings with ES2024 target +- Platform-specific module suffixes (`.web`, `.native`) + +### Key Base Features +- **`base/app/`** - Application lifecycle management +- **`base/conference/`** - Core conference logic +- **`base/tracks/`** - Media track management +- **`base/participants/`** - Participant management +- **`base/config/`** - Configuration management +- **`base/redux/`** - Redux infrastructure + +### Component Patterns +- **Abstract Components** - Base classes for cross-platform components +- **Platform-Specific Components** - Separate implementations in `web/` and `native/` directories +- **Hook-based patterns** - Modern React patterns for component logic +### Testing Framework +- WebDriverIO for end-to-end testing +- Test files are located in `tests/specs/` and use page objects in `tests/pageobjects/`. +- Environment configuration via `.env` files +- Support for Chrome, Firefox, and grid testing + +## Development Guidelines + +### Adding New Features +1. Create feature directory under `react/features/[feature-name]/` +2. Follow the standard file structure (actionTypes, actions, reducer, etc.) +3. Register reducers and middleware using the registry pattern +4. Define TypeScript interfaces for state and props +5. Use platform-specific files for web/native differences +6. Add feature-specific logger for debugging + +### Working with Existing Features +1. Check for existing `.any.ts`, `.web.ts`, `.native.ts` variants +2. Follow established action-reducer-middleware patterns +3. Use existing base utilities rather than creating new ones +4. Leverage abstract components for cross-platform logic +5. Maintain type safety across the entire state tree + +### Testing +The project uses WebDriver (WebdriverIO) for end-to-end testing. Test files are located in `tests/specs/` and use page objects in `tests/pageobjects/`. + +### Build System +- **Webpack** - Main build system for web bundles +- **Makefile** - Coordinates build process and asset deployment +- **Metro** - React Native bundler (configured in `metro.config.js`) + +### Platform-Specific Notes +- Web builds exclude files matching `**/native/*`, `**/*.native.ts`, etc. +- Native builds exclude files matching `**/web/*`, `**/*.web.ts`, etc. +- Use `moduleSuffixes` in TypeScript config to handle platform-specific imports +- Check `tsconfig.web.json` and `tsconfig.native.json` for platform-specific exclusions + +## Environment and Setup Requirements + +### System Requirements +- **Node.js and npm** are required +- Development server runs at https://localhost:8080/ +- Certificate errors in development are expected (self-signed certificates) + +### Development Workflow +- Development server proxies to configurable target (default: https://alpha.jitsi.net) +- Hot module replacement enabled for development +- Bundle analysis available via `ANALYZE_BUNDLE=true` environment variable +- Circular dependency detection via `DETECT_CIRCULAR_DEPS=true` + +## Code Quality Requirements +- All code must pass `npm run lint:ci` and `npm run tsc:ci` with 0 warnings before committing +- TypeScript strict mode enabled - avoid `any` type +- ESLint config extends `@jitsi/eslint-config` +- Prefer TypeScript for new features, convert existing JavaScript when possible + +## Code Style and Standards + +### Conventional Commits Format +Follow [Conventional Commits](https://www.conventionalcommits.org) with **mandatory scopes**: +``` +feat(feature-name): description +fix(feature-name): description +docs(section): description +``` +Available types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test + +### Feature Layout Structure +When adding new features: +``` +react/features/sample/ +├── actionTypes.ts +├── actions.ts +├── components/ +│ ├── AnotherComponent.tsx +│ └── OneComponent.tsx +├── middleware.ts +└── reducer.ts +``` + +### TypeScript Requirements +- All new features must be written in TypeScript +- Convert JavaScript to TypeScript when modifying existing code +- Import middleware in `react/features/app/middlewares.{any,native,web}.js` +- Import reducers in appropriate registry files +- Avoid `index` files + +### Bundle Size Management +- Bundle size limits are enforced to prevent bloat +- For increases, analyze first: `npx webpack -p --analyze-bundle` +- Open analyzer: `npx webpack-bundle-analyzer build/app-stats.json` +- Justify any dependency additions that increase bundle size + +## Testing and Quality Assurance + +### Tests +- End-to-end tests are defined in the tests/ +- Tests run automatically for project member PRs via Jenkins +- Tests cover peer-to-peer, invites, iOS, Android, and web platforms +- Beta testing available at https://beta.meet.jit.si/ + +### Manual Testing Checklist +- Test with 2 participants (P2P mode) +- Test with 3+ participants (JVB mode) +- Verify audio/video in both modes +- Test mobile apps if changes affect mobile +- Check that TLS certificate chain is complete for mobile app compatibility + +## Common Issues and Debugging + +### P2P vs JVB Problems +- **Works with 2 participants, fails with 3+**: JVB/firewall issue, check UDP 10000 +- **Works on web, fails on mobile apps**: TLS certificate chain issue, need fullchain.pem +- Use the tests from tests/ directory to verify functionality across platforms + +### Development Server Issues +- Certificate warnings are normal for development (self-signed) +- Use different backend with WEBPACK_DEV_SERVER_PROXY_TARGET environment variable +- Check firewall settings if local development fails + +### Configuration and Customization +- Extensive configuration options documented in handbook +- See `config.js` for client-side options +- Options marked 🚫 are not overwritable through `configOverwrite` +- Reference [Configuration Guide](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-configuration) for details + +## Architecture Deep Dive + +### Core Application Files +- **`./conference.js`** - Foundation for user-conference interactions (connection, joining, muting) +- **`./modules/external-api`** - External API for iframe integration and events +- **`./lang/`** - Translations in `main-[language].json` files +- **`./css/`** - SCSS files organized by features, matching React feature structure + +### State Management Flow +1. Actions dispatched from components +2. Middleware processes side effects +3. Reducers update state +4. Components re-render based on state changes +5. Registry pattern keeps features decoupled + +### Cross-Platform Strategy +- Abstract components handle shared logic +- Platform files (.web.ts, .native.ts) handle platform differences +- Build system excludes irrelevant platform files +- TypeScript configs ensure proper platform targeting + +## External Resources +- [Jitsi Handbook](https://jitsi.github.io/handbook/) - Comprehensive documentation +- [Community Forum](https://community.jitsi.org/) - Ask questions and get support +- [Architecture Guide](https://jitsi.github.io/handbook/docs/architecture) - System overview +- [Contributing Guidelines](https://jitsi.github.io/handbook/docs/dev-guide/contributing) - Detailed contribution process \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..db57433 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,20 @@ +# Follow Our Updated Guide to See How You Can Contribute + +**Hello there! 👋** + +We're thrilled that you're eager to contribute to **Jitsi Meet! ❤️** + +Your interest in improving our platform means a lot to us. To ensure your contributions align seamlessly with our goals and processes, we've recently updated our guide. This guide will provide you with clear instructions on how to get involved effectively. + +### 📖 Get Started + +Ready to get started? Head over to our [Jitsi Meet Handbook](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-contributing/) and let's make **Jitsi Meet** even better together! + +### 💬 Join the Discussion + +Have questions or need help? Join our community discussions on the [Jitsi Forum](https://community.jitsi.org/) where contributors and maintainers can assist you. + +### ❗️Additional Note +Before sending us your code, double-check that it meets our coding standards. You can do this by running a command: `npm run lint`. If there are any issues, don't worry! You can fix them by running: `npm run lint-fix`. Once your code passes these checks, feel free to submit your pull request. + +**Happy coding!** diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..3fc60dc --- /dev/null +++ b/Gemfile @@ -0,0 +1,16 @@ +source "https://rubygems.org" + +ruby ">= 3.4.0" + +gem "cocoapods", "~> 1.16" + +# (Optional) Fastlane for automation +gem "fastlane" +gem "abbrev" +gem "logger" +gem "mutex_m" +gem "csv" +gem "bigdecimal" + +# (Optional) Bundler itself to ensure consistency +gem "bundler" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..edf2a4e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,331 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + abbrev (0.1.2) + activesupport (7.2.2.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.3.1) + aws-partitions (1.1050.0) + aws-sdk-core (3.218.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.98.0) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.181.0) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.11.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + benchmark (0.4.0) + bigdecimal (3.1.9) + claide (1.1.0) + cocoapods (1.16.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.16.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) + csv (3.3.2) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + drb (2.2.1) + emoji_regex (3.2.3) + escape (0.0.4) + ethon (0.16.0) + ffi (>= 1.15.0) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.226.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.0) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + ffi (1.17.1) + ffi (1.17.1-aarch64-linux-gnu) + ffi (1.17.1-aarch64-linux-musl) + ffi (1.17.1-arm-linux-gnu) + ffi (1.17.1-arm-linux-musl) + ffi (1.17.1-arm64-darwin) + ffi (1.17.1-x86-linux-gnu) + ffi (1.17.1-x86-linux-musl) + ffi (1.17.1-x86_64-darwin) + ffi (1.17.1-x86_64-linux-gnu) + ffi (1.17.1-x86_64-linux-musl) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.7.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.4.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.8.3) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + jmespath (1.6.2) + json (2.10.1) + jwt (2.10.1) + base64 + logger (1.6.6) + mini_magick (4.13.2) + mini_mime (1.1.5) + minitest (5.25.4) + molinillo (0.8.0) + multi_json (1.15.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + nap (1.1.0) + naturally (2.2.1) + netrc (0.11.0) + nkf (0.2.0) + optparse (0.6.0) + os (1.1.4) + plist (3.7.2) + public_suffix (4.0.7) + rake (13.2.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.4.1) + rouge (3.28.0) + ruby-macho (2.5.1) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + securerandom (0.4.1) + security (0.1.5) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + typhoeus (1.4.1) + ethon (>= 0.9.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.0) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + abbrev + bigdecimal + bundler + cocoapods (~> 1.16) + csv + fastlane + logger + mutex_m + +RUBY VERSION + ruby 3.4.2p28 + +BUNDLED WITH + 2.6.3 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..da61429 --- /dev/null +++ b/LICENSE @@ -0,0 +1,219 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +Note: + +This project was originally contributed to the community under the MIT license and with the following notice: + +The MIT License (MIT) + +Copyright (c) 2013 ESTOS GmbH +Copyright (c) 2013 BlueJimp SARL + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..456821e --- /dev/null +++ b/Makefile @@ -0,0 +1,128 @@ +BUILD_DIR = build +CLEANCSS = ./node_modules/.bin/cleancss +DEPLOY_DIR = libs +LIBJITSIMEET_DIR = node_modules/lib-jitsi-meet +OLM_DIR = node_modules/@matrix-org/olm +TF_WASM_DIR = node_modules/@tensorflow/tfjs-backend-wasm/dist/ +RNNOISE_WASM_DIR = node_modules/@jitsi/rnnoise-wasm/dist +EXCALIDRAW_DIR = node_modules/@jitsi/excalidraw/dist/excalidraw-assets +EXCALIDRAW_DIR_DEV = node_modules/@jitsi/excalidraw/dist/excalidraw-assets-dev +TFLITE_WASM = react/features/stream-effects/virtual-background/vendor/tflite +MEET_MODELS_DIR = react/features/stream-effects/virtual-background/vendor/models +FACE_MODELS_DIR = node_modules/@vladmandic/human-models/models +NODE_SASS = ./node_modules/.bin/sass +NPM = npm +OUTPUT_DIR = . +STYLES_BUNDLE = css/all.bundle.css +STYLES_DESTINATION = css/all.css +STYLES_MAIN = css/main.scss +ifeq ($(OS),Windows_NT) + WEBPACK = .\node_modules\.bin\webpack --progress + WEBPACK_DEV_SERVER = .\node_modules\.bin\webpack serve --mode development --progress +else + WEBPACK = ./node_modules/.bin/webpack --progress + WEBPACK_DEV_SERVER = ./node_modules/.bin/webpack serve --mode development --progress +endif + +all: compile deploy + +compile: clean + NODE_OPTIONS=--max-old-space-size=8192 \ + $(WEBPACK) + +clean: + rm -fr $(BUILD_DIR) + +.NOTPARALLEL: +deploy: deploy-init deploy-appbundle deploy-rnnoise-binary deploy-excalidraw deploy-tflite deploy-meet-models deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-css deploy-local deploy-face-landmarks + +deploy-init: + rm -fr $(DEPLOY_DIR) + mkdir -p $(DEPLOY_DIR) + +deploy-appbundle: + cp \ + $(BUILD_DIR)/app.bundle.min.js \ + $(BUILD_DIR)/app.bundle.min.js.map \ + $(BUILD_DIR)/external_api.min.js \ + $(BUILD_DIR)/external_api.min.js.map \ + $(BUILD_DIR)/alwaysontop.min.js \ + $(BUILD_DIR)/alwaysontop.min.js.map \ + $(BUILD_DIR)/face-landmarks-worker.min.js \ + $(BUILD_DIR)/face-landmarks-worker.min.js.map \ + $(BUILD_DIR)/noise-suppressor-worklet.min.js \ + $(BUILD_DIR)/noise-suppressor-worklet.min.js.map \ + $(BUILD_DIR)/screenshot-capture-worker.min.js \ + $(BUILD_DIR)/screenshot-capture-worker.min.js.map \ + $(DEPLOY_DIR) + cp \ + $(BUILD_DIR)/close3.min.js \ + $(BUILD_DIR)/close3.min.js.map \ + $(DEPLOY_DIR) || true + +deploy-lib-jitsi-meet: + cp \ + $(LIBJITSIMEET_DIR)/dist/umd/lib-jitsi-meet.* \ + $(DEPLOY_DIR) + +deploy-olm: + cp \ + $(OLM_DIR)/olm.wasm \ + $(DEPLOY_DIR) + +deploy-tf-wasm: + cp \ + $(TF_WASM_DIR)/*.wasm \ + $(DEPLOY_DIR) + +deploy-rnnoise-binary: + cp \ + $(RNNOISE_WASM_DIR)/rnnoise.wasm \ + $(DEPLOY_DIR) + +deploy-tflite: + cp \ + $(TFLITE_WASM)/*.wasm \ + $(DEPLOY_DIR) + +deploy-excalidraw: + cp -R \ + $(EXCALIDRAW_DIR) \ + $(DEPLOY_DIR)/ + +deploy-excalidraw-dev: + cp -R \ + $(EXCALIDRAW_DIR_DEV) \ + $(DEPLOY_DIR)/ + +deploy-meet-models: + cp \ + $(MEET_MODELS_DIR)/*.tflite \ + $(DEPLOY_DIR) + +deploy-face-landmarks: + cp \ + $(FACE_MODELS_DIR)/blazeface-front.bin \ + $(FACE_MODELS_DIR)/blazeface-front.json \ + $(FACE_MODELS_DIR)/emotion.bin \ + $(FACE_MODELS_DIR)/emotion.json \ + $(DEPLOY_DIR) + +deploy-css: + $(NODE_SASS) $(STYLES_MAIN) $(STYLES_BUNDLE) && \ + $(CLEANCSS) --skip-rebase $(STYLES_BUNDLE) > $(STYLES_DESTINATION) && \ + rm $(STYLES_BUNDLE) + +deploy-local: + ([ ! -x deploy-local.sh ] || ./deploy-local.sh) + +.NOTPARALLEL: +dev: deploy-init deploy-css deploy-rnnoise-binary deploy-tflite deploy-meet-models deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-excalidraw-dev deploy-face-landmarks + $(WEBPACK_DEV_SERVER) + +source-package: compile deploy + mkdir -p source_package/jitsi-meet/css && \ + cp -r *.js *.html resources/*.txt fonts images libs static sounds LICENSE lang source_package/jitsi-meet && \ + cp css/all.css source_package/jitsi-meet/css && \ + (cd source_package ; tar cjf ../jitsi-meet.tar.bz2 jitsi-meet) && \ + rm -rf source_package diff --git a/README.md b/README.md new file mode 100644 index 0000000..d181cc5 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +#

Jitsi Meet

+ +Jitsi Meet is a set of Open Source projects which empower users to use and deploy +video conferencing platforms with state-of-the-art video quality and features. + +
+ +

+ +

+ +
+ +Amongst others here are the main features Jitsi Meet offers: + +* Support for all current browsers +* Mobile applications +* Web and native SDKs for integration +* HD audio and video +* Content sharing +* Raise hand and reactions +* Chat with private conversations +* Polls +* Virtual backgrounds + +And many more! + +## Using Jitsi Meet + +Using Jitsi Meet is straightforward, as it's browser based. Head over to [meet.jit.si](https://meet.jit.si) and give it a try. It's scalable and free to use. All you need is a Google, Facebook or GitHub account in order to start a meeting. All browsers are supported! + +Using mobile? No problem, you can either use your mobile web browser or our fully-featured +mobile apps: + +| Android | Android (F-Droid) | iOS | +|:-:|:-:|:-:| +| [](https://play.google.com/store/apps/details?id=org.jitsi.meet) | [](https://f-droid.org/packages/org.jitsi.meet/) | [](https://itunes.apple.com/us/app/jitsi-meet/id1165103905) | + +If you are feeling adventurous and want to get an early scoop of the features as they are being +developed you can also sign up for our open beta testing here: + +* [Android](https://play.google.com/apps/testing/org.jitsi.meet) +* [iOS](https://testflight.apple.com/join/isy6ja7S) + +## Running your own instance + +If you'd like to run your own Jitsi Meet installation head over to the [handbook](https://jitsi.github.io/handbook/docs/devops-guide/) to get started. + +We provide Debian packages and a comprehensive Docker setup to make deployments as simple as possible. +Advanced users also have the possibility of building all the components from source. + +You can check the latest releases [here](https://jitsi.github.io/handbook/docs/releases). + +## Jitsi as a Service + +If you like the branding capabilities of running your own instance but you'd like +to avoid dealing with the complexity of monitoring, scaling and updates, JaaS might be +for you. + +[8x8 Jitsi as a Service (JaaS)](https://jaas.8x8.vc) is an enterprise-ready video meeting platform that allows developers, organizations and businesses to easily build and deploy video solutions. With Jitsi as a Service we now give you all the power of Jitsi running on our global platform so you can focus on building secure and branded video experiences. + +## Documentation + +All the Jitsi Meet documentation is available in [the handbook](https://jitsi.github.io/handbook/). + +## Security + +For a comprehensive description of all Jitsi Meet's security aspects, please check [this link](https://jitsi.org/security). + +For a detailed description of Jitsi Meet's End-to-End Encryption (E2EE) implementation, +please check [this link](https://jitsi.org/e2ee-whitepaper/). + +For information on reporting security vulnerabilities in Jitsi Meet, see [SECURITY.md](./SECURITY.md). + +## Contributing + +If you are looking to contribute to Jitsi Meet, first of all, thank you! Please +see our [guidelines for contributing](CONTRIBUTING.md). + +
+
+ +
+

+Built with ❤️ by the Jitsi team at 8x8 and our community. +

+
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a9f668a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security + +## Reporting security issues + +We take security very seriously and develop all Jitsi projects to be secure and safe. + +If you find (or simply suspect) a security issue in any of the Jitsi projects, please report it to us via [HackerOne](https://hackerone.com/8x8-bounty) or send us an email to security@jitsi.org. + +**We encourage responsible disclosure for the sake of our users, so please reach out before posting in a public space.** diff --git a/_unlock b/_unlock new file mode 100644 index 0000000..d86bac9 --- /dev/null +++ b/_unlock @@ -0,0 +1 @@ +OK diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..b69457e --- /dev/null +++ b/android/README.md @@ -0,0 +1,3 @@ +# Jitsi Meet SDK for Android + +This document has been moved to [The Handbook](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-android-sdk). diff --git a/android/app/.classpath b/android/app/.classpath new file mode 100644 index 0000000..eb19361 --- /dev/null +++ b/android/app/.classpath @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/.project b/android/app/.project new file mode 100644 index 0000000..ac485d7 --- /dev/null +++ b/android/app/.project @@ -0,0 +1,23 @@ + + + app + Project app created by Buildship. + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.buildship.core.gradleprojectnature + + diff --git a/android/app/.settings/org.eclipse.buildship.core.prefs b/android/app/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..b1886ad --- /dev/null +++ b/android/app/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,2 @@ +connection.project.dir=.. +eclipse.preferences.version=1 diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..a83ba38 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,187 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +// Crashlytics integration is done as part of Firebase now, so it gets +// automagically activated with google-services.json +if (googleServicesEnabled) { + apply plugin: 'com.google.firebase.crashlytics' +} + +// Use the number of seconds/10 since Jan 1 2019 as the versionCode. +// This lets us upload a new build at most every 10 seconds for the +// next ~680 years. +// https://stackoverflow.com/a/38643838 +def vcode = (int) (((new Date().getTime() / 1000) - 1546297200) / 10) + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + + defaultConfig { + applicationId 'org.jitsi.meet' + versionCode vcode + versionName project.appVersion + + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + + ndk { + abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + } + + externalNativeBuild { + cmake { + arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON", "-DANDROID_STL=c++_shared" + cppFlags "-std=c++17" + cFlags "-DANDROID_PLATFORM=android-26" + } + } + } + + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + + buildTypes { + debug { + buildConfigField "boolean", "GOOGLE_SERVICES_ENABLED", "${googleServicesEnabled}" + buildConfigField "boolean", "LIBRE_BUILD", "${rootProject.ext.libreBuild}" + applicationIdSuffix ".debug" + } + release { + // Uncomment the following line for signing a test release build. + // signingConfig signingConfigs.debug + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules-release.pro' + buildConfigField "boolean", "GOOGLE_SERVICES_ENABLED", "${googleServicesEnabled}" + buildConfigField "boolean", "LIBRE_BUILD", "${rootProject.ext.libreBuild}" + } + } + + sourceSets { + main { + java { + if (rootProject.ext.libreBuild) { + srcDir "src" + exclude "**/GoogleServicesHelper.java" + } + } + } + } + + compileOptions { + sourceCompatibility rootProject.ext.javaVersion + targetCompatibility rootProject.ext.javaVersion + } + + kotlinOptions { + jvmTarget = rootProject.ext.jvmTargetVersion + } + + kotlin { + jvmToolchain(rootProject.ext.jvmToolchainVersion) + } + + namespace 'org.jitsi.meet' +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.5.1' + + debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13' + + if (!rootProject.ext.libreBuild) { + // Sync with react-native-google-signin + implementation 'com.google.android.gms:play-services-auth:20.5.0' + + // Firebase + // - Crashlytics + implementation 'com.google.firebase:firebase-analytics:21.3.0' + implementation 'com.google.firebase:firebase-crashlytics:18.4.3' + } + + implementation project(':sdk') +} + +gradle.projectsEvaluated { + // Dropbox integration + def dropboxAppKey + if (project.file('dropbox.key').exists()) { + dropboxAppKey = project.file('dropbox.key').text.trim() - 'db-' + } + + if (dropboxAppKey) { + android.defaultConfig.resValue('string', 'dropbox_app_key', "${dropboxAppKey}") + + def dropboxActivity = """ + + + + + + + + """ + + android.applicationVariants.all { variant -> + variant.outputs.each { output -> + output.getProcessManifestProvider().get().doLast { + def outputDir = multiApkManifestOutputDirectory.get().asFile + def manifestPath = new File(outputDir, 'AndroidManifest.xml') + def charset = 'UTF-8' + def text + text = manifestPath.getText(charset) + text = text.replace('', "${dropboxActivity}") + manifestPath.write(text, charset) + } + } + } + } + + // Run React packager + android.applicationVariants.all { variant -> + def targetName = variant.name.capitalize() + + def currentRunPackagerTask = tasks.create( + name: "run${targetName}ReactPackager", + type: Exec) { + group = "react" + description = "Run the React packager." + + doFirst { + println "Starting the React packager..." + + def androidRoot = file("${projectDir}/../") + + // Set up the call to the script + workingDir androidRoot + + // Run the packager + commandLine("scripts/run-packager.sh") + } + + // Set up dev mode + def devEnabled = !targetName.toLowerCase().contains("release") + + // Only enable for dev builds + enabled devEnabled + } + + def packageTask = variant.packageApplicationProvider.get() + + packageTask.dependsOn(currentRunPackagerTask) + } +} + +if (googleServicesEnabled) { + apply plugin: 'com.google.gms.google-services' +} diff --git a/android/app/proguard-rules-release.pro b/android/app/proguard-rules-release.pro new file mode 100644 index 0000000..ceedaaf --- /dev/null +++ b/android/app/proguard-rules-release.pro @@ -0,0 +1,10 @@ +-include proguard-rules.pro + +# Crashlytics +-keepattributes *Annotation* +-keepattributes SourceFile,LineNumberTable +-keep public class * extends java.lang.Exception + +# R8 missing classes - suppress warnings +-dontwarn com.facebook.memory.config.MemorySpikeConfig +-dontwarn kotlinx.parcelize.Parcelize diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..8d3cc19 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,98 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# Disabling obfuscation is useful if you collect stack traces from production crashes +# (unless you are using a system that supports de-obfuscate the stack traces). +# -dontobfuscate + +# React Native + +# Keep our interfaces so they can be used by other ProGuard rules. +# See http://sourceforge.net/p/proguard/bugs/466/ +-keep,allowobfuscation @interface com.facebook.proguard.annotations.DoNotStrip +-keep,allowobfuscation @interface com.facebook.proguard.annotations.KeepGettersAndSetters + +# Do not strip any method/class that is annotated with @DoNotStrip +-keep @com.facebook.proguard.annotations.DoNotStrip class * +-keepclassmembers class * { + @com.facebook.proguard.annotations.DoNotStrip *; +} + +-keep @com.facebook.proguard.annotations.DoNotStripAny class * { + *; +} + +-keepclassmembers @com.facebook.proguard.annotations.KeepGettersAndSetters class * { + void set*(***); + *** get*(); +} + +-keep class * implements com.facebook.react.bridge.JavaScriptModule { *; } +-keep class * implements com.facebook.react.bridge.NativeModule { *; } +-keepclassmembers,includedescriptorclasses class * { native ; } +-keepclassmembers class * { @com.facebook.react.uimanager.annotations.ReactProp ; } +-keepclassmembers class * { @com.facebook.react.uimanager.annotations.ReactPropGroup ; } + +-dontwarn com.facebook.react.** +-keep,includedescriptorclasses class com.facebook.react.bridge.** { *; } +-keep,includedescriptorclasses class com.facebook.react.turbomodule.core.** { *; } + +# hermes +-keep class com.facebook.jni.** { *; } + +# okio +-keep class sun.misc.Unsafe { *; } +-dontwarn java.nio.file.* +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn okio.** + +# yoga +-keep,allowobfuscation @interface com.facebook.yoga.annotations.DoNotStrip +-keep @com.facebook.yoga.annotations.DoNotStrip class * +-keepclassmembers class * { + @com.facebook.yoga.annotations.DoNotStrip *; +} + +# WebRTC + +-keep class org.webrtc.** { *; } +-dontwarn org.chromium.build.BuildHooksAndroid + +# Jisti Meet SDK + +-keep class org.jitsi.meet.** { *; } +-keep class org.jitsi.meet.sdk.** { *; } + +# We added the following when we switched minifyEnabled on. Probably because we +# ran the app and hit problems... + +-keep class com.facebook.react.bridge.CatalystInstanceImpl { *; } +-keep class com.facebook.react.bridge.ExecutorToken { *; } +-keep class com.facebook.react.bridge.JavaScriptExecutor { *; } +-keep class com.facebook.react.bridge.ModuleRegistryHolder { *; } +-keep class com.facebook.react.bridge.ReadableType { *; } +-keep class com.facebook.react.bridge.queue.NativeRunnable { *; } +-keep class com.facebook.react.devsupport.** { *; } + +-dontwarn com.facebook.react.devsupport.** +-dontwarn com.google.appengine.** +-dontwarn com.squareup.okhttp.** +-dontwarn javax.servlet.** + +# ^^^ We added the above when we switched minifyEnabled on. + +# Rule to avoid build errors related to SVGs. +-keep public class com.horcrux.svg.** {*;} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..04114e6 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/org/jitsi/meet/GoogleServicesHelper.java b/android/app/src/main/java/org/jitsi/meet/GoogleServicesHelper.java new file mode 100644 index 0000000..4f80ce1 --- /dev/null +++ b/android/app/src/main/java/org/jitsi/meet/GoogleServicesHelper.java @@ -0,0 +1,26 @@ +package org.jitsi.meet; + +import android.net.Uri; +import android.util.Log; + +import com.google.firebase.crashlytics.FirebaseCrashlytics; + +import org.jitsi.meet.sdk.JitsiMeet; +import org.jitsi.meet.sdk.JitsiMeetActivity; + +/** + * Helper class to initialize Google related services and functionality. + * This functionality is compiled conditionally and called via reflection, that's why it was + * extracted here. + * + * "Libre builds" (builds with the LIBRE_BUILD flag set) will not include this file. + */ +final class GoogleServicesHelper { + public static void initialize(JitsiMeetActivity activity) { + if (BuildConfig.GOOGLE_SERVICES_ENABLED) { + Log.d(activity.getClass().getSimpleName(), "Initializing Google Services"); + + FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!JitsiMeet.isCrashReportingDisabled(activity)); + } + } +} diff --git a/android/app/src/main/java/org/jitsi/meet/MainActivity.java b/android/app/src/main/java/org/jitsi/meet/MainActivity.java new file mode 100644 index 0000000..3b18fca --- /dev/null +++ b/android/app/src/main/java/org/jitsi/meet/MainActivity.java @@ -0,0 +1,233 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.RestrictionEntry; +import android.content.RestrictionsManager; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.util.Log; +import android.view.KeyEvent; + +import androidx.annotation.Nullable; + +import com.oney.WebRTCModule.WebRTCModuleOptions; + +import org.jitsi.meet.sdk.JitsiMeet; +import org.jitsi.meet.sdk.JitsiMeetActivity; +import org.jitsi.meet.sdk.JitsiMeetConferenceOptions; +import org.webrtc.Logging; + +import java.lang.reflect.Method; +import java.net.URL; +import java.util.Collection; + +/** + * The one and only Activity that the Jitsi Meet app needs. The + * {@code Activity} is launched in {@code singleTask} mode, so it will be + * created upon application initialization and there will be a single instance + * of it. Further attempts at launching the application once it was already + * launched will result in {@link MainActivity#onNewIntent(Intent)} being called. + */ +public class MainActivity extends JitsiMeetActivity { + /** + * The request code identifying requests for the permission to draw on top + * of other apps. The value must be 16-bit and is arbitrarily chosen here. + */ + private static final int OVERLAY_PERMISSION_REQUEST_CODE + = (int) (Math.random() * Short.MAX_VALUE); + + /** + * ServerURL configuration key for restriction configuration using {@link android.content.RestrictionsManager} + */ + public static final String RESTRICTION_SERVER_URL = "SERVER_URL"; + + /** + * Broadcast receiver for restrictions handling + */ + private BroadcastReceiver broadcastReceiver; + + /** + * Flag if configuration is provided by RestrictionManager + */ + private boolean configurationByRestrictions = false; + + /** + * Default URL as could be obtained from RestrictionManager + */ + private String defaultURL; + + // JitsiMeetActivity overrides + // + + @Override + protected void onCreate(Bundle savedInstanceState) { + JitsiMeet.showSplashScreen(this); + + WebRTCModuleOptions options = WebRTCModuleOptions.getInstance(); + options.loggingSeverity = Logging.Severity.LS_ERROR; + + super.onCreate(null); + } + + @Override + protected boolean extraInitialize() { + Log.d(this.getClass().getSimpleName(), "LIBRE_BUILD="+BuildConfig.LIBRE_BUILD); + + // Setup Crashlytics and Firebase Dynamic Links + // Here we are using reflection since it may have been disabled at compile time. + try { + Class cls = Class.forName("org.jitsi.meet.GoogleServicesHelper"); + Method m = cls.getMethod("initialize", JitsiMeetActivity.class); + m.invoke(null, this); + } catch (Exception e) { + // Ignore any error, the module is not compiled when LIBRE_BUILD is enabled. + } + + // In Debug builds React needs permission to write over other apps in + // order to display the warning and error overlays. + if (BuildConfig.DEBUG) { + if (!Settings.canDrawOverlays(this)) { + Intent intent + = new Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + getPackageName())); + + startActivityForResult(intent, OVERLAY_PERMISSION_REQUEST_CODE); + + return true; + } + } + + return false; + } + + @Override + protected void initialize() { + broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + // As new restrictions including server URL are received, + // conference should be restarted with new configuration. + leave(); + recreate(); + } + }; + registerReceiver(broadcastReceiver, + new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)); + + resolveRestrictions(); + setJitsiMeetConferenceDefaultOptions(); + super.initialize(); + } + + @Override + public void onDestroy() { + if (broadcastReceiver != null) { + unregisterReceiver(broadcastReceiver); + broadcastReceiver = null; + } + + super.onDestroy(); + } + + private void setJitsiMeetConferenceDefaultOptions() { + + // Set default options + JitsiMeetConferenceOptions defaultOptions + = new JitsiMeetConferenceOptions.Builder() + .setServerURL(buildURL(defaultURL)) + .setFeatureFlag("welcomepage.enabled", true) + .setFeatureFlag("server-url-change.enabled", !configurationByRestrictions) + .build(); + JitsiMeet.setDefaultConferenceOptions(defaultOptions); + } + + private void resolveRestrictions() { + RestrictionsManager manager = + (RestrictionsManager) getSystemService(Context.RESTRICTIONS_SERVICE); + Bundle restrictions = manager.getApplicationRestrictions(); + Collection entries = manager.getManifestRestrictions( + getApplicationContext().getPackageName()); + for (RestrictionEntry restrictionEntry : entries) { + String key = restrictionEntry.getKey(); + if (RESTRICTION_SERVER_URL.equals(key)) { + // If restrictions are passed to the application. + if (restrictions != null && + restrictions.containsKey(RESTRICTION_SERVER_URL)) { + defaultURL = restrictions.getString(RESTRICTION_SERVER_URL); + configurationByRestrictions = true; + // Otherwise use default URL from app-restrictions.xml. + } else { + defaultURL = restrictionEntry.getSelectedString(); + configurationByRestrictions = false; + } + } + } + } + + // Activity lifecycle method overrides + // + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == OVERLAY_PERMISSION_REQUEST_CODE) { + if (Settings.canDrawOverlays(this)) { + initialize(); + return; + } + + throw new RuntimeException("Overlay permission is required when running in Debug mode."); + } + + super.onActivityResult(requestCode, resultCode, data); + } + + // ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_MENU) { + JitsiMeet.showDevOptions(); + return true; + } + + return super.onKeyUp(keyCode, event); + } + + @Override + public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode); + + Log.d(TAG, "Is in picture-in-picture mode: " + isInPictureInPictureMode); + } + + // Helper methods + // + + private @Nullable URL buildURL(String urlStr) { + try { + return new URL(urlStr); + } catch (Exception e) { + return null; + } + } +} diff --git a/android/app/src/main/res/drawable/ic_jitsi_logosvg.xml b/android/app/src/main/res/drawable/ic_jitsi_logosvg.xml new file mode 100644 index 0000000..cc63663 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_jitsi_logosvg.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..29a1a81 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..f35d996 --- /dev/null +++ b/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/launch_screen.xml b/android/app/src/main/res/layout/launch_screen.xml new file mode 100644 index 0000000..f61656a --- /dev/null +++ b/android/app/src/main/res/layout/launch_screen.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..081998b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..081998b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b738ac7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5575431 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..733f88e Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d8d1a59 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d9c88d3 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..bb5f602 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..50eb5b1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ab3898a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5f9eed6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4c0eea3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e4c57dc Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0183c2a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..e91898a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7d3e730 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2f3c6f9 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..7ff1327 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #17A0DB + #040404 + #040404 + \ No newline at end of file diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..55c2377 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #66A8DD + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..bd12652 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Jitsi Meet + URL of Jitsi Meet server instance to connect to + Server URL + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..6e4737b --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/xml/app_restrictions.xml b/android/app/src/main/res/xml/app_restrictions.xml new file mode 100644 index 0000000..d501b94 --- /dev/null +++ b/android/app/src/main/res/xml/app_restrictions.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..cfdf6a5 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + + + + + + localhost + 10.0.2.2 + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..5bce937 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,217 @@ +import groovy.json.JsonSlurper +import org.gradle.util.VersionNumber + +// Top-level build file where you can add configuration options common to all +// sub-projects/modules. + +buildscript { + ext { + kotlinVersion = "2.0.21" + gradlePluginVersion = "8.6.0" + buildToolsVersion = "35.0.0" + compileSdkVersion = 35 + minSdkVersion = 26 + targetSdkVersion = 35 + supportLibVersion = "28.0.0" + ndkVersion = "27.1.12297006" + + // The Maven artifact groupId of the third-party react-native modules which + // Jitsi Meet SDK for Android depends on and which are not available in + // third-party Maven repositories so we have to deploy to a Maven repository + // of ours. + moduleGroupId = 'com.facebook.react' + + // Maven repo where artifacts will be published + mavenRepo = System.env.MVN_REPO ?: "" + mavenUser = System.env.MVN_USER ?: "" + mavenPassword = System.env.MVN_PASSWORD ?: "" + + // Libre build + libreBuild = (System.env.LIBRE_BUILD ?: "false").toBoolean() + + googleServicesEnabled = project.file('app/google-services.json').exists() && !libreBuild + + //React Native and Hermes Version + rnVersion = "0.77.2" + + // Java dependencies + javaVersion = JavaVersion.VERSION_17 + jvmToolchainVersion = 17 + jvmTargetVersion = '17' + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$rootProject.ext.kotlinVersion" + classpath "com.android.tools.build:gradle:$rootProject.ext.gradlePluginVersion" + classpath 'com.google.gms:google-services:4.4.0' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9' + } +} + +allprojects { + repositories { + mavenCentral() + google() + maven { url 'https://www.jitpack.io' } + } + + // Make sure we use the react-native version in node_modules and not the one + // published in jcenter / elsewhere. + configurations.all { + resolutionStrategy { + eachDependency { DependencyResolveDetails details -> + if (details.requested.group == 'com.facebook.react') { + if (details.requested.name == 'react-native') { + details.useTarget "com.facebook.react:react-android:$rnVersion" + } + if (details.requested.name == 'react-android') { + details.useVersion rootProject.ext.rnVersion + } + } + } + } + } + + // Third-party react-native modules which Jitsi Meet SDK for Android depends + // on and which are not available in third-party Maven repositories need to + // be deployed in a Maven repository of ours. + + if (project.name.startsWith('react-native-')) { + apply plugin: 'maven-publish' + publishing { + publications {} + repositories { + maven { + url rootProject.ext.mavenRepo + if (!rootProject.ext.mavenRepo.startsWith("file")) { + credentials { + username rootProject.ext.mavenUser + password rootProject.ext.mavenPassword + } + } + } + } + } + } + + // Use the number of seconds/10 since Jan 1 2019 as the version qualifier number. + // This will last for the next ~680 years. + // https://stackoverflow.com/a/38643838 + def versionQualifierNumber = (int)(((new Date().getTime()/1000) - 1546297200) / 10) + + afterEvaluate { project -> + if (project.plugins.hasPlugin('android') || project.plugins.hasPlugin('android-library')) { + project.android { + compileSdkVersion rootProject.ext.compileSdkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + } + } + + if (project.name.startsWith('react-native-')) { + def npmManifest = project.file('../package.json') + def json = new JsonSlurper().parseText(npmManifest.text) + + // Release every dependency the SDK has with a -jitsi-XXX qualified version. This allows + // us to pin the dependencies and make sure they are always updated, no matter what. + + project.version = "${json.version}-jitsi-${versionQualifierNumber}" + + task jitsiAndroidSourcesJar(type: Jar) { + archiveClassifier = 'sources' + from android.sourceSets.main.java.source + } + + publishing.publications { + aarArchive(MavenPublication) { + groupId rootProject.ext.moduleGroupId + artifactId project.name + version project.version + + artifact("${project.buildDir}/outputs/aar/${project.name}-release.aar") { + extension "aar" + } + artifact(jitsiAndroidSourcesJar) + pom.withXml { + def pomXml = asNode() + pomXml.appendNode('name', project.name) + pomXml.appendNode('description', json.description) + pomXml.appendNode('url', json.homepage) + if (json.license) { + def license = pomXml.appendNode('licenses').appendNode('license') + license.appendNode('name', json.license) + license.appendNode('distribution', 'repo') + } + + def dependencies = pomXml.appendNode('dependencies') + configurations.getByName('releaseCompileClasspath').getResolvedConfiguration().getFirstLevelModuleDependencies().each { + def artifactId = it.moduleName + def version = it.moduleVersion + // React Native signals breaking changes by + // increasing the minor version number. So the + // (third-party) React Native modules we utilize can + // depend not on a specific react-native release but + // a wider range. + if (artifactId == 'react-native') { + def versionNumber = VersionNumber.parse(version) + version = "${versionNumber.major}.${versionNumber.minor}" + } + + def dependency = dependencies.appendNode('dependency') + dependency.appendNode('groupId', it.moduleGroup) + dependency.appendNode('artifactId', artifactId) + dependency.appendNode('version', version) + } + } + } + } + } + } +} + +// Force the version of the Android build tools we have chosen on all subprojects. +subprojects { subproject -> + afterEvaluate{ + if ((subproject.plugins.hasPlugin('android') + || subproject.plugins.hasPlugin('android-library')) + && rootProject.ext.has('buildToolsVersion')) { + + android { + buildToolsVersion rootProject.ext.buildToolsVersion + + buildFeatures { + buildConfig true + } + + // Set JVM target across all subprojects + compileOptions { + sourceCompatibility rootProject.ext.javaVersion + targetCompatibility rootProject.ext.javaVersion + } + + // Disable lint errors for problematic third-party modules + // react-native-background-timer + // react-native-calendar-events + lint { + abortOnError = false + } + } + } + + // Add Kotlin configuration for subprojects that use Kotlin + if (subproject.plugins.hasPlugin('kotlin-android')) { + subproject.kotlin { + jvmToolchain(rootProject.ext.jvmToolchainVersion) + } + + // Set Kotlin JVM target + subproject.android { + kotlinOptions { + jvmTarget = rootProject.ext.jvmTargetVersion + } + } + } + } +} diff --git a/android/fastlane/Appfile b/android/fastlane/Appfile new file mode 100644 index 0000000..f4690bf --- /dev/null +++ b/android/fastlane/Appfile @@ -0,0 +1,2 @@ +json_key_file("") +package_name("org.jitsi.meet") diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile new file mode 100644 index 0000000..82c5de7 --- /dev/null +++ b/android/fastlane/Fastfile @@ -0,0 +1,34 @@ +ENV["FASTLANE_SKIP_UPDATE_CHECK"] = "1" +opt_out_usage + +default_platform(:android) + +platform :android do + desc "Deploy a new version to Goolge Play (Closed Beta)" + lane :deploy do + # Cleanup + gradle(task: "clean") + + # Build and sign the app + gradle( + task: "assemble", + build_type: "Release", + print_command: false, + properties: { + "android.injected.signing.store.file" => ENV["JITSI_KEYSTORE"], + "android.injected.signing.store.password" => ENV["JITSI_KEYSTORE_PASSWORD"], + "android.injected.signing.key.alias" => ENV["JITSI_KEY_ALIAS"], + "android.injected.signing.key.password" => ENV["JITSI_KEY_PASSWORD"], + } + ) + + # Upload built artifact to the Closed Beta track + upload_to_play_store( + track: "beta", + json_key: ENV["JITSI_JSON_KEY_FILE"], + skip_upload_metadata: true, + skip_upload_images: true, + skip_upload_screenshots: true + ) + end +end diff --git a/android/fastlane/README.md b/android/fastlane/README.md new file mode 100644 index 0000000..e2b1828 --- /dev/null +++ b/android/fastlane/README.md @@ -0,0 +1,29 @@ +fastlane documentation +================ +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +``` +xcode-select --install +``` + +Install _fastlane_ using +``` +[sudo] gem install fastlane -NV +``` +or alternatively using `brew cask install fastlane` + +# Available Actions +## Android +### android deploy +``` +fastlane android deploy +``` +Deploy a new version to Goolge Play (Closed Beta) + +---- + +This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run. +More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). +The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/android/fastlane/screenshots/Feature-Graphic-1024x500-1-1.png b/android/fastlane/screenshots/Feature-Graphic-1024x500-1-1.png new file mode 100644 index 0000000..02fc9a3 Binary files /dev/null and b/android/fastlane/screenshots/Feature-Graphic-1024x500-1-1.png differ diff --git a/android/fastlane/screenshots/Feature-Graphic-1024x500-1.png b/android/fastlane/screenshots/Feature-Graphic-1024x500-1.png new file mode 100644 index 0000000..26655db Binary files /dev/null and b/android/fastlane/screenshots/Feature-Graphic-1024x500-1.png differ diff --git a/android/fastlane/screenshots/Feature-Graphic-1024x500-2.png b/android/fastlane/screenshots/Feature-Graphic-1024x500-2.png new file mode 100644 index 0000000..b3459a5 Binary files /dev/null and b/android/fastlane/screenshots/Feature-Graphic-1024x500-2.png differ diff --git a/android/fastlane/screenshots/Feature-Graphic-1024x500-3.png b/android/fastlane/screenshots/Feature-Graphic-1024x500-3.png new file mode 100644 index 0000000..36de496 Binary files /dev/null and b/android/fastlane/screenshots/Feature-Graphic-1024x500-3.png differ diff --git a/android/fastlane/screenshots/GroupCall.png b/android/fastlane/screenshots/GroupCall.png new file mode 100644 index 0000000..b591b2a Binary files /dev/null and b/android/fastlane/screenshots/GroupCall.png differ diff --git a/android/fastlane/screenshots/GroupCall_framed.png b/android/fastlane/screenshots/GroupCall_framed.png new file mode 100644 index 0000000..fe293a3 Binary files /dev/null and b/android/fastlane/screenshots/GroupCall_framed.png differ diff --git a/android/fastlane/screenshots/More Menu.png b/android/fastlane/screenshots/More Menu.png new file mode 100644 index 0000000..52b95cb Binary files /dev/null and b/android/fastlane/screenshots/More Menu.png differ diff --git a/android/fastlane/screenshots/More Menu_framed.png b/android/fastlane/screenshots/More Menu_framed.png new file mode 100644 index 0000000..ff6062d Binary files /dev/null and b/android/fastlane/screenshots/More Menu_framed.png differ diff --git a/android/fastlane/screenshots/Nexus 9 Body.png b/android/fastlane/screenshots/Nexus 9 Body.png new file mode 100644 index 0000000..02feb81 Binary files /dev/null and b/android/fastlane/screenshots/Nexus 9 Body.png differ diff --git a/android/fastlane/screenshots/Nexus-9-Landscape.png b/android/fastlane/screenshots/Nexus-9-Landscape.png new file mode 100644 index 0000000..433e8ea Binary files /dev/null and b/android/fastlane/screenshots/Nexus-9-Landscape.png differ diff --git a/android/fastlane/screenshots/Nexus-9-Portrait.png b/android/fastlane/screenshots/Nexus-9-Portrait.png new file mode 100644 index 0000000..52eb230 Binary files /dev/null and b/android/fastlane/screenshots/Nexus-9-Portrait.png differ diff --git a/android/fastlane/screenshots/Video-Call-1-1024x768.png b/android/fastlane/screenshots/Video-Call-1-1024x768.png new file mode 100644 index 0000000..3cea201 Binary files /dev/null and b/android/fastlane/screenshots/Video-Call-1-1024x768.png differ diff --git a/android/fastlane/screenshots/Video-Call-1-1280x720.png b/android/fastlane/screenshots/Video-Call-1-1280x720.png new file mode 100644 index 0000000..e067a90 Binary files /dev/null and b/android/fastlane/screenshots/Video-Call-1-1280x720.png differ diff --git a/android/fastlane/screenshots/Video-Call-2-1024x768.png b/android/fastlane/screenshots/Video-Call-2-1024x768.png new file mode 100644 index 0000000..e9d7cc5 Binary files /dev/null and b/android/fastlane/screenshots/Video-Call-2-1024x768.png differ diff --git a/android/fastlane/screenshots/Video-Call-2-1280x720.png b/android/fastlane/screenshots/Video-Call-2-1280x720.png new file mode 100644 index 0000000..6d6b635 Binary files /dev/null and b/android/fastlane/screenshots/Video-Call-2-1280x720.png differ diff --git a/android/fastlane/screenshots/WelcomePage-Calendar.png b/android/fastlane/screenshots/WelcomePage-Calendar.png new file mode 100644 index 0000000..d37e107 Binary files /dev/null and b/android/fastlane/screenshots/WelcomePage-Calendar.png differ diff --git a/android/fastlane/screenshots/WelcomePage-Calendar_framed.png b/android/fastlane/screenshots/WelcomePage-Calendar_framed.png new file mode 100644 index 0000000..b924e50 Binary files /dev/null and b/android/fastlane/screenshots/WelcomePage-Calendar_framed.png differ diff --git a/android/fastlane/screenshots/WelcomeScreen-1024x768.png b/android/fastlane/screenshots/WelcomeScreen-1024x768.png new file mode 100644 index 0000000..98404e9 Binary files /dev/null and b/android/fastlane/screenshots/WelcomeScreen-1024x768.png differ diff --git a/android/fastlane/screenshots/WelcomeScreen-1280x720.png b/android/fastlane/screenshots/WelcomeScreen-1280x720.png new file mode 100644 index 0000000..a1c6612 Binary files /dev/null and b/android/fastlane/screenshots/WelcomeScreen-1280x720.png differ diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..0858cd7 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,36 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m + +org.gradle.jvmargs=-Xmx4048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +android.useAndroidX=true + +android.enableJetifier=true + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=false + +# Use this property to enable or disable the Hermes JS engine. +hermesEnabled=true + +appVersion=99.0.0 +sdkVersion=0.0.0 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..01b8bf6 Binary files /dev/null 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 new file mode 100644 index 0000000..5c82cb0 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/keystores/BUCK b/android/keystores/BUCK new file mode 100644 index 0000000..88e4c31 --- /dev/null +++ b/android/keystores/BUCK @@ -0,0 +1,8 @@ +keystore( + name = "debug", + properties = "debug.keystore.properties", + store = "debug.keystore", + visibility = [ + "PUBLIC", + ], +) diff --git a/android/keystores/debug.keystore.properties b/android/keystores/debug.keystore.properties new file mode 100644 index 0000000..121bfb4 --- /dev/null +++ b/android/keystores/debug.keystore.properties @@ -0,0 +1,4 @@ +key.store=debug.keystore +key.alias=androiddebugkey +key.store.password=android +key.alias.password=android diff --git a/android/scripts/check_elf_alignment.sh b/android/scripts/check_elf_alignment.sh new file mode 100755 index 0000000..0cd8f92 --- /dev/null +++ b/android/scripts/check_elf_alignment.sh @@ -0,0 +1,113 @@ +#!/bin/bash +progname="${0##*/}" +progname="${progname%.sh}" + +# usage: check_elf_alignment.sh [path to *.so files|path to *.apk] + +cleanup_trap() { + if [ -n "${tmp}" -a -d "${tmp}" ]; then + rm -rf ${tmp} + fi + exit $1 +} + +usage() { + echo "Host side script to check the ELF alignment of shared libraries." + echo "Shared libraries are reported ALIGNED when their ELF regions are" + echo "16 KB or 64 KB aligned. Otherwise they are reported as UNALIGNED." + echo + echo "Usage: ${progname} [input-path|input-APK|input-APEX]" +} + +if [ ${#} -ne 1 ]; then + usage + exit +fi + +case ${1} in + --help | -h | -\?) + usage + exit + ;; + + *) + dir="${1}" + ;; +esac + +if ! [ -f "${dir}" -o -d "${dir}" ]; then + echo "Invalid file: ${dir}" >&2 + exit 1 +fi + +if [[ "${dir}" == *.apk ]]; then + trap 'cleanup_trap' EXIT + + echo + echo "Recursively analyzing $dir" + echo + + if { zipalign --help 2>&1 | grep -q "\-P "; }; then + echo "=== APK zip-alignment ===" + zipalign -v -c -P 16 4 "${dir}" | egrep 'lib/arm64-v8a|lib/x86_64|Verification' + echo "=========================" + else + echo "NOTICE: Zip alignment check requires build-tools version 35.0.0-rc3 or higher." + echo " You can install the latest build-tools by running the below command" + echo " and updating your \$PATH:" + echo + echo " sdkmanager \"build-tools;35.0.0-rc3\"" + fi + + dir_filename=$(basename "${dir}") + tmp=$(mktemp -d -t "${dir_filename%.apk}_out_XXXXX") + unzip "${dir}" lib/* -d "${tmp}" >/dev/null 2>&1 + dir="${tmp}" +fi + +if [[ "${dir}" == *.apex ]]; then + trap 'cleanup_trap' EXIT + + echo + echo "Recursively analyzing $dir" + echo + + dir_filename=$(basename "${dir}") + tmp=$(mktemp -d -t "${dir_filename%.apex}_out_XXXXX") + deapexer extract "${dir}" "${tmp}" || { echo "Failed to deapex." && exit 1; } + dir="${tmp}" +fi + +RED="\e[31m" +GREEN="\e[32m" +ENDCOLOR="\e[0m" + +unaligned_libs=() + +echo +echo "=== ELF alignment ===" + +matches="$(find "${dir}" -type f)" +IFS=$'\n' +for match in $matches; do + # We could recursively call this script or rewrite it to though. + [[ "${match}" == *".apk" ]] && echo "WARNING: doesn't recursively inspect .apk file: ${match}" + [[ "${match}" == *".apex" ]] && echo "WARNING: doesn't recursively inspect .apex file: ${match}" + + [[ $(file "${match}") == *"ELF"* ]] || continue + + res="$(objdump -p "${match}" | grep LOAD | awk '{ print $NF }' | head -1)" + if [[ $res =~ 2\*\*(1[4-9]|[2-9][0-9]|[1-9][0-9]{2,}) ]]; then + echo -e "${match}: ${GREEN}ALIGNED${ENDCOLOR} ($res)" + else + echo -e "${match}: ${RED}UNALIGNED${ENDCOLOR} ($res)" + unaligned_libs+=("${match}") + fi +done + +if [ ${#unaligned_libs[@]} -gt 0 ]; then + echo -e "${RED}Found ${#unaligned_libs[@]} unaligned libs (only arm64-v8a/x86_64 libs need to be aligned).${ENDCOLOR}" +elif [ -n "${dir_filename}" ]; then + echo -e "ELF Verification Successful" +fi +echo "=====================" \ No newline at end of file diff --git a/android/scripts/logcat.sh b/android/scripts/logcat.sh new file mode 100755 index 0000000..c7e9ab2 --- /dev/null +++ b/android/scripts/logcat.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +PKG_NAME=${1:-org.jitsi.meet} +APP_PID=$(adb shell ps | grep $PKG_NAME | awk '{print $2}') + +if [[ -z "$APP_PID" ]]; then + echo "App is not running" + exit 1 +fi + +exec adb logcat --pid=$APP_PID diff --git a/android/scripts/release-sdk.sh b/android/scripts/release-sdk.sh new file mode 100755 index 0000000..63536a9 --- /dev/null +++ b/android/scripts/release-sdk.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -e -u + + +THIS_DIR=$(cd -P "$(dirname "$(readlink "${BASH_SOURCE[0]}" || echo "${BASH_SOURCE[0]}")")" && pwd) +DEFAULT_MVN_REPO="${THIS_DIR}/../../../jitsi-maven-repository/releases" +THE_MVN_REPO=${MVN_REPO:-${1:-$DEFAULT_MVN_REPO}} +MVN_HTTP=0 +DEFAULT_SDK_VERSION=$(grep sdkVersion ${THIS_DIR}/../gradle.properties | cut -d"=" -f2) +SDK_VERSION=${OVERRIDE_SDK_VERSION:-${DEFAULT_SDK_VERSION}} + +if [[ $THE_MVN_REPO == http* ]]; then + MVN_HTTP=1 +else + MVN_REPO_PATH=$(realpath $THE_MVN_REPO) + THE_MVN_REPO="file:${MVN_REPO_PATH}" +fi + +export MVN_REPO=$THE_MVN_REPO + +echo "Releasing Jitsi Meet SDK ${SDK_VERSION}" +echo "Using ${MVN_REPO} as the Maven repo" + + if [[ $MVN_HTTP == 0 ]]; then + # Check if an SDK with that same version has already been released + if [[ -d ${MVN_REPO}/org/jitsi/react/jitsi-meet-sdk/${SDK_VERSION} ]]; then + echo "There is already a release with that version in the Maven repo!" + exit 1 + fi +fi + +# Now build and publish the Jitsi Meet SDK and its dependencies +echo "Building and publishing the Jitsi Meet SDK" +pushd ${THIS_DIR}/../ +./gradlew clean +./gradlew assembleRelease +./gradlew publish +popd + +# The artifacts are now on the Maven repo, commit them +if [[ $MVN_HTTP == 0 ]]; then + pushd ${MVN_REPO_PATH} + git add -A . + git commit -m "Jitsi Meet SDK + dependencies: ${SDK_VERSION}" + popd +fi + +# Done! +echo "Finished! Don't forget to push the tag and the Maven repo artifacts." diff --git a/android/scripts/run-packager-helper.command b/android/scripts/run-packager-helper.command new file mode 100755 index 0000000..cf9ebe0 --- /dev/null +++ b/android/scripts/run-packager-helper.command @@ -0,0 +1,5 @@ +#!/bin/bash + +THIS_DIR=$(cd -P "$(dirname "$(readlink "${BASH_SOURCE[0]}" || echo "${BASH_SOURCE[0]}")")" && pwd) + +exec ${THIS_DIR}/../../node_modules/react-native/scripts/packager.sh --reset-cache diff --git a/android/scripts/run-packager.sh b/android/scripts/run-packager.sh new file mode 100755 index 0000000..f7a5bce --- /dev/null +++ b/android/scripts/run-packager.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# This script is executed bt Gradle to start the React packager for Debug +# targets. + +THIS_DIR=$(cd -P "$(dirname "$(readlink "${BASH_SOURCE[0]}" || echo "${BASH_SOURCE[0]}")")" && pwd) + +export RCT_METRO_PORT="${RCT_METRO_PORT:=8081}" +echo "export RCT_METRO_PORT=${RCT_METRO_PORT}" > "${THIS_DIR}/../../node_modules/react-native/scripts/.packager.env" + +adb reverse tcp:$RCT_METRO_PORT tcp:$RCT_METRO_PORT + +if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then + if ! curl -s "http://localhost:${RCT_METRO_PORT}/status" | grep -q "packager-status:running" ; then + echo "Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly" + exit 2 + fi +else + CMD="$THIS_DIR/run-packager-helper.command" + if [[ `uname` == "Darwin" ]]; then + open -g "${CMD}" || echo "Can't start packager automatically" + else + xdg-open "${CMD}" || echo "Can't start packager automatically" + fi +fi diff --git a/android/sdk/.classpath b/android/sdk/.classpath new file mode 100644 index 0000000..eb19361 --- /dev/null +++ b/android/sdk/.classpath @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/sdk/.project b/android/sdk/.project new file mode 100644 index 0000000..4736e19 --- /dev/null +++ b/android/sdk/.project @@ -0,0 +1,23 @@ + + + sdk + Project sdk created by Buildship. + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.buildship.core.gradleprojectnature + + diff --git a/android/sdk/.settings/org.eclipse.buildship.core.prefs b/android/sdk/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..b1886ad --- /dev/null +++ b/android/sdk/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,2 @@ +connection.project.dir=.. +eclipse.preferences.version=1 diff --git a/android/sdk/build.gradle b/android/sdk/build.gradle new file mode 100644 index 0000000..e17903d --- /dev/null +++ b/android/sdk/build.gradle @@ -0,0 +1,318 @@ +apply plugin: 'com.android.library' +apply plugin: 'maven-publish' + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + ndkVersion rootProject.ext.ndkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + buildConfigField "String", "SDK_VERSION", "\"$sdkVersion\"" + } + + buildTypes { + debug { + buildConfigField "boolean", "LIBRE_BUILD", "${rootProject.ext.libreBuild}" + buildConfigField "boolean", "GOOGLE_SERVICES_ENABLED", "${rootProject.ext.googleServicesEnabled}" + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + buildConfigField "boolean", "LIBRE_BUILD", "${rootProject.ext.libreBuild}" + buildConfigField "boolean", "GOOGLE_SERVICES_ENABLED", "${rootProject.ext.googleServicesEnabled}" + } + } + + sourceSets { + main { + java { + exclude "test/" + } + } + } + namespace 'org.jitsi.meet.sdk' +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'androidx.fragment:fragment:1.4.1' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + + api "com.facebook.react:react-android:$rootProject.ext.rnVersion" + api "com.facebook.react:hermes-android:$rootProject.ext.rnVersion" + + implementation 'com.dropbox.core:dropbox-core-sdk:4.0.1' + implementation 'com.jakewharton.timber:timber:5.0.1' + implementation 'com.google.code.gson:gson:2.8.6' + implementation 'androidx.startup:startup-runtime:1.1.0' + implementation 'com.google.j2objc:j2objc-annotations:3.0.0' + + // Only add these packages if we are NOT doing a LIBRE_BUILD + if (!rootProject.ext.libreBuild) { + implementation project(':react-native-amplitude') + implementation project(':react-native-giphy') + implementation(project(':react-native-google-signin')) { + exclude group: 'com.google.android.gms' + exclude group: 'androidx' + } + } + + implementation project(':react-native-async-storage') + implementation project(':react-native-background-timer') + implementation project(':react-native-calendar-events') + implementation project(':react-native-community_clipboard') + implementation project(':react-native-community_netinfo') + implementation project(':react-native-default-preference') + implementation(project(':react-native-device-info')) { + exclude group: 'com.google.firebase' + exclude group: 'com.google.android.gms' + exclude group: 'com.android.installreferrer' + } + implementation project(':react-native-gesture-handler') + implementation project(':react-native-get-random-values') + implementation project(':react-native-keep-awake') + implementation project(':react-native-orientation-locker') + implementation project(':react-native-pager-view') + implementation project(':react-native-performance') + implementation project(':react-native-safe-area-context') + implementation project(':react-native-screens') + implementation project(':react-native-slider') + implementation project(':react-native-sound') + implementation project(':react-native-splash-view') + implementation project(':react-native-svg') + implementation project(':react-native-video') + implementation project(':react-native-webview') + implementation project(':react-native-worklets-core') + + // Use `api` here so consumers can use WebRTCModuleOptions. + api project(':react-native-webrtc') + + testImplementation 'junit:junit:4.12' +} + + +// Here we bundle all assets, resources and React files. We cannot use the +// react.gradle file provided by react-native because it's designed to be used +// in an application (it taps into applicationVariants, but the SDK is a library +// so we need libraryVariants instead). +android.libraryVariants.all { def variant -> + // Create variant and target names + def targetName = variant.name.capitalize() + def targetPath = variant.dirName + + // React js bundle directories + def jsBundleDir = file("$buildDir/generated/assets/react/${targetPath}") + def resourcesDir = file("$buildDir/generated/res/react/${targetPath}") + + def jsBundleFile = file("$jsBundleDir/index.android.bundle") + + def currentBundleTask = tasks.create( + name: "bundle${targetName}JsAndAssets", + type: Exec) { + group = "react" + description = "bundle JS and assets for ${targetName}." + + // Create dirs if they are not there (e.g. the "clean" task just ran) + doFirst { + jsBundleDir.deleteDir() + jsBundleDir.mkdirs() + resourcesDir.deleteDir() + resourcesDir.mkdirs() + } + + // Set up inputs and outputs so gradle can cache the result + def reactRoot = file("${projectDir}/../../") + inputs.files fileTree(dir: reactRoot, excludes: ["android/**", "ios/**"]) + outputs.dir jsBundleDir + outputs.dir resourcesDir + + // Set up the call to the react-native cli + workingDir reactRoot + + // Set up dev mode + def devEnabled = !targetName.toLowerCase().contains("release") + + // Run the bundler + // Use full path to node to avoid PATH issues in Gradle + def nodePath = System.getenv('NVM_BIN') ? "${System.getenv('NVM_BIN')}/node" : "node" + + // Debug: Print the node path and environment + println "Using node path: ${nodePath}" + println "NVM_BIN: ${System.getenv('NVM_BIN')}" + println "Working directory: ${reactRoot}" + + commandLine( + nodePath, + "node_modules/react-native/scripts/bundle.js", + "--platform", "android", + "--dev", "${devEnabled}", + "--reset-cache", + "--entry-file", "index.android.js", + "--bundle-output", jsBundleFile, + "--assets-dest", resourcesDir) + + // Disable bundling on dev builds + enabled !devEnabled + } + + // GRADLE REQUIREMENTS (Gradle 8.7+ / AGP 8.5.0+): + + // This task requires explicit dependencies on resource tasks from all React Native modules + // due to Gradle's strict validation of task dependencies. + + // Without these dependencies, + // builds will fail with errors like: + // "Task ':sdk:bundleReleaseJsAndAssets' uses the output of task ':react-native-amplitude:packageReleaseResources' + // without declaring a dependency on it." + + // The automatic dependency resolution below ensures all required resource tasks are properly + // declared as dependencies before this task executes. + + if (variant.name.toLowerCase().contains("release")) { + rootProject.subprojects.each { subproject -> + if ( + subproject.name.startsWith("react-native-") || + subproject.name.startsWith("@react-native-") || + subproject.name.startsWith("@giphy/") + ) { + [ + "packageReleaseResources", + "generateReleaseResValues", + "generateReleaseResources", + "generateReleaseBuildConfig", + "processReleaseManifest", + "writeReleaseAarMetadata", + "generateReleaseRFile", + "compileReleaseLibraryResources", + "compileReleaseJavaWithJavac", + "javaPreCompileRelease", + "bundleLibCompileToJarRelease", + "exportReleaseConsumerProguardFiles", + "mergeReleaseGeneratedProguardFiles", + "mergeReleaseJniLibFolders", + "mergeReleaseShaders", + "packageReleaseAssets", + "processReleaseJavaRes", + "prepareReleaseArtProfile", + "copyReleaseJniLibsProjectOnly", + "extractDeepLinksRelease", + "createFullJarRelease", + "generateReleaseLintModel", + "writeReleaseLintModelMetadata", + "generateReleaseLintVitalModel", + "lintVitalAnalyzeRelease", + "lintReportRelease", + "lintAnalyzeRelease", + "lintReportDebug", + "lintAnalyzeDebug" + ].each { taskName -> + if (subproject.tasks.findByName(taskName)) { + currentBundleTask.dependsOn(subproject.tasks.named(taskName)) + } + } + + // Also depend on the main build task to ensure all sub-tasks are completed + if (subproject.tasks.findByName("build")) { + currentBundleTask.dependsOn(subproject.tasks.named("build")) + } + } + } + } + + currentBundleTask.ext.generatedResFolders = files(resourcesDir).builtBy(currentBundleTask) + currentBundleTask.ext.generatedAssetsFolders = files(jsBundleDir).builtBy(currentBundleTask) + variant.registerGeneratedResFolders(currentBundleTask.generatedResFolders) + + def mergeAssetsTask = variant.mergeAssetsProvider.get() + def mergeResourcesTask = variant.mergeResourcesProvider.get() + + mergeAssetsTask.dependsOn(currentBundleTask) + mergeResourcesTask.dependsOn(currentBundleTask) + + mergeAssetsTask.doLast { + def assetsDir = mergeAssetsTask.outputDir.get() + + // Bundle sounds + // + copy { + from("${projectDir}/../../sounds") + include("*.wav") + include("*.mp3") + into("${assetsDir}/sounds") + } + + // Copy React assets + // + if (currentBundleTask.enabled) { + copy { + from(jsBundleFile) + into(assetsDir) + } + } + } + + mergeResourcesTask.doLast { + // Copy React resources + // + if (currentBundleTask.enabled) { + copy { + from(resourcesDir) + into(mergeResourcesTask.outputDir.get()) + } + } + } +} + + +publishing { + publications { + aarArchive(MavenPublication) { + groupId 'org.jitsi.react' + artifactId 'jitsi-meet-sdk' + version System.env.OVERRIDE_SDK_VERSION ?: project.sdkVersion + + artifact("${project.buildDir}/outputs/aar/${project.name}-release.aar") { + extension "aar" + } + pom.withXml { + def pomXml = asNode() + pomXml.appendNode('name', 'jitsi-meet-sdk') + pomXml.appendNode('description', 'Jitsi Meet SDK for Android') + def dependencies = pomXml.appendNode('dependencies') + configurations.getByName('releaseCompileClasspath').getResolvedConfiguration().getFirstLevelModuleDependencies().each { + // The (third-party) React Native modules that we depend on + // are in source code form and do not have groupId. That is + // why we have a dedicated groupId for them. But the other + // dependencies come through Maven and, consequently, have + // groupId. + def groupId = it.moduleGroup + def artifactId = it.moduleName + + if (artifactId.startsWith('react-native-')) { + groupId = rootProject.ext.moduleGroupId + } + + def dependency = dependencies.appendNode('dependency') + dependency.appendNode('groupId', groupId) + dependency.appendNode('artifactId', artifactId) + dependency.appendNode('version', it.moduleVersion) + } + } + } + + } + repositories { + maven { + url rootProject.ext.mavenRepo + if (!rootProject.ext.mavenRepo.startsWith("file")) { + credentials { + username rootProject.ext.mavenUser + password rootProject.ext.mavenPassword + } + } + } + } +} diff --git a/android/sdk/src/debug/AndroidManifest.xml b/android/sdk/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..b239f19 --- /dev/null +++ b/android/sdk/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/sdk/src/main/AndroidManifest.xml b/android/sdk/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d869c4e --- /dev/null +++ b/android/sdk/src/main/AndroidManifest.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/AndroidSettingsModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/AndroidSettingsModule.java new file mode 100644 index 0000000..5d67388 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/AndroidSettingsModule.java @@ -0,0 +1,56 @@ +/** + * Adapted from + * {@link https://github.com/Aleksandern/react-native-android-settings-library}. + */ + +package org.jitsi.meet.sdk; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.provider.Settings; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.module.annotations.ReactModule; + +@ReactModule(name = AndroidSettingsModule.NAME) +class AndroidSettingsModule + extends ReactContextBaseJavaModule { + + public static final String NAME = "AndroidSettings"; + + public AndroidSettingsModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return NAME; + } + + @ReactMethod + public void open(Promise promise) { + Context context = getReactApplicationContext(); + Intent intent = new Intent(); + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData( + Uri.fromParts("package", context.getPackageName(), null)); + + try { + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + // Some devices may give an error here. + // https://developer.android.com/reference/android/provider/Settings.html#ACTION_APPLICATION_DETAILS_SETTINGS + promise.reject(e); + return; + } + + promise.resolve(null); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/AppInfoModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/AppInfoModule.java new file mode 100644 index 0000000..7377fc1 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/AppInfoModule.java @@ -0,0 +1,150 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.module.annotations.ReactModule; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +@ReactModule(name = AppInfoModule.NAME) +class AppInfoModule + extends ReactContextBaseJavaModule { + + private static final String BUILD_CONFIG = "org.jitsi.meet.sdk.BuildConfig"; + public static final String NAME = "AppInfo"; + public static final boolean GOOGLE_SERVICES_ENABLED = getGoogleServicesEnabled(); + public static final boolean LIBRE_BUILD = getLibreBuild(); + public static final String SDK_VERSION = getSdkVersion(); + + public AppInfoModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + /** + * Gets a {@code Map} of constants this module exports to JS. Supports JSON + * types. + * + * @return a {@link Map} of constants this module exports to JS + */ + @Override + public Map getConstants() { + Context context = getReactApplicationContext(); + PackageManager packageManager = context.getPackageManager(); + ApplicationInfo applicationInfo; + PackageInfo packageInfo; + + try { + String packageName = context.getPackageName(); + + applicationInfo + = packageManager.getApplicationInfo(packageName, 0); + packageInfo = packageManager.getPackageInfo(packageName, 0); + } catch (PackageManager.NameNotFoundException e) { + applicationInfo = null; + packageInfo = null; + } + + Map constants = new HashMap<>(); + + constants.put( + "buildNumber", + packageInfo == null ? "" : String.valueOf(packageInfo.versionCode)); + constants.put( + "name", + applicationInfo == null + ? "" + : packageManager.getApplicationLabel(applicationInfo)); + constants.put( + "version", + packageInfo == null ? "" : packageInfo.versionName); + constants.put("sdkVersion", SDK_VERSION); + constants.put("LIBRE_BUILD", LIBRE_BUILD); + constants.put("GOOGLE_SERVICES_ENABLED", GOOGLE_SERVICES_ENABLED); + + return constants; + } + + @Override + public String getName() { + return NAME; + } + + /** + * Checks if libre google services object is null based on build configuration. + */ + private static boolean getGoogleServicesEnabled() { + Object googleServicesEnabled = getBuildConfigValue("GOOGLE_SERVICES_ENABLED"); + + if (googleServicesEnabled !=null) { + return (Boolean) googleServicesEnabled; + } + + return false; + } + + /** + * Checks if libre build field is null based on build configuration. + */ + private static boolean getLibreBuild() { + Object libreBuild = getBuildConfigValue("LIBRE_BUILD"); + + if (libreBuild !=null) { + return (Boolean) libreBuild; + } + + return false; + } + + /** + * Gets the SDK version. + */ + private static String getSdkVersion() { + Object sdkVersion = getBuildConfigValue("SDK_VERSION"); + + if (sdkVersion !=null) { + return (String) sdkVersion; + } + + return ""; + } + + /** + * Gets build config value of a certain field. + * + * @param fieldName Field from build config. + */ + private static Object getBuildConfigValue(String fieldName) { + try { + Class c = Class.forName(BUILD_CONFIG); + Field f = c.getDeclaredField(fieldName); + f.setAccessible(true); + return f.get(null); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioDeviceHandlerConnectionService.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioDeviceHandlerConnectionService.java new file mode 100644 index 0000000..9b5bb1f --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioDeviceHandlerConnectionService.java @@ -0,0 +1,185 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.content.Context; +import android.media.AudioManager; +import android.telecom.CallAudioState; +import androidx.annotation.RequiresApi; + +import com.facebook.react.bridge.ReactContext; + +import java.util.HashSet; +import java.util.Set; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + + +/** + * {@link AudioModeModule.AudioDeviceHandlerInterface} module implementing device handling for + * Android versions >= O when ConnectionService is enabled. + */ +class AudioDeviceHandlerConnectionService implements + AudioModeModule.AudioDeviceHandlerInterface, + RNConnectionService.CallAudioStateListener { + + private final static String TAG = AudioDeviceHandlerConnectionService.class.getSimpleName(); + + /** + * {@link AudioManager} instance used to interact with the Android audio subsystem. + */ + private AudioManager audioManager; + + /** + * Reference to the main {@code AudioModeModule}. + */ + private AudioModeModule module; + + private RNConnectionService rcs; + + /** + * Converts any of the "DEVICE_" constants into the corresponding + * {@link android.telecom.CallAudioState} "ROUTE_" number. + * + * @param audioDevice one of the "DEVICE_" constants. + * @return a route number {@link android.telecom.CallAudioState#ROUTE_EARPIECE} if + * no match is found. + */ + private static int audioDeviceToRouteInt(String audioDevice) { + if (audioDevice == null) { + return CallAudioState.ROUTE_SPEAKER; + } + switch (audioDevice) { + case AudioModeModule.DEVICE_BLUETOOTH: + return CallAudioState.ROUTE_BLUETOOTH; + case AudioModeModule.DEVICE_EARPIECE: + return CallAudioState.ROUTE_EARPIECE; + case AudioModeModule.DEVICE_HEADPHONES: + return CallAudioState.ROUTE_WIRED_HEADSET; + case AudioModeModule.DEVICE_SPEAKER: + return CallAudioState.ROUTE_SPEAKER; + default: + JitsiMeetLogger.e(TAG + " Unsupported device name: " + audioDevice); + return CallAudioState.ROUTE_SPEAKER; + } + } + + /** + * Populates given route mask into the "DEVICE_" list. + * + * @param supportedRouteMask an integer coming from + * {@link android.telecom.CallAudioState#getSupportedRouteMask()}. + * @return a list of device names. + */ + private static Set routesToDeviceNames(int supportedRouteMask) { + Set devices = new HashSet<>(); + if ((supportedRouteMask & CallAudioState.ROUTE_EARPIECE) == CallAudioState.ROUTE_EARPIECE) { + devices.add(AudioModeModule.DEVICE_EARPIECE); + } + if ((supportedRouteMask & CallAudioState.ROUTE_BLUETOOTH) == CallAudioState.ROUTE_BLUETOOTH) { + devices.add(AudioModeModule.DEVICE_BLUETOOTH); + } + if ((supportedRouteMask & CallAudioState.ROUTE_SPEAKER) == CallAudioState.ROUTE_SPEAKER) { + devices.add(AudioModeModule.DEVICE_SPEAKER); + } + if ((supportedRouteMask & CallAudioState.ROUTE_WIRED_HEADSET) == CallAudioState.ROUTE_WIRED_HEADSET) { + devices.add(AudioModeModule.DEVICE_HEADPHONES); + } + return devices; + } + + /** + * Used to store the most recently reported audio devices. + * Makes it easier to compare for a change, because the devices are stored + * as a mask in the {@link android.telecom.CallAudioState}. The mask is populated into + * the {@code availableDevices} on each update. + */ + private int supportedRouteMask = -1; + + public AudioDeviceHandlerConnectionService(AudioManager audioManager) { + this.audioManager = audioManager; + } + + @Override + public void onCallAudioStateChange(final CallAudioState state) { + module.runInAudioThread(new Runnable() { + @Override + public void run() { + boolean audioRouteChanged + = audioDeviceToRouteInt(module.getSelectedDevice()) != state.getRoute(); + int newSupportedRoutes = state.getSupportedRouteMask(); + boolean audioDevicesChanged = supportedRouteMask != newSupportedRoutes; + if (audioDevicesChanged) { + supportedRouteMask = newSupportedRoutes; + Set devices = routesToDeviceNames(supportedRouteMask); + module.replaceDevices(devices); + JitsiMeetLogger.i(TAG + " Available audio devices: " + devices.toString()); + } + + if (audioRouteChanged || audioDevicesChanged) { + module.resetSelectedDevice(); + module.updateAudioRoute(); + } + } + }); + } + + @Override + public void start(AudioModeModule audioModeModule) { + JitsiMeetLogger.i("Using " + TAG + " as the audio device handler"); + + module = audioModeModule; + rcs = module.getContext().getNativeModule(RNConnectionService.class); + + if (rcs != null) { + rcs.setCallAudioStateListener(this); + } else { + JitsiMeetLogger.w(TAG + " Couldn't set call audio state listener, module is null"); + } + } + + @Override + public void stop() { + if (rcs != null) { + rcs.setCallAudioStateListener(null); + rcs = null; + } else { + JitsiMeetLogger.w(TAG + " Couldn't set call audio state listener, module is null"); + } + } + + public void setAudioRoute(String audioDevice) { + int newAudioRoute = audioDeviceToRouteInt(audioDevice); + + RNConnectionService.setAudioRoute(newAudioRoute); + } + + @Override + public boolean setMode(int mode) { + if (mode != AudioModeModule.DEFAULT) { + // This shouldn't be needed when using ConnectionService, but some devices have been + // observed not doing it. + try { + audioManager.setMicrophoneMute(false); + } catch (Throwable tr) { + JitsiMeetLogger.w(tr, TAG + " Failed to unmute the microphone"); + } + } + + return true; + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioDeviceHandlerGeneric.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioDeviceHandlerGeneric.java new file mode 100644 index 0000000..000cf92 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioDeviceHandlerGeneric.java @@ -0,0 +1,248 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.media.AudioAttributes; +import android.media.AudioDeviceInfo; +import android.media.AudioFocusRequest; +import android.media.AudioManager; + +import java.util.HashSet; +import java.util.Set; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + + +/** + * {@link AudioModeModule.AudioDeviceHandlerInterface} module implementing device handling for + * all post-M Android versions. This handler can be used on any Android versions >= M, but by + * default it's only used on versions < O, since versions >= O use ConnectionService, but it + * can be disabled. + */ +class AudioDeviceHandlerGeneric implements + AudioModeModule.AudioDeviceHandlerInterface, + AudioManager.OnAudioFocusChangeListener { + + private final static String TAG = AudioDeviceHandlerGeneric.class.getSimpleName(); + + /** + * Reference to the main {@code AudioModeModule}. + */ + private AudioModeModule module; + + /** + * Constant defining a Hearing Aid. Only available on API level >= 28. + * The value of: AudioDeviceInfo.TYPE_HEARING_AID + */ + private static final int TYPE_HEARING_AID = 23; + + /** + * Constant defining a USB headset. Only available on API level >= 26. + * The value of: AudioDeviceInfo.TYPE_USB_HEADSET + */ + private static final int TYPE_USB_HEADSET = 22; + + /** + * Indicator that we have lost audio focus. + */ + private boolean audioFocusLost = false; + + /** + * {@link AudioManager} instance used to interact with the Android audio + * subsystem. + */ + private AudioManager audioManager; + + /** + * {@link Runnable} for running audio device detection in the audio thread. + * This is only used on Android >= M. + */ + private final Runnable onAudioDeviceChangeRunner = new Runnable() { + @Override + public void run() { + Set devices = new HashSet<>(); + AudioDeviceInfo[] deviceInfos = audioManager.getDevices(AudioManager.GET_DEVICES_ALL); + + for (AudioDeviceInfo info: deviceInfos) { + switch (info.getType()) { + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + devices.add(AudioModeModule.DEVICE_BLUETOOTH); + break; + case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE: + devices.add(AudioModeModule.DEVICE_EARPIECE); + break; + case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER: + case AudioDeviceInfo.TYPE_HDMI: + devices.add(AudioModeModule.DEVICE_SPEAKER); + break; + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + case TYPE_HEARING_AID: + case TYPE_USB_HEADSET: + devices.add(AudioModeModule.DEVICE_HEADPHONES); + break; + } + } + + module.replaceDevices(devices); + + JitsiMeetLogger.i(TAG + " Available audio devices: " + devices.toString()); + + module.updateAudioRoute(); + } + }; + + private final android.media.AudioDeviceCallback audioDeviceCallback = + new android.media.AudioDeviceCallback() { + @Override + public void onAudioDevicesAdded( + AudioDeviceInfo[] addedDevices) { + JitsiMeetLogger.d(TAG + " Audio devices added"); + onAudioDeviceChange(); + } + + @Override + public void onAudioDevicesRemoved( + AudioDeviceInfo[] removedDevices) { + JitsiMeetLogger.d(TAG + " Audio devices removed"); + onAudioDeviceChange(); + } + }; + + public AudioDeviceHandlerGeneric(AudioManager audioManager) { + this.audioManager = audioManager; + } + + /** + * Helper method to trigger an audio route update when devices change. It + * makes sure the operation is performed on the audio thread. + */ + private void onAudioDeviceChange() { + module.runInAudioThread(onAudioDeviceChangeRunner); + } + + /** + * {@link AudioManager.OnAudioFocusChangeListener} interface method. Called + * when the audio focus of the system is updated. + * + * @param focusChange - The type of focus change. + */ + @Override + public void onAudioFocusChange(final int focusChange) { + module.runInAudioThread(new Runnable() { + @Override + public void run() { + switch (focusChange) { + case AudioManager.AUDIOFOCUS_GAIN: { + JitsiMeetLogger.d(TAG + " Audio focus gained"); + // Some other application potentially stole our audio focus + // temporarily. Restore our mode. + if (audioFocusLost) { + module.resetAudioRoute(); + } + audioFocusLost = false; + break; + } + case AudioManager.AUDIOFOCUS_LOSS: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: { + JitsiMeetLogger.d(TAG + " Audio focus lost"); + audioFocusLost = true; + break; + } + } + } + }); + } + + /** + * Helper method to set the output route to a Bluetooth device. + * + * @param enabled true if Bluetooth should use used, false otherwise. + */ + private void setBluetoothAudioRoute(boolean enabled) { + if (enabled) { + audioManager.startBluetoothSco(); + audioManager.setBluetoothScoOn(true); + } else { + audioManager.setBluetoothScoOn(false); + audioManager.stopBluetoothSco(); + } + } + + @Override + public void start(AudioModeModule audioModeModule) { + JitsiMeetLogger.i("Using " + TAG + " as the audio device handler"); + + module = audioModeModule; + + // Setup runtime device change detection. + audioManager.registerAudioDeviceCallback(audioDeviceCallback, null); + + // Do an initial detection. + onAudioDeviceChange(); + } + + @Override + public void stop() { + audioManager.unregisterAudioDeviceCallback(audioDeviceCallback); + } + + @Override + public void setAudioRoute(String device) { + // Turn speaker on / off + audioManager.setSpeakerphoneOn(device.equals(AudioModeModule.DEVICE_SPEAKER)); + + // Turn bluetooth on / off + setBluetoothAudioRoute(device.equals(AudioModeModule.DEVICE_BLUETOOTH)); + } + + @Override + public boolean setMode(int mode) { + if (mode == AudioModeModule.DEFAULT) { + audioFocusLost = false; + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.abandonAudioFocus(this); + audioManager.setSpeakerphoneOn(false); + setBluetoothAudioRoute(false); + + return true; + } + + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + audioManager.setMicrophoneMute(false); + + int gotFocus = audioManager.requestAudioFocus(new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + ) + .setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(this) + .build() + ); + + if (gotFocus == AudioManager.AUDIOFOCUS_REQUEST_FAILED) { + JitsiMeetLogger.w(TAG + " Audio focus request failed"); + return false; + } + + return true; + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java new file mode 100644 index 0000000..95c1241 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java @@ -0,0 +1,529 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.media.AudioManager; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.modules.core.DeviceEventManagerModule; + + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Module implementing a simple API to select the appropriate audio device for a + * conference call. + * + * Audio calls should use {@code AudioModeModule.AUDIO_CALL}, which uses the + * builtin earpiece, wired headset or bluetooth headset. The builtin earpiece is + * the default audio device. + * + * Video calls should should use {@code AudioModeModule.VIDEO_CALL}, which uses + * the builtin speaker, earpiece, wired headset or bluetooth headset. The + * builtin speaker is the default audio device. + * + * Before a call has started and after it has ended the + * {@code AudioModeModule.DEFAULT} mode should be used. + */ +@ReactModule(name = AudioModeModule.NAME) +class AudioModeModule extends ReactContextBaseJavaModule { + public static final String NAME = "AudioMode"; + + /** + * Constants representing the audio mode. + * - DEFAULT: Used before and after every call. It represents the default + * audio routing scheme. + * - AUDIO_CALL: Used for audio only calls. It will use the earpiece by + * default, unless a wired or Bluetooth headset is connected. + * - VIDEO_CALL: Used for video calls. It will use the speaker by default, + * unless a wired or Bluetooth headset is connected. + */ + static final int DEFAULT = 0; + static final int AUDIO_CALL = 1; + static final int VIDEO_CALL = 2; + + /** + * The {@code Log} tag {@code AudioModeModule} is to log messages with. + */ + static final String TAG = NAME; + + /** + * Whether or not the ConnectionService is used for selecting audio devices. + */ + private static boolean useConnectionService_ = true; + + static boolean useConnectionService() { + return useConnectionService_; + } + + /** + * {@link AudioManager} instance used to interact with the Android audio + * subsystem. + */ + private AudioManager audioManager; + + private AudioDeviceHandlerInterface audioDeviceHandler; + + /** + * {@link ExecutorService} for running all audio operations on a dedicated + * thread. + */ + private static final ExecutorService executor = Executors.newSingleThreadExecutor(); + + /** + * Audio mode currently in use. + */ + private int mode = -1; + + /** + * Audio device types. + */ + static final String DEVICE_BLUETOOTH = "BLUETOOTH"; + static final String DEVICE_EARPIECE = "EARPIECE"; + static final String DEVICE_HEADPHONES = "HEADPHONES"; + static final String DEVICE_SPEAKER = "SPEAKER"; + + /** + * Device change event. + */ + private static final String DEVICE_CHANGE_EVENT = "org.jitsi.meet:features/audio-mode#devices-update"; + + /** + * List of currently available audio devices. + */ + private Set availableDevices = new HashSet<>(); + + /** + * Currently selected device. + */ + private String selectedDevice; + + /** + * User selected device. When null the default is used depending on the + * mode. + */ + private String userSelectedDevice; + + /** + * Whether or not audio is disabled. + */ + private boolean audioDisabled; + + /** + * Initializes a new module instance. There shall be a single instance of + * this module throughout the lifetime of the application. + * + * @param reactContext the {@link ReactApplicationContext} where this module + * is created. + */ + public AudioModeModule(ReactApplicationContext reactContext) { + super(reactContext); + + audioManager = (AudioManager)reactContext.getSystemService(Context.AUDIO_SERVICE); + } + + @ReactMethod + public void addListener(String eventName) { + // Keep: Required for RN built in Event Emitter Calls. + } + + @ReactMethod + public void removeListeners(Integer count) { + // Keep: Required for RN built in Event Emitter Calls. + } + + /** + * Gets a mapping with the constants this module is exporting. + * + * @return a {@link Map} mapping the constants to be exported with their + * values. + */ + @Override + public Map getConstants() { + Map constants = new HashMap<>(); + + constants.put("DEVICE_CHANGE_EVENT", DEVICE_CHANGE_EVENT); + constants.put("AUDIO_CALL", AUDIO_CALL); + constants.put("DEFAULT", DEFAULT); + constants.put("VIDEO_CALL", VIDEO_CALL); + + return constants; + } + + /** + * Notifies JS land that the devices list has changed. + */ + private void notifyDevicesChanged() { + runInAudioThread(new Runnable() { + @Override + public void run() { + WritableArray data = Arguments.createArray(); + final boolean hasHeadphones = availableDevices.contains(DEVICE_HEADPHONES); + for (String device : availableDevices) { + if (hasHeadphones && device.equals(DEVICE_EARPIECE)) { + // Skip earpiece when headphones are plugged in. + continue; + } + WritableMap deviceInfo = Arguments.createMap(); + deviceInfo.putString("type", device); + deviceInfo.putBoolean("selected", device.equals(selectedDevice)); + data.pushMap(deviceInfo); + } + getContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(DEVICE_CHANGE_EVENT, data); + JitsiMeetLogger.i(TAG + " Updating audio device list"); + } + }); + } + + /** + * Gets the name for this module to be used in the React Native bridge. + * + * @return a string with the module name. + */ + @Override + public String getName() { + return NAME; + } + + public ReactContext getContext(){ + return this.getReactApplicationContext(); + } + + /** + * Initializes the audio device handler module. This function is called *after* all Catalyst + * modules have been created, and that's why we use it, because {@link AudioDeviceHandlerConnectionService} + * needs access to another Catalyst module, so doing this in the constructor would be too early. + */ + @Override + public void initialize() { + runInAudioThread(new Runnable() { + @Override + public void run() { + setAudioDeviceHandler(); + } + }); + } + + private void setAudioDeviceHandler() { + if (audioDeviceHandler != null) { + audioDeviceHandler.stop(); + } + + audioDeviceHandler = null; + + if (audioDisabled) { + return; + } + + if (useConnectionService()) { + audioDeviceHandler = new AudioDeviceHandlerConnectionService(audioManager); + } else { + audioDeviceHandler = new AudioDeviceHandlerGeneric(audioManager); + } + + audioDeviceHandler.start(this); + } + + /** + * Helper function to run operations on a dedicated thread. + * @param runnable + */ + void runInAudioThread(Runnable runnable) { + executor.execute(runnable); + } + + /** + * Sets the user selected audio device as the active audio device. + * + * @param device the desired device which will become active. + */ + @ReactMethod + public void setAudioDevice(final String device) { + runInAudioThread(new Runnable() { + @Override + public void run() { + if (!availableDevices.contains(device)) { + JitsiMeetLogger.w(TAG + " Audio device not available: " + device); + userSelectedDevice = null; + return; + } + + if (mode != -1) { + JitsiMeetLogger.i(TAG + " User selected device set to: " + device); + userSelectedDevice = device; + updateAudioRoute(mode, false); + } + } + }); + } + + @ReactMethod + public void setDisabled(final boolean disabled, final Promise promise) { + if (audioDisabled == disabled) { + promise.resolve(null); + return; + } + + JitsiMeetLogger.i(TAG + " audio disabled: " + disabled); + + audioDisabled = disabled; + setAudioDeviceHandler(); + + if (disabled) { + mode = -1; + availableDevices.clear(); + resetSelectedDevice(); + } + + promise.resolve(null); + } + + /** + * Public method to set the current audio mode. + * + * @param mode the desired audio mode. + * @param promise a {@link Promise} which will be resolved if the audio mode + * could be updated successfully, and it will be rejected otherwise. + */ + @ReactMethod + public void setMode(final int mode, final Promise promise) { + if (audioDisabled) { + promise.resolve(null); + return; + } + + if (mode < DEFAULT || mode > VIDEO_CALL) { + promise.reject("setMode", "Invalid audio mode " + mode); + return; + } + + Activity currentActivity = getCurrentActivity(); + if (currentActivity != null) { + if (mode == DEFAULT) { + currentActivity.setVolumeControlStream(AudioManager.USE_DEFAULT_STREAM_TYPE); + } else { + currentActivity.setVolumeControlStream(AudioManager.STREAM_VOICE_CALL); + } + } + + runInAudioThread(new Runnable() { + @Override + public void run() { + boolean success; + + try { + success = updateAudioRoute(mode, false); + } catch (Throwable e) { + success = false; + JitsiMeetLogger.e(e, TAG + " Failed to update audio route for mode: " + mode); + } + if (success) { + AudioModeModule.this.mode = mode; + promise.resolve(null); + } else { + promise.reject("setMode", "Failed to set audio mode to " + mode); + } + } + }); + } + + /** + * Sets whether ConnectionService should be used (if available) for setting the audio mode + * or not. + * + * @param use Boolean indicator of where it should be used or not. + */ + @ReactMethod + public void setUseConnectionService(final boolean use) { + runInAudioThread(new Runnable() { + @Override + public void run() { + useConnectionService_ = use; + setAudioDeviceHandler(); + } + }); + } + + /** + * Updates the audio route for the given mode. + * + * @param mode the audio mode to be used when computing the audio route. + * @return {@code true} if the audio route was updated successfully; + * {@code false}, otherwise. + */ + private boolean updateAudioRoute(int mode, boolean force) { + JitsiMeetLogger.i(TAG + " Update audio route for mode: " + mode); + + if (!audioDeviceHandler.setMode(mode)) { + return false; + } + + if (mode == DEFAULT) { + selectedDevice = null; + userSelectedDevice = null; + + notifyDevicesChanged(); + return true; + } + + boolean bluetoothAvailable = availableDevices.contains(DEVICE_BLUETOOTH); + boolean headsetAvailable = availableDevices.contains(DEVICE_HEADPHONES); + + // Pick the desired device based on what's available and the mode. + String audioDevice; + if (bluetoothAvailable) { + audioDevice = DEVICE_BLUETOOTH; + } else if (headsetAvailable) { + audioDevice = DEVICE_HEADPHONES; + } else { + audioDevice = DEVICE_SPEAKER; + } + + // Consider the user's selection + if (userSelectedDevice != null && availableDevices.contains(userSelectedDevice)) { + audioDevice = userSelectedDevice; + } + + // If the previously selected device and the current default one + // match, do nothing. + if (!force && selectedDevice != null && selectedDevice.equals(audioDevice)) { + return true; + } + + selectedDevice = audioDevice; + JitsiMeetLogger.i(TAG + " Selected audio device: " + audioDevice); + + audioDeviceHandler.setAudioRoute(audioDevice); + + notifyDevicesChanged(); + return true; + } + + /** + * Gets the currently selected audio device. + * + * @return The selected audio device. + */ + String getSelectedDevice() { + return selectedDevice; + } + + /** + * Resets the current device selection. + */ + void resetSelectedDevice() { + selectedDevice = null; + userSelectedDevice = null; + } + + /** + * Adds a new device to the list of available devices. + * + * @param device The new device. + */ + void addDevice(String device) { + availableDevices.add(device); + resetSelectedDevice(); + } + + /** + * Removes a device from the list of available devices. + * + * @param device The old device to the removed. + */ + void removeDevice(String device) { + availableDevices.remove(device); + resetSelectedDevice(); + } + + /** + * Replaces the current list of available devices with a new one. + * + * @param devices The new devices list. + */ + void replaceDevices(Set devices) { + availableDevices = devices; + resetSelectedDevice(); + } + + /** + * Re-sets the current audio route. Needed when devices changes have happened. + */ + void updateAudioRoute() { + if (mode != -1) { + updateAudioRoute(mode, false); + } + } + + /** + * Re-sets the current audio route. Needed when focus is lost and regained. + */ + void resetAudioRoute() { + if (mode != -1) { + updateAudioRoute(mode, true); + } + } + + /** + * Interface for the modules implementing the actual audio device management. + */ + interface AudioDeviceHandlerInterface { + /** + * Start detecting audio device changes. + * @param audioModeModule Reference to the main {@link AudioModeModule}. + */ + void start(AudioModeModule audioModeModule); + + /** + * Stop audio device detection. + */ + void stop(); + + /** + * Set the appropriate route for the given audio device. + * + * @param device Audio device for which the route must be set. + */ + void setAudioRoute(String device); + + /** + * Set the given audio mode. + * + * @param mode The new audio mode to be used. + * @return Whether the operation was successful or not. + */ + boolean setMode(int mode); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java new file mode 100644 index 0000000..81af3cd --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java @@ -0,0 +1,66 @@ +package org.jitsi.meet.sdk; + +import android.content.Intent; +import android.os.Bundle; + +/** + * Wraps the name and extra data for events that were broadcasted locally. + */ +public class BroadcastAction { + private static final String TAG = BroadcastAction.class.getSimpleName(); + + private final Type type; + private final Bundle data; + + public BroadcastAction(Intent intent) { + this.type = Type.buildTypeFromAction(intent.getAction()); + this.data = intent.getExtras(); + } + + public Type getType() { + return this.type; + } + + public Bundle getData() { + return this.data; + } + + enum Type { + SET_AUDIO_MUTED("org.jitsi.meet.SET_AUDIO_MUTED"), + HANG_UP("org.jitsi.meet.HANG_UP"), + SEND_ENDPOINT_TEXT_MESSAGE("org.jitsi.meet.SEND_ENDPOINT_TEXT_MESSAGE"), + TOGGLE_SCREEN_SHARE("org.jitsi.meet.TOGGLE_SCREEN_SHARE"), + RETRIEVE_PARTICIPANTS_INFO("org.jitsi.meet.RETRIEVE_PARTICIPANTS_INFO"), + OPEN_CHAT("org.jitsi.meet.OPEN_CHAT"), + CLOSE_CHAT("org.jitsi.meet.CLOSE_CHAT"), + SEND_CHAT_MESSAGE("org.jitsi.meet.SEND_CHAT_MESSAGE"), + SET_VIDEO_MUTED("org.jitsi.meet.SET_VIDEO_MUTED"), + SET_CLOSED_CAPTIONS_ENABLED("org.jitsi.meet.SET_CLOSED_CAPTIONS_ENABLED"), + TOGGLE_CAMERA("org.jitsi.meet.TOGGLE_CAMERA"), + SHOW_NOTIFICATION("org.jitsi.meet.SHOW_NOTIFICATION"), + HIDE_NOTIFICATION("org.jitsi.meet.HIDE_NOTIFICATION"), + START_RECORDING("org.jitsi.meet.START_RECORDING"), + STOP_RECORDING("org.jitsi.meet.STOP_RECORDING"), + OVERWRITE_CONFIG("org.jitsi.meet.OVERWRITE_CONFIG"), + SEND_CAMERA_FACING_MODE_MESSAGE("org.jitsi.meet.SEND_CAMERA_FACING_MODE_MESSAGE"); + + private final String action; + + Type(String action) { + this.action = action; + } + + public String getAction() { + return action; + } + + private static Type buildTypeFromAction(String action) { + for (Type type : Type.values()) { + if (type.action.equalsIgnoreCase(action)) { + return type; + } + } + return null; + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEmitter.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEmitter.java new file mode 100644 index 0000000..bc6ed18 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEmitter.java @@ -0,0 +1,30 @@ +package org.jitsi.meet.sdk; + +import android.content.Context; +import android.content.Intent; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.facebook.react.bridge.ReadableMap; + +/** + * Class used to emit events through the LocalBroadcastManager, called when events + * from JS occurred. Takes an action name from JS, builds and broadcasts the {@link BroadcastEvent} + */ +public class BroadcastEmitter { + private final LocalBroadcastManager localBroadcastManager; + + public BroadcastEmitter(Context context) { + localBroadcastManager = LocalBroadcastManager.getInstance(context); + } + + public void sendBroadcast(String name, ReadableMap data) { + BroadcastEvent event = new BroadcastEvent(name, data); + + Intent intent = event.buildIntent(); + + if (intent != null) { + localBroadcastManager.sendBroadcast(intent); + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEvent.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEvent.java new file mode 100644 index 0000000..6a97671 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEvent.java @@ -0,0 +1,182 @@ +package org.jitsi.meet.sdk; + +import android.content.Intent; +import android.os.Bundle; + +import com.facebook.react.bridge.ReadableMap; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.HashMap; + +/** + * Wraps the name and extra data for the events that occur on the JS side and are + * to be broadcasted. + */ +public class BroadcastEvent { + + private static final String TAG = BroadcastEvent.class.getSimpleName(); + + private final Type type; + private final HashMap data; + + public BroadcastEvent(String name, ReadableMap data) { + this.type = Type.buildTypeFromName(name); + this.data = data.toHashMap(); + } + + public BroadcastEvent(Intent intent) { + this.type = Type.buildTypeFromAction(intent.getAction()); + this.data = buildDataFromBundle(intent.getExtras()); + } + + public Type getType() { + return this.type; + } + + public HashMap getData() { + return this.data; + } + + public Intent buildIntent() { + if (type != null && type.action != null) { + Intent intent = new Intent(type.action); + + for (String key : this.data.keySet()) { + try { + intent.putExtra(key, this.data.get(key).toString()); + } catch (Exception e) { + JitsiMeetLogger.w(TAG + " invalid extra data in event", e); + } + } + + return intent; + } + + return null; + } + + private static HashMap buildDataFromBundle(Bundle bundle) { + if (bundle != null) { + try { + HashMap map = new HashMap<>(); + + for (String key : bundle.keySet()) { + map.put(key, bundle.get(key)); + } + + return map; + } catch (Exception e) { + JitsiMeetLogger.w(TAG + " invalid extra data", e); + } + } + + return null; + } + + public enum Type { + CONFERENCE_BLURRED("org.jitsi.meet.CONFERENCE_BLURRED"), + CONFERENCE_FOCUSED("org.jitsi.meet.CONFERENCE_FOCUSED"), + CONFERENCE_JOINED("org.jitsi.meet.CONFERENCE_JOINED"), + CONFERENCE_TERMINATED("org.jitsi.meet.CONFERENCE_TERMINATED"), + CONFERENCE_WILL_JOIN("org.jitsi.meet.CONFERENCE_WILL_JOIN"), + AUDIO_MUTED_CHANGED("org.jitsi.meet.AUDIO_MUTED_CHANGED"), + PARTICIPANT_JOINED("org.jitsi.meet.PARTICIPANT_JOINED"), + PARTICIPANT_LEFT("org.jitsi.meet.PARTICIPANT_LEFT"), + ENDPOINT_TEXT_MESSAGE_RECEIVED("org.jitsi.meet.ENDPOINT_TEXT_MESSAGE_RECEIVED"), + SCREEN_SHARE_TOGGLED("org.jitsi.meet.SCREEN_SHARE_TOGGLED"), + PARTICIPANTS_INFO_RETRIEVED("org.jitsi.meet.PARTICIPANTS_INFO_RETRIEVED"), + CHAT_MESSAGE_RECEIVED("org.jitsi.meet.CHAT_MESSAGE_RECEIVED"), + CHAT_TOGGLED("org.jitsi.meet.CHAT_TOGGLED"), + VIDEO_MUTED_CHANGED("org.jitsi.meet.VIDEO_MUTED_CHANGED"), + READY_TO_CLOSE("org.jitsi.meet.READY_TO_CLOSE"), + TRANSCRIPTION_CHUNK_RECEIVED("org.jitsi.meet.TRANSCRIPTION_CHUNK_RECEIVED"), + CUSTOM_BUTTON_PRESSED("org.jitsi.meet.CUSTOM_BUTTON_PRESSED"), + CONFERENCE_UNIQUE_ID_SET("org.jitsi.meet.CONFERENCE_UNIQUE_ID_SET"), + RECORDING_STATUS_CHANGED("org.jitsi.meet.RECORDING_STATUS_CHANGED"); + + private static final String CONFERENCE_BLURRED_NAME = "CONFERENCE_BLURRED"; + private static final String CONFERENCE_FOCUSED_NAME = "CONFERENCE_FOCUSED"; + private static final String CONFERENCE_WILL_JOIN_NAME = "CONFERENCE_WILL_JOIN"; + private static final String CONFERENCE_JOINED_NAME = "CONFERENCE_JOINED"; + private static final String CONFERENCE_TERMINATED_NAME = "CONFERENCE_TERMINATED"; + private static final String AUDIO_MUTED_CHANGED_NAME = "AUDIO_MUTED_CHANGED"; + private static final String PARTICIPANT_JOINED_NAME = "PARTICIPANT_JOINED"; + private static final String PARTICIPANT_LEFT_NAME = "PARTICIPANT_LEFT"; + private static final String ENDPOINT_TEXT_MESSAGE_RECEIVED_NAME = "ENDPOINT_TEXT_MESSAGE_RECEIVED"; + private static final String SCREEN_SHARE_TOGGLED_NAME = "SCREEN_SHARE_TOGGLED"; + private static final String PARTICIPANTS_INFO_RETRIEVED_NAME = "PARTICIPANTS_INFO_RETRIEVED"; + private static final String CHAT_MESSAGE_RECEIVED_NAME = "CHAT_MESSAGE_RECEIVED"; + private static final String CHAT_TOGGLED_NAME = "CHAT_TOGGLED"; + private static final String VIDEO_MUTED_CHANGED_NAME = "VIDEO_MUTED_CHANGED"; + private static final String READY_TO_CLOSE_NAME = "READY_TO_CLOSE"; + private static final String TRANSCRIPTION_CHUNK_RECEIVED_NAME = "TRANSCRIPTION_CHUNK_RECEIVED"; + private static final String CUSTOM_BUTTON_PRESSED_NAME = "CUSTOM_BUTTON_PRESSED"; + private static final String CONFERENCE_UNIQUE_ID_SET_NAME = "CONFERENCE_UNIQUE_ID_SET"; + private static final String RECORDING_STATUS_CHANGED_NAME = "RECORDING_STATUS_CHANGED"; + + private final String action; + + Type(String action) { + this.action = action; + } + + public String getAction() { + return action; + } + + private static Type buildTypeFromAction(String action) { + for (Type type : Type.values()) { + if (type.action.equalsIgnoreCase(action)) { + return type; + } + } + return null; + } + + private static Type buildTypeFromName(String name) { + switch (name) { + case CONFERENCE_BLURRED_NAME: + return CONFERENCE_BLURRED; + case CONFERENCE_FOCUSED_NAME: + return CONFERENCE_FOCUSED; + case CONFERENCE_WILL_JOIN_NAME: + return CONFERENCE_WILL_JOIN; + case CONFERENCE_JOINED_NAME: + return CONFERENCE_JOINED; + case CONFERENCE_TERMINATED_NAME: + return CONFERENCE_TERMINATED; + case AUDIO_MUTED_CHANGED_NAME: + return AUDIO_MUTED_CHANGED; + case PARTICIPANT_JOINED_NAME: + return PARTICIPANT_JOINED; + case PARTICIPANT_LEFT_NAME: + return PARTICIPANT_LEFT; + case ENDPOINT_TEXT_MESSAGE_RECEIVED_NAME: + return ENDPOINT_TEXT_MESSAGE_RECEIVED; + case SCREEN_SHARE_TOGGLED_NAME: + return SCREEN_SHARE_TOGGLED; + case PARTICIPANTS_INFO_RETRIEVED_NAME: + return PARTICIPANTS_INFO_RETRIEVED; + case CHAT_MESSAGE_RECEIVED_NAME: + return CHAT_MESSAGE_RECEIVED; + case CHAT_TOGGLED_NAME: + return CHAT_TOGGLED; + case VIDEO_MUTED_CHANGED_NAME: + return VIDEO_MUTED_CHANGED; + case READY_TO_CLOSE_NAME: + return READY_TO_CLOSE; + case TRANSCRIPTION_CHUNK_RECEIVED_NAME: + return TRANSCRIPTION_CHUNK_RECEIVED; + case CUSTOM_BUTTON_PRESSED_NAME: + return CUSTOM_BUTTON_PRESSED; + case CONFERENCE_UNIQUE_ID_SET_NAME: + return CONFERENCE_UNIQUE_ID_SET; + case RECORDING_STATUS_CHANGED_NAME: + return RECORDING_STATUS_CHANGED; + } + + return null; + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java new file mode 100644 index 0000000..f912712 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java @@ -0,0 +1,157 @@ +package org.jitsi.meet.sdk; + +import android.content.Intent; +import android.os.Bundle; + +public class BroadcastIntentHelper { + public static Intent buildSetAudioMutedIntent(boolean muted) { + Intent intent = new Intent(BroadcastAction.Type.SET_AUDIO_MUTED.getAction()); + intent.putExtra("muted", muted); + + return intent; + } + + public static Intent buildHangUpIntent() { + return new Intent(BroadcastAction.Type.HANG_UP.getAction()); + } + + public static Intent buildSendEndpointTextMessageIntent(String to, String message) { + Intent intent = new Intent(BroadcastAction.Type.SEND_ENDPOINT_TEXT_MESSAGE.getAction()); + intent.putExtra("to", to); + intent.putExtra("message", message); + + return intent; + } + + public static Intent buildToggleScreenShareIntent(boolean enabled) { + Intent intent = new Intent(BroadcastAction.Type.TOGGLE_SCREEN_SHARE.getAction()); + intent.putExtra("enabled", enabled); + + return intent; + } + + public static Intent buildOpenChatIntent(String participantId) { + Intent intent = new Intent(BroadcastAction.Type.OPEN_CHAT.getAction()); + intent.putExtra("to", participantId); + + return intent; + } + + public static Intent buildCloseChatIntent() { + return new Intent(BroadcastAction.Type.CLOSE_CHAT.getAction()); + } + + public static Intent buildSendChatMessageIntent(String participantId, String message) { + Intent intent = new Intent(BroadcastAction.Type.SEND_CHAT_MESSAGE.getAction()); + intent.putExtra("to", participantId); + intent.putExtra("message", message); + + return intent; + } + + public static Intent buildSetVideoMutedIntent(boolean muted) { + Intent intent = new Intent(BroadcastAction.Type.SET_VIDEO_MUTED.getAction()); + intent.putExtra("muted", muted); + + return intent; + } + + public static Intent buildSetClosedCaptionsEnabledIntent(boolean enabled) { + Intent intent = new Intent(BroadcastAction.Type.SET_CLOSED_CAPTIONS_ENABLED.getAction()); + intent.putExtra("enabled", enabled); + + return intent; + } + + public static Intent buildRetrieveParticipantsInfo(String requestId) { + Intent intent = new Intent(BroadcastAction.Type.RETRIEVE_PARTICIPANTS_INFO.getAction()); + intent.putExtra("requestId", requestId); + + return intent; + } + + public static Intent buildToggleCameraIntent() { + return new Intent(BroadcastAction.Type.TOGGLE_CAMERA.getAction()); + } + + public static Intent buildShowNotificationIntent( + String appearance, String description, String timeout, String title, String uid) { + Intent intent = new Intent(BroadcastAction.Type.SHOW_NOTIFICATION.getAction()); + intent.putExtra("appearance", appearance); + intent.putExtra("description", description); + intent.putExtra("timeout", timeout); + intent.putExtra("title", title); + intent.putExtra("uid", uid); + + return intent; + } + + public static Intent buildHideNotificationIntent(String uid) { + Intent intent = new Intent(BroadcastAction.Type.HIDE_NOTIFICATION.getAction()); + intent.putExtra("uid", uid); + + return intent; + } + + public enum RecordingMode { + FILE("file"), + STREAM("stream"); + + private final String mode; + + RecordingMode(String mode) { + this.mode = mode; + } + + public String getMode() { + return mode; + } + } + + public static Intent buildStartRecordingIntent( + RecordingMode mode, + String dropboxToken, + boolean shouldShare, + String rtmpStreamKey, + String rtmpBroadcastID, + String youtubeStreamKey, + String youtubeBroadcastID, + Bundle extraMetadata, + boolean transcription) { + Intent intent = new Intent(BroadcastAction.Type.START_RECORDING.getAction()); + intent.putExtra("mode", mode.getMode()); + intent.putExtra("dropboxToken", dropboxToken); + intent.putExtra("shouldShare", shouldShare); + intent.putExtra("rtmpStreamKey", rtmpStreamKey); + intent.putExtra("rtmpBroadcastID", rtmpBroadcastID); + intent.putExtra("youtubeStreamKey", youtubeStreamKey); + intent.putExtra("youtubeBroadcastID", youtubeBroadcastID); + intent.putExtra("extraMetadata", extraMetadata); + intent.putExtra("transcription", transcription); + + return intent; + } + + public static Intent buildStopRecordingIntent(RecordingMode mode, boolean transcription) { + Intent intent = new Intent(BroadcastAction.Type.STOP_RECORDING.getAction()); + intent.putExtra("mode", mode.getMode()); + intent.putExtra("transcription", transcription); + + return intent; + } + + public static Intent buildOverwriteConfigIntent(Bundle config) { + Intent intent = new Intent(BroadcastAction.Type.OVERWRITE_CONFIG.getAction()); + intent.putExtra("config", config); + + return intent; + } + + public static Intent buildSendCameraFacingModeMessageIntent(String to, String facingMode) { + Intent intent = new Intent(BroadcastAction.Type.SEND_CAMERA_FACING_MODE_MESSAGE.getAction()); + intent.putExtra("to", to); + intent.putExtra("facingMode", facingMode); + + return intent; + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastReceiver.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastReceiver.java new file mode 100644 index 0000000..64cec20 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastReceiver.java @@ -0,0 +1,44 @@ +package org.jitsi.meet.sdk; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; + +import com.facebook.react.bridge.Arguments; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +/** + * Listens for {@link BroadcastAction}s on LocalBroadcastManager. When one occurs, + * it emits it to JS. + */ +public class BroadcastReceiver extends android.content.BroadcastReceiver { + + public BroadcastReceiver(Context context) { + LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(context); + + IntentFilter intentFilter = new IntentFilter(); + + for (BroadcastAction.Type type : BroadcastAction.Type.values()) { + intentFilter.addAction(type.getAction()); + } + + localBroadcastManager.registerReceiver(this, intentFilter); + } + + @Override + public void onReceive(Context context, Intent intent) { + BroadcastAction action = new BroadcastAction(intent); + String actionName = action.getType().getAction(); + Bundle data = action.getData(); + + // For actions without data bundle (like hangup), we create an empty map + // instead of attempting to convert a null bundle to avoid crashes. + if (data != null) { + ReactInstanceManagerHolder.emitEvent(actionName, Arguments.fromBundle(data)); + } else { + ReactInstanceManagerHolder.emitEvent(actionName, Arguments.createMap()); + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ConnectionService.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ConnectionService.java new file mode 100644 index 0000000..d66e1d8 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ConnectionService.java @@ -0,0 +1,452 @@ +package org.jitsi.meet.sdk; + +import android.content.ComponentName; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.telecom.CallAudioState; +import android.telecom.Connection; +import android.telecom.ConnectionRequest; +import android.telecom.DisconnectCause; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import android.telecom.VideoProfile; + +import androidx.annotation.RequiresApi; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableNativeMap; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Jitsi Meet implementation of {@link ConnectionService}. At the time of this + * writing it implements only the outgoing call scenario. + * + * NOTE the class needs to be public, but is not part of the SDK API and should + * never be used directly. + * + * @author Pawel Domas + */ +public class ConnectionService extends android.telecom.ConnectionService { + + /** + * Tag used for logging. + */ + static final String TAG = "JitsiConnectionService"; + + /** + * The extra added to the {@link ConnectionImpl} and + * {@link ConnectionRequest} which stores the {@link PhoneAccountHandle} + * created for the call. + */ + static final String EXTRA_PHONE_ACCOUNT_HANDLE + = "org.jitsi.meet.sdk.connection_service.PHONE_ACCOUNT_HANDLE"; + + /** + * Connections mapped by call UUID. + */ + static private final Map connections + = new HashMap<>(); + + /** + * The start call Promises mapped by call UUID. + */ + static private final HashMap startCallPromises + = new HashMap<>(); + + /** + * Aborts all ongoing connections. This is a last resort mechanism which forces all resources to + * be freed on the system in case of fatal error. + */ + static void abortConnections() { + for (ConnectionImpl connection: getConnections()) { + connection.onAbort(); + } + } + + /** + * Adds {@link ConnectionImpl} to the list. + * + * @param connection - {@link ConnectionImpl} + */ + static void addConnection(ConnectionImpl connection) { + connections.put(connection.getCallUUID(), connection); + } + + /** + * Returns all {@link ConnectionImpl} instances held in this list. + * + * @return a list of {@link ConnectionImpl}. + */ + static List getConnections() { + return new ArrayList<>(connections.values()); + } + + /** + * @return {@code true} if running a Samsung device. + */ + static boolean isSamsungDevice() { + return android.os.Build.MANUFACTURER.toLowerCase().contains("samsung"); + } + + /** + * Registers a start call promise. + * + * @param uuid - the call UUID to which the start call promise belongs to. + * @param promise - the Promise instance to be stored for later use. + */ + static void registerStartCallPromise(String uuid, Promise promise) { + startCallPromises.put(uuid, promise); + } + + /** + * Removes {@link ConnectionImpl} from the list. + * + * @param connection - {@link ConnectionImpl} + */ + static void removeConnection(ConnectionImpl connection) { + connections.remove(connection.getCallUUID()); + } + + /** + * Used to adjusts the connection's state to + * {@link android.telecom.Connection#STATE_ACTIVE}. + * + * @param callUUID the call UUID which identifies the connection. + * @return Whether the connection was set as active or not. + */ + static boolean setConnectionActive(String callUUID) { + ConnectionImpl connection = connections.get(callUUID); + + if (connection != null) { + connection.setActive(); + return true; + } else { + JitsiMeetLogger.w("%s setConnectionActive - no connection for UUID: %s", TAG, callUUID); + return false; + } + } + + /** + * Used to adjusts the connection's state to + * {@link android.telecom.Connection#STATE_DISCONNECTED}. + * + * @param callUUID the call UUID which identifies the connection. + * @param cause disconnection reason. + */ + static void setConnectionDisconnected(String callUUID, DisconnectCause cause) { + ConnectionImpl connection = connections.get(callUUID); + + if (connection != null) { + if (isSamsungDevice()) { + // Required to release the audio focus correctly. + connection.setOnHold(); + // Prevents from including in the native phone calls history + connection.setConnectionProperties( + Connection.PROPERTY_SELF_MANAGED + | Connection.PROPERTY_IS_EXTERNAL_CALL); + } + // Note that the connection is not removed from the list here, but + // in ConnectionImpl's state changed callback. It's a safer + // approach, because in case the app would crash on the JavaScript + // side the calls would be cleaned up by the system they would still + // be removed from the ConnectionList. + connection.setDisconnected(cause); + connection.destroy(); + } else { + JitsiMeetLogger.e(TAG + " endCall no connection for UUID: " + callUUID); + } + } + + /** + * Unregisters a start call promise. Must be called after the Promise is + * rejected or resolved. + * + * @param uuid the call UUID which identifies the call to which the promise + * belongs to. + * @return the unregistered Promise instance or null if there + * wasn't any for the given call UUID. + */ + static Promise unregisterStartCallPromise(String uuid) { + return startCallPromises.remove(uuid); + } + + /** + * Used to adjusts the call's state. + * + * @param callUUID the call UUID which identifies the connection. + * @param callState a map which carries the properties to be modified. See + * "KEY_*" constants in {@link ConnectionImpl} for the list of keys. + */ + static void updateCall(String callUUID, ReadableMap callState) { + ConnectionImpl connection = connections.get(callUUID); + + if (connection != null) { + if (callState.hasKey(ConnectionImpl.KEY_HAS_VIDEO)) { + boolean hasVideo + = callState.getBoolean(ConnectionImpl.KEY_HAS_VIDEO); + + JitsiMeetLogger.i(" %s updateCall: %s hasVideo: %s", TAG, callUUID, hasVideo); + connection.setVideoState( + hasVideo + ? VideoProfile.STATE_BIDIRECTIONAL + : VideoProfile.STATE_AUDIO_ONLY); + } + } else { + JitsiMeetLogger.e(TAG + " updateCall no connection for UUID: " + callUUID); + } + } + + @Override + public Connection onCreateOutgoingConnection( + PhoneAccountHandle accountHandle, ConnectionRequest request) { + ConnectionImpl connection = new ConnectionImpl(); + + connection.setConnectionProperties(Connection.PROPERTY_SELF_MANAGED); + connection.setAddress( + request.getAddress(), + TelecomManager.PRESENTATION_UNKNOWN); + connection.setExtras(request.getExtras()); + + connection.setAudioModeIsVoip(true); + + // NOTE there's a time gap between the placeCall and this callback when + // things could get out of sync, but they are put back in sync once + // the startCall Promise is resolved below. That's because on + // the JavaScript side there's a logic to sync up in .then() callback. + connection.setVideoState(request.getVideoState()); + + Bundle moreExtras = new Bundle(); + + moreExtras.putParcelable( + EXTRA_PHONE_ACCOUNT_HANDLE, + Objects.requireNonNull(request.getAccountHandle(), "accountHandle")); + connection.putExtras(moreExtras); + + addConnection(connection); + + Promise startCallPromise + = unregisterStartCallPromise(connection.getCallUUID()); + + if (startCallPromise != null) { + JitsiMeetLogger.d(TAG + " onCreateOutgoingConnection " + connection.getCallUUID()); + startCallPromise.resolve(null); + } else { + JitsiMeetLogger.e( + TAG + " onCreateOutgoingConnection: no start call Promise for " + connection.getCallUUID()); + } + + return connection; + } + + @Override + public Connection onCreateIncomingConnection( + PhoneAccountHandle accountHandle, ConnectionRequest request) { + throw new RuntimeException("Not implemented"); + } + + @Override + public void onCreateIncomingConnectionFailed( + PhoneAccountHandle accountHandle, ConnectionRequest request) { + throw new RuntimeException("Not implemented"); + } + + @Override + public void onCreateOutgoingConnectionFailed( + PhoneAccountHandle accountHandle, ConnectionRequest request) { + PhoneAccountHandle theAccountHandle = request.getAccountHandle(); + String callUUID = theAccountHandle.getId(); + + JitsiMeetLogger.e(TAG + " onCreateOutgoingConnectionFailed " + callUUID); + + if (callUUID != null) { + Promise startCallPromise = unregisterStartCallPromise(callUUID); + + if (startCallPromise != null) { + startCallPromise.reject( + "CREATE_OUTGOING_CALL_FAILED", + "The request has been denied by the system"); + } else { + JitsiMeetLogger.e(TAG + " startCallFailed - no start call Promise for UUID: " + callUUID); + } + } else { + JitsiMeetLogger.e(TAG + " onCreateOutgoingConnectionFailed - no call UUID"); + } + + unregisterPhoneAccount(theAccountHandle); + } + + private void unregisterPhoneAccount(PhoneAccountHandle phoneAccountHandle) { + TelecomManager telecom = getSystemService(TelecomManager.class); + if (telecom != null) { + if (phoneAccountHandle != null) { + telecom.unregisterPhoneAccount(phoneAccountHandle); + } else { + JitsiMeetLogger.e(TAG + " unregisterPhoneAccount - account handle is null"); + } + } else { + JitsiMeetLogger.e(TAG + " unregisterPhoneAccount - telecom is null"); + } + } + + /** + * Registers new {@link PhoneAccountHandle}. + * + * @param context the current Android context. + * @param address the phone account's address. At the time of this writing + * it's the call handle passed from the Java Script side. + * @param callUUID the call's UUID for which the account is to be created. + * It will be used as the account's id. + * @return {@link PhoneAccountHandle} described by the given arguments. + */ + static PhoneAccountHandle registerPhoneAccount( + Context context, Uri address, String callUUID) { + PhoneAccountHandle phoneAccountHandle + = new PhoneAccountHandle( + new ComponentName(context, ConnectionService.class), + callUUID); + + PhoneAccount.Builder builder + = PhoneAccount.builder(phoneAccountHandle, address.toString()) + .setAddress(address) + .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED | + PhoneAccount.CAPABILITY_VIDEO_CALLING | + PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING) + .addSupportedUriScheme(PhoneAccount.SCHEME_SIP); + + PhoneAccount account = builder.build(); + + TelecomManager telecomManager + = context.getSystemService(TelecomManager.class); + telecomManager.registerPhoneAccount(account); + + return phoneAccountHandle; + } + + /** + * Connection implementation for Jitsi Meet's {@link ConnectionService}. + * + * @author Pawel Domas + */ + class ConnectionImpl extends Connection { + + /** + * The constant which defines the key for the "has video" property. + * The key is used in the map which carries the call's state passed as + * the argument of the {@link RNConnectionService#updateCall} method. + */ + static final String KEY_HAS_VIDEO = "hasVideo"; + + /** + * Called when system wants to disconnect the call. + * + * {@inheritDoc} + */ + @Override + public void onDisconnect() { + JitsiMeetLogger.i(TAG + " onDisconnect " + getCallUUID()); + WritableNativeMap data = new WritableNativeMap(); + data.putString("callUUID", getCallUUID()); + RNConnectionService.getInstance().emitEvent( + "org.jitsi.meet:features/connection_service#disconnect", + data); + // The JavaScript side will not go back to the native with + // 'endCall', so the Connection must be removed immediately. + setConnectionDisconnected( + getCallUUID(), + new DisconnectCause(DisconnectCause.LOCAL)); + } + + /** + * Called when system wants to abort the call. + * + * {@inheritDoc} + */ + @Override + public void onAbort() { + JitsiMeetLogger.i(TAG + " onAbort " + getCallUUID()); + WritableNativeMap data = new WritableNativeMap(); + data.putString("callUUID", getCallUUID()); + RNConnectionService.getInstance().emitEvent( + "org.jitsi.meet:features/connection_service#abort", + data); + // The JavaScript side will not go back to the native with + // 'endCall', so the Connection must be removed immediately. + setConnectionDisconnected( + getCallUUID(), + new DisconnectCause(DisconnectCause.CANCELED)); + } + + @Override + public void onHold() { + // What ?! Android will still call this method even if we do not add + // the HOLD capability, so do the same thing as on abort. + // TODO implement HOLD + JitsiMeetLogger.w(TAG + " onHold %s - HOLD is not supported, aborting the call...", getCallUUID()); + this.onAbort(); + } + + /** + * Called when there's change to the call audio state. Either by + * the system after the connection is initialized or in response to + * {@link #setAudioRoute(int)}. + * + * @param state the new {@link CallAudioState} + */ + @Override + public void onCallAudioStateChanged(CallAudioState state) { + JitsiMeetLogger.d(TAG + " onCallAudioStateChanged: " + state); + RNConnectionService module = RNConnectionService.getInstance(); + if (module != null) { + module.onCallAudioStateChange(state); + } + } + + /** + * Unregisters the account when the call is disconnected. + * + * @param state - the new connection's state. + */ + @Override + public void onStateChanged(int state) { + JitsiMeetLogger.d( + "%s onStateChanged: %s %s", TAG, Connection.stateToString(state), getCallUUID()); + + if (state == STATE_DISCONNECTED) { + removeConnection(this); + unregisterPhoneAccount(getPhoneAccountHandle()); + } + } + + /** + * Retrieves the UUID of the call associated with this connection. + * + * @return call UUID + */ + String getCallUUID() { + return getPhoneAccountHandle().getId(); + } + + private PhoneAccountHandle getPhoneAccountHandle() { + return getExtras().getParcelable( + ConnectionService.EXTRA_PHONE_ACCOUNT_HANDLE); + } + + @Override + public String toString() { + return String.format( + "ConnectionImpl[address=%s, uuid=%s]@%d", + getAddress(), getCallUUID(), hashCode()); + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/DefaultHardwareBackBtnHandlerImpl.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/DefaultHardwareBackBtnHandlerImpl.java new file mode 100644 index 0000000..e31a72f --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/DefaultHardwareBackBtnHandlerImpl.java @@ -0,0 +1,65 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * Copyright @ 2017-2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.app.Activity; + +import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler; + +/** + * Defines the default behavior of {@code JitsiMeetFragment} and + * {@code JitsiMeetView} upon invoking the back button if no + * {@code JitsiMeetView} handles the invocation. For example, a + * {@code JitsiMeetView} may (1) handle the invocation of the back button + * during a conference by leaving the conference and (2) not handle the + * invocation when not in a conference. + */ +class DefaultHardwareBackBtnHandlerImpl implements DefaultHardwareBackBtnHandler { + + /** + * The {@code Activity} to which the default handling of the back button + * is being provided by this instance. + */ + private final Activity activity; + + /** + * Initializes a new {@code DefaultHardwareBackBtnHandlerImpl} instance to + * provide the default handling of the back button to a specific + * {@code Activity}. + * + * @param activity the {@code Activity} to which the new instance is to + * provide the default behavior of the back button + */ + public DefaultHardwareBackBtnHandlerImpl(Activity activity) { + this.activity = activity; + } + + /** + * {@inheritDoc} + * + * Finishes the associated {@code Activity}. + */ + @Override + public void invokeDefaultOnBackPressed() { + // Technically, we'd like to invoke Activity#onBackPressed(). + // Practically, it's not possible. Fortunately, the documentation of + // Activity#onBackPressed() specifies that "[t]he default implementation + // simply finishes the current activity," + activity.finish(); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/DropboxModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/DropboxModule.java new file mode 100644 index 0000000..ce137bb --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/DropboxModule.java @@ -0,0 +1,204 @@ +package org.jitsi.meet.sdk; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.text.TextUtils; + +import com.dropbox.core.DbxException; +import com.dropbox.core.DbxRequestConfig; +import com.dropbox.core.android.Auth; +import com.dropbox.core.oauth.DbxCredential; +import com.dropbox.core.v2.DbxClientV2; +import com.dropbox.core.v2.users.FullAccount; +import com.dropbox.core.v2.users.SpaceAllocation; +import com.dropbox.core.v2.users.SpaceUsage; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.module.annotations.ReactModule; + +import java.util.HashMap; +import java.util.Map; + +/** + * Implements the react-native module for the dropbox integration. + */ +@ReactModule(name = DropboxModule.NAME) +class DropboxModule + extends ReactContextBaseJavaModule + implements LifecycleEventListener { + + public static final String NAME = "Dropbox"; + + private String appKey; + + private String clientId; + + private final boolean isEnabled; + + private Promise promise; + + public DropboxModule(ReactApplicationContext reactContext) { + super(reactContext); + + String pkg = reactContext.getApplicationContext().getPackageName(); + int resId = reactContext.getResources() + .getIdentifier("dropbox_app_key", "string", pkg); + appKey + = reactContext.getString(resId); + isEnabled = !TextUtils.isEmpty(appKey); + + clientId = generateClientId(); + + reactContext.addLifecycleEventListener(this); + } + + /** + * Executes the dropbox auth flow. + * + * @param promise The promise used to return the result of the auth flow. + */ + @ReactMethod + public void authorize(final Promise promise) { + if (isEnabled) { + Auth.startOAuth2PKCE(this.getCurrentActivity(), appKey, DbxRequestConfig.newBuilder(clientId).build()); + this.promise = promise; + } else { + promise.reject( + new Exception("Dropbox integration isn't configured.")); + } + } + + /** + * Generate a client identifier for the dropbox sdk. + * + * @returns a client identifier for the dropbox sdk. + * @see {https://dropbox.github.io/dropbox-sdk-java/api-docs/v3.0.x/com/dropbox/core/DbxRequestConfig.html#getClientIdentifier--} + */ + private String generateClientId() { + Context context = getReactApplicationContext(); + PackageManager packageManager = context.getPackageManager(); + ApplicationInfo applicationInfo = null; + PackageInfo packageInfo = null; + + try { + String packageName = context.getPackageName(); + + applicationInfo = packageManager.getApplicationInfo(packageName, 0); + packageInfo = packageManager.getPackageInfo(packageName, 0); + } catch (PackageManager.NameNotFoundException e) { + } + + String applicationLabel + = applicationInfo == null + ? "JitsiMeet" + : packageManager.getApplicationLabel(applicationInfo).toString() + .replaceAll("\\s", ""); + String version = packageInfo == null ? "dev" : packageInfo.versionName; + + return applicationLabel + "/" + version; + } + + @Override + public Map getConstants() { + Map constants = new HashMap<>(); + + constants.put("ENABLED", isEnabled); + + return constants; + } + + /** + * Resolves the current user dropbox display name. + * + * @param token A dropbox access token. + * @param promise The promise used to return the result of the auth flow. + */ + @ReactMethod + public void getDisplayName(final String token, final Promise promise) { + DbxRequestConfig config = DbxRequestConfig.newBuilder(clientId).build(); + DbxClientV2 client = new DbxClientV2(config, token); + + // Get current account info + try { + FullAccount account = client.users().getCurrentAccount(); + + promise.resolve(account.getName().getDisplayName()); + } catch (DbxException e) { + promise.reject(e); + } + } + + @Override + public String getName() { + return NAME; + } + + /** + * Resolves the current user space usage. + * + * @param token A dropbox access token. + * @param promise The promise used to return the result of the auth flow. + */ + @ReactMethod + public void getSpaceUsage(final String token, final Promise promise) { + DbxRequestConfig config = DbxRequestConfig.newBuilder(clientId).build(); + DbxClientV2 client = new DbxClientV2(config, token); + + try { + SpaceUsage spaceUsage = client.users().getSpaceUsage(); + WritableMap map = Arguments.createMap(); + + map.putString("used", String.valueOf(spaceUsage.getUsed())); + + SpaceAllocation allocation = spaceUsage.getAllocation(); + long allocated = 0; + + if (allocation.isIndividual()) { + allocated += allocation.getIndividualValue().getAllocated(); + } + if (allocation.isTeam()) { + allocated += allocation.getTeamValue().getAllocated(); + } + map.putString("allocated", String.valueOf(allocated)); + + promise.resolve(map); + } catch (DbxException e) { + promise.reject(e); + } + } + + @Override + public void onHostDestroy() {} + + @Override + public void onHostPause() {} + + @Override + public void onHostResume() { + DbxCredential credential = Auth.getDbxCredential(); + + if (this.promise != null ) { + if (credential != null) { + WritableMap result = Arguments.createMap(); + result.putString("token", credential.getAccessToken()); + result.putString("rToken", credential.getRefreshToken()); + result.putDouble("expireDate", credential.getExpiresAt()); + + this.promise.resolve(result); + this.promise = null; + } else { + this.promise.reject("Invalid dropbox credentials"); + } + + this.promise = null; + } + + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java new file mode 100644 index 0000000..5b24140 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java @@ -0,0 +1,126 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.module.annotations.ReactModule; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.HashMap; +import java.util.Map; + +/** + * Module implementing an API for sending events from JavaScript to native code. + */ +@ReactModule(name = ExternalAPIModule.NAME) +class ExternalAPIModule extends ReactContextBaseJavaModule { + + public static final String NAME = "ExternalAPI"; + + private static final String TAG = NAME; + + private final BroadcastEmitter broadcastEmitter; + private final BroadcastReceiver broadcastReceiver; + + /** + * Initializes a new module instance. There shall be a single instance of + * this module throughout the lifetime of the app. + * + * @param reactContext the {@link ReactApplicationContext} where this module + * is created. + */ + public ExternalAPIModule(ReactApplicationContext reactContext) { + super(reactContext); + + broadcastEmitter = new BroadcastEmitter(reactContext); + broadcastReceiver = new BroadcastReceiver(reactContext); + + ParticipantsService.init(reactContext); + } + + @ReactMethod + public void addListener(String eventName) { + // Keep: Required for RN built in Event Emitter Calls. + } + + @ReactMethod + public void removeListeners(Integer count) { + // Keep: Required for RN built in Event Emitter Calls. + } + + /** + * Gets the name of this module to be used in the React Native bridge. + * + * @return The name of this module to be used in the React Native bridge. + */ + @Override + public String getName() { + return NAME; + } + + /** + * Gets a mapping with the constants this module is exporting. + * + * @return a {@link Map} mapping the constants to be exported with their + * values. + */ + @Override + public Map getConstants() { + Map constants = new HashMap<>(); + + constants.put("SET_AUDIO_MUTED", BroadcastAction.Type.SET_AUDIO_MUTED.getAction()); + constants.put("HANG_UP", BroadcastAction.Type.HANG_UP.getAction()); + constants.put("SEND_ENDPOINT_TEXT_MESSAGE", BroadcastAction.Type.SEND_ENDPOINT_TEXT_MESSAGE.getAction()); + constants.put("TOGGLE_SCREEN_SHARE", BroadcastAction.Type.TOGGLE_SCREEN_SHARE.getAction()); + constants.put("RETRIEVE_PARTICIPANTS_INFO", BroadcastAction.Type.RETRIEVE_PARTICIPANTS_INFO.getAction()); + constants.put("OPEN_CHAT", BroadcastAction.Type.OPEN_CHAT.getAction()); + constants.put("CLOSE_CHAT", BroadcastAction.Type.CLOSE_CHAT.getAction()); + constants.put("SEND_CHAT_MESSAGE", BroadcastAction.Type.SEND_CHAT_MESSAGE.getAction()); + constants.put("SET_VIDEO_MUTED", BroadcastAction.Type.SET_VIDEO_MUTED.getAction()); + constants.put("SET_CLOSED_CAPTIONS_ENABLED", BroadcastAction.Type.SET_CLOSED_CAPTIONS_ENABLED.getAction()); + constants.put("TOGGLE_CAMERA", BroadcastAction.Type.TOGGLE_CAMERA.getAction()); + constants.put("SHOW_NOTIFICATION", BroadcastAction.Type.SHOW_NOTIFICATION.getAction()); + constants.put("HIDE_NOTIFICATION", BroadcastAction.Type.HIDE_NOTIFICATION.getAction()); + constants.put("START_RECORDING", BroadcastAction.Type.START_RECORDING.getAction()); + constants.put("STOP_RECORDING", BroadcastAction.Type.STOP_RECORDING.getAction()); + constants.put("OVERWRITE_CONFIG", BroadcastAction.Type.OVERWRITE_CONFIG.getAction()); + constants.put("SEND_CAMERA_FACING_MODE_MESSAGE", BroadcastAction.Type.SEND_CAMERA_FACING_MODE_MESSAGE.getAction()); + + return constants; + } + + /** + * Dispatches an event that occurred on the JavaScript side of the SDK to + * the native side. + * + * @param name The name of the event. + * @param data The details/specifics of the event to send determined + * by/associated with the specified {@code name}. + */ + @ReactMethod + public void sendEvent(String name, ReadableMap data) { + // Keep track of the current ongoing conference. + OngoingConferenceTracker.getInstance().onExternalAPIEvent(name, data); + + JitsiMeetLogger.d(TAG + " Sending event: " + name + " with data: " + data); + broadcastEmitter.sendBroadcast(name, data); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiInitializer.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiInitializer.java new file mode 100644 index 0000000..48544a5 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiInitializer.java @@ -0,0 +1,64 @@ +/* + * Copyright @ 2021-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.meet.sdk; + +import android.app.Application; +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.startup.Initializer; + +import com.facebook.soloader.SoLoader; +import com.facebook.react.soloader.OpenSourceMergedSoMapping; +import org.wonday.orientation.OrientationActivityLifecycle; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +public class JitsiInitializer implements Initializer { + + @NonNull + @Override + public Boolean create(@NonNull Context context) { + Log.d(this.getClass().getCanonicalName(), "create"); + + try { + SoLoader.init(context, OpenSourceMergedSoMapping.INSTANCE); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // Register our uncaught exception handler. + JitsiMeetUncaughtExceptionHandler.register(); + + // Register activity lifecycle handler for the orientation locker module. + ((Application) context).registerActivityLifecycleCallbacks(OrientationActivityLifecycle.getInstance()); + + // Initialize ReactInstanceManager during application startup + // This ensures it's ready before any Activity onCreate is called + ReactInstanceManagerHolder.initReactInstanceManager((Application) context); + + return true; + } + + @NonNull + @Override + public List>> dependencies() { + return Collections.emptyList(); + } +} \ No newline at end of file diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeet.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeet.java new file mode 100644 index 0000000..b8873e9 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeet.java @@ -0,0 +1,100 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.meet.sdk; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; + +import com.facebook.react.ReactInstanceManager; + +import com.splashview.SplashView; +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +public class JitsiMeet { + + /** + * Default {@link JitsiMeetConferenceOptions} which will be used for all conferences. When + * joining a conference these options will be merged with the ones passed to + * {@link JitsiMeetView} join(). + */ + private static JitsiMeetConferenceOptions defaultConferenceOptions; + + public static JitsiMeetConferenceOptions getDefaultConferenceOptions() { + return defaultConferenceOptions; + } + + public static void setDefaultConferenceOptions(JitsiMeetConferenceOptions options) { + if (options != null && options.getRoom() != null) { + throw new RuntimeException("'room' must be null in the default conference options"); + } + defaultConferenceOptions = options; + } + + /** + * Returns the current conference URL as a string. + * + * @return the current conference URL. + */ + public static String getCurrentConference() { + return OngoingConferenceTracker.getInstance().getCurrentConference(); + } + + /** + * Helper to get the default conference options as a {@link Bundle}. + * + * @return a {@link Bundle} with the default conference options. + */ + static Bundle getDefaultProps() { + if (defaultConferenceOptions != null) { + return defaultConferenceOptions.asProps(); + } + + return new Bundle(); + } + + /** + * Used in development mode. It displays the React Native development menu. + */ + public static void showDevOptions() { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.showDevOptionsDialog(); + } + } + + public static boolean isCrashReportingDisabled(Context context) { + SharedPreferences preferences = context.getSharedPreferences("jitsi-default-preferences", Context.MODE_PRIVATE); + String value = preferences.getString("isCrashReportingDisabled", ""); + return Boolean.parseBoolean(value); + } + + /** + * Helper method to show the SplashScreen. + * + * @param activity - The activity on which to show the SplashScreen {@link Activity}. + */ + public static void showSplashScreen(Activity activity) { + try { + SplashView.INSTANCE.showSplashView(activity); + } catch (Exception e) { + JitsiMeetLogger.e(e, "Failed to show splash screen"); + } + } +} \ No newline at end of file diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java new file mode 100644 index 0000000..7c06191 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java @@ -0,0 +1,422 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.facebook.react.modules.core.PermissionListener; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.HashMap; + +/** + * A base activity for SDK users to embed. It contains all the required wiring + * between the {@code JitsiMeetView} and the Activity lifecycle methods. + * + * In this activity we use a single {@code JitsiMeetView} instance. This + * instance gives us access to a view which displays the welcome page and the + * conference itself. All lifecycle methods associated with this Activity are + * hooked to the React Native subsystem via proxy calls through the + * {@code JitsiMeetActivityDelegate} static methods. + */ +public class JitsiMeetActivity extends AppCompatActivity + implements JitsiMeetActivityInterface { + + protected static final String TAG = JitsiMeetActivity.class.getSimpleName(); + + private static final String ACTION_JITSI_MEET_CONFERENCE = "org.jitsi.meet.CONFERENCE"; + private static final String JITSI_MEET_CONFERENCE_OPTIONS = "JitsiMeetConferenceOptions"; + + private boolean isReadyToClose; + + private final BroadcastReceiver broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + onBroadcastReceived(intent); + } + }; + + /** + * Instance of the {@link JitsiMeetView} which this activity will display. + */ + private JitsiMeetView jitsiView; + + // Helpers for starting the activity + // + + public static void launch(Context context, JitsiMeetConferenceOptions options) { + Intent intent = new Intent(context, JitsiMeetActivity.class); + intent.setAction(ACTION_JITSI_MEET_CONFERENCE); + intent.putExtra(JITSI_MEET_CONFERENCE_OPTIONS, options); + if (!(context instanceof Activity)) { + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + context.startActivity(intent); + } + + public static void launch(Context context, String url) { + JitsiMeetConferenceOptions options + = new JitsiMeetConferenceOptions.Builder().setRoom(url).build(); + launch(context, options); + } + + public static void addTopBottomInsets(@NonNull Window w, @NonNull View v) { + + View decorView = w.getDecorView(); + + decorView.post(() -> { + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(decorView); + if (insets != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) v.getLayoutParams(); + params.topMargin = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top; + params.bottomMargin = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom; + v.setLayoutParams(params); + + decorView.setOnApplyWindowInsetsListener((view, windowInsets) -> { + view.setBackgroundColor(JitsiMeetView.BACKGROUND_COLOR); + + return windowInsets; + }); + } + }); + } + + // Overrides + // + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + Intent intent = new Intent("onConfigurationChanged"); + intent.putExtra("newConfig", newConfig); + this.sendBroadcast(intent); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // ReactInstanceManager is now initialized by JitsiInitializer during application startup + // Just call onHostResume since the manager is already ready + JitsiMeetActivityDelegate.onHostResume(this); + + setContentView(R.layout.activity_jitsi_meet); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM + && getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + addTopBottomInsets(getWindow(), findViewById(android.R.id.content)); + } + + this.jitsiView = findViewById(R.id.jitsiView); + + registerForBroadcastMessages(); + + if (!extraInitialize()) { + initialize(); + } + } + + @Override + public void onResume() { + super.onResume(); + JitsiMeetActivityDelegate.onHostResume(this); + } + + @Override + public void onStop() { + JitsiMeetActivityDelegate.onHostPause(this); + super.onStop(); + } + + @Override + public void onDestroy() { + JitsiMeetLogger.i("onDestroy()"); + + // Here we are trying to handle the following corner case: an application using the SDK + // is using this Activity for displaying meetings, but there is another "main" Activity + // with other content. If this Activity is "swiped out" from the recent list we will get + // Activity#onDestroy() called without warning. At this point we can try to leave the + // current meeting, but when our view is detached from React the JS <-> Native bridge won't + // be operational so the external API won't be able to notify the native side that the + // conference terminated. Thus, try our best to clean up. + if (!isReadyToClose) { + JitsiMeetLogger.i("onDestroy(): leaving..."); + leave(); + } + + this.jitsiView = null; + + if (AudioModeModule.useConnectionService()) { + ConnectionService.abortConnections(); + } + JitsiMeetOngoingConferenceService.abort(this); + + LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver); + + JitsiMeetActivityDelegate.onHostDestroy(this); + + super.onDestroy(); + } + + @Override + public void finish() { + if (!isReadyToClose) { + JitsiMeetLogger.i("finish(): leaving..."); + leave(); + } + + JitsiMeetLogger.i("finish(): finishing..."); + super.finish(); + } + + // Helper methods + // + + protected JitsiMeetView getJitsiView() { + return jitsiView; + } + + public void join(@Nullable String url) { + JitsiMeetConferenceOptions options + = new JitsiMeetConferenceOptions.Builder() + .setRoom(url) + .build(); + join(options); + } + + public void join(JitsiMeetConferenceOptions options) { + if (this.jitsiView != null) { + this.jitsiView.join(options); + } else { + JitsiMeetLogger.w("Cannot join, view is null"); + } + } + + protected void leave() { + if (this.jitsiView != null) { + this.jitsiView.abort(); + } else { + JitsiMeetLogger.w("Cannot leave, view is null"); + } + } + + private @Nullable + JitsiMeetConferenceOptions getConferenceOptions(Intent intent) { + String action = intent.getAction(); + + if (Intent.ACTION_VIEW.equals(action)) { + Uri uri = intent.getData(); + if (uri != null) { + return new JitsiMeetConferenceOptions.Builder().setRoom(uri.toString()).build(); + } + } else if (ACTION_JITSI_MEET_CONFERENCE.equals(action)) { + return intent.getParcelableExtra(JITSI_MEET_CONFERENCE_OPTIONS); + } + + return null; + } + + /** + * Helper function called during activity initialization. If {@code true} is returned, the + * initialization is delayed and the {@link JitsiMeetActivity#initialize()} method is not + * called. In this case, it's up to the subclass to call the initialize method when ready. + *

+ * This is mainly required so we do some extra initialization in the Jitsi Meet app. + * + * @return {@code true} if the initialization will be delayed, {@code false} otherwise. + */ + protected boolean extraInitialize() { + return false; + } + + protected void initialize() { + // Join the room specified by the URL the app was launched with. + // Joining without the room option displays the welcome page. + join(getConferenceOptions(getIntent())); + } + + protected void onConferenceJoined(HashMap extraData) { + JitsiMeetLogger.i("Conference joined: " + extraData); + // Launch the service for the ongoing notification. + JitsiMeetOngoingConferenceService.launch(this, extraData); + } + + protected void onConferenceTerminated(HashMap extraData) { + JitsiMeetLogger.i("Conference terminated: " + extraData); + } + + protected void onConferenceWillJoin(HashMap extraData) { + JitsiMeetLogger.i("Conference will join: " + extraData); + } + + protected void onParticipantJoined(HashMap extraData) { + try { + JitsiMeetLogger.i("Participant joined: ", extraData); + } catch (Exception e) { + JitsiMeetLogger.w("Invalid participant joined extraData", e); + } + } + + protected void onParticipantLeft(HashMap extraData) { + try { + JitsiMeetLogger.i("Participant left: ", extraData); + } catch (Exception e) { + JitsiMeetLogger.w("Invalid participant left extraData", e); + } + } + + protected void onReadyToClose() { + JitsiMeetLogger.i("SDK is ready to close"); + isReadyToClose = true; + finish(); + } + +// protected void onTranscriptionChunkReceived(HashMap extraData) { +// JitsiMeetLogger.i("Transcription chunk received: " + extraData); +// } + +// protected void onCustomButtonPressed(HashMap extraData) { +// JitsiMeetLogger.i("Custom button pressed: " + extraData); +// } + +// protected void onConferenceUniqueIdSet(HashMap extraData) { +// JitsiMeetLogger.i("Conference unique id set: " + extraData); +// } + +// protected void onRecordingStatusChanged(HashMap extraData) { +// JitsiMeetLogger.i("Recording status changed: " + extraData); +// } + + // Activity lifecycle methods + // + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + JitsiMeetActivityDelegate.onActivityResult(this, requestCode, resultCode, data); + } + + @Override + public void onBackPressed() { + JitsiMeetActivityDelegate.onBackPressed(); + } + + @Override + public void onNewIntent(Intent intent) { + super.onNewIntent(intent); + + JitsiMeetConferenceOptions options; + + if ((options = getConferenceOptions(intent)) != null) { + join(options); + return; + } + + JitsiMeetActivityDelegate.onNewIntent(intent); + } + + @Override + protected void onUserLeaveHint() { + if (this.jitsiView != null) { + this.jitsiView.enterPictureInPicture(); + } + } + + // JitsiMeetActivityInterface + // + + @Override + public void requestPermissions(String[] permissions, int requestCode, PermissionListener listener) { + JitsiMeetActivityDelegate.requestPermissions(this, permissions, requestCode, listener); + } + + @SuppressLint("MissingSuperCall") + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + JitsiMeetActivityDelegate.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + + private void registerForBroadcastMessages() { + IntentFilter intentFilter = new IntentFilter(); + + for (BroadcastEvent.Type type : BroadcastEvent.Type.values()) { + intentFilter.addAction(type.getAction()); + } + + LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, intentFilter); + } + + private void onBroadcastReceived(Intent intent) { + if (intent != null) { + BroadcastEvent event = new BroadcastEvent(intent); + + switch (event.getType()) { + case CONFERENCE_JOINED: + onConferenceJoined(event.getData()); + break; + case CONFERENCE_WILL_JOIN: + onConferenceWillJoin(event.getData()); + break; + case CONFERENCE_TERMINATED: + onConferenceTerminated(event.getData()); + break; + case PARTICIPANT_JOINED: + onParticipantJoined(event.getData()); + break; + case PARTICIPANT_LEFT: + onParticipantLeft(event.getData()); + break; + case READY_TO_CLOSE: + onReadyToClose(); + break; + // case TRANSCRIPTION_CHUNK_RECEIVED: + // onTranscriptionChunkReceived(event.getData()); + // break; + // case CUSTOM_BUTTON_PRESSED: + // onCustomButtonPressed(event.getData()); + // break; + // case CONFERENCE_UNIQUE_ID_SET: + // onConferenceUniqueIdSet(event.getData()); + // break; + // case RECORDING_STATUS_CHANGED: + // onRecordingStatusChanged(event.getData()); + // break; + } + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivityDelegate.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivityDelegate.java new file mode 100644 index 0000000..f933683 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivityDelegate.java @@ -0,0 +1,186 @@ +/* + * Copyright @ 2018-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.app.Activity; +import android.content.Intent; + +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.modules.core.PermissionListener; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +/** + * Helper class to encapsulate the work which needs to be done on + * {@link Activity} lifecycle methods in order for the React side to be aware of + * it. + */ +public class JitsiMeetActivityDelegate { + /** + * Needed for making sure this class working with the "PermissionsAndroid" + * React Native module. + */ + private static PermissionListener permissionListener; + + /** + * Tells whether or not the permissions request is currently in progress. + * + * @return {@code true} if the permissions are being requested or {@code false} otherwise. + */ + static boolean arePermissionsBeingRequested() { + return permissionListener != null; + } + + /** + * {@link Activity} lifecycle method which should be called from + * {@code Activity#onActivityResult} so we are notified about results of external intents + * started/finished. + * + * @param activity {@code Activity} activity from where the result comes from. + * @param requestCode {@code int} code of the request. + * @param resultCode {@code int} code of the result. + * @param data {@code Intent} the intent of the activity. + */ + public static void onActivityResult( + Activity activity, + int requestCode, + int resultCode, + Intent data) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.onActivityResult(activity, requestCode, resultCode, data); + } + } + + /** + * {@link Activity} lifecycle method which should be called from + * {@link Activity#onBackPressed} so we can do the required internal + * processing. + * + * @return {@code true} if the back-press was processed; {@code false}, + * otherwise. If {@code false}, the application should call the + * {@code super}'s implementation. + */ + public static void onBackPressed() { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.onBackPressed(); + } + } + + /** + * {@link Activity} lifecycle method which should be called from + * {@code Activity#onDestroy} so we can do the required internal + * processing. + * + * @param activity {@code Activity} being destroyed. + */ + public static void onHostDestroy(Activity activity) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.onHostDestroy(activity); + } + } + + /** + * {@link Activity} lifecycle method which should be called from + * {@code Activity#onPause} so we can do the required internal processing. + * + * @param activity {@code Activity} being paused. + */ + public static void onHostPause(Activity activity) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + try { + reactInstanceManager.onHostPause(activity); + } catch (AssertionError e) { + // There seems to be a problem in RN when resuming an Activity when + // rotation is involved and the planets align. There doesn't seem to + // be a proper solution, but since the activity is going away anyway, + // we'll YOLO-ignore the exception and hope fo the best. + // Ref: https://github.com/facebook/react-native/search?q=Pausing+an+activity+that+is+not+the+current+activity%2C+this+is+incorrect%21&type=issues + JitsiMeetLogger.e(e, "Error running onHostPause, ignoring"); + } + } + } + + /** + * {@link Activity} lifecycle method which should be called from + * {@code Activity#onResume} so we can do the required internal processing. + * + * @param activity {@code Activity} being resumed. + */ + public static void onHostResume(Activity activity) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.onHostResume(activity, new DefaultHardwareBackBtnHandlerImpl(activity)); + } + } + + /** + * {@link Activity} lifecycle method which should be called from + * {@code Activity#onNewIntent} so we can do the required internal + * processing. Note that this is only needed if the activity's "launchMode" + * was set to "singleTask". This is required for deep linking to work once + * the application is already running. + * + * @param intent {@code Intent} instance which was received. + */ + public static void onNewIntent(Intent intent) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.onNewIntent(intent); + } + } + + public static void onRequestPermissionsResult( + final int requestCode, final String[] permissions, final int[] grantResults) { + // Invoke the callback immediately + if (permissionListener != null && permissionListener.onRequestPermissionsResult(requestCode, permissions, grantResults)) { + permissionListener = null; + } + } + + public static void requestPermissions(Activity activity, String[] permissions, int requestCode, PermissionListener listener) { + permissionListener = listener; + + // The RN Permissions module calls this in a non-UI thread. What we observe is a crash in ViewGroup.dispatchCancelPendingInputEvents, + // which is called on the calling (ie, non-UI) thread. This doesn't look very safe, so try to avoid a crash by pretending the permission + // was denied. + + try { + activity.requestPermissions(permissions, requestCode); + } catch (Exception e) { + JitsiMeetLogger.e(e, "Error requesting permissions"); + onRequestPermissionsResult(requestCode, permissions, new int[0]); + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivityInterface.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivityInterface.java new file mode 100644 index 0000000..2616594 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivityInterface.java @@ -0,0 +1,15 @@ +package org.jitsi.meet.sdk; + +import androidx.core.app.ActivityCompat; + +import com.facebook.react.modules.core.PermissionAwareActivity; + +/** + * This interface serves as the umbrella interface that applications not using + * {@code JitsiMeetFragment} must implement in order to ensure full + * functionality. + */ +public interface JitsiMeetActivityInterface + extends ActivityCompat.OnRequestPermissionsResultCallback, + PermissionAwareActivity { +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetConferenceOptions.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetConferenceOptions.java new file mode 100644 index 0000000..22406d3 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetConferenceOptions.java @@ -0,0 +1,332 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +import java.net.URL; +import java.util.ArrayList; + + +/** + * This class represents the options when joining a Jitsi Meet conference. The user can create an + * instance by using {@link JitsiMeetConferenceOptions.Builder} and setting the desired options + * there. + * + * The resulting {@link JitsiMeetConferenceOptions} object is immutable and represents how the + * conference will be joined. + */ +public class JitsiMeetConferenceOptions implements Parcelable { + /** + * Server where the conference should take place. + */ + private URL serverURL; + /** + * Room name. + */ + private String room; + /** + * JWT token used for authentication. + */ + private String token; + + /** + * Config. See: https://github.com/jitsi/jitsi-meet/blob/master/config.js + */ + private Bundle config; + + /** + * Feature flags. See: https://github.com/jitsi/jitsi-meet/blob/master/react/features/base/flags/constants.js + */ + private Bundle featureFlags; + + /** + * USer information, to be used when no token is specified. + */ + private JitsiMeetUserInfo userInfo; + + public URL getServerURL() { + return serverURL; + } + + public String getRoom() { + return room; + } + + public String getToken() { + return token; + } + + public Bundle getFeatureFlags() { + return featureFlags; + } + + public JitsiMeetUserInfo getUserInfo() { + return userInfo; + } + + /** + * Class used to build the immutable {@link JitsiMeetConferenceOptions} object. + */ + public static class Builder { + private URL serverURL; + private String room; + private String token; + + private Bundle config; + private Bundle featureFlags; + + private JitsiMeetUserInfo userInfo; + + public Builder() { + config = new Bundle(); + featureFlags = new Bundle(); + } + + /**\ + * Sets the server URL. + * @param url - {@link URL} of the server where the conference should take place. + * @return - The {@link Builder} object itself so the method calls can be chained. + */ + public Builder setServerURL(URL url) { + this.serverURL = url; + + return this; + } + + /** + * Sets the room where the conference will take place. + * @param room - Name of the room. + * @return - The {@link Builder} object itself so the method calls can be chained. + */ + public Builder setRoom(String room) { + this.room = room; + + return this; + } + + /** + * Sets the conference subject. + * @param subject - Subject for the conference. + * @return - The {@link Builder} object itself so the method calls can be chained. + */ + public Builder setSubject(String subject) { + setConfigOverride("subject", subject); + + return this; + } + + /** + * Sets the JWT token to be used for authentication when joining a conference. + * @param token - The JWT token to be used for authentication. + * @return - The {@link Builder} object itself so the method calls can be chained. + */ + public Builder setToken(String token) { + this.token = token; + + return this; + } + + /** + * Indicates the conference will be joined with the microphone muted. + * @param audioMuted - Muted indication. + * @return - The {@link Builder} object itself so the method calls can be chained. + */ + public Builder setAudioMuted(boolean audioMuted) { + setConfigOverride("startWithAudioMuted", audioMuted); + + return this; + } + + /** + * Indicates the conference will be joined in audio-only mode. In this mode no video is + * sent or received. + * @param audioOnly - Audio-mode indicator. + * @return - The {@link Builder} object itself so the method calls can be chained. + */ + public Builder setAudioOnly(boolean audioOnly) { + setConfigOverride("startAudioOnly", audioOnly); + + return this; + } + /** + * Indicates the conference will be joined with the camera muted. + * @param videoMuted - Muted indication. + * @return - The {@link Builder} object itself so the method calls can be chained. + */ + public Builder setVideoMuted(boolean videoMuted) { + setConfigOverride("startWithVideoMuted", videoMuted); + + return this; + } + + public Builder setFeatureFlag(String flag, boolean value) { + this.featureFlags.putBoolean(flag, value); + + return this; + } + + public Builder setFeatureFlag(String flag, String value) { + this.featureFlags.putString(flag, value); + + return this; + } + + public Builder setFeatureFlag(String flag, int value) { + this.featureFlags.putInt(flag, value); + + return this; + } + + public Builder setUserInfo(JitsiMeetUserInfo userInfo) { + this.userInfo = userInfo; + + return this; + } + + public Builder setConfigOverride(String config, String value) { + this.config.putString(config, value); + + return this; + } + + public Builder setConfigOverride(String config, int value) { + this.config.putInt(config, value); + + return this; + } + + public Builder setConfigOverride(String config, boolean value) { + this.config.putBoolean(config, value); + + return this; + } + + public Builder setConfigOverride(String config, Bundle bundle) { + this.config.putBundle(config, bundle); + + return this; + } + + public Builder setConfigOverride(String config, String[] list) { + this.config.putStringArray(config, list); + + return this; + } + + public Builder setConfigOverride(String config, ArrayList arrayList) { + this.config.putParcelableArrayList(config, arrayList); + + return this; + } + + /** + * Builds the immutable {@link JitsiMeetConferenceOptions} object with the configuration + * that this {@link Builder} instance specified. + * @return - The built {@link JitsiMeetConferenceOptions} object. + */ + public JitsiMeetConferenceOptions build() { + JitsiMeetConferenceOptions options = new JitsiMeetConferenceOptions(); + + options.serverURL = this.serverURL; + options.room = this.room; + options.token = this.token; + options.config = this.config; + options.featureFlags = this.featureFlags; + options.userInfo = this.userInfo; + + return options; + } + } + + private JitsiMeetConferenceOptions() { + } + + private JitsiMeetConferenceOptions(Parcel in) { + serverURL = (URL) in.readSerializable(); + room = in.readString(); + token = in.readString(); + config = in.readBundle(); + featureFlags = in.readBundle(); + userInfo = new JitsiMeetUserInfo(in.readBundle()); + } + + Bundle asProps() { + Bundle props = new Bundle(); + + props.putBundle("flags", featureFlags); + + Bundle urlProps = new Bundle(); + + // The room is fully qualified + if (room != null && room.contains("://")) { + urlProps.putString("url", room); + } else { + if (serverURL != null) { + urlProps.putString("serverURL", serverURL.toString()); + } + if (room != null) { + urlProps.putString("room", room); + } + } + + if (token != null) { + urlProps.putString("jwt", token); + } + + if (userInfo != null) { + props.putBundle("userInfo", userInfo.asBundle()); + } + + urlProps.putBundle("config", config); + props.putBundle("url", urlProps); + + return props; + } + + // Parcelable interface + // + + public static final Creator CREATOR = new Creator() { + @Override + public JitsiMeetConferenceOptions createFromParcel(Parcel in) { + return new JitsiMeetConferenceOptions(in); + } + + @Override + public JitsiMeetConferenceOptions[] newArray(int size) { + return new JitsiMeetConferenceOptions[size]; + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeSerializable(serverURL); + dest.writeString(room); + dest.writeString(token); + dest.writeBundle(config); + dest.writeBundle(featureFlags); + dest.writeBundle(userInfo != null ? userInfo.asBundle() : new Bundle()); + } + + @Override + public int describeContents() { + return 0; + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java new file mode 100644 index 0000000..7535105 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java @@ -0,0 +1,319 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import static android.Manifest.permission.POST_NOTIFICATIONS; +import static android.Manifest.permission.RECORD_AUDIO; + +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.facebook.react.modules.core.PermissionListener; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Random; + +/** + * This class implements an Android {@link Service}, a foreground one specifically, and it's + * responsible for presenting an ongoing notification when a conference is in progress. + * The service will help keep the app running while in the background. + * + * See: https://developer.android.com/guide/components/services + */ +public class JitsiMeetOngoingConferenceService extends Service implements OngoingConferenceTracker.OngoingConferenceListener { + private static final String TAG = JitsiMeetOngoingConferenceService.class.getSimpleName(); + private static final String ACTIVITY_DATA_KEY = "activityDataKey"; + private static final String EXTRA_DATA_KEY = "extraDataKey"; + private static final String EXTRA_DATA_BUNDLE_KEY = "extraDataBundleKey"; + private static final String IS_AUDIO_MUTED_KEY = "isAudioMuted"; + + private static final int PERMISSIONS_REQUEST_CODE = (int) (Math.random() * Short.MAX_VALUE); + + private final BroadcastReceiver broadcastReceiver = new BroadcastReceiver(); + + private boolean isAudioMuted; + private Class tapBackActivity; + + static final int NOTIFICATION_ID = new Random().nextInt(99999) + 10000; + + private static void doLaunch(Context context, HashMap extraData) { + Activity activity = (Activity) context; + + OngoingNotification.createNotificationChannel(activity); + + Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class); + + Bundle extraDataBundle = new Bundle(); + extraDataBundle.putSerializable(EXTRA_DATA_KEY, extraData); + + intent.putExtra(EXTRA_DATA_BUNDLE_KEY, extraDataBundle); + intent.putExtra(ACTIVITY_DATA_KEY, activity.getClass().getCanonicalName()); + + ComponentName componentName; + + try { + componentName = context.startForegroundService(intent); + } catch (RuntimeException e) { + // Avoid crashing due to ForegroundServiceStartNotAllowedException (API level 31). + // See: https://developer.android.com/guide/components/foreground-services#background-start-restrictions + JitsiMeetLogger.w(TAG + " Ongoing conference service not started", e); + return; + } + + if (componentName == null) { + JitsiMeetLogger.w(TAG + " Ongoing conference service not started"); + } + } + + + public static void launch(Context context, HashMap extraData) { + List permissionsList = new ArrayList<>(); + Activity activity = (Activity) context; + + PermissionListener listener = new PermissionListener() { + @Override + public boolean onRequestPermissionsResult(int i, String[] strings, int[] results) { + int counter = 0; + + if (results.length > 0) { + for (int result : results) { + if (result == PackageManager.PERMISSION_GRANTED) { + counter++; + } + } + + if (counter == results.length){ + doLaunch(context, extraData); + JitsiMeetLogger.w(TAG + " Service launched, permissions were granted"); + } else { + JitsiMeetLogger.w(TAG + " Couldn't launch service, permissions were not granted"); + } + } + + return true; + } + }; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionsList.add(POST_NOTIFICATIONS); + permissionsList.add(RECORD_AUDIO); + } + + String[] permissionsArray = new String[ permissionsList.size() ]; + permissionsArray = permissionsList.toArray( permissionsArray ); + + if (permissionsArray.length > 0) { + JitsiMeetActivityDelegate.requestPermissions( + activity, + permissionsArray, + PERMISSIONS_REQUEST_CODE, + listener + ); + } else { + doLaunch(context, extraData); + JitsiMeetLogger.w(TAG + " Service launched"); + } + } + + public static void abort(Context context) { + Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class); + context.stopService(intent); + } + + @Override + public void onCreate() { + super.onCreate(); + + Notification notification = OngoingNotification.buildOngoingConferenceNotification(isAudioMuted, this, tapBackActivity); + if (notification == null) { + stopSelf(); + JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null"); + } else { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK | ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE); + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); + } else { + startForeground(NOTIFICATION_ID, notification); + } + } catch (Exception e) { + // Handle ForegroundServiceStartNotAllowedException when app is in background and cannot start foreground service. + // See: https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start#wiu-restrictions + JitsiMeetLogger.w(TAG + " Failed to start foreground service", e); + stopSelf(); + return; + } + } + + OngoingConferenceTracker.getInstance().addListener(this); + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(BroadcastEvent.Type.AUDIO_MUTED_CHANGED.getAction()); + LocalBroadcastManager.getInstance(getApplicationContext()).registerReceiver(broadcastReceiver, intentFilter); + } + + @Override + public void onDestroy() { + OngoingConferenceTracker.getInstance().removeListener(this); + LocalBroadcastManager.getInstance(getApplicationContext()).unregisterReceiver(broadcastReceiver); + + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + final String actionName = intent.getAction(); + final Action action = Action.fromName(actionName); + + if (action != Action.HANGUP) { + Boolean isAudioMuted = tryParseIsAudioMuted(intent); + + if (isAudioMuted != null) { + this.isAudioMuted = Boolean.parseBoolean(intent.getStringExtra("muted")); + } + + if (tapBackActivity == null) { + String targetActivityName = intent.getExtras().getString(ACTIVITY_DATA_KEY); + Class targetActivity = null; + try { + targetActivity = Class.forName(targetActivityName).asSubclass(Activity.class); + tapBackActivity = targetActivity; + } catch (ClassNotFoundException e) { + JitsiMeetLogger.w(TAG + " Could not find target Activity: " + targetActivityName); + } + } + + Notification notification = OngoingNotification.buildOngoingConferenceNotification(this.isAudioMuted, this, tapBackActivity); + if (notification == null) { + stopSelf(); + JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null"); + } else { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(NOTIFICATION_ID, notification); + } + } + + // When starting the service, there is no action passed in the intent + if (action != null) { + switch (action) { + case UNMUTE: + case MUTE: + Intent muteBroadcastIntent = BroadcastIntentHelper.buildSetAudioMutedIntent(action == Action.MUTE); + LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(muteBroadcastIntent); + break; + case HANGUP: + JitsiMeetLogger.i(TAG + " Hangup requested"); + + Intent hangupBroadcastIntent = BroadcastIntentHelper.buildHangUpIntent(); + LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(hangupBroadcastIntent); + + stopSelf(); + break; + default: + JitsiMeetLogger.w(TAG + " Unknown action received: " + action); + break; + } + } + + return START_NOT_STICKY; + } + + @Override + public void onCurrentConferenceChanged(String conferenceUrl) { + if (conferenceUrl == null) { + stopSelf(); + OngoingNotification.resetStartingtime(); + JitsiMeetLogger.i(TAG + "Service stopped"); + } + } + + public enum Action { + HANGUP(TAG + ":HANGUP"), + MUTE(TAG + ":MUTE"), + UNMUTE(TAG + ":UNMUTE"); + + private final String name; + + Action(String name) { + this.name = name; + } + + public static Action fromName(String name) { + for (Action action : Action.values()) { + if (action.name.equalsIgnoreCase(name)) { + return action; + } + } + return null; + } + + public String getName() { + return name; + } + } + + private Boolean tryParseIsAudioMuted(Intent intent) { + try { + HashMap extraData = (HashMap) intent.getBundleExtra(EXTRA_DATA_BUNDLE_KEY).getSerializable(EXTRA_DATA_KEY); + return Boolean.parseBoolean((String) extraData.get(IS_AUDIO_MUTED_KEY)); + } catch (Exception ignored) { + } + return null; + } + + private class BroadcastReceiver extends android.content.BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + Class tapBackActivity = JitsiMeetOngoingConferenceService.this.tapBackActivity; + isAudioMuted = Boolean.parseBoolean(intent.getStringExtra("muted")); + Notification notification = OngoingNotification.buildOngoingConferenceNotification(isAudioMuted, context, tapBackActivity); + if (notification == null) { + stopSelf(); + JitsiMeetLogger.w(TAG + " Couldn't update service, notification is null"); + } else { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(NOTIFICATION_ID, notification); + + JitsiMeetLogger.i(TAG + " audio muted changed"); + } + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetUncaughtExceptionHandler.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetUncaughtExceptionHandler.java new file mode 100644 index 0000000..bafc02e --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetUncaughtExceptionHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright @ 2018-present 8x8, Inc. + * Copyright @ 2017-2018 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +class JitsiMeetUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler { + private final Thread.UncaughtExceptionHandler defaultUncaughtExceptionHandler; + + public static void register() { + Thread.UncaughtExceptionHandler defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); + + JitsiMeetUncaughtExceptionHandler uncaughtExceptionHandler + = new JitsiMeetUncaughtExceptionHandler(defaultUncaughtExceptionHandler); + + Thread.setDefaultUncaughtExceptionHandler(uncaughtExceptionHandler); + } + + private JitsiMeetUncaughtExceptionHandler(Thread.UncaughtExceptionHandler defaultUncaughtExceptionHandler) { + this.defaultUncaughtExceptionHandler = defaultUncaughtExceptionHandler; + } + + @Override + public void uncaughtException(Thread t, Throwable e) { + JitsiMeetLogger.e(e, this.getClass().getSimpleName() + " FATAL ERROR"); + + // Abort all ConnectionService ongoing calls + if (AudioModeModule.useConnectionService()) { + ConnectionService.abortConnections(); + } + + if (defaultUncaughtExceptionHandler != null) { + defaultUncaughtExceptionHandler.uncaughtException(t, e); + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetUserInfo.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetUserInfo.java new file mode 100644 index 0000000..7d59909 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetUserInfo.java @@ -0,0 +1,107 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.os.Bundle; + +import java.net.MalformedURLException; +import java.net.URL; + +/** + * This class represents user information to be passed to {@link JitsiMeetConferenceOptions} for + * identifying a user. + */ +public class JitsiMeetUserInfo { + /** + * User's display name. + */ + private String displayName; + + /** + * User's email address. + */ + private String email; + + /** + * User's avatar URL. + */ + private URL avatar; + + public JitsiMeetUserInfo() {} + + public JitsiMeetUserInfo(Bundle b) { + super(); + + if (b.containsKey("displayName")) { + displayName = b.getString("displayName"); + } + + if (b.containsKey("email")) { + email = b.getString("email"); + } + + if (b.containsKey("avatarURL")) { + String avatarURL = b.getString("avatarURL"); + try { + avatar = new URL(avatarURL); + } catch (MalformedURLException e) { + } + } + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public URL getAvatar() { + return avatar; + } + + public void setAvatar(URL avatar) { + this.avatar = avatar; + } + + Bundle asBundle() { + Bundle b = new Bundle(); + + if (displayName != null) { + b.putString("displayName", displayName); + } + + if (email != null) { + b.putString("email", email); + } + + if (avatar != null) { + b.putString("avatarURL", avatar.toString()); + } + + return b; + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java new file mode 100644 index 0000000..d107d17 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java @@ -0,0 +1,229 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import android.os.Bundle; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.facebook.react.ReactRootView; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + + +public class JitsiMeetView extends FrameLayout { + + /** + * Background color. Should match the background color set in JS. + */ + public static final int BACKGROUND_COLOR = 0xFF040404; + + /** + * React Native root view. + */ + private ReactRootView reactRootView; + + /** + * Helper method to recursively merge 2 {@link Bundle} objects representing React Native props. + * + * @param a - The first {@link Bundle}. + * @param b - The second {@link Bundle}. + * @return The merged {@link Bundle} object. + */ + private static Bundle mergeProps(@Nullable Bundle a, @Nullable Bundle b) { + Bundle result = new Bundle(); + + if (a == null) { + if (b != null) { + result.putAll(b); + } + + return result; + } + + if (b == null) { + result.putAll(a); + + return result; + } + + // Start by putting all of a in the result. + result.putAll(a); + + // Iterate over each key in b and override if appropriate. + for (String key : b.keySet()) { + Object bValue = b.get(key); + Object aValue = a.get(key); + String valueType = bValue.getClass().getSimpleName(); + + if (valueType.contentEquals("Boolean")) { + result.putBoolean(key, (Boolean)bValue); + } else if (valueType.contentEquals("String")) { + result.putString(key, (String)bValue); + } else if (valueType.contentEquals("Integer")) { + result.putInt(key, (int)bValue); + } else if (valueType.contentEquals("Bundle")) { + result.putBundle(key, mergeProps((Bundle)aValue, (Bundle)bValue)); + } else { + throw new RuntimeException("Unsupported type: " + valueType); + } + } + + return result; + } + + public JitsiMeetView(@NonNull Context context) { + super(context); + initialize(context); + } + + public JitsiMeetView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context); + } + + public JitsiMeetView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context); + } + + /** + * Releases the React resources (specifically the {@link ReactRootView}) + * associated with this view. + * + * MUST be called when the {@link Activity} holding this view is destroyed, + * typically in the {@code onDestroy} method. + */ + public void dispose() { + if (reactRootView != null) { + removeView(reactRootView); + reactRootView.unmountReactApplication(); + reactRootView = null; + } + } + + /** + * Enters Picture-In-Picture mode, if possible. This method is designed to + * be called from the {@code Activity.onUserLeaveHint} method. + * + * This is currently not mandatory, but if used will provide automatic + * handling of the picture in picture mode when user minimizes the app. It + * will be probably the most useful in case the app is using the welcome + * page. + */ + public void enterPictureInPicture() { + PictureInPictureModule pipModule + = ReactInstanceManagerHolder.getNativeModule( + PictureInPictureModule.class); + if (pipModule != null + && pipModule.isPictureInPictureSupported() + && !JitsiMeetActivityDelegate.arePermissionsBeingRequested()) { + try { + pipModule.enterPictureInPicture(); + } catch (RuntimeException re) { + JitsiMeetLogger.e(re, "Failed to enter PiP mode"); + } + } + } + + /** + * Joins the conference specified by the given {@link JitsiMeetConferenceOptions}. If there is + * already an active conference, it will be left and the new one will be joined. + * @param options - Description of what conference must be joined and what options will be used + * when doing so. + */ + public void join(@Nullable JitsiMeetConferenceOptions options) { + setProps(options != null ? options.asProps() : new Bundle()); + } + + /** + * Internal method which aborts running RN by passing empty props. + * This is only meant to be used from the enclosing Activity's onDestroy. + */ + public void abort() { + setProps(new Bundle()); + } + + /** + * Creates the {@code ReactRootView} for the given app name with the given + * props. Once created it's set as the view of this {@code FrameLayout}. + * + * @param appName - The name of the "app" (in React Native terms) to load. + * @param props - The React Component props to pass to the app. + */ + private void createReactRootView(String appName, @Nullable Bundle props) { + if (props == null) { + props = new Bundle(); + } + + if (reactRootView == null) { + reactRootView = new ReactRootView(getContext()); + reactRootView.startReactApplication( + ReactInstanceManagerHolder.getReactInstanceManager(), + appName, + props); + reactRootView.setBackgroundColor(BACKGROUND_COLOR); + addView(reactRootView); + } else { + reactRootView.setAppProperties(props); + } + } + + private void initialize(@NonNull Context context) { + // Check if the parent Activity implements JitsiMeetActivityInterface, + // otherwise things may go wrong. + if (!(context instanceof JitsiMeetActivityInterface)) { + throw new RuntimeException("Enclosing Activity must implement JitsiMeetActivityInterface"); + } + + setBackgroundColor(BACKGROUND_COLOR); + } + + /** + * Helper method to set the React Native props. + * @param newProps - New props to be set on the React Native view. + */ + private void setProps(@NonNull Bundle newProps) { + // Merge the default options with the newly provided ones. + Bundle props = mergeProps(JitsiMeet.getDefaultProps(), newProps); + + // XXX The setProps() method is supposed to be imperative i.e. + // a second invocation with one and the same URL is expected to join + // the respective conference again if the first invocation was followed + // by leaving the conference. However, React and, respectively, + // appProperties/initialProperties are declarative expressions i.e. one + // and the same URL will not trigger an automatic re-render in the + // JavaScript source code. The workaround implemented below introduces + // "imperativeness" in React Component props by defining a unique value + // per setProps() invocation. + props.putLong("timestamp", System.currentTimeMillis()); + + createReactRootView("App", props); + } + + @Override + protected void onDetachedFromWindow() { + dispose(); + super.onDetachedFromWindow(); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiVideoDecoderFactory.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiVideoDecoderFactory.java new file mode 100644 index 0000000..1beaf4c --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiVideoDecoderFactory.java @@ -0,0 +1,84 @@ +package org.jitsi.meet.sdk; + +/* + * Copyright 2017 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +import android.media.MediaCodecInfo; +import androidx.annotation.Nullable; + +import com.oney.WebRTCModule.webrtcutils.SoftwareVideoDecoderFactoryProxy; + +import org.webrtc.EglBase; +import org.webrtc.HardwareVideoDecoderFactory; +import org.webrtc.JitsiPlatformVideoDecoderFactory; +import org.webrtc.Predicate; +import org.webrtc.VideoCodecInfo; +import org.webrtc.VideoDecoder; +import org.webrtc.VideoDecoderFactory; +import org.webrtc.VideoDecoderFallback; + +import java.util.Arrays; +import java.util.LinkedHashSet; + +/** + * Custom decoder factory which uses HW decoders and falls back to SW. + */ +public class JitsiVideoDecoderFactory implements VideoDecoderFactory { + private final VideoDecoderFactory hardwareVideoDecoderFactory; + private final VideoDecoderFactory softwareVideoDecoderFactory = new SoftwareVideoDecoderFactoryProxy(); + private final VideoDecoderFactory platformSoftwareVideoDecoderFactory; + + /** + * Predicate to filter out the AV1 hardware decoder, as we've seen decoding issues with it. + */ + private static final String GOOGLE_AV1_DECODER = "c2.google.av1"; + private static final Predicate hwCodecPredicate = arg -> { + // Filter out the Google AV1 codec. + return !arg.getName().startsWith(GOOGLE_AV1_DECODER); + }; + private static final Predicate swCodecPredicate = arg -> { + // Noop, just making sure we can customize it easily if needed. + return true; + }; + + /** + * Create decoder factory using default hardware decoder factory. + */ + public JitsiVideoDecoderFactory(@Nullable EglBase.Context eglContext) { + this.hardwareVideoDecoderFactory = new HardwareVideoDecoderFactory(eglContext, hwCodecPredicate); + this.platformSoftwareVideoDecoderFactory = new JitsiPlatformVideoDecoderFactory(eglContext, swCodecPredicate); + } + + @Override + public @Nullable VideoDecoder createDecoder(VideoCodecInfo codecType) { + VideoDecoder softwareDecoder = softwareVideoDecoderFactory.createDecoder(codecType); + final VideoDecoder hardwareDecoder = hardwareVideoDecoderFactory.createDecoder(codecType); + if (softwareDecoder == null) { + softwareDecoder = platformSoftwareVideoDecoderFactory.createDecoder(codecType); + } + if (hardwareDecoder != null && softwareDecoder != null) { + // Both hardware and software supported, wrap it in a software fallback + return new VideoDecoderFallback( + /* fallback= */ softwareDecoder, /* primary= */ hardwareDecoder); + } + return hardwareDecoder != null ? hardwareDecoder : softwareDecoder; + } + + @Override + public VideoCodecInfo[] getSupportedCodecs() { + LinkedHashSet supportedCodecInfos = new LinkedHashSet<>(); + + supportedCodecInfos.addAll(Arrays.asList(softwareVideoDecoderFactory.getSupportedCodecs())); + supportedCodecInfos.addAll(Arrays.asList(hardwareVideoDecoderFactory.getSupportedCodecs())); + supportedCodecInfos.addAll(Arrays.asList(platformSoftwareVideoDecoderFactory.getSupportedCodecs())); + + return supportedCodecInfos.toArray(new VideoCodecInfo[supportedCodecInfos.size()]); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiVideoEncoderFactory.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiVideoEncoderFactory.java new file mode 100644 index 0000000..5d075ab --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiVideoEncoderFactory.java @@ -0,0 +1,16 @@ +package org.jitsi.meet.sdk; + +import androidx.annotation.Nullable; + +import com.oney.WebRTCModule.webrtcutils.H264AndSoftwareVideoEncoderFactory; + +import org.webrtc.EglBase; + +/** + * Custom encoder factory which uses HW for H.264 and SW for everything else. + */ +public class JitsiVideoEncoderFactory extends H264AndSoftwareVideoEncoderFactory { + public JitsiVideoEncoderFactory(@Nullable EglBase.Context eglContext) { + super(eglContext); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/LocaleDetector.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/LocaleDetector.java new file mode 100644 index 0000000..73b5a86 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/LocaleDetector.java @@ -0,0 +1,58 @@ +/* + * Copyright @ 2018-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Based on https://github.com/DylanVann/react-native-locale-detector + */ + +package org.jitsi.meet.sdk; + +import android.content.Context; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; + +import java.util.HashMap; +import java.util.Map; + +/** + * Module which provides information about the system locale. + */ +class LocaleDetector extends ReactContextBaseJavaModule { + + public LocaleDetector(ReactApplicationContext reactContext) { + super(reactContext); + } + + /** + * Gets a {@code Map} of constants this module exports to JS. Supports JSON + * types. + * + * @return a {@link Map} of constants this module exports to JS + */ + @Override + public Map getConstants() { + Context context = getReactApplicationContext(); + HashMap constants = new HashMap<>(); + constants.put("locale", context.getResources().getConfiguration().locale.toLanguageTag()); + return constants; + } + + @Override + public String getName() { + return "LocaleDetector"; + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/LogBridgeModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/LogBridgeModule.java new file mode 100644 index 0000000..2b7d349 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/LogBridgeModule.java @@ -0,0 +1,73 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.module.annotations.ReactModule; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import javax.annotation.Nonnull; + +/** + * Module implementing a "bridge" between the JS loggers and the native one. + */ +@ReactModule(name = LogBridgeModule.NAME) +class LogBridgeModule extends ReactContextBaseJavaModule { + public static final String NAME = "LogBridge"; + + public LogBridgeModule(@Nonnull ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return NAME; + } + + @ReactMethod + public void trace(final String message) { + JitsiMeetLogger.v(message); + } + + @ReactMethod + public void debug(final String message) { + JitsiMeetLogger.d(message); + } + + @ReactMethod + public void info(final String message) { + JitsiMeetLogger.i(message); + } + + @ReactMethod + public void log(final String message) { + JitsiMeetLogger.i(message); + } + + @ReactMethod + public void warn(final String message) { + JitsiMeetLogger.w(message); + } + + @ReactMethod + public void error(final String message) { + JitsiMeetLogger.e(message); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingConferenceTracker.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingConferenceTracker.java new file mode 100644 index 0000000..f603d67 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingConferenceTracker.java @@ -0,0 +1,99 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import com.facebook.react.bridge.ReadableMap; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; + + +/** + * Helper class to keep track of what the current conference is. + */ +class OngoingConferenceTracker { + private static final OngoingConferenceTracker instance = new OngoingConferenceTracker(); + + private static final String CONFERENCE_WILL_JOIN = "CONFERENCE_WILL_JOIN"; + private static final String CONFERENCE_TERMINATED = "CONFERENCE_TERMINATED"; + + private final Collection listeners = + Collections.synchronizedSet(new HashSet()); + private String currentConference; + + public OngoingConferenceTracker() { + } + + public static OngoingConferenceTracker getInstance() { + return instance; + } + + /** + * Gets the current active conference URL. + * + * @return - The current conference URL as a String. + */ + synchronized String getCurrentConference() { + return currentConference; + } + + synchronized void onExternalAPIEvent(String name, ReadableMap data) { + if (!data.hasKey("url")) { + return; + } + + String url = data.getString("url"); + if (url == null) { + return; + } + + switch(name) { + case CONFERENCE_WILL_JOIN: + currentConference = url; + updateListeners(); + break; + + case CONFERENCE_TERMINATED: + if (url.equals(currentConference)) { + currentConference = null; + updateListeners(); + } + break; + } + } + + void addListener(OngoingConferenceListener listener) { + listeners.add(listener); + } + + void removeListener(OngoingConferenceListener listener) { + listeners.remove(listener); + } + + private void updateListeners() { + synchronized (listeners) { + for (OngoingConferenceListener listener : listeners) { + listener.onCurrentConferenceChanged(currentConference); + } + } + } + + public interface OngoingConferenceListener { + void onCurrentConferenceChanged(String conferenceUrl); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingNotification.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingNotification.java new file mode 100644 index 0000000..63484f9 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingNotification.java @@ -0,0 +1,124 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import android.app.Activity; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; + + +/** + * Helper class for creating the ongoing notification which is used with + * {@link JitsiMeetOngoingConferenceService}. It allows the user to easily get back to the app + * and to hangup from within the notification itself. + */ +class OngoingNotification { + private static final String TAG = OngoingNotification.class.getSimpleName(); + + private static long startingTime = 0; + + static final String ONGOING_CONFERENCE_CHANNEL_ID = "JitsiOngoingConferenceChannel"; + + static void createNotificationChannel(Activity context) { + if (context == null) { + JitsiMeetLogger.w(TAG + " Cannot create notification channel: no current context"); + return; + } + + NotificationManager notificationManager + = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + NotificationChannel channel + = notificationManager.getNotificationChannel(ONGOING_CONFERENCE_CHANNEL_ID); + + if (channel != null) { + // The channel was already created, no need to do it again. + return; + } + + channel = new NotificationChannel(ONGOING_CONFERENCE_CHANNEL_ID, context.getString(R.string.ongoing_notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT); + channel.enableLights(false); + channel.enableVibration(false); + channel.setShowBadge(false); + + notificationManager.createNotificationChannel(channel); + } + + static Notification buildOngoingConferenceNotification(Boolean isMuted, Context context, Class tapBackActivity) { + if (context == null) { + JitsiMeetLogger.w(TAG + " Cannot create notification: no current context"); + return null; + } + + Intent notificationIntent = new Intent(context, tapBackActivity == null ? context.getClass() : tapBackActivity); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, ONGOING_CONFERENCE_CHANNEL_ID); + + if (startingTime == 0) { + startingTime = System.currentTimeMillis(); + } + + builder + .setCategory(NotificationCompat.CATEGORY_CALL) + .setContentTitle(context.getString(R.string.ongoing_notification_title)) + .setContentText(context.getString(R.string.ongoing_notification_text)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setWhen(startingTime) + .setUsesChronometer(true) + .setAutoCancel(false) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) + .setSmallIcon(context.getResources().getIdentifier("ic_notification", "drawable", context.getPackageName())); + + NotificationCompat.Action hangupAction = createAction(context, JitsiMeetOngoingConferenceService.Action.HANGUP, R.string.ongoing_notification_action_hang_up); + + JitsiMeetOngoingConferenceService.Action toggleAudioAction = isMuted + ? JitsiMeetOngoingConferenceService.Action.UNMUTE : JitsiMeetOngoingConferenceService.Action.MUTE; + int toggleAudioTitle = isMuted ? R.string.ongoing_notification_action_unmute : R.string.ongoing_notification_action_mute; + NotificationCompat.Action audioAction = createAction(context, toggleAudioAction, toggleAudioTitle); + + builder.addAction(hangupAction); + builder.addAction(audioAction); + + return builder.build(); + } + + static void resetStartingtime() { + startingTime = 0; + } + + private static NotificationCompat.Action createAction(Context context, JitsiMeetOngoingConferenceService.Action action, @StringRes int titleId) { + Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class); + intent.setAction(action.getName()); + PendingIntent pendingIntent + = PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); + String title = context.getString(titleId); + return new NotificationCompat.Action(0, title, pendingIntent); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ParticipantInfo.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ParticipantInfo.java new file mode 100644 index 0000000..ad95159 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ParticipantInfo.java @@ -0,0 +1,27 @@ +package org.jitsi.meet.sdk; + +import com.google.gson.annotations.SerializedName; + +public class ParticipantInfo { + + @SerializedName("participantId") + public String id; + + @SerializedName("displayName") + public String displayName; + + @SerializedName("avatarUrl") + public String avatarUrl; + + @SerializedName("email") + public String email; + + @SerializedName("name") + public String name; + + @SerializedName("isLocal") + public boolean isLocal; + + @SerializedName("role") + public String role; +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ParticipantsService.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ParticipantsService.java new file mode 100644 index 0000000..667f538 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ParticipantsService.java @@ -0,0 +1,90 @@ +package org.jitsi.meet.sdk; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import javax.annotation.Nullable; + +public class ParticipantsService extends android.content.BroadcastReceiver { + + private static final String TAG = ParticipantsService.class.getSimpleName(); + private static final String REQUEST_ID = "requestId"; + + private final Map> participantsInfoCallbackMap = new HashMap<>(); + + private static ParticipantsService instance; + + @Nullable + public static ParticipantsService getInstance() { + return instance; + } + + private ParticipantsService(Context context) { + LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(context); + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(BroadcastEvent.Type.PARTICIPANTS_INFO_RETRIEVED.getAction()); + localBroadcastManager.registerReceiver(this, intentFilter); + } + + static void init(Context context) { + instance = new ParticipantsService(context); + } + + public void retrieveParticipantsInfo(ParticipantsInfoCallback participantsInfoCallback) { + String callbackKey = UUID.randomUUID().toString(); + this.participantsInfoCallbackMap.put(callbackKey, new WeakReference<>(participantsInfoCallback)); + + String actionName = BroadcastAction.Type.RETRIEVE_PARTICIPANTS_INFO.getAction(); + WritableMap data = Arguments.createMap(); + data.putString(REQUEST_ID, callbackKey); + ReactInstanceManagerHolder.emitEvent(actionName, data); + } + + @Override + public void onReceive(Context context, Intent intent) { + BroadcastEvent event = new BroadcastEvent(intent); + + switch (event.getType()) { + case PARTICIPANTS_INFO_RETRIEVED: + try { + List participantInfoList = new Gson().fromJson( + event.getData().get("participantsInfo").toString(), + new TypeToken>() { + }.getType()); + + ParticipantsInfoCallback participantsInfoCallback = this.participantsInfoCallbackMap.get(event.getData().get(REQUEST_ID).toString()).get(); + + if (participantsInfoCallback != null) { + participantsInfoCallback.onReceived(participantInfoList); + this.participantsInfoCallbackMap.remove(participantsInfoCallback); + } + } catch (Exception e) { + JitsiMeetLogger.w(TAG + "error parsing participantsList", e); + } + + break; + } + } + + public interface ParticipantsInfoCallback { + void onReceived(List participantInfoList); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/PictureInPictureModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/PictureInPictureModule.java new file mode 100644 index 0000000..4a95846 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/PictureInPictureModule.java @@ -0,0 +1,145 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.PictureInPictureParams; +import android.util.Rational; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.module.annotations.ReactModule; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.HashMap; +import java.util.Map; + +import static android.content.Context.ACTIVITY_SERVICE; + +@ReactModule(name = PictureInPictureModule.NAME) +class PictureInPictureModule extends ReactContextBaseJavaModule { + + public static final String NAME = "PictureInPicture"; + private static final String TAG = NAME; + + private static boolean isSupported; + private boolean isEnabled; + + public PictureInPictureModule(ReactApplicationContext reactContext) { + super(reactContext); + + ActivityManager am = (ActivityManager) reactContext.getSystemService(ACTIVITY_SERVICE); + + // Android Go devices don't support PiP. There doesn't seem to be a better way to detect it than + // to use ActivityManager.isLowRamDevice(). + // https://stackoverflow.com/questions/58340558/how-to-detect-android-go + isSupported = !am.isLowRamDevice(); + } + + /** + * Gets a {@code Map} of constants this module exports to JS. Supports JSON + * types. + * + * @return a {@link Map} of constants this module exports to JS + */ + @Override + public Map getConstants() { + Map constants = new HashMap<>(); + constants.put("SUPPORTED", isSupported); + return constants; + } + + /** + * Enters Picture-in-Picture (mode) for the current {@link Activity}. + * Supported on Android API >= 26 (Oreo) only. + * + * @throws IllegalStateException if {@link #isPictureInPictureSupported()} + * returns {@code false} or if {@link #getCurrentActivity()} returns + * {@code null}. + * @throws RuntimeException if + * {@link Activity#enterPictureInPictureMode(PictureInPictureParams)} fails. + * That method can also throw a {@link RuntimeException} in various cases, + * including when the activity is not visible (paused or stopped), if the + * screen is locked or if the user has an activity pinned. + */ + public void enterPictureInPicture() { + if (!isEnabled) { + return; + } + + if (!isSupported) { + throw new IllegalStateException("Picture-in-Picture not supported"); + } + + Activity currentActivity = getCurrentActivity(); + + if (currentActivity == null) { + throw new IllegalStateException("No current Activity!"); + } + + JitsiMeetLogger.i(TAG + " Entering Picture-in-Picture"); + + PictureInPictureParams.Builder builder + = new PictureInPictureParams.Builder() + .setAspectRatio(new Rational(1, 1)); + + // https://developer.android.com/reference/android/app/Activity.html#enterPictureInPictureMode(android.app.PictureInPictureParams) + // + // The system may disallow entering picture-in-picture in various cases, + // including when the activity is not visible, if the screen is locked + // or if the user has an activity pinned. + if (!currentActivity.enterPictureInPictureMode(builder.build())) { + throw new RuntimeException("Failed to enter Picture-in-Picture"); + } + } + + /** + * Enters Picture-in-Picture (mode) for the current {@link Activity}. + * Supported on Android API >= 26 (Oreo) only. + * + * @param promise a {@code Promise} which will resolve with a {@code null} + * value upon success, and an {@link Exception} otherwise. + */ + @ReactMethod + public void enterPictureInPicture(Promise promise) { + try { + enterPictureInPicture(); + promise.resolve(null); + } catch (RuntimeException re) { + promise.reject(re); + } + } + + @ReactMethod + public void setPictureInPictureEnabled(Boolean enabled) { + this.isEnabled = enabled; + } + + public boolean isPictureInPictureSupported() { + return isSupported; + } + + @Override + public String getName() { + return NAME; + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ProximityModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ProximityModule.java new file mode 100644 index 0000000..68e4354 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ProximityModule.java @@ -0,0 +1,107 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.content.Context; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.module.annotations.ReactModule; + +/** + * Module implementing a simple API to enable a proximity sensor-controlled + * wake lock. When the lock is held, if the proximity sensor detects a nearby + * object it will dim the screen and disable touch controls. The functionality + * is used with the conference audio-only mode. + */ +@ReactModule(name = ProximityModule.NAME) +class ProximityModule extends ReactContextBaseJavaModule { + + public static final String NAME = "Proximity"; + + /** + * {@link WakeLock} instance. + */ + private final WakeLock wakeLock; + + /** + * Initializes a new module instance. There shall be a single instance of + * this module throughout the lifetime of the application. + * + * @param reactContext The {@link ReactApplicationContext} where this module + * is created. + */ + public ProximityModule(ReactApplicationContext reactContext) { + super(reactContext); + + WakeLock wakeLock; + PowerManager powerManager + = (PowerManager) + reactContext.getSystemService(Context.POWER_SERVICE); + + try { + wakeLock + = powerManager.newWakeLock( + PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, + "jitsi:"+NAME); + } catch (Throwable ignored) { + wakeLock = null; + } + + this.wakeLock = wakeLock; + } + + /** + * Gets the name of this module to be used in the React Native bridge. + * + * @return The name of this module to be used in the React Native bridge. + */ + @Override + public String getName() { + return NAME; + } + + /** + * Acquires / releases the proximity sensor wake lock. + * + * @param enabled {@code true} to enable the proximity sensor; otherwise, + * {@code false}. + */ + @ReactMethod + public void setEnabled(final boolean enabled) { + if (wakeLock == null) { + return; + } + + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + if (enabled) { + if (!wakeLock.isHeld()) { + wakeLock.acquire(); + } + } else if (wakeLock.isHeld()) { + wakeLock.release(); + } + } + }); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/RNConnectionService.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/RNConnectionService.java new file mode 100644 index 0000000..24451e7 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/RNConnectionService.java @@ -0,0 +1,255 @@ +package org.jitsi.meet.sdk; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.telecom.DisconnectCause; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import android.telecom.VideoProfile; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.modules.core.DeviceEventManagerModule; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +/** + * The react-native side of Jitsi Meet's {@link ConnectionService}. Exposes + * the Java Script API. + * + * @author Pawel Domas + */ +@ReactModule(name = RNConnectionService.NAME) +class RNConnectionService extends ReactContextBaseJavaModule { + + public static final String NAME = "ConnectionService"; + + private static final String TAG = ConnectionService.TAG; + + private static RNConnectionService sRNConnectionServiceInstance; + /** + * Handler for dealing with call state changes. We are acting as a proxy between ConnectionService + * and other modules such as {@link AudioModeModule}. + */ + private CallAudioStateListener callAudioStateListener; + + /** + * Sets the audio route on all existing {@link android.telecom.Connection}s + * + * @param audioRoute the new audio route to be set. See + * {@link android.telecom.CallAudioState} constants prefixed with "ROUTE_". + */ + static void setAudioRoute(int audioRoute) { + for (ConnectionService.ConnectionImpl c + : ConnectionService.getConnections()) { + c.setAudioRoute(audioRoute); + } + } + + RNConnectionService(ReactApplicationContext reactContext) { + super(reactContext); + sRNConnectionServiceInstance = this; + } + + static RNConnectionService getInstance() { + return sRNConnectionServiceInstance; + } + + @ReactMethod + public void addListener(String eventName) { + // Keep: Required for RN built in Event Emitter Calls. + } + + @ReactMethod + public void removeListeners(Integer count) { + // Keep: Required for RN built in Event Emitter Calls. + } + + /** + * Starts a new outgoing call. + * + * @param callUUID - unique call identifier assigned by Jitsi Meet to + * a conference call. + * @param handle - a call handle which by default is Jitsi Meet room's URL. + * @param hasVideo - whether or not user starts with the video turned on. + * @param promise - the Promise instance passed by the React-native bridge, + * so that this method returns a Promise on the JS side. + * + * NOTE regarding the "missingPermission" suppress - SecurityException will + * be handled as part of the Exception try catch block and the Promise will + * be rejected. + */ + @SuppressLint("MissingPermission") + @ReactMethod + public void startCall( + String callUUID, + String handle, + boolean hasVideo, + Promise promise) { + JitsiMeetLogger.d("%s startCall UUID=%s, h=%s, v=%s", + TAG, + callUUID, + handle, + hasVideo); + + ReactApplicationContext ctx = getReactApplicationContext(); + + Uri address = Uri.fromParts(PhoneAccount.SCHEME_SIP, handle, null); + PhoneAccountHandle accountHandle; + + try { + accountHandle + = ConnectionService.registerPhoneAccount(getReactApplicationContext(), address, callUUID); + } catch (Throwable tr) { + JitsiMeetLogger.e(tr, TAG + " error in startCall"); + + promise.reject(tr); + return; + } + + Bundle extras = new Bundle(); + extras.putParcelable( + TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, + accountHandle); + extras.putInt( + TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, + hasVideo + ? VideoProfile.STATE_BIDIRECTIONAL + : VideoProfile.STATE_AUDIO_ONLY); + + ConnectionService.registerStartCallPromise(callUUID, promise); + + TelecomManager tm = null; + + try { + tm = (TelecomManager) ctx.getSystemService(Context.TELECOM_SERVICE); + tm.placeCall(address, extras); + } catch (Throwable tr) { + JitsiMeetLogger.e(tr, TAG + " error in startCall"); + if (tm != null) { + try { + tm.unregisterPhoneAccount(accountHandle); + } catch (Throwable tr1) { + // UnsupportedOperationException: System does not support feature android.software.connectionservice + // was observed here. Ignore. + } + } + ConnectionService.unregisterStartCallPromise(callUUID); + promise.reject(tr); + } + } + + /** + * Called by the JS side of things to mark the call as failed. + * + * @param callUUID - the call's UUID. + */ + @ReactMethod + public void reportCallFailed(String callUUID) { + JitsiMeetLogger.d(TAG + " reportCallFailed " + callUUID); + ConnectionService.setConnectionDisconnected( + callUUID, + new DisconnectCause(DisconnectCause.ERROR)); + } + + /** + * Called by the JS side of things to mark the call as disconnected. + * + * @param callUUID - the call's UUID. + */ + @ReactMethod + public void endCall(String callUUID) { + JitsiMeetLogger.d(TAG + " endCall " + callUUID); + ConnectionService.setConnectionDisconnected( + callUUID, + new DisconnectCause(DisconnectCause.LOCAL)); + } + + /** + * Called by the JS side of things to mark the call as active. + * + * @param callUUID - the call's UUID. + */ + @ReactMethod + public void reportConnectedOutgoingCall(String callUUID, Promise promise) { + JitsiMeetLogger.d(TAG + " reportConnectedOutgoingCall " + callUUID); + if (ConnectionService.setConnectionActive(callUUID)) { + promise.resolve(null); + } else { + promise.reject("CONNECTION_NOT_FOUND_ERROR", "Connection wasn't found."); + } + } + + @Override + public String getName() { + return NAME; + } + + /** + * Called by the JS side to update the call's state. + * + * @param callUUID - the call's UUID. + * @param callState - the map which carries info about the current call's + * state. See static fields in {@link ConnectionService.ConnectionImpl} + * prefixed with "KEY_" for the values supported by the Android + * implementation. + */ + @ReactMethod + public void updateCall(String callUUID, ReadableMap callState) { + ConnectionService.updateCall(callUUID, callState); + } + + public CallAudioStateListener getCallAudioStateListener() { + return callAudioStateListener; + } + + public void setCallAudioStateListener(CallAudioStateListener callAudioStateListener) { + this.callAudioStateListener = callAudioStateListener; + } + + /** + * Handler for call state changes. {@code ConnectionServiceImpl} will call this handler when the + * call audio state changes. + * + * @param callAudioState The current call's audio state. + */ + void onCallAudioStateChange(android.telecom.CallAudioState callAudioState) { + if (callAudioStateListener != null) { + callAudioStateListener.onCallAudioStateChange(callAudioState); + } + } + + interface CallAudioStateListener { + void onCallAudioStateChange(android.telecom.CallAudioState callAudioState); + } + + /** + * Helper function to send an event to JavaScript. + * + * @param eventName {@code String} containing the event name. + * @param data {@code Object} optional ancillary data for the event. + */ + void emitEvent( + String eventName, + @Nullable Object data) { + ReactContext reactContext = getReactApplicationContext(); + + if (reactContext != null) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(eventName, data); + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactInstanceManagerHolder.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactInstanceManagerHolder.java new file mode 100644 index 0000000..cac5e78 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactInstanceManagerHolder.java @@ -0,0 +1,243 @@ +/* + * Copyright @ 2017-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +import android.annotation.SuppressLint; +import android.app.Application; + +import androidx.annotation.Nullable; + +import com.facebook.hermes.reactexecutor.HermesExecutorFactory; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.common.LifecycleState; +import com.facebook.react.modules.core.DeviceEventManagerModule; +import com.facebook.react.uimanager.ViewManager; +import com.oney.WebRTCModule.EglUtils; +import com.oney.WebRTCModule.WebRTCModuleOptions; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; +import org.webrtc.EglBase; + +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +class ReactInstanceManagerHolder { + private static final String TAG = ReactInstanceManagerHolder.class.getSimpleName(); + + /** + * FIXME (from linter): Do not place Android context classes in static + * fields (static reference to ReactInstanceManager which has field + * mApplicationContext pointing to Context); this is a memory leak (and + * also breaks Instant Run). + * + * React Native bridge. The instance manager allows embedding applications + * to create multiple root views off the same JavaScript bundle. + */ + private static ReactInstanceManager reactInstanceManager; + + private static List createNativeModules(ReactApplicationContext reactContext) { + List nativeModules + = new ArrayList<>(Arrays.asList( + new AndroidSettingsModule(reactContext), + new AppInfoModule(reactContext), + new AudioModeModule(reactContext), + new DropboxModule(reactContext), + new ExternalAPIModule(reactContext), + new LocaleDetector(reactContext), + new LogBridgeModule(reactContext), + new PictureInPictureModule(reactContext), + new ProximityModule(reactContext), + new org.jitsi.meet.sdk.net.NAT64AddrInfoModule(reactContext))); + + if (AudioModeModule.useConnectionService()) { + nativeModules.add(new RNConnectionService(reactContext)); + } + + return nativeModules; + } + + private static List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + static List getReactNativePackages() { + List packages + = new ArrayList<>(Arrays.asList( + new com.reactnativecommunity.asyncstorage.AsyncStoragePackage(), + new com.ocetnik.timer.BackgroundTimerPackage(), + new com.calendarevents.RNCalendarEventsPackage(), + new com.sayem.keepawake.KCKeepAwakePackage(), + new com.facebook.react.shell.MainReactPackage(), + new com.reactnativecommunity.clipboard.ClipboardPackage(), + new com.reactnativecommunity.netinfo.NetInfoPackage(), + new com.reactnativepagerview.PagerViewPackage(), + new com.oblador.performance.PerformancePackage(), + new com.reactnativecommunity.slider.ReactSliderPackage(), + new com.brentvatne.react.ReactVideoPackage(), + new com.reactnativecommunity.webview.RNCWebViewPackage(), + new com.kevinresol.react_native_default_preference.RNDefaultPreferencePackage(), + new com.learnium.RNDeviceInfo.RNDeviceInfo(), + new com.oney.WebRTCModule.WebRTCModulePackage(), + new com.swmansion.gesturehandler.RNGestureHandlerPackage(), + new org.linusu.RNGetRandomValuesPackage(), + new com.swmansion.rnscreens.RNScreensPackage(), + new com.zmxv.RNSound.RNSoundPackage(), + new com.th3rdwave.safeareacontext.SafeAreaContextPackage(), + new com.horcrux.svg.SvgPackage(), + new org.wonday.orientation.OrientationPackage(), + new com.splashview.SplashViewPackage(), + new com.worklets.WorkletsCorePackage(), + new ReactPackageAdapter() { + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + return ReactInstanceManagerHolder.createNativeModules(reactContext); + } + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return ReactInstanceManagerHolder.createViewManagers(reactContext); + } + })); + + // AmplitudeReactNativePackage + try { + Class amplitudePackageClass = Class.forName("com.amplitude.reactnative.AmplitudeReactNativePackage"); + Constructor constructor = amplitudePackageClass.getConstructor(); + packages.add((ReactPackage)constructor.newInstance()); + } catch (Exception e) { + // Ignore any error, the module is not compiled when LIBRE_BUILD is enabled. + JitsiMeetLogger.d(TAG, "Not loading AmplitudeReactNativePackage"); + } + + // GiphyReactNativeSdkPackage + try { + Class giphyPackageClass = Class.forName("com.giphyreactnativesdk.RTNGiphySdkPackage"); + Constructor constructor = giphyPackageClass.getConstructor(); + packages.add((ReactPackage)constructor.newInstance()); + } catch (Exception e) { + // Ignore any error, the module is not compiled when LIBRE_BUILD is enabled. + JitsiMeetLogger.d(TAG, "Not loading GiphyReactNativeSdkPackage"); + } + + // RNGoogleSignInPackage + try { + Class googlePackageClass = Class.forName("com.reactnativegooglesignin.RNGoogleSigninPackage"); + Constructor constructor = googlePackageClass.getConstructor(); + packages.add((ReactPackage)constructor.newInstance()); + } catch (Exception e) { + // Ignore any error, the module is not compiled when LIBRE_BUILD is enabled. + JitsiMeetLogger.d(TAG, "Not loading RNGoogleSignInPackage"); + } + + return packages; + } + + /** + * Helper function to send an event to JavaScript. + * + * @param eventName {@code String} containing the event name. + * @param data {@code Object} optional ancillary data for the event. + */ + static void emitEvent( + String eventName, + @Nullable Object data) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + @SuppressLint("VisibleForTests") ReactContext reactContext + = reactInstanceManager.getCurrentReactContext(); + + if (reactContext != null) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(eventName, data); + } + } + } + + /** + * Finds a native React module for given class. + * + * @param nativeModuleClass the native module's class for which an instance + * is to be retrieved from the {@link #reactInstanceManager}. + * @param the module's type. + * @return {@link NativeModule} instance for given interface type or + * {@code null} if no instance for this interface is available, or if + * {@link #reactInstanceManager} has not been initialized yet. + */ + static T getNativeModule( + Class nativeModuleClass) { + @SuppressLint("VisibleForTests") ReactContext reactContext + = reactInstanceManager != null + ? reactInstanceManager.getCurrentReactContext() : null; + + return reactContext != null + ? reactContext.getNativeModule(nativeModuleClass) : null; + } + + static ReactInstanceManager getReactInstanceManager() { + return reactInstanceManager; + } + + /** + * Internal method to initialize the React Native instance manager. We + * create a single instance in order to load the JavaScript bundle a single + * time. All {@code ReactRootView} instances will be tied to the one and + * only {@code ReactInstanceManager}. + * + * @param app {@code Application} + */ + static void initReactInstanceManager(Application app) { + if (reactInstanceManager != null) { + return; + } + + // Initialize the WebRTC module options. + WebRTCModuleOptions options = WebRTCModuleOptions.getInstance(); + options.enableMediaProjectionService = true; + if (options.videoDecoderFactory == null || options.videoEncoderFactory == null) { + EglBase.Context eglContext = EglUtils.getRootEglBaseContext(); + if (options.videoDecoderFactory == null) { + options.videoDecoderFactory = new JitsiVideoDecoderFactory(eglContext); + } + if (options.videoEncoderFactory == null) { + options.videoEncoderFactory = new JitsiVideoEncoderFactory(eglContext); + } + } + + JitsiMeetLogger.d(TAG, "initializing RN"); + + reactInstanceManager + = ReactInstanceManager.builder() + .setApplication(app) + .setCurrentActivity(null) + .setBundleAssetName("index.android.bundle") + .setJSMainModulePath("index.android") + .setJavaScriptExecutorFactory(new HermesExecutorFactory()) + .addPackages(getReactNativePackages()) + .setUseDeveloperSupport(BuildConfig.DEBUG) + .setInitialLifecycleState(LifecycleState.BEFORE_CREATE) + .build(); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactPackageAdapter.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactPackageAdapter.java new file mode 100644 index 0000000..fe9c4b8 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactPackageAdapter.java @@ -0,0 +1,41 @@ +/* + * Copyright @ 2017-present Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk; + +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; + +class ReactPackageAdapter + implements ReactPackage { + + @Override + public List createNativeModules( + ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createViewManagers( + ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/log/JitsiMeetBaseLogHandler.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/log/JitsiMeetBaseLogHandler.java new file mode 100644 index 0000000..0a8b288 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/log/JitsiMeetBaseLogHandler.java @@ -0,0 +1,49 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk.log; + +import android.util.Log; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.text.MessageFormat; + +import timber.log.Timber; + +/** + * Base class for all custom log handlers. Implementations must inherit from this class and + * implement a `doLog` method which does the actual logging, in addition with a `getTag` method + * with which to tag all logs coming into this logger. + * + * See {@link JitsiMeetDefaultLogHandler} for an example. + */ +public abstract class JitsiMeetBaseLogHandler extends Timber.Tree { + @Override + protected void log(int priority, @Nullable String tag, @NotNull String msg, @Nullable Throwable t) { + String errmsg = Log.getStackTraceString(t); + if (errmsg.isEmpty()) { + doLog(priority, getDefaultTag(), msg); + } else { + doLog(priority, getDefaultTag(), MessageFormat.format("{0}\n{1}", msg, errmsg)); + } + } + + protected abstract void doLog(int priority, @NotNull String tag, @NotNull String msg); + + protected abstract String getDefaultTag(); +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/log/JitsiMeetDefaultLogHandler.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/log/JitsiMeetDefaultLogHandler.java new file mode 100644 index 0000000..09fb63c --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/log/JitsiMeetDefaultLogHandler.java @@ -0,0 +1,39 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk.log; + +import android.util.Log; + +import org.jetbrains.annotations.NotNull; + +/** + * Default implementation of a {@link JitsiMeetBaseLogHandler}. This is the main SDK logger, which + * logs using the Android util.Log module. + */ +public class JitsiMeetDefaultLogHandler extends JitsiMeetBaseLogHandler { + private static final String TAG = "JitsiMeetSDK"; + + @Override + protected void doLog(int priority, @NotNull String tag, @NotNull String msg) { + Log.println(priority, tag, msg); + } + + @Override + protected String getDefaultTag() { + return TAG; + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/log/JitsiMeetLogger.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/log/JitsiMeetLogger.java new file mode 100644 index 0000000..b0765a9 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/log/JitsiMeetLogger.java @@ -0,0 +1,107 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jitsi.meet.sdk.log; + +import timber.log.Timber; + +public class JitsiMeetLogger { + static { + addHandler(new JitsiMeetDefaultLogHandler()); + } + + public static void addHandler(JitsiMeetBaseLogHandler handler) { + if (!Timber.forest().contains(handler)) { + try { + Timber.plant(handler); + } catch (Throwable t) { + Timber.w(t, "Couldn't add log handler"); + } + + } + } + + public static void removeHandler(JitsiMeetBaseLogHandler handler) { + if (Timber.forest().contains(handler)) { + try { + Timber.uproot(handler); + } catch (Throwable t) { + Timber.w(t, "Couldn't remove log handler"); + } + } + } + + public static void v(String message, Object... args) { + Timber.v(message, args); + } + + public static void v(Throwable t, String message, Object... args) { + Timber.v(t, message, args); + } + + public static void v(Throwable t) { + Timber.v(t); + } + + public static void d(String message, Object... args) { + Timber.d(message, args); + } + + public static void d(Throwable t, String message, Object... args) { + Timber.d(t, message, args); + } + + public static void d(Throwable t) { + Timber.d(t); + } + + public static void i(String message, Object... args) { + Timber.i(message, args); + } + + public static void i(Throwable t, String message, Object... args) { + Timber.i(t, message, args); + } + + public static void i(Throwable t) { + Timber.i(t); + } + + public static void w(String message, Object... args) { + Timber.w(message, args); + } + + public static void w(Throwable t, String message, Object... args) { + Timber.w(t, message, args); + } + + public static void w(Throwable t) { + Timber.w(t); + } + + public static void e(String message, Object... args) { + Timber.e(message, args); + } + + public static void e(Throwable t, String message, Object... args) { + Timber.e(t, message, args); + } + + public static void e(Throwable t) { + Timber.e(t); + } + +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/net/NAT64AddrInfo.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/net/NAT64AddrInfo.java new file mode 100644 index 0000000..389ab9d --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/net/NAT64AddrInfo.java @@ -0,0 +1,238 @@ +/* + * Copyright @ 2018-present Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.meet.sdk.net; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Constructs IPv6 addresses for IPv4 addresses in the NAT64 environment. + * + * NAT64 translates IPv4 to IPv6 addresses by adding "well known" prefix and + * suffix configured by the administrator. Those are figured out by discovering + * both IPv6 and IPv4 addresses of a host and then trying to find a place where + * the IPv4 address fits into the format described here: + * https://tools.ietf.org/html/rfc6052#section-2.2 + */ +public class NAT64AddrInfo { + /** + * Coverts bytes array to upper case HEX string. + * + * @param bytes an array of bytes to be converted + * @return ex. "010AFF" for an array of {1, 10, 255}. + */ + static String bytesToHexString(byte[] bytes) { + StringBuilder hexStr = new StringBuilder(); + + for (byte b : bytes) { + hexStr.append(String.format("%02X", b)); + } + + return hexStr.toString(); + } + + /** + * Tries to discover the NAT64 prefix/suffix based on the IPv4 and IPv6 + * addresses resolved for given {@code host}. + * + * @param host the host for which the code will try to discover IPv4 and + * IPv6 addresses which then will be used to figure out the NAT64 prefix. + * @return {@link NAT64AddrInfo} instance if the NAT64 prefix/suffix was + * successfully discovered or {@code null} if it failed for any reason. + * @throws UnknownHostException thrown by {@link InetAddress#getAllByName}. + */ + public static NAT64AddrInfo discover(String host) + throws UnknownHostException { + InetAddress ipv4 = null; + InetAddress ipv6 = null; + + for(InetAddress addr : InetAddress.getAllByName(host)) { + byte[] bytes = addr.getAddress(); + + if (bytes.length == 4) { + ipv4 = addr; + } else if (bytes.length == 16) { + ipv6 = addr; + } + } + + if (ipv4 != null && ipv6 != null) { + return figureOutNAT64AddrInfo(ipv4.getAddress(), ipv6.getAddress()); + } + + return null; + } + + /** + * Based on IPv4 and IPv6 addresses of the same host, the method will make + * an attempt to figure out what are the NAT64 prefix and suffix. + * + * @param ipv4AddrBytes the IPv4 address of the same host in NAT64 network, + * as returned by {@link InetAddress#getAddress()}. + * @param ipv6AddrBytes the IPv6 address of the same host in NAT64 network, + * as returned by {@link InetAddress#getAddress()}. + * @return {@link NAT64AddrInfo} instance which contains the prefix/suffix + * of the current NAT64 network or {@code null} if the prefix could not be + * found. + */ + static NAT64AddrInfo figureOutNAT64AddrInfo( + byte[] ipv4AddrBytes, + byte[] ipv6AddrBytes) { + String ipv6Str = bytesToHexString(ipv6AddrBytes); + String ipv4Str = bytesToHexString(ipv4AddrBytes); + + // NAT64 address format: + // +--+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ + // |PL| 0-------------32--40--48--56--64--72--80--88--96--104---------| + // +--+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ + // |32| prefix |v4(32) | u | suffix | + // +--+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ + // |40| prefix |v4(24) | u |(8)| suffix | + // +--+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ + // |48| prefix |v4(16) | u | (16) | suffix | + // +--+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ + // |56| prefix |(8)| u | v4(24) | suffix | + // +--+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ + // |64| prefix | u | v4(32) | suffix | + // +--+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ + // |96| prefix | v4(32) | + // +--+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ + int prefixLength = 96; + int suffixLength = 0; + String prefix = null; + String suffix = null; + + if (ipv4Str.equalsIgnoreCase(ipv6Str.substring(prefixLength / 4))) { + prefix = ipv6Str.substring(0, prefixLength / 4); + } else { + // Cut out the 'u' octet + ipv6Str = ipv6Str.substring(0, 16) + ipv6Str.substring(18); + + for (prefixLength = 64, suffixLength = 6; prefixLength >= 32; ) { + if (ipv4Str.equalsIgnoreCase( + ipv6Str.substring( + prefixLength / 4, prefixLength / 4 + 8))) { + prefix = ipv6Str.substring(0, prefixLength / 4); + suffix = ipv6Str.substring(ipv6Str.length() - suffixLength); + break; + } + + prefixLength -= 8; + suffixLength += 2; + } + } + + return prefix != null ? new NAT64AddrInfo(prefix, suffix) : null; + } + + /** + * An overload for {@link #hexStringToIPv6String(StringBuilder)}. + * + * @param hexStr a hex representation of IPv6 address bytes. + * @return an IPv6 address string. + */ + static String hexStringToIPv6String(String hexStr) { + return hexStringToIPv6String(new StringBuilder(hexStr)); + } + + /** + * Converts from HEX representation of IPv6 address bytes into IPv6 address + * string which includes the ':' signs. + * + * @param str a hex representation of IPv6 address bytes. + * @return eg. FE80:CD00:0000:0CDA:1357:0000:212F:749C + */ + static String hexStringToIPv6String(StringBuilder str) { + for (int i = 32 - 4; i > 0; i -= 4) { + str.insert(i, ":"); + } + + return str.toString().toUpperCase(); + } + + /** + * Parses an IPv4 address string and returns it's byte array representation. + * + * @param ipv4Address eg. '192.168.3.23' + * @return byte representation of given IPv4 address string. + * @throws IllegalArgumentException if the address is not in valid format. + */ + static byte[] ipv4AddressStringToBytes(String ipv4Address) { + InetAddress address; + + try { + address = InetAddress.getByName(ipv4Address); + } catch (UnknownHostException e) { + throw new IllegalArgumentException( + "Invalid IP address: " + ipv4Address, e); + } + + byte[] bytes = address.getAddress(); + + if (bytes.length != 4) { + throw new IllegalArgumentException( + "Not an IPv4 address: " + ipv4Address); + } + + return bytes; + } + + /** + * The NAT64 prefix added to construct IPv6 from an IPv4 address. + */ + private final String prefix; + + /** + * The NAT64 suffix (if any) used to construct IPv6 from an IPv4 address. + */ + private final String suffix; + + /** + * Creates new instance of {@link NAT64AddrInfo}. + * + * @param prefix the NAT64 prefix. + * @param suffix the NAT64 suffix. + */ + private NAT64AddrInfo(String prefix, String suffix) { + this.prefix = prefix; + this.suffix = suffix; + } + + /** + * Based on the NAT64 prefix and suffix will create an IPv6 representation + * of the given IPv4 address. + * + * @param ipv4Address eg. '192.34.2.3' + * @return IPv6 address string eg. FE80:CD00:0000:0CDA:1357:0000:212F:749C + * @throws IllegalArgumentException if given string is not a valid IPv4 + * address. + */ + public String getIPv6Address(String ipv4Address) { + byte[] ipv4AddressBytes = ipv4AddressStringToBytes(ipv4Address); + StringBuilder newIPv6Str = new StringBuilder(); + + newIPv6Str.append(prefix); + newIPv6Str.append(bytesToHexString(ipv4AddressBytes)); + + if (suffix != null) { + // Insert the 'u' octet. + newIPv6Str.insert(16, "00"); + newIPv6Str.append(suffix); + } + + return hexStringToIPv6String(newIPv6Str); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/net/NAT64AddrInfoModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/net/NAT64AddrInfoModule.java new file mode 100644 index 0000000..d88872f --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/net/NAT64AddrInfoModule.java @@ -0,0 +1,123 @@ +/* + * Copyright @ 2018-present Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.meet.sdk.net; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.module.annotations.ReactModule; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.net.UnknownHostException; + +/** + * This module exposes the functionality of creating an IPv6 representation + * of IPv4 addresses in NAT64 environment. + * + * See[1] and [2] for more info on what NAT64 is. + * [1]: https://tools.ietf.org/html/rfc6146 + * [2]: https://tools.ietf.org/html/rfc6052 + */ +@ReactModule(name = NAT64AddrInfoModule.NAME) +public class NAT64AddrInfoModule + extends ReactContextBaseJavaModule { + + public final static String NAME = "NAT64AddrInfo"; + + /** + * The host for which the module wil try to resolve both IPv4 and IPv6 + * addresses in order to figure out the NAT64 prefix. + */ + private final static String HOST = "ipv4only.arpa"; + + /** + * How long is the {@link NAT64AddrInfo} instance valid. + */ + private final static long INFO_LIFETIME = 60 * 1000; + + /** + * The {@code Log} tag {@code NAT64AddrInfoModule} is to log messages with. + */ + private final static String TAG = NAME; + + /** + * The {@link NAT64AddrInfo} instance which holds NAT64 prefix/suffix. + */ + private NAT64AddrInfo info; + + /** + * When {@link #info} was created. + */ + private long infoTimestamp; + + /** + * Creates new {@link NAT64AddrInfoModule}. + * + * @param reactContext the react context to be used by the new module + * instance. + */ + public NAT64AddrInfoModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + /** + * Tries to obtain IPv6 address for given IPv4 address in NAT64 environment. + * + * @param ipv4Address IPv4 address string. + * @param promise a {@link Promise} which will be resolved either with IPv6 + * address for given IPv4 address or with {@code null} if no + * {@link NAT64AddrInfo} was resolved for the current network. Will be + * rejected if given {@code ipv4Address} is not a valid IPv4 address. + */ + @ReactMethod + public void getIPv6Address(String ipv4Address, final Promise promise) { + // Reset if cached for too long. + if (System.currentTimeMillis() - infoTimestamp > INFO_LIFETIME) { + info = null; + } + + if (info == null) { + String host = HOST; + + try { + info = NAT64AddrInfo.discover(host); + } catch (UnknownHostException e) { + JitsiMeetLogger.e(e, TAG + " NAT64AddrInfo.discover: " + host); + } + infoTimestamp = System.currentTimeMillis(); + } + + String result; + + try { + result = info == null ? null : info.getIPv6Address(ipv4Address); + } catch (IllegalArgumentException exc) { + JitsiMeetLogger.e(exc, TAG + " Failed to get IPv6 address for: " + ipv4Address); + + // We don't want to reject. It's not a big deal if there's no IPv6 + // address resolved. + result = null; + } + promise.resolve(result); + } + + @Override + public String getName() { + return NAME; + } +} diff --git a/android/sdk/src/main/java/org/webrtc/JitsiPlatformVideoDecoderFactory.java b/android/sdk/src/main/java/org/webrtc/JitsiPlatformVideoDecoderFactory.java new file mode 100644 index 0000000..97522a3 --- /dev/null +++ b/android/sdk/src/main/java/org/webrtc/JitsiPlatformVideoDecoderFactory.java @@ -0,0 +1,40 @@ +/* + * Copyright 2018 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +package org.webrtc; + +import android.media.MediaCodecInfo; +import androidx.annotation.Nullable; + +/** Factory for Android platform software VideoDecoders. */ +public class JitsiPlatformVideoDecoderFactory extends MediaCodecVideoDecoderFactory { + /** + * Default allowed predicate. + */ + private static final Predicate defaultAllowedPredicate = + codecInfo -> { + // We only want to use the platform software codecs. + return MediaCodecUtils.isSoftwareOnly(codecInfo); + }; + + /** + * Creates a PlatformSoftwareVideoDecoderFactory that supports surface texture rendering. + * + * @param sharedContext The textures generated will be accessible from this context. May be null, + * this disables texture support. + */ + public JitsiPlatformVideoDecoderFactory(@Nullable EglBase.Context sharedContext) { + super(sharedContext, defaultAllowedPredicate); + } + + public JitsiPlatformVideoDecoderFactory(@Nullable EglBase.Context sharedContext, @Nullable Predicate codecAllowedPredicate) { + super(sharedContext, codecAllowedPredicate == null ? defaultAllowedPredicate : codecAllowedPredicate.and(defaultAllowedPredicate)); + } +} diff --git a/android/sdk/src/main/res/drawable-hdpi/ic_notification.png b/android/sdk/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000..da701e5 Binary files /dev/null and b/android/sdk/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/android/sdk/src/main/res/drawable-mdpi/ic_notification.png b/android/sdk/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000..7540f39 Binary files /dev/null and b/android/sdk/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/android/sdk/src/main/res/drawable-xhdpi/ic_notification.png b/android/sdk/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000..bed1734 Binary files /dev/null and b/android/sdk/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/android/sdk/src/main/res/drawable-xxhdpi/ic_notification.png b/android/sdk/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000..88bf389 Binary files /dev/null and b/android/sdk/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/android/sdk/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/sdk/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000..f071fac Binary files /dev/null and b/android/sdk/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/android/sdk/src/main/res/layout/activity_jitsi_meet.xml b/android/sdk/src/main/res/layout/activity_jitsi_meet.xml new file mode 100644 index 0000000..e2213b0 --- /dev/null +++ b/android/sdk/src/main/res/layout/activity_jitsi_meet.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/android/sdk/src/main/res/values-ru/strings.xml b/android/sdk/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..48573ce --- /dev/null +++ b/android/sdk/src/main/res/values-ru/strings.xml @@ -0,0 +1,9 @@ + + + Текущая встреча + Нажмите, чтобы вернуться к встрече. + Отключиться + Отключить звук + Включить звук + Ongoing Conference Notifications + \ No newline at end of file diff --git a/android/sdk/src/main/res/values/colors.xml b/android/sdk/src/main/res/values/colors.xml new file mode 100644 index 0000000..cd07ec8 --- /dev/null +++ b/android/sdk/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #040404 + #040404 + \ No newline at end of file diff --git a/android/sdk/src/main/res/values/strings.xml b/android/sdk/src/main/res/values/strings.xml new file mode 100644 index 0000000..8bc4c5b --- /dev/null +++ b/android/sdk/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + Jitsi Meet SDK + + Media projection + You are currently sharing your screen. + Ongoing meeting + You are currently in a meeting. Tap to return to it. + Hang up + Mute + Unmute + Ongoing Conference Notifications + diff --git a/android/sdk/src/main/res/values/styles.xml b/android/sdk/src/main/res/values/styles.xml new file mode 100644 index 0000000..dd4c3ba --- /dev/null +++ b/android/sdk/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + diff --git a/android/sdk/src/test/java/org/jitsi/meet/sdk/net/NAT64AddrInfoTest.java b/android/sdk/src/test/java/org/jitsi/meet/sdk/net/NAT64AddrInfoTest.java new file mode 100644 index 0000000..c01ecaf --- /dev/null +++ b/android/sdk/src/test/java/org/jitsi/meet/sdk/net/NAT64AddrInfoTest.java @@ -0,0 +1,150 @@ +/* + * Copyright @ 2017-present Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.meet.sdk.net; + +import org.junit.Test; + +import java.math.BigInteger; +import java.net.UnknownHostException; + +import static org.junit.Assert.*; + +/** + * Tests for {@link NAT64AddrInfo} class. + */ +public class NAT64AddrInfoTest { + /** + * Test case for the 96 prefix length. + */ + @Test + public void test96Prefix() { + testPrefixSuffix( + "260777000000000400000000", "", "203.0.113.1", "23.17.23.3"); + } + + /** + * Test case for the 64 prefix length. + */ + @Test + public void test64Prefix() { + String prefix = "1FF2A227B3AAF3D2"; + String suffix = "BB87C8"; + + testPrefixSuffix(prefix, suffix, "48.46.87.34", "23.87.145.4"); + } + + /** + * Test case for the 56 prefix length. + */ + @Test + public void test56Prefix() { + String prefix = "1FF2A227B3AAF3"; + String suffix = "A2BB87C8"; + + testPrefixSuffix(prefix, suffix, "34.72.234.255", "1.235.3.65"); + } + + /** + * Test case for the 48 prefix length. + */ + @Test + public void test48Prefix() { + String prefix = "1FF2A227B3AA"; + String suffix = "72A2BB87C8"; + + testPrefixSuffix(prefix, suffix, "97.54.3.23", "77.49.0.33"); + } + + /** + * Test case for the 40 prefix length. + */ + @Test + public void test40Prefix() { + String prefix = "1FF2A227B3"; + String suffix = "D972A2BB87C8"; + + testPrefixSuffix(prefix, suffix, "10.23.56.121", "97.65.32.21"); + } + + /** + * Test case for the 32 prefix length. + */ + @Test + public void test32Prefix() + throws UnknownHostException { + String prefix = "1FF2A227"; + String suffix = "20D972A2BB87C8"; + + testPrefixSuffix(prefix, suffix, "162.63.65.189", "135.222.84.206"); + } + + private static String buildIPv6Addr( + String prefix, String suffix, String ipv4Hex) { + String ipv6Str = prefix + ipv4Hex + suffix; + + if (suffix.length() > 0) { + ipv6Str = new StringBuilder(ipv6Str).insert(16, "00").toString(); + } + + return ipv6Str; + } + + private void testPrefixSuffix( + String prefix, String suffix, String ipv4, String otherIPv4) { + byte[] ipv4Bytes = NAT64AddrInfo.ipv4AddressStringToBytes(ipv4); + String ipv4String = NAT64AddrInfo.bytesToHexString(ipv4Bytes); + String ipv6Str = buildIPv6Addr(prefix, suffix, ipv4String); + + BigInteger ipv6Address = new BigInteger(ipv6Str, 16); + + NAT64AddrInfo nat64AddrInfo + = NAT64AddrInfo.figureOutNAT64AddrInfo( + ipv4Bytes, ipv6Address.toByteArray()); + + assertNotNull("Failed to figure out NAT64 info", nat64AddrInfo); + + String newIPv6 = nat64AddrInfo.getIPv6Address(ipv4); + + assertEquals( + NAT64AddrInfo.hexStringToIPv6String(ipv6Address.toString(16)), + newIPv6); + + byte[] ipv4Addr2 = NAT64AddrInfo.ipv4AddressStringToBytes(otherIPv4); + String ipv4Addr2Hex = NAT64AddrInfo.bytesToHexString(ipv4Addr2); + + newIPv6 = nat64AddrInfo.getIPv6Address(otherIPv4); + + assertEquals( + NAT64AddrInfo.hexStringToIPv6String( + buildIPv6Addr(prefix, suffix, ipv4Addr2Hex)), + newIPv6); + } + + @Test + public void testInvalidIPv4Format() { + testInvalidIPv4Format("256.1.2.3"); + testInvalidIPv4Format("FE80:CD00:0000:0CDA:1357:0000:212F:749C"); + } + + private void testInvalidIPv4Format(String ipv4Str) { + try { + NAT64AddrInfo.ipv4AddressStringToBytes(ipv4Str); + fail("Did not throw IllegalArgumentException"); + } catch (IllegalArgumentException exc) { + /* OK */ + } + } +} diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..da189df --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,54 @@ +include ':app', ':sdk' + +include ':react-native-amplitude' +project(':react-native-amplitude').projectDir = new File(rootProject.projectDir, '../node_modules/@amplitude/analytics-react-native/android') +include ':react-native-async-storage' +project(':react-native-async-storage').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-async-storage/async-storage/android') +include ':react-native-background-timer' +project(':react-native-background-timer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-background-timer/android') +include ':react-native-calendar-events' +project(':react-native-calendar-events').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-calendar-events/android') +include ':react-native-community_clipboard' +project(':react-native-community_clipboard').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-clipboard/clipboard/android') +include ':react-native-community_netinfo' +project(':react-native-community_netinfo').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/netinfo/android') +include ':react-native-default-preference' +project(':react-native-default-preference').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-default-preference/android') +include ':react-native-device-info' +project(':react-native-device-info').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-device-info/android') +include ':react-native-gesture-handler' +project(':react-native-gesture-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-gesture-handler/android') +include ':react-native-get-random-values' +project(':react-native-get-random-values').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-get-random-values/android') +include ':react-native-giphy' +project(':react-native-giphy').projectDir = new File(rootProject.projectDir, '../node_modules/@giphy/react-native-sdk/android') +include ':react-native-google-signin' +project(':react-native-google-signin').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-google-signin/google-signin/android') +include ':react-native-keep-awake' +project(':react-native-keep-awake').projectDir = new File(rootProject.projectDir, '../node_modules/@sayem314/react-native-keep-awake/android') +include ':react-native-orientation-locker' +project(':react-native-orientation-locker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-orientation-locker/android') +include ':react-native-pager-view' +project(':react-native-pager-view').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-pager-view/android') +include ':react-native-performance' +project(':react-native-performance').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-performance/android') +include ':react-native-safe-area-context' +project(':react-native-safe-area-context').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-safe-area-context/android') +include ':react-native-screens' +project(':react-native-screens').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-screens/android') +include ':react-native-slider' +project(':react-native-slider').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/slider/android') +include ':react-native-sound' +project(':react-native-sound').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sound/android') +include ':react-native-splash-view' +project(':react-native-splash-view').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-splash-view/android') +include ':react-native-svg' +project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android') +include ':react-native-video' +project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android') +include ':react-native-webrtc' +project(':react-native-webrtc').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-webrtc/android') +include ':react-native-webview' +project(':react-native-webview').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-webview/android') +include ':react-native-worklets-core' +project(':react-native-worklets-core').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-worklets-core/android') diff --git a/app.js b/app.js new file mode 100644 index 0000000..4505556 --- /dev/null +++ b/app.js @@ -0,0 +1,70 @@ +/* Jitsi Meet app main entrypoint. */ + +// Re-export jQuery +// FIXME: Remove this requirement from torture tests. +import $ from 'jquery'; + +window.$ = window.jQuery = $; + +import '@matrix-org/olm'; + +import 'focus-visible'; + +/* +* Safari polyfill for createImageBitmap +* https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/createImageBitmap +* +* Support source image types: Canvas. +*/ +if (!('createImageBitmap' in window)) { + window.createImageBitmap = function(data) { + return new Promise((resolve, reject) => { + let dataURL; + + if (data instanceof HTMLCanvasElement) { + dataURL = data.toDataURL(); + } else { + reject(new Error('createImageBitmap does not handle the provided image source type')); + } + const img = document.createElement('img'); + + img.addEventListener('load', () => { + resolve(img); + }); + img.src = dataURL; + }); + }; +} + +// We need to setup the jitsi-local-storage as early as possible so that we can start using it. +// NOTE: If jitsi-local-storage is used before the initial setup is performed this will break the use case when we use +// the local storage from the parent page when the localStorage is disabled. Also the setup is relying that +// window.location is not changed and still has all URL parameters. +import './react/features/base/jitsi-local-storage/setup'; +import conference from './conference'; +import API from './modules/API'; +import UI from './modules/UI/UI'; +import translation from './modules/translation/translation'; + +// Initialize Olm as early as possible. +if (window.Olm) { + window.Olm.init().catch(e => { + console.error('Failed to initialize Olm, E2EE will be disabled', e); + delete window.Olm; + }); +} + +window.APP = { + API, + conference, + translation, + UI +}; + +// TODO The execution of the mobile app starts from react/index.native.js. +// Similarly, the execution of the Web app should start from react/index.web.js +// for the sake of consistency and ease of understanding. Temporarily though +// because we are at the beginning of introducing React into the Web app, allow +// the execution of the Web app to start from app.js in order to reduce the +// complexity of the beginning step. +import './react'; diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..4141f6a --- /dev/null +++ b/babel.config.js @@ -0,0 +1,20 @@ +module.exports = { + presets: [ 'module:@react-native/babel-preset' ], + env: { + production: { + plugins: [ 'react-native-paper/babel' ] + } + }, + + // This happens because react native has conflict with @babel/plugin-transform-private-methods plugin + // https://github.com/ethers-io/ethers.js/discussions/4309#discussioncomment-6694524 + plugins: [ + 'optional-require', + [ + '@babel/plugin-transform-private-methods', { + 'loose': true + } + ], + 'react-native-worklets-core/plugin' + ] +}; diff --git a/base.html b/base.html new file mode 100644 index 0000000..f5d75e2 --- /dev/null +++ b/base.html @@ -0,0 +1 @@ + diff --git a/body.html b/body.html new file mode 100644 index 0000000..e69de29 diff --git a/conference.js b/conference.js new file mode 100644 index 0000000..18560a9 --- /dev/null +++ b/conference.js @@ -0,0 +1,2363 @@ +/* global APP, JitsiMeetJS, config, interfaceConfig */ + +import { jitsiLocalStorage } from '@jitsi/js-utils'; +import Logger from '@jitsi/logger'; + +import { ENDPOINT_TEXT_MESSAGE_NAME } from './modules/API/constants'; +import mediaDeviceHelper from './modules/devices/mediaDeviceHelper'; +import Recorder from './modules/recorder/Recorder'; +import { createTaskQueue } from './modules/util/helpers'; +import { + createDeviceChangedEvent, + createScreenSharingEvent, + createStartSilentEvent, + createTrackMutedEvent +} from './react/features/analytics/AnalyticsEvents'; +import { sendAnalytics } from './react/features/analytics/functions'; +import { + maybeRedirectToWelcomePage, + reloadWithStoredParams +} from './react/features/app/actions'; +import { + _conferenceWillJoin, + authStatusChanged, + conferenceFailed, + conferenceJoinInProgress, + conferenceJoined, + conferenceLeft, + conferencePropertiesChanged, + conferenceSubjectChanged, + conferenceTimestampChanged, + conferenceUniqueIdSet, + conferenceWillInit, + conferenceWillLeave, + dataChannelClosed, + dataChannelOpened, + e2eRttChanged, + endpointMessageReceived, + kickedOut, + lockStateChanged, + nonParticipantMessageReceived, + onStartMutedPolicyChanged, + p2pStatusChanged +} from './react/features/base/conference/actions'; +import { + AVATAR_URL_COMMAND, + CONFERENCE_LEAVE_REASONS, + EMAIL_COMMAND +} from './react/features/base/conference/constants'; +import { + commonUserJoinedHandling, + commonUserLeftHandling, + getConferenceOptions, + sendLocalParticipant, + updateTrackMuteState +} from './react/features/base/conference/functions'; +import { getReplaceParticipant, getSsrcRewritingFeatureFlag } from './react/features/base/config/functions'; +import { connect } from './react/features/base/connection/actions.web'; +import { + checkAndNotifyForNewDevice, + getAvailableDevices, + notifyCameraError, + notifyMicError, + updateDeviceList +} from './react/features/base/devices/actions.web'; +import { + areDevicesDifferent, + filterIgnoredDevices, + flattenAvailableDevices, + getDefaultDeviceId, + logDevices, + setAudioOutputDeviceId +} from './react/features/base/devices/functions.web'; +import { + JitsiConferenceErrors, + JitsiConferenceEvents, + JitsiE2ePingEvents, + JitsiMediaDevicesEvents, + JitsiTrackEvents, + browser +} from './react/features/base/lib-jitsi-meet'; +import { + gumPending, + setAudioAvailable, + setAudioMuted, + setAudioUnmutePermissions, + setInitialGUMPromise, + setVideoAvailable, + setVideoMuted, + setVideoUnmutePermissions +} from './react/features/base/media/actions'; +import { MEDIA_TYPE, VIDEO_MUTISM_AUTHORITY, VIDEO_TYPE } from './react/features/base/media/constants'; +import { + getStartWithAudioMuted, + getStartWithVideoMuted, + isVideoMutedByUser +} from './react/features/base/media/functions'; +import { IGUMPendingState } from './react/features/base/media/types'; +import { + dominantSpeakerChanged, + localParticipantAudioLevelChanged, + localParticipantRoleChanged, + participantKicked, + participantMutedUs, + participantPresenceChanged, + participantRoleChanged, + participantSourcesUpdated, + participantUpdated, + screenshareParticipantDisplayNameChanged, + updateRemoteParticipantFeatures +} from './react/features/base/participants/actions'; +import { + getLocalParticipant, + getNormalizedDisplayName, + getParticipantByIdOrUndefined, + getVirtualScreenshareParticipantByOwnerId +} from './react/features/base/participants/functions'; +import { updateSettings } from './react/features/base/settings/actions'; +import { + addLocalTrack, + createInitialAVTracks, + destroyLocalTracks, + displayErrorsForCreateInitialLocalTracks, + replaceLocalTrack, + setGUMPendingStateOnFailedTracks, + toggleScreensharing as toggleScreensharingA, + trackAdded, + trackRemoved +} from './react/features/base/tracks/actions'; +import { + createLocalTracksF, + getLocalJitsiAudioTrack, + getLocalJitsiVideoTrack, + getLocalVideoTrack, + isLocalTrackMuted, + isUserInteractionRequiredForUnmute +} from './react/features/base/tracks/functions'; +import { getLocalJitsiAudioTrackSettings } from './react/features/base/tracks/functions.web'; +import { downloadJSON } from './react/features/base/util/downloadJSON'; +import { getJitsiMeetGlobalNSConnectionTimes } from './react/features/base/util/helpers'; +import { openLeaveReasonDialog } from './react/features/conference/actions.web'; +import { showDesktopPicker } from './react/features/desktop-picker/actions'; +import { appendSuffix } from './react/features/display-name/functions'; +import { maybeOpenFeedbackDialog, submitFeedback } from './react/features/feedback/actions'; +import { maybeSetLobbyChatMessageListener } from './react/features/lobby/actions.any'; +import { setNoiseSuppressionEnabled } from './react/features/noise-suppression/actions'; +import { + hideNotification, + showErrorNotification, + showNotification, + showWarningNotification +} from './react/features/notifications/actions'; +import { + DATA_CHANNEL_CLOSED_NOTIFICATION_ID, + NOTIFICATION_TIMEOUT_TYPE +} from './react/features/notifications/constants'; +import { suspendDetected } from './react/features/power-monitor/actions'; +import { initPrejoin, isPrejoinPageVisible } from './react/features/prejoin/functions'; +import { disableReceiver, stopReceiver } from './react/features/remote-control/actions'; +import { setScreenAudioShareState } from './react/features/screen-share/actions.web'; +import { isScreenAudioShared } from './react/features/screen-share/functions'; +import { toggleScreenshotCaptureSummary } from './react/features/screenshot-capture/actions'; +import { setAudioSettings } from './react/features/settings/actions.web'; +import { AudioMixerEffect } from './react/features/stream-effects/audio-mixer/AudioMixerEffect'; +import { createRnnoiseProcessor } from './react/features/stream-effects/rnnoise'; +import { handleToggleVideoMuted } from './react/features/toolbox/actions.any'; +import { transcriberJoined, transcriberLeft } from './react/features/transcribing/actions'; +import { muteLocal } from './react/features/video-menu/actions.any'; + +const logger = Logger.getLogger('app:conference-web'); +let room; + +/* + * Logic to open a desktop picker put on the window global for + * lib-jitsi-meet to detect and invoke. + * + * TODO: remove once the Electron SDK supporting gDM has been out for a while. + */ +window.JitsiMeetScreenObtainer = { + openDesktopPicker(options, onSourceChoose) { + APP.store.dispatch(showDesktopPicker(options, onSourceChoose)); + } +}; + +/** + * Known custom conference commands. + */ +const commands = { + AVATAR_URL: AVATAR_URL_COMMAND, + CUSTOM_ROLE: 'custom-role', + EMAIL: EMAIL_COMMAND, + ETHERPAD: 'etherpad' +}; + +/** + * Share data to other users. + * @param command the command + * @param {string} value new value + */ +function sendData(command, value) { + if (!room) { + return; + } + + room.removeCommand(command); + room.sendCommand(command, { value }); +} + +/** + * A queue for the async replaceLocalTrack action so that multiple audio + * replacements cannot happen simultaneously. This solves the issue where + * replaceLocalTrack is called multiple times with an oldTrack of null, causing + * multiple local tracks of the same type to be used. + * + * @private + * @type {Object} + */ +const _replaceLocalAudioTrackQueue = createTaskQueue(); + +/** + * A task queue for replacement local video tracks. This separate queue exists + * so video replacement is not blocked by audio replacement tasks in the queue + * {@link _replaceLocalAudioTrackQueue}. + * + * @private + * @type {Object} + */ +const _replaceLocalVideoTrackQueue = createTaskQueue(); + +/** + * + */ +class ConferenceConnector { + /** + * + */ + constructor(resolve, reject, conference) { + this._conference = conference; + this._resolve = resolve; + this._reject = reject; + this.reconnectTimeout = null; + room.on(JitsiConferenceEvents.CONFERENCE_JOINED, + this._handleConferenceJoined.bind(this)); + room.on(JitsiConferenceEvents.CONFERENCE_FAILED, + this._onConferenceFailed.bind(this)); + } + + /** + * + */ + _handleConferenceFailed(err) { + this._unsubscribe(); + this._reject(err); + } + + /** + * + */ + _onConferenceFailed(err, ...params) { + APP.store.dispatch(conferenceFailed(room, err, ...params)); + logger.error('CONFERENCE FAILED:', err, ...params); + + switch (err) { + + case JitsiConferenceErrors.RESERVATION_ERROR: { + const [ code, msg ] = params; + + APP.store.dispatch(showErrorNotification({ + descriptionArguments: { + code, + msg + }, + descriptionKey: 'dialog.reservationErrorMsg', + titleKey: 'dialog.reservationError' + })); + break; + } + + case JitsiConferenceErrors.GRACEFUL_SHUTDOWN: + APP.store.dispatch(showErrorNotification({ + descriptionKey: 'dialog.gracefulShutdown', + titleKey: 'dialog.serviceUnavailable' + })); + break; + + // FIXME FOCUS_DISCONNECTED is a confusing event name. + // What really happens there is that the library is not ready yet, + // because Jicofo is not available, but it is going to give it another + // try. + case JitsiConferenceErrors.FOCUS_DISCONNECTED: { + const [ focus, retrySec ] = params; + + APP.store.dispatch(showNotification({ + descriptionKey: focus, + titleKey: retrySec + }, NOTIFICATION_TIMEOUT_TYPE.SHORT)); + break; + } + + case JitsiConferenceErrors.FOCUS_LEFT: + case JitsiConferenceErrors.ICE_FAILED: + case JitsiConferenceErrors.VIDEOBRIDGE_NOT_AVAILABLE: + case JitsiConferenceErrors.OFFER_ANSWER_FAILED: + APP.store.dispatch(conferenceWillLeave(room)); + + // FIXME the conference should be stopped by the library and not by + // the app. Both the errors above are unrecoverable from the library + // perspective. + room.leave(CONFERENCE_LEAVE_REASONS.UNRECOVERABLE_ERROR).then(() => APP.connection.disconnect()); + break; + + case JitsiConferenceErrors.INCOMPATIBLE_SERVER_VERSIONS: + APP.store.dispatch(reloadWithStoredParams()); + break; + + default: + this._handleConferenceFailed(err, ...params); + } + } + + /** + * + */ + _unsubscribe() { + room.off( + JitsiConferenceEvents.CONFERENCE_JOINED, + this._handleConferenceJoined); + room.off( + JitsiConferenceEvents.CONFERENCE_FAILED, + this._onConferenceFailed); + if (this.reconnectTimeout !== null) { + clearTimeout(this.reconnectTimeout); + } + } + + /** + * + */ + _handleConferenceJoined() { + this._unsubscribe(); + this._resolve(); + } + + /** + * + */ + connect() { + const replaceParticipant = getReplaceParticipant(APP.store.getState()); + + // the local storage overrides here and in connection.js can be used by jibri + room.join(jitsiLocalStorage.getItem('xmpp_conference_password_override'), replaceParticipant); + } +} + +/** + * Disconnects the connection. + * @returns resolved Promise. We need this in order to make the Promise.all + * call in hangup() to resolve when all operations are finished. + */ +function disconnect() { + const onDisconnected = () => { + APP.API.notifyConferenceLeft(APP.conference.roomName); + + return Promise.resolve(); + }; + + if (!APP.connection) { + return onDisconnected(); + } + + return APP.connection.disconnect().then(onDisconnected, onDisconnected); +} + +export default { + /** + * Flag used to delay modification of the muted status of local media tracks + * until those are created (or not, but at that point it's certain that + * the tracks won't exist). + */ + _localTracksInitialized: false, + + /** + * Flag used to prevent the creation of another local video track in this.muteVideo if one is already in progress. + */ + isCreatingLocalTrack: false, + + isSharingScreen: false, + + /** + * Returns an object containing a promise which resolves with the created tracks & + * the errors resulting from that process. + * @param {object} options + * @param {boolean} options.startAudioOnly=false - if true then + * only audio track will be created and the audio only mode will be turned + * on. + * @param {boolean} options.startScreenSharing=false - if true + * should start with screensharing instead of camera video. + * @param {boolean} options.startWithAudioMuted - will start the conference + * without any audio tracks. + * @param {boolean} options.startWithVideoMuted - will start the conference + * without any video tracks. + * @param {boolean} recordTimeMetrics - If true time metrics will be recorded. + * @returns {Promise, Object} + */ + createInitialLocalTracks(options = {}, recordTimeMetrics = false) { + const errors = {}; + + // Always get a handle on the audio input device so that we have statistics (such as "No audio input" or + // "Are you trying to speak?" ) even if the user joins the conference muted. + const initialDevices = config.startSilent || config.disableInitialGUM ? [] : [ MEDIA_TYPE.AUDIO ]; + const requestedAudio = !config.disableInitialGUM; + let requestedVideo = false; + + if (!config.disableInitialGUM + && !options.startWithVideoMuted + && !options.startAudioOnly + && !options.startScreenSharing) { + initialDevices.push(MEDIA_TYPE.VIDEO); + requestedVideo = true; + } + + let tryCreateLocalTracks = Promise.resolve([]); + + // On Electron there is no permission prompt for granting permissions. That's why we don't need to + // spend much time displaying the overlay screen. If GUM is not resolved within 15 seconds it will + // probably never resolve. + const timeout = browser.isElectron() ? 15000 : 60000; + const audioOptions = { + devices: [ MEDIA_TYPE.AUDIO ], + timeout + }; + + // Spot uses the _desktopSharingSourceDevice config option to use an external video input device label as + // screenshare and calls getUserMedia instead of getDisplayMedia for capturing the media. + if (options.startScreenSharing && config._desktopSharingSourceDevice) { + tryCreateLocalTracks = this._createDesktopTrack() + .then(([ desktopStream ]) => { + if (!requestedAudio) { + return [ desktopStream ]; + } + + return createLocalTracksF(audioOptions) + .then(([ audioStream ]) => + [ desktopStream, audioStream ]) + .catch(error => { + errors.audioOnlyError = error; + + return [ desktopStream ]; + }); + }) + .catch(error => { + logger.error('Failed to obtain desktop stream', error); + errors.screenSharingError = error; + + return requestedAudio ? createLocalTracksF(audioOptions) : []; + }) + .catch(error => { + errors.audioOnlyError = error; + + return []; + }); + } else if (requestedAudio || requestedVideo) { + tryCreateLocalTracks = APP.store.dispatch(createInitialAVTracks({ + devices: initialDevices, + timeout + }, recordTimeMetrics)).then(({ tracks, errors: pErrors }) => { + Object.assign(errors, pErrors); + + return tracks; + }); + } + + return { + tryCreateLocalTracks, + errors + }; + }, + + startConference(tracks) { + tracks.forEach(track => { + if ((track.isAudioTrack() && this.isLocalAudioMuted()) + || (track.isVideoTrack() && this.isLocalVideoMuted())) { + const mediaType = track.getType(); + + sendAnalytics( + createTrackMutedEvent(mediaType, 'initial mute')); + logger.log(`${mediaType} mute: initially muted.`); + track.mute(); + } + }); + + this._createRoom(tracks); + + // if user didn't give access to mic or camera or doesn't have + // them at all, we mark corresponding toolbar buttons as muted, + // so that the user can try unmute later on and add audio/video + // to the conference + if (!tracks.find(t => t.isAudioTrack())) { + this.updateAudioIconEnabled(); + } + + if (!tracks.find(t => t.isVideoTrack())) { + this.setVideoMuteStatus(); + } + + if (config.iAmRecorder) { + this.recorder = new Recorder(); + } + + if (config.startSilent) { + sendAnalytics(createStartSilentEvent()); + APP.store.dispatch(showNotification({ + descriptionKey: 'notify.startSilentDescription', + titleKey: 'notify.startSilentTitle' + }, NOTIFICATION_TIMEOUT_TYPE.LONG)); + } + + // XXX The API will take care of disconnecting from the XMPP + // server (and, thus, leaving the room) on unload. + return new Promise((resolve, reject) => { + new ConferenceConnector(resolve, reject, this).connect(); + }); + }, + + /** + * Open new connection and join the conference when prejoin page is not enabled. + * If prejoin page is enabled open an new connection in the background + * and create local tracks. + * + * @param {{ roomName: string, shouldDispatchConnect }} options + * @returns {Promise} + */ + async init({ roomName, shouldDispatchConnect }) { + const state = APP.store.getState(); + const initialOptions = { + startAudioOnly: config.startAudioOnly, + startScreenSharing: config.startScreenSharing, + startWithAudioMuted: getStartWithAudioMuted(state) || isUserInteractionRequiredForUnmute(state), + startWithVideoMuted: getStartWithVideoMuted(state) || isUserInteractionRequiredForUnmute(state) + }; + const connectionTimes = getJitsiMeetGlobalNSConnectionTimes(); + const startTime = window.performance.now(); + + connectionTimes['conference.init.start'] = startTime; + + logger.debug(`Executed conference.init with roomName: ${roomName} (performance.now=${startTime})`); + + this.roomName = roomName; + + try { + // Initialize the device list first. This way, when creating tracks based on preferred devices, loose label + // matching can be done in cases where the exact ID match is no longer available, such as - + // 1. When the camera device has switched USB ports. + // 2. When in startSilent mode we want to start with audio muted + await this._initDeviceList(); + } catch (error) { + logger.warn('initial device list initialization failed', error); + } + + // Filter out the local tracks based on various config options, i.e., when user joins muted or is muted by + // focus. However, audio track will always be created even though it is not added to the conference since we + // want audio related features (noisy mic, talk while muted, etc.) to work even if the mic is muted. + const handleInitialTracks = (options, tracks) => { + let localTracks = tracks; + + if (options.startWithAudioMuted) { + // Always add the track on Safari because of a known issue where audio playout doesn't happen + // if the user joins audio and video muted, i.e., if there is no local media capture. + if (browser.isWebKitBased()) { + this.muteAudio(true, true); + } else { + localTracks = localTracks.filter(track => { + if (track.getType() === MEDIA_TYPE.AUDIO) { + track.stopStream(); + + return false; + } + + return true; + }); + } + } + + return localTracks; + }; + const { dispatch, getState } = APP.store; + const createLocalTracksStart = window.performance.now(); + + connectionTimes['conference.init.createLocalTracks.start'] = createLocalTracksStart; + + logger.debug(`(TIME) createInitialLocalTracks: ${createLocalTracksStart} `); + + const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(initialOptions, true); + + tryCreateLocalTracks.then(tr => { + const createLocalTracksEnd = window.performance.now(); + + connectionTimes['conference.init.createLocalTracks.end'] = createLocalTracksEnd; + logger.debug(`(TIME) createInitialLocalTracks finished: ${createLocalTracksEnd} `); + const tracks = handleInitialTracks(initialOptions, tr); + + this._initDeviceList(true); + + const { initialGUMPromise } = getState()['features/base/media']; + + if (isPrejoinPageVisible(getState())) { + dispatch(gumPending([ MEDIA_TYPE.AUDIO, MEDIA_TYPE.VIDEO ], IGUMPendingState.NONE)); + + // Since the conference is not yet created in redux this function will execute synchronous + // which will guarantee us that the local tracks are added to redux before we proceed. + initPrejoin(tracks, errors, dispatch); + + connectionTimes['conference.init.end'] = window.performance.now(); + + // resolve the initialGUMPromise in case connect have finished so that we can proceed to join. + if (initialGUMPromise) { + logger.debug('Resolving the initialGUM promise! (prejoinVisible=true)'); + initialGUMPromise.resolve({ + tracks, + errors + }); + } + + logger.debug('Clear the initialGUM promise! (prejoinVisible=true)'); + + // For prejoin we don't need the initial GUM promise since the tracks are already added to the store + // via initPrejoin + dispatch(setInitialGUMPromise()); + } else { + APP.store.dispatch(displayErrorsForCreateInitialLocalTracks(errors)); + setGUMPendingStateOnFailedTracks(tracks, APP.store.dispatch); + + connectionTimes['conference.init.end'] = window.performance.now(); + if (initialGUMPromise) { + logger.debug('Resolving the initialGUM promise!'); + initialGUMPromise.resolve({ + tracks, + errors + }); + } + } + }); + + if (shouldDispatchConnect) { + logger.info('Dispatching connect from init since prejoin is not visible.'); + dispatch(connect()); + } + }, + + /** + * Check if id is id of the local user. + * @param {string} id id to check + * @returns {boolean} + */ + isLocalId(id) { + return this.getMyUserId() === id; + }, + + /** + * Tells whether the local video is muted or not. + * @return {boolean} + */ + isLocalVideoMuted() { + // If the tracks are not ready, read from base/media state + return this._localTracksInitialized + ? isLocalTrackMuted(APP.store.getState()['features/base/tracks'], MEDIA_TYPE.VIDEO) + : isVideoMutedByUser(APP.store); + }, + + /** + * Verify if there is an ongoing system audio sharing session and apply to the provided track + * as a AudioMixer effect. + * + * @param {*} localAudioTrack - track to which system audio track will be applied as an effect, most likely + * microphone local audio track. + */ + async _maybeApplyAudioMixerEffect(localAudioTrack) { + + // At the time of writing this comment there were two separate flows for toggling screen-sharing + // and system audio sharing, the first is the legacy method using the functionality from conference.js + // the second is used when both sendMultipleVideoStreams and sourceNameSignaling flags are set to true. + // The second flow uses functionality from base/conference/middleware.web.js. + // We check if system audio sharing was done using the first flow by verifying this._desktopAudioStream and + // for the second by checking 'features/screen-share' state. + const { desktopAudioTrack } = APP.store.getState()['features/screen-share']; + const currentDesktopAudioTrack = this._desktopAudioStream || desktopAudioTrack; + + // If system audio is already being sent, mix it with the provided audio track. + if (currentDesktopAudioTrack) { + // In case system audio sharing was done in the absence of an initial mic audio track, there is no + // AudioMixerEffect so we have to remove system audio track from the room before setting it as an effect. + await room.replaceTrack(currentDesktopAudioTrack, null); + this._mixerEffect = new AudioMixerEffect(currentDesktopAudioTrack); + logger.debug('Mixing new audio track with existing screen audio track.'); + await localAudioTrack.setEffect(this._mixerEffect); + } + }, + + /** + * Simulates toolbar button click for audio mute. Used by shortcuts and API. + * + * @param {boolean} mute true for mute and false for unmute. + * dialogs in case of media permissions error. + * @returns {Promise} + */ + async muteAudio(mute) { + const state = APP.store.getState(); + + if (!mute + && isUserInteractionRequiredForUnmute(state)) { + logger.error('Unmuting audio requires user interaction'); + + return; + } + + await APP.store.dispatch(setAudioMuted(mute, true)); + }, + + /** + * Returns whether local audio is muted or not. + * @returns {boolean} + */ + isLocalAudioMuted() { + // If the tracks are not ready, read from base/media state + return this._localTracksInitialized + ? isLocalTrackMuted( + APP.store.getState()['features/base/tracks'], + MEDIA_TYPE.AUDIO) + : Boolean( + APP.store.getState()['features/base/media'].audio.muted); + }, + + /** + * Simulates toolbar button click for audio mute. Used by shortcuts + * and API. + * @param {boolean} [showUI] when set to false will not display any error + * dialogs in case of media permissions error. + */ + toggleAudioMuted(showUI = true) { + this.muteAudio(!this.isLocalAudioMuted(), showUI); + }, + + /** + * Simulates toolbar button click for video mute. Used by shortcuts and API. + * @param mute true for mute and false for unmute. + * dialogs in case of media permissions error. + */ + muteVideo(mute) { + const state = APP.store.getState(); + + if (!mute + && isUserInteractionRequiredForUnmute(state)) { + logger.error('Unmuting video requires user interaction'); + + return; + } + + APP.store.dispatch(setVideoMuted(mute, VIDEO_MUTISM_AUTHORITY.USER, true)); + }, + + /** + * Simulates toolbar button click for video mute. Used by shortcuts and API. + * @param {boolean} [showUI] when set to false will not display any error + * dialogs in case of media permissions error. + * @param {boolean} ensureTrack - True if we want to ensure that a new track is + * created if missing. + */ + toggleVideoMuted(showUI = true, ensureTrack = false) { + const mute = !this.isLocalVideoMuted(); + + APP.store.dispatch(handleToggleVideoMuted(mute, showUI, ensureTrack)); + }, + + /** + * Retrieve list of ids of conference participants (without local user). + * @returns {string[]} + */ + listMembersIds() { + return room.getParticipants().map(p => p.getId()); + }, + + /** + * Checks whether the participant identified by id is a moderator. + * @id id to search for participant + * @return {boolean} whether the participant is moderator + */ + isParticipantModerator(id) { + const user = room.getParticipantById(id); + + return user && user.isModerator(); + }, + + /** + * Retrieve list of conference participants (without local user). + * @returns {JitsiParticipant[]} + * + * NOTE: Used by jitsi-meet-torture! + */ + listMembers() { + return room.getParticipants(); + }, + + /** + * Used by Jibri to detect when it's alone and the meeting should be terminated. + */ + get membersCount() { + return room.getParticipants() + .filter(p => !p.isHidden() || !(config.iAmRecorder && p.isHiddenFromRecorder())).length + 1; + }, + + /** + * Get speaker stats that track total dominant speaker time. + * + * @returns {object} A hash with keys being user ids and values being the + * library's SpeakerStats model used for calculating time as dominant + * speaker. + */ + getSpeakerStats() { + return room.getSpeakerStats(); + }, + + // used by torture currently + isJoined() { + return room && room.isJoined(); + }, + getConnectionState() { + return room && room.getConnectionState(); + }, + + /** + * Obtains current P2P ICE connection state. + * @return {string|null} ICE connection state or null if there's no + * P2P connection + */ + getP2PConnectionState() { + return room && room.getP2PConnectionState(); + }, + + /** + * Starts P2P (for tests only) + * @private + */ + _startP2P() { + try { + room && room.startP2PSession(); + } catch (error) { + logger.error('Start P2P failed', error); + throw error; + } + }, + + /** + * Stops P2P (for tests only) + * @private + */ + _stopP2P() { + try { + room && room.stopP2PSession(); + } catch (error) { + logger.error('Stop P2P failed', error); + throw error; + } + }, + + /** + * Checks whether or not our connection is currently in interrupted and + * reconnect attempts are in progress. + * + * @returns {boolean} true if the connection is in interrupted state or + * false otherwise. + */ + isConnectionInterrupted() { + return room.isConnectionInterrupted(); + }, + + /** + * Finds JitsiParticipant for given id. + * + * @param {string} id participant's identifier(MUC nickname). + * + * @returns {JitsiParticipant|null} participant instance for given id or + * null if not found. + */ + getParticipantById(id) { + return room ? room.getParticipantById(id) : null; + }, + + getMyUserId() { + return room && room.myUserId(); + }, + + /** + * Will be filled with values only when config.testing.testMode is true. + * Its used by torture to check audio levels. + */ + audioLevelsMap: {}, + + /** + * Returns the stored audio level (stored only if config.debug is enabled) + * @param id the id for the user audio level to return (the id value is + * returned for the participant using getMyUserId() method) + */ + getPeerSSRCAudioLevel(id) { + return this.audioLevelsMap[id]; + }, + + /** + * @return {number} the number of participants in the conference with at + * least one track. + */ + getNumberOfParticipantsWithTracks() { + return room.getParticipants() + .filter(p => p.getTracks().length > 0) + .length; + }, + + /** + * Returns the stats. + */ + getStats() { + return room.connectionQuality.getStats(); + }, + + // end used by torture + + /** + * Download logs, a function that can be called from console while + * debugging. + * @param filename (optional) specify target filename + */ + saveLogs(filename = 'meetlog.json') { + // this can be called from console and will not have reference to this + // that's why we reference the global var + const logs = APP.connection.getLogs(); + + downloadJSON(logs, filename); + }, + + /** + * Download app state, a function that can be called from console while debugging. + * @param filename (optional) specify target filename + */ + saveState(filename = 'meet-state.json') { + downloadJSON(APP.store.getState(), filename); + }, + + /** + * Exposes a Command(s) API on this instance. It is necessitated by (1) the + * desire to keep room private to this instance and (2) the need of other + * modules to send and receive commands to and from participants. + * Eventually, this instance remains in control with respect to the + * decision whether the Command(s) API of room (i.e. lib-jitsi-meet's + * JitsiConference) is to be used in the implementation of the Command(s) + * API of this instance. + */ + commands: { + /** + * Known custom conference commands. + */ + defaults: commands, + + /** + * Receives notifications from other participants about commands aka + * custom events (sent by sendCommand or sendCommandOnce methods). + * @param command {String} the name of the command + * @param handler {Function} handler for the command + */ + addCommandListener() { + // eslint-disable-next-line prefer-rest-params + room.addCommandListener(...arguments); + }, + + /** + * Removes command. + * @param name {String} the name of the command. + */ + removeCommand() { + // eslint-disable-next-line prefer-rest-params + room.removeCommand(...arguments); + }, + + /** + * Sends command. + * @param name {String} the name of the command. + * @param values {Object} with keys and values that will be sent. + */ + sendCommand() { + // eslint-disable-next-line prefer-rest-params + room.sendCommand(...arguments); + }, + + /** + * Sends command one time. + * @param name {String} the name of the command. + * @param values {Object} with keys and values that will be sent. + */ + sendCommandOnce() { + // eslint-disable-next-line prefer-rest-params + room.sendCommandOnce(...arguments); + } + }, + + /** + * Used by the Breakout Rooms feature to join a breakout room or go back to the main room. + */ + async joinRoom(roomName, options) { + APP.store.dispatch(conferenceWillInit()); + + // Restore initial state. + this._localTracksInitialized = false; + this.roomName = roomName; + + const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(options); + const localTracks = await tryCreateLocalTracks; + + APP.store.dispatch(displayErrorsForCreateInitialLocalTracks(errors)); + localTracks.forEach(track => { + if ((track.isAudioTrack() && this.isLocalAudioMuted()) + || (track.isVideoTrack() && this.isLocalVideoMuted())) { + track.mute(); + } + }); + this._createRoom(localTracks); + + return new Promise((resolve, reject) => { + new ConferenceConnector(resolve, reject, this).connect(); + }); + }, + + _createRoom(localTracks) { + room = APP.connection.initJitsiConference(APP.conference.roomName, this._getConferenceOptions()); + + // Filter out the tracks that are muted (except on Safari). + let tracks = localTracks; + + if (!browser.isWebKitBased()) { + const mutedTrackTypes = []; + + tracks = localTracks.filter(track => { + if (!track.isMuted()) { + return true; + } + + if (track.getVideoType() !== VIDEO_TYPE.DESKTOP) { + mutedTrackTypes.push(track.getType()); + } + + return false; + }); + APP.store.dispatch(gumPending(mutedTrackTypes, IGUMPendingState.NONE)); + } + + this._room = room; // FIXME do not use this + + APP.store.dispatch(_conferenceWillJoin(room)); + + this._setLocalAudioVideoStreams(tracks); + + sendLocalParticipant(APP.store, room); + + this._setupListeners(); + }, + + /** + * Sets local video and audio streams. + * @param {JitsiLocalTrack[]} tracks=[] + * @returns {Promise[]} + * @private + */ + _setLocalAudioVideoStreams(tracks = []) { + const { dispatch } = APP.store; + const pendingGUMDevicesToRemove = []; + const promises = tracks.map(track => { + if (track.isAudioTrack()) { + pendingGUMDevicesToRemove.push(MEDIA_TYPE.AUDIO); + + return this.useAudioStream(track); + } else if (track.isVideoTrack()) { + logger.debug(`_setLocalAudioVideoStreams is calling useVideoStream with track: ${track}`); + pendingGUMDevicesToRemove.push(MEDIA_TYPE.VIDEO); + + return this.useVideoStream(track); + } + + logger.error('Ignored not an audio nor a video track: ', track); + + return Promise.resolve(); + + }); + + return Promise.allSettled(promises).then(() => { + if (pendingGUMDevicesToRemove.length > 0) { + dispatch(gumPending(pendingGUMDevicesToRemove, IGUMPendingState.NONE)); + } + + this._localTracksInitialized = true; + logger.log(`Initialized with ${tracks.length} local tracks`); + }); + }, + + _getConferenceOptions() { + const options = getConferenceOptions(APP.store.getState()); + + options.createVADProcessor = createRnnoiseProcessor; + + return options; + }, + + /** + * Start using provided video stream. + * Stops previous video stream. + * @param {JitsiLocalTrack} newTrack - new track to use or null + * @returns {Promise} + */ + useVideoStream(newTrack) { + logger.debug(`useVideoStream: ${newTrack}`); + + return new Promise((resolve, reject) => { + _replaceLocalVideoTrackQueue.enqueue(onFinish => { + const state = APP.store.getState(); + const oldTrack = getLocalJitsiVideoTrack(state); + + logger.debug(`useVideoStream: Replacing ${oldTrack} with ${newTrack}`); + + if (oldTrack === newTrack || (!oldTrack && !newTrack)) { + resolve(); + onFinish(); + + return; + } + + // Add the track to the conference if there is no existing track, replace it otherwise. + const trackAction = oldTrack + ? replaceLocalTrack(oldTrack, newTrack, room) + : addLocalTrack(newTrack); + + APP.store.dispatch(trackAction) + .then(() => { + this.setVideoMuteStatus(); + }) + .then(resolve) + .catch(error => { + logger.error(`useVideoStream failed: ${error}`); + reject(error); + }) + .then(onFinish); + }); + }); + }, + + /** + * Start using provided audio stream. + * Stops previous audio stream. + * @param {JitsiLocalTrack} newTrack - new track to use or null + * @returns {Promise} + */ + useAudioStream(newTrack) { + return new Promise((resolve, reject) => { + _replaceLocalAudioTrackQueue.enqueue(onFinish => { + const oldTrack = getLocalJitsiAudioTrack(APP.store.getState()); + + if (oldTrack === newTrack) { + resolve(); + onFinish(); + + return; + } + + APP.store.dispatch(replaceLocalTrack(oldTrack, newTrack, room)) + .then(() => { + this.updateAudioIconEnabled(); + }) + .then(resolve) + .catch(reject) + .then(onFinish); + }); + }); + }, + + /** + * Returns whether or not the conference is currently in audio only mode. + * + * @returns {boolean} + */ + isAudioOnly() { + return Boolean(APP.store.getState()['features/base/audio-only'].enabled); + }, + + /** + * This fields stores a handler which will create a Promise which turns off + * the screen sharing and restores the previous video state (was there + * any video, before switching to screen sharing ? was it muted ?). + * + * Once called this fields is cleared to null. + * @type {Function|null} + */ + _untoggleScreenSharing: null, + + /** + * Creates a Promise which turns off the screen sharing and restores + * the previous state described by the arguments. + * + * This method is bound to the appropriate values, after switching to screen + * sharing and stored in {@link _untoggleScreenSharing}. + * + * @param {boolean} didHaveVideo indicates if there was a camera video being + * used, before switching to screen sharing. + * @param {boolean} ignoreDidHaveVideo indicates if the camera video should be + * ignored when switching screen sharing off. + * @return {Promise} resolved after the screen sharing is turned off, or + * rejected with some error (no idea what kind of error, possible GUM error) + * in case it fails. + * @private + */ + async _turnScreenSharingOff(didHaveVideo, ignoreDidHaveVideo) { + this._untoggleScreenSharing = null; + + APP.store.dispatch(stopReceiver()); + + this._stopProxyConnection(); + + APP.store.dispatch(toggleScreenshotCaptureSummary(false)); + const tracks = APP.store.getState()['features/base/tracks']; + const duration = getLocalVideoTrack(tracks)?.jitsiTrack.getDuration() ?? 0; + + // If system audio was also shared stop the AudioMixerEffect and dispose of the desktop audio track. + if (this._mixerEffect) { + const localAudio = getLocalJitsiAudioTrack(APP.store.getState()); + + await localAudio.setEffect(undefined); + await this._desktopAudioStream.dispose(); + this._mixerEffect = undefined; + this._desktopAudioStream = undefined; + + // In case there was no local audio when screen sharing was started the fact that we set the audio stream to + // null will take care of the desktop audio stream cleanup. + } else if (this._desktopAudioStream) { + await room.replaceTrack(this._desktopAudioStream, null); + this._desktopAudioStream.dispose(); + this._desktopAudioStream = undefined; + } + + APP.store.dispatch(setScreenAudioShareState(false)); + let promise; + + if (didHaveVideo && !ignoreDidHaveVideo) { + promise = createLocalTracksF({ devices: [ 'video' ] }) + .then(([ stream ]) => { + logger.debug(`_turnScreenSharingOff using ${stream} for useVideoStream`); + + return this.useVideoStream(stream); + }) + .catch(error => { + logger.error('failed to switch back to local video', error); + + return this.useVideoStream(null).then(() => + + // Still fail with the original err + Promise.reject(error) + ); + }); + } else { + promise = this.useVideoStream(null); + } + + return promise.then( + () => { + sendAnalytics(createScreenSharingEvent('stopped', + duration === 0 ? null : duration)); + logger.info('Screen sharing stopped.'); + }, + error => { + logger.error(`_turnScreenSharingOff failed: ${error}`); + + throw error; + }); + }, + + /** + * Creates desktop (screensharing) {@link JitsiLocalTrack} + * + * @return {Promise.} - A Promise resolved with + * {@link JitsiLocalTrack} for the screensharing or rejected with + * {@link JitsiTrackError}. + * + * @private + */ + _createDesktopTrack() { + const didHaveVideo = !this.isLocalVideoMuted(); + + const getDesktopStreamPromise = createLocalTracksF({ + desktopSharingSourceDevice: config._desktopSharingSourceDevice, + devices: [ 'desktop' ] + }); + + return getDesktopStreamPromise.then(desktopStreams => { + // Stores the "untoggle" handler which remembers whether was + // there any video before and whether was it muted. + this._untoggleScreenSharing + = this._turnScreenSharingOff.bind(this, didHaveVideo); + + const desktopAudioStream = desktopStreams.find(stream => stream.getType() === MEDIA_TYPE.AUDIO); + + if (desktopAudioStream) { + desktopAudioStream.on( + JitsiTrackEvents.LOCAL_TRACK_STOPPED, + () => { + logger.debug('Local screensharing audio track stopped.'); + + // Handle case where screen share was stopped from the browsers 'screen share in progress' + // window. If audio screen sharing is stopped via the normal UX flow this point shouldn't + // be reached. + isScreenAudioShared(APP.store.getState()) + && this._untoggleScreenSharing + && this._untoggleScreenSharing(); + } + ); + } + + return desktopStreams; + }, error => { + throw error; + }); + }, + + /** + * Setup interaction between conference and UI. + */ + _setupListeners() { + // add local streams when joined to the conference + room.on(JitsiConferenceEvents.CONFERENCE_JOINED, () => { + this._onConferenceJoined(); + }); + room.on( + JitsiConferenceEvents.CONFERENCE_JOIN_IN_PROGRESS, + () => APP.store.dispatch(conferenceJoinInProgress(room))); + + room.on( + JitsiConferenceEvents.CONFERENCE_LEFT, + (...args) => { + APP.store.dispatch(conferenceTimestampChanged(0)); + APP.store.dispatch(conferenceLeft(room, ...args)); + }); + + room.on( + JitsiConferenceEvents.CONFERENCE_UNIQUE_ID_SET, + (...args) => { + // Preserve the sessionId so that the value is accessible even after room + // is disconnected. + room.sessionId = room.getMeetingUniqueId(); + APP.store.dispatch(conferenceUniqueIdSet(room, ...args)); + }); + + // we want to ignore this event in case of tokenAuthUrl config + // we are deprecating this and at some point will get rid of it + if (!config.tokenAuthUrl) { + room.on( + JitsiConferenceEvents.AUTH_STATUS_CHANGED, + (authEnabled, authLogin) => + APP.store.dispatch(authStatusChanged(authEnabled, authLogin))); + } + + room.on(JitsiConferenceEvents.PARTCIPANT_FEATURES_CHANGED, user => { + APP.store.dispatch(updateRemoteParticipantFeatures(user)); + }); + room.on(JitsiConferenceEvents.USER_JOINED, (id, user) => { + if (config.iAmRecorder && user.isHiddenFromRecorder()) { + return; + } + + // The logic shared between RN and web. + commonUserJoinedHandling(APP.store, room, user); + + if (user.isHidden()) { + return; + } + + APP.store.dispatch(updateRemoteParticipantFeatures(user)); + logger.log(`USER ${id} connected`); + APP.UI.addUser(user); + }); + + room.on(JitsiConferenceEvents.USER_LEFT, (id, user) => { + // The logic shared between RN and web. + commonUserLeftHandling(APP.store, room, user); + + if (user.isHidden()) { + return; + } + + logger.log(`USER ${id} LEFT:`, user); + }); + + room.on(JitsiConferenceEvents.USER_STATUS_CHANGED, (id, status) => { + APP.store.dispatch(participantPresenceChanged(id, status)); + + const user = room.getParticipantById(id); + + if (user) { + APP.UI.updateUserStatus(user, status); + } + }); + + room.on(JitsiConferenceEvents.USER_ROLE_CHANGED, (id, role) => { + if (this.isLocalId(id)) { + logger.info(`My role changed, new role: ${role}`); + + if (role === 'moderator') { + APP.store.dispatch(maybeSetLobbyChatMessageListener()); + } + + APP.store.dispatch(localParticipantRoleChanged(role)); + } else { + APP.store.dispatch(participantRoleChanged(id, role)); + } + }); + + room.on(JitsiConferenceEvents.TRACK_ADDED, track => { + if (!track || track.isLocal()) { + return; + } + + if (config.iAmRecorder) { + const participant = room.getParticipantById(track.getParticipantId()); + + if (participant.isHiddenFromRecorder()) { + return; + } + } + + APP.store.dispatch(trackAdded(track)); + }); + + room.on(JitsiConferenceEvents.TRACK_REMOVED, track => { + if (!track || track.isLocal()) { + return; + } + + APP.store.dispatch(trackRemoved(track)); + }); + + room.on(JitsiConferenceEvents.TRACK_AUDIO_LEVEL_CHANGED, (id, lvl) => { + const localAudio = getLocalJitsiAudioTrack(APP.store.getState()); + let newLvl = lvl; + + if (this.isLocalId(id)) { + APP.store.dispatch(localParticipantAudioLevelChanged(lvl)); + } + + if (this.isLocalId(id) && localAudio?.isMuted()) { + newLvl = 0; + } + + if (config.testing?.testMode) { + this.audioLevelsMap[id] = newLvl; + if (config.testing?.debugAudioLevels) { + logger.log(`AudioLevel:${id}/${newLvl}`); + } + } + + APP.UI.setAudioLevel(id, newLvl); + }); + + room.on(JitsiConferenceEvents.TRACK_MUTE_CHANGED, (track, participantThatMutedUs) => { + if (participantThatMutedUs) { + APP.store.dispatch(participantMutedUs(participantThatMutedUs, track)); + } + }); + + room.on(JitsiConferenceEvents.TRACK_UNMUTE_REJECTED, track => APP.store.dispatch(destroyLocalTracks(track))); + + room.on(JitsiConferenceEvents.SUBJECT_CHANGED, + subject => APP.store.dispatch(conferenceSubjectChanged(subject))); + + room.on( + JitsiConferenceEvents.LAST_N_ENDPOINTS_CHANGED, + (leavingIds, enteringIds) => + APP.UI.handleLastNEndpoints(leavingIds, enteringIds)); + + room.on( + JitsiConferenceEvents.P2P_STATUS, + (jitsiConference, p2p) => + APP.store.dispatch(p2pStatusChanged(p2p))); + + room.on( + JitsiConferenceEvents.DOMINANT_SPEAKER_CHANGED, + (dominant, previous, silence) => { + APP.store.dispatch(dominantSpeakerChanged(dominant, previous, Boolean(silence), room)); + }); + + room.on( + JitsiConferenceEvents.CONFERENCE_CREATED_TIMESTAMP, + conferenceTimestamp => { + APP.store.dispatch(conferenceTimestampChanged(conferenceTimestamp)); + APP.API.notifyConferenceCreatedTimestamp(conferenceTimestamp); + } + ); + + room.on( + JitsiConferenceEvents.DISPLAY_NAME_CHANGED, + (id, displayName) => { + const formattedDisplayName + = getNormalizedDisplayName(displayName); + const state = APP.store.getState(); + const { + defaultRemoteDisplayName + } = state['features/base/config']; + + APP.store.dispatch(participantUpdated({ + conference: room, + id, + name: formattedDisplayName + })); + + const virtualScreenshareParticipantId = getVirtualScreenshareParticipantByOwnerId(state, id)?.id; + + if (virtualScreenshareParticipantId) { + APP.store.dispatch( + screenshareParticipantDisplayNameChanged(virtualScreenshareParticipantId, formattedDisplayName) + ); + } + + APP.API.notifyDisplayNameChanged(id, { + displayName: formattedDisplayName, + formattedDisplayName: + appendSuffix( + formattedDisplayName + || defaultRemoteDisplayName) + }); + } + ); + + room.on( + JitsiConferenceEvents.SILENT_STATUS_CHANGED, + (id, isSilent) => { + APP.store.dispatch(participantUpdated({ + conference: room, + id, + isSilent + })); + } + ); + + room.on( + JitsiConferenceEvents.BOT_TYPE_CHANGED, + (id, botType) => { + + APP.store.dispatch(participantUpdated({ + conference: room, + id, + botType + })); + } + ); + + room.on( + JitsiConferenceEvents.TRANSCRIPTION_STATUS_CHANGED, + (status, id, abruptly) => { + if (status === JitsiMeetJS.constants.transcriptionStatus.ON) { + APP.store.dispatch(transcriberJoined(id)); + } else if (status === JitsiMeetJS.constants.transcriptionStatus.OFF) { + APP.store.dispatch(transcriberLeft(id, abruptly)); + } + }); + + room.on( + JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED, + (participant, data) => { + APP.store.dispatch(endpointMessageReceived(participant, data)); + if (data?.name === ENDPOINT_TEXT_MESSAGE_NAME) { + APP.API.notifyEndpointTextMessageReceived({ + senderInfo: { + jid: participant.getJid(), + id: participant.getId() + }, + eventData: data + }); + } + }); + + room.on( + JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED, + (id, data) => { + APP.store.dispatch(nonParticipantMessageReceived(id, data)); + APP.API.notifyNonParticipantMessageReceived(id, data); + }); + + room.on( + JitsiConferenceEvents.LOCK_STATE_CHANGED, + (...args) => APP.store.dispatch(lockStateChanged(room, ...args))); + + room.on( + JitsiConferenceEvents.PROPERTIES_CHANGED, + properties => APP.store.dispatch(conferencePropertiesChanged(properties))); + + room.on(JitsiConferenceEvents.KICKED, (participant, reason, isReplaced) => { + if (isReplaced) { + // this event triggers when the local participant is kicked, `participant` + // is the kicker. In replace participant case, kicker is undefined, + // as the server initiated it. We mark in store the local participant + // as being replaced based on jwt. + const localParticipant = getLocalParticipant(APP.store.getState()); + + APP.store.dispatch(participantUpdated({ + conference: room, + id: localParticipant.id, + isReplaced + })); + + // we send readyToClose when kicked participant is replace so that + // embedding app can choose to dispose the iframe API on the handler. + APP.API.notifyReadyToClose(); + } + APP.store.dispatch(kickedOut(room, participant)); + }); + + room.on(JitsiConferenceEvents.PARTICIPANT_KICKED, (kicker, kicked) => { + APP.store.dispatch(participantKicked(kicker, kicked)); + }); + + room.on(JitsiConferenceEvents.PARTICIPANT_SOURCE_UPDATED, + jitsiParticipant => { + APP.store.dispatch(participantSourcesUpdated(jitsiParticipant)); + }); + + room.on(JitsiConferenceEvents.SUSPEND_DETECTED, () => { + APP.store.dispatch(suspendDetected()); + }); + + room.on( + JitsiConferenceEvents.AUDIO_UNMUTE_PERMISSIONS_CHANGED, + disableAudioMuteChange => { + APP.store.dispatch(setAudioUnmutePermissions(disableAudioMuteChange)); + }); + room.on( + JitsiConferenceEvents.VIDEO_UNMUTE_PERMISSIONS_CHANGED, + disableVideoMuteChange => { + APP.store.dispatch(setVideoUnmutePermissions(disableVideoMuteChange)); + }); + + room.on( + JitsiE2ePingEvents.E2E_RTT_CHANGED, + (...args) => APP.store.dispatch(e2eRttChanged(...args))); + + room.addCommandListener(this.commands.defaults.ETHERPAD, + ({ value }) => { + APP.UI.initEtherpad(value); + } + ); + + room.addCommandListener(this.commands.defaults.EMAIL, (data, from) => { + APP.store.dispatch(participantUpdated({ + conference: room, + id: from, + email: data.value + })); + }); + + room.addCommandListener( + this.commands.defaults.AVATAR_URL, + (data, from) => { + const participant = getParticipantByIdOrUndefined(APP.store, from); + + // if already set from presence(jwt), skip the command processing + if (!participant?.avatarURL) { + APP.store.dispatch( + participantUpdated({ + conference: room, + id: from, + avatarURL: data.value + })); + } + }); + + room.on( + JitsiConferenceEvents.START_MUTED_POLICY_CHANGED, + ({ audio, video }) => { + APP.store.dispatch(onStartMutedPolicyChanged(audio, video)); + + const state = APP.store.getState(); + + updateTrackMuteState(state, APP.store.dispatch, true); + updateTrackMuteState(state, APP.store.dispatch, false); + } + ); + + room.on( + JitsiConferenceEvents.DATA_CHANNEL_OPENED, () => { + APP.store.dispatch(dataChannelOpened()); + APP.store.dispatch(hideNotification(DATA_CHANNEL_CLOSED_NOTIFICATION_ID)); + } + ); + + room.on( + JitsiConferenceEvents.DATA_CHANNEL_CLOSED, ev => { + const state = APP.store.getState(); + const { dataChannelOpen } = state['features/base/conference']; + const timeout = typeof dataChannelOpen === 'undefined' ? 15000 : 60000; + + // Show the notification only when the data channel connection doesn't get re-established in 60 secs if + // it was already established at the beginning of the call, show it sooner otherwise. This notification + // can be confusing and alarming to users even when there is no significant impact to user experience + // if the the reconnect happens immediately. + setTimeout(() => { + const { dataChannelOpen: open } = APP.store.getState()['features/base/conference']; + + if (!open) { + const descriptionKey = getSsrcRewritingFeatureFlag(state) + ? 'notify.dataChannelClosedDescriptionWithAudio' : 'notify.dataChannelClosedDescription'; + const titleKey = getSsrcRewritingFeatureFlag(state) + ? 'notify.dataChannelClosedWithAudio' : 'notify.dataChannelClosed'; + + APP.store.dispatch(dataChannelClosed(ev.code, ev.reason)); + APP.store.dispatch(showWarningNotification({ + descriptionKey, + titleKey, + uid: DATA_CHANNEL_CLOSED_NOTIFICATION_ID + }, NOTIFICATION_TIMEOUT_TYPE.STICKY)); + } + }, timeout); + } + ); + + room.on(JitsiConferenceEvents.PERMISSIONS_RECEIVED, p => { + const localParticipant = getLocalParticipant(APP.store.getState()); + + APP.store.dispatch(participantUpdated({ + id: localParticipant.id, + local: true, + features: p + })); + }); + }, + + /** + * Handles audio device changes. + * + * @param {string} cameraDeviceId - The new device id. + * @returns {Promise} + */ + async onAudioDeviceChanged(micDeviceId) { + const audioWasMuted = this.isLocalAudioMuted(); + + // Disable noise suppression if it was enabled on the previous track. + await APP.store.dispatch(setNoiseSuppressionEnabled(false)); + + // When the 'default' mic needs to be selected, we need to pass the real device id to gUM instead of + // 'default' in order to get the correct MediaStreamTrack from chrome because of the following bug. + // https://bugs.chromium.org/p/chromium/issues/detail?id=997689. + const isDefaultMicSelected = micDeviceId === 'default'; + const selectedDeviceId = isDefaultMicSelected + ? getDefaultDeviceId(APP.store.getState(), 'audioInput') + : micDeviceId; + + logger.info(`Switching audio input device to ${selectedDeviceId}`); + sendAnalytics(createDeviceChangedEvent('audio', 'input')); + createLocalTracksF({ + devices: [ 'audio' ], + micDeviceId: selectedDeviceId + }) + .then(([ stream ]) => { + // if audio was muted before changing the device, mute + // with the new device + if (audioWasMuted) { + return stream.mute() + .then(() => stream); + } + + return stream; + }) + .then(async stream => { + await this._maybeApplyAudioMixerEffect(stream); + + return this.useAudioStream(stream); + }) + .then(() => { + const state = APP.store.getState(); + const localAudio = getLocalJitsiAudioTrack(state); + const settings = getLocalJitsiAudioTrackSettings(state); + + APP.store.dispatch(setAudioSettings(settings)); + + if (localAudio && isDefaultMicSelected) { + // workaround for the default device to be shown as selected in the + // settings even when the real device id was passed to gUM because of the + // above mentioned chrome bug. + localAudio._realDeviceId = localAudio.deviceId = 'default'; + } + }) + .catch(err => { + logger.error(`Failed to switch to selected audio input device ${selectedDeviceId}, error=${err}`); + APP.store.dispatch(notifyMicError(err)); + }); + }, + + /** + * Handles video device changes. + * + * @param {string} cameraDeviceId - The new device id. + * @returns {void} + */ + onVideoDeviceChanged(cameraDeviceId) { + const videoWasMuted = this.isLocalVideoMuted(); + const localVideoTrack = getLocalJitsiVideoTrack(APP.store.getState()); + + if (localVideoTrack?.getDeviceId() === cameraDeviceId) { + return; + } + + sendAnalytics(createDeviceChangedEvent('video', 'input')); + + createLocalTracksF({ + devices: [ 'video' ], + cameraDeviceId + }) + .then(([ stream ]) => { + // if we are in audio only mode or video was muted before + // changing device, then mute + if (this.isAudioOnly() || videoWasMuted) { + return stream.mute() + .then(() => stream); + } + + return stream; + }) + .then(stream => { + logger.info(`Switching the local video device to ${cameraDeviceId}.`); + + return this.useVideoStream(stream); + }) + .catch(error => { + logger.error(`Failed to switch to selected camera:${cameraDeviceId}, error:${error}`); + + return APP.store.dispatch(notifyCameraError(error)); + }); + }, + + /** + * Handles audio only changes. + */ + onToggleAudioOnly() { + // Immediately update the UI by having remote videos and the large video update themselves. + const displayedUserId = APP.UI.getLargeVideoID(); + + if (displayedUserId) { + APP.UI.updateLargeVideo(displayedUserId, true); + } + }, + + /** + * Cleanups local conference on suspend. + */ + onSuspendDetected() { + // After wake up, we will be in a state where conference is left + // there will be dialog shown to user. + // We do not want video/audio as we show an overlay and after it + // user need to rejoin or close, while waking up we can detect + // camera wakeup as a problem with device. + // We also do not care about device change, which happens + // on resume after suspending PC. + if (this.deviceChangeListener) { + JitsiMeetJS.mediaDevices.removeEventListener( + JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED, + this.deviceChangeListener); + } + }, + + /** + * Callback invoked when the conference has been successfully joined. + * Initializes the UI and various other features. + * + * @private + * @returns {void} + */ + _onConferenceJoined() { + const { dispatch } = APP.store; + + APP.UI.initConference(); + + dispatch(conferenceJoined(room)); + + const jwt = APP.store.getState()['features/base/jwt']; + + if (jwt?.user?.hiddenFromRecorder) { + dispatch(muteLocal(true, MEDIA_TYPE.AUDIO)); + dispatch(muteLocal(true, MEDIA_TYPE.VIDEO)); + dispatch(setAudioUnmutePermissions(true, true)); + dispatch(setVideoUnmutePermissions(true, true)); + } + }, + + /** + * Updates the list of current devices. + * @param {boolean} setDeviceListChangeHandler - Whether to add the deviceList change handlers. + * @private + * @returns {Promise} + */ + _initDeviceList(setDeviceListChangeHandler = false) { + const { mediaDevices } = JitsiMeetJS; + + if (mediaDevices.isDeviceChangeAvailable()) { + if (setDeviceListChangeHandler) { + this.deviceChangeListener = devices => + window.setTimeout(() => this._onDeviceListChanged(devices), 0); + mediaDevices.addEventListener( + JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED, + this.deviceChangeListener); + } + + const { dispatch } = APP.store; + + return dispatch(getAvailableDevices()) + .then(() => { + this.updateAudioIconEnabled(); + this.updateVideoIconEnabled(); + }); + } + + return Promise.resolve(); + }, + + /** + * Event listener for JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED to + * handle change of available media devices. + * @private + * @param {MediaDeviceInfo[]} devices + * @returns {Promise} + */ + async _onDeviceListChanged(devices) { + const state = APP.store.getState(); + const { filteredDevices, ignoredDevices } = filterIgnoredDevices(devices); + const oldDevices = state['features/base/devices'].availableDevices; + + if (!areDevicesDifferent(flattenAvailableDevices(oldDevices), filteredDevices)) { + return Promise.resolve(); + } + + logDevices(ignoredDevices, 'Ignored devices on device list changed:'); + + const localAudio = getLocalJitsiAudioTrack(state); + const localVideo = getLocalJitsiVideoTrack(state); + + APP.store.dispatch(updateDeviceList(filteredDevices)); + + // Firefox users can choose their preferred device in the gUM prompt. In that case + // we should respect that and not attempt to switch to the preferred device from + // our settings. + const newLabelsOnly = mediaDeviceHelper.newDeviceListAddedLabelsOnly(oldDevices, filteredDevices); + const newDevices + = mediaDeviceHelper.getNewMediaDevicesAfterDeviceListChanged( + filteredDevices, + localVideo, + localAudio, + newLabelsOnly); + const promises = []; + const requestedInput = { + audio: Boolean(newDevices.audioinput), + video: Boolean(newDevices.videoinput) + }; + + if (typeof newDevices.audiooutput !== 'undefined') { + const { dispatch } = APP.store; + const setAudioOutputPromise + = setAudioOutputDeviceId(newDevices.audiooutput, dispatch) + .catch(err => { + logger.error(`Failed to set the audio output device to ${newDevices.audiooutput} - ${err}`); + }); + + promises.push(setAudioOutputPromise); + } + + // Handles the use case when the default device is changed (we are always stopping the streams because it's + // simpler): + // If the default device is changed we need to first stop the local streams and then call GUM. Otherwise GUM + // will return a stream using the old default device. + if (requestedInput.audio && localAudio) { + localAudio.stopStream(); + } + + if (requestedInput.video && localVideo) { + localVideo.stopStream(); + } + + // Let's handle unknown/non-preferred devices + const newAvailDevices = APP.store.getState()['features/base/devices'].availableDevices; + let newAudioDevices = []; + let oldAudioDevices = []; + + if (typeof newDevices.audiooutput === 'undefined') { + newAudioDevices = newAvailDevices.audioOutput; + oldAudioDevices = oldDevices.audioOutput; + } + + if (!requestedInput.audio) { + newAudioDevices = newAudioDevices.concat(newAvailDevices.audioInput); + oldAudioDevices = oldAudioDevices.concat(oldDevices.audioInput); + } + + // check for audio + if (newAudioDevices.length > 0) { + APP.store.dispatch(checkAndNotifyForNewDevice(newAudioDevices, oldAudioDevices)); + } + + // check for video + if (requestedInput.video) { + APP.store.dispatch(checkAndNotifyForNewDevice(newAvailDevices.videoInput, oldDevices.videoInput)); + } + + // When the 'default' mic needs to be selected, we need to pass the real device id to gUM instead of 'default' + // in order to get the correct MediaStreamTrack from chrome because of the following bug. + // https://bugs.chromium.org/p/chromium/issues/detail?id=997689 + const hasDefaultMicChanged = newDevices.audioinput === 'default'; + + // When the local video is muted and a preferred device is connected, update the settings and remove the track + // from the conference. A new track will be created and replaced when the user unmutes their camera. + if (requestedInput.video && this.isLocalVideoMuted()) { + APP.store.dispatch(updateSettings({ + cameraDeviceId: newDevices.videoinput + })); + requestedInput.video = false; + delete newDevices.videoinput; + + // Remove the track from the conference. + if (localVideo) { + await this.useVideoStream(null); + logger.debug('_onDeviceListChanged: Removed the current video track.'); + } + } + + // When the local audio is muted and a preferred device is connected, update the settings and remove the track + // from the conference. A new track will be created and replaced when the user unmutes their mic. + if (requestedInput.audio && this.isLocalAudioMuted()) { + APP.store.dispatch(updateSettings({ + micDeviceId: newDevices.audioinput + })); + requestedInput.audio = false; + delete newDevices.audioinput; + + // Remove the track from the conference. + if (localAudio) { + await this.useAudioStream(null); + logger.debug('_onDeviceListChanged: Removed the current audio track.'); + } + } + + // Create the tracks and replace them only if the user is unmuted. + if (requestedInput.audio || requestedInput.video) { + let tracks = []; + const realAudioDeviceId = hasDefaultMicChanged + ? getDefaultDeviceId(APP.store.getState(), 'audioInput') : newDevices.audioinput; + + try { + tracks = await mediaDeviceHelper.createLocalTracksAfterDeviceListChanged( + createLocalTracksF, + requestedInput.video ? newDevices.videoinput : null, + requestedInput.audio ? realAudioDeviceId : null + ); + } catch (error) { + logger.error(`Track creation failed on device change, ${error}`); + + return Promise.reject(error); + } + + for (const track of tracks) { + if (track.isAudioTrack()) { + promises.push( + this.useAudioStream(track) + .then(() => { + hasDefaultMicChanged && (track._realDeviceId = track.deviceId = 'default'); + })); + } else { + promises.push( + this.useVideoStream(track)); + } + } + } + + return Promise.all(promises) + .then(() => { + this.updateAudioIconEnabled(); + this.updateVideoIconEnabled(); + }); + }, + + /** + * Determines whether or not the audio button should be enabled. + */ + updateAudioIconEnabled() { + const localAudio = getLocalJitsiAudioTrack(APP.store.getState()); + const audioMediaDevices = APP.store.getState()['features/base/devices'].availableDevices.audioInput; + const audioDeviceCount = audioMediaDevices ? audioMediaDevices.length : 0; + + // The audio functionality is considered available if there are any + // audio devices detected or if the local audio stream already exists. + const available = audioDeviceCount > 0 || Boolean(localAudio); + + APP.store.dispatch(setAudioAvailable(available)); + }, + + /** + * Determines whether or not the video button should be enabled. + */ + updateVideoIconEnabled() { + const videoMediaDevices + = APP.store.getState()['features/base/devices'].availableDevices.videoInput; + const videoDeviceCount + = videoMediaDevices ? videoMediaDevices.length : 0; + const localVideo = getLocalJitsiVideoTrack(APP.store.getState()); + + // The video functionality is considered available if there are any + // video devices detected or if there is local video stream already + // active which could be either screensharing stream or a video track + // created before the permissions were rejected (through browser + // config). + const available = videoDeviceCount > 0 || Boolean(localVideo); + + APP.store.dispatch(setVideoAvailable(available)); + APP.API.notifyVideoAvailabilityChanged(available); + }, + + /** + * Disconnect from the conference and optionally request user feedback. + * @param {boolean} [requestFeedback=false] if user feedback should be + * @param {string} [hangupReason] the reason for leaving the meeting + * requested + * @param {boolean} [notifyOnConferenceTermination] whether to notify + * the user on conference termination + */ + hangup(requestFeedback = false, hangupReason, notifyOnConferenceTermination) { + APP.store.dispatch(disableReceiver()); + + this._stopProxyConnection(); + + APP.store.dispatch(destroyLocalTracks()); + this._localTracksInitialized = false; + + // Remove unnecessary event listeners from firing callbacks. + if (this.deviceChangeListener) { + JitsiMeetJS.mediaDevices.removeEventListener( + JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED, + this.deviceChangeListener); + } + + let feedbackResultPromise = Promise.resolve({}); + + if (requestFeedback) { + const feedbackDialogClosed = (feedbackResult = {}) => { + if (!feedbackResult.wasDialogShown && hangupReason && notifyOnConferenceTermination) { + return APP.store.dispatch( + openLeaveReasonDialog(hangupReason)).then(() => feedbackResult); + } + + return Promise.resolve(feedbackResult); + }; + + feedbackResultPromise + = APP.store.dispatch(maybeOpenFeedbackDialog(room, hangupReason)) + .then(feedbackDialogClosed, feedbackDialogClosed); + } + + const leavePromise = this.leaveRoom().catch(() => Promise.resolve()); + + Promise.allSettled([ feedbackResultPromise, leavePromise ]).then(([ feedback, _ ]) => { + this._room = undefined; + room = undefined; + + /** + * Don't call {@code notifyReadyToClose} if the promotional page flag is set + * and let the page take care of sending the message, since there will be + * a redirect to the page anyway. + */ + if (!interfaceConfig.SHOW_PROMOTIONAL_CLOSE_PAGE) { + APP.API.notifyReadyToClose(); + } + + APP.store.dispatch(maybeRedirectToWelcomePage(feedback.value ?? {})); + }); + + + }, + + /** + * Leaves the room. + * + * @param {boolean} doDisconnect - Whether leaving the room should also terminate the connection. + * @param {string} reason - reason for leaving the room. + * @returns {Promise} + */ + leaveRoom(doDisconnect = true, reason = '') { + APP.store.dispatch(conferenceWillLeave(room)); + + const maybeDisconnect = () => { + if (doDisconnect) { + return disconnect(); + } + }; + + if (room && room.isJoined()) { + return room.leave(reason).then(() => maybeDisconnect()) + .catch(e => { + logger.error(e); + + return maybeDisconnect(); + }); + } + + return maybeDisconnect(); + }, + + /** + * Changes the email for the local user + * @param email {string} the new email + */ + changeLocalEmail(email = '') { + const formattedEmail = String(email).trim(); + + APP.store.dispatch(updateSettings({ + email: formattedEmail + })); + + sendData(commands.EMAIL, formattedEmail); + }, + + /** + * Changes the avatar url for the local user + * @param url {string} the new url + */ + changeLocalAvatarUrl(url = '') { + const formattedUrl = String(url).trim(); + + APP.store.dispatch(updateSettings({ + avatarURL: formattedUrl + })); + + sendData(commands.AVATAR_URL, url); + }, + + /** + * Sends a message via the data channel. + * @param {string} to the id of the endpoint that should receive the + * message. If "" - the message will be sent to all participants. + * @param {object} payload the payload of the message. + * @throws NetworkError or InvalidStateError or Error if the operation + * fails. + */ + sendEndpointMessage(to, payload) { + room.sendEndpointMessage(to, payload); + }, + + /** + * Callback invoked by the external api create or update a direct connection + * from the local client to an external client. + * + * @param {Object} event - The object containing information that should be + * passed to the {@code ProxyConnectionService}. + * @returns {void} + */ + onProxyConnectionEvent(event) { + if (!this._proxyConnection) { + this._proxyConnection = new JitsiMeetJS.ProxyConnectionService({ + + /** + * Pass the {@code JitsiConnection} instance which will be used + * to fetch TURN credentials. + */ + jitsiConnection: APP.connection, + + /** + * The proxy connection feature is currently tailored towards + * taking a proxied video stream and showing it as a local + * desktop screen. + */ + convertVideoToDesktop: true, + + /** + * Callback invoked when the connection has been closed + * automatically. Triggers cleanup of screensharing if active. + * + * @returns {void} + */ + onConnectionClosed: () => { + if (this._untoggleScreenSharing) { + this._untoggleScreenSharing(); + } + }, + + /** + * Callback invoked to pass messages from the local client back + * out to the external client. + * + * @param {string} peerJid - The jid of the intended recipient + * of the message. + * @param {Object} data - The message that should be sent. For + * screensharing this is an iq. + * @returns {void} + */ + onSendMessage: (peerJid, data) => + APP.API.sendProxyConnectionEvent({ + data, + to: peerJid + }), + + /** + * Callback invoked when the remote peer of the proxy connection + * has provided a video stream, intended to be used as a local + * desktop stream. + * + * @param {JitsiLocalTrack} remoteProxyStream - The media + * stream to use as a local desktop stream. + * @returns {void} + */ + onRemoteStream: desktopStream => { + if (desktopStream.videoType !== 'desktop') { + logger.warn('Received a non-desktop stream to proxy.'); + desktopStream.dispose(); + + return; + } + + APP.store.dispatch(toggleScreensharingA(undefined, false, { desktopStream })); + } + }); + } + + this._proxyConnection.processMessage(event); + }, + + /** + * Sets the video muted status. + */ + setVideoMuteStatus() { + APP.UI.setVideoMuted(this.getMyUserId()); + }, + + /** + * Dispatches the passed in feedback for submission. The submitted score + * should be a number inclusively between 1 through 5, or -1 for no score. + * + * @param {number} score - a number between 1 and 5 (inclusive) or -1 for no + * score. + * @param {string} message - An optional message to attach to the feedback + * in addition to the score. + * @returns {void} + */ + submitFeedback(score = -1, message = '') { + if (score === -1 || (score >= 1 && score <= 5)) { + APP.store.dispatch(submitFeedback(score, message, room)); + } + }, + + /** + * Terminates any proxy screensharing connection that is active. + * + * @private + * @returns {void} + */ + _stopProxyConnection() { + if (this._proxyConnection) { + this._proxyConnection.stop(); + } + + this._proxyConnection = null; + } +}; diff --git a/config.js b/config.js new file mode 100644 index 0000000..b560e59 --- /dev/null +++ b/config.js @@ -0,0 +1,1928 @@ +/* eslint-disable comma-dangle, no-unused-vars, no-var, prefer-template, vars-on-top */ + +/* + * NOTE: If you add a new option please remember to document it here: + * https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-configuration + */ + +var subdir = ''; +var subdomain = ''; + +if (subdomain) { + subdomain = subdomain.substr(0, subdomain.length - 1).split('.') + .join('_') + .toLowerCase() + '.'; +} + +// In case of no ssi provided by the webserver, use empty strings +if (subdir.startsWith('/' + subdir + 'conference-request/v1', + + // Options related to the bridge (colibri) data channel + bridgeChannel: { + // If the backend advertises multiple colibri websockets, this options allows + // to filter some of them out based on the domain name. We use the first URL + // which does not match ignoreDomain, falling back to the first one that matches + // ignoreDomain. Has no effect if undefined. + // ignoreDomain: 'example.com', + + // Prefer SCTP (WebRTC data channels over the media path) over a colibri websocket. + // If SCTP is available in the backend it will be used instead of a WS. Defaults to + // false (SCTP is used only if available and no WS are available). + // preferSctp: false + }, + + // Testing / experimental features. + // + + testing: { + // Allows the setting of a custom bandwidth value from the UI. + // assumeBandwidth: true, + + // Enables use of getDisplayMedia in electron + // electronUseGetDisplayMedia: false, + + // Enables AV1 codec for FF. Note: By default it is disabled. + // enableAV1ForFF: false, + + // Enables the use of the codec selection API supported by the browsers . + // enableCodecSelectionAPI: false, + + // P2P test mode disables automatic switching to P2P when there are 2 + // participants in the conference. + // p2pTestMode: false, + + // Enables the test specific features consumed by jitsi-meet-torture + // testMode: false, + + // Disables the auto-play behavior of *all* newly created video element. + // This is useful when the client runs on a host with limited resources. + // noAutoPlayVideo: false, + + // Experiment: Whether to skip interim transcriptions. + // skipInterimTranscriptions: false, + + // Dump transcripts to a element for debugging. + // dumpTranscript: false, + + // Log the audio levels. + // debugAudioLevels: true, + + // Will replace ice candidates IPs with invalid ones in order to fail ice. + // failICE: true, + + // When running on Spot TV, this controls whether to show the recording consent dialog. + // If false (default), Spot instances will not show the recording consent dialog. + // If true, Spot instances will show the recording consent dialog like regular clients. + // showSpotConsentDialog: false, + }, + + // Disables moderator indicators. + // disableModeratorIndicator: false, + + // Disables the reactions feature. + // disableReactions: true, + + // Disables the reactions moderation feature. + // disableReactionsModeration: false, + + // Disables the reactions in chat feature. + // disableReactionsInChat: false, + + // Disables polls feature. + // disablePolls: false, + + // Disables chat feature entirely including notifications, sounds, and private messages. + // disableChat: false, + + // Disables demote button from self-view + // disableSelfDemote: false, + + // Disables self-view tile. (hides it from tile view and from filmstrip) + // disableSelfView: false, + + // Disables self-view settings in UI + // disableSelfViewSettings: false, + + // Shows/hides the moderator setting for chat permissions. + // showChatPermissionsModeratorSetting: false, + + // screenshotCapture : { + // Enables the screensharing capture feature. + // enabled: false, + // + // The mode for the screenshot capture feature. + // Can be either 'recording' - screensharing screenshots are taken + // only when the recording is also on, + // or 'always' - screensharing screenshots are always taken. + // mode: 'recording', + // } + + // Disables ICE/UDP by filtering out local and remote UDP candidates in + // signalling. + // webrtcIceUdpDisable: false, + + // Disables ICE/TCP by filtering out local and remote TCP candidates in + // signalling. + // webrtcIceTcpDisable: false, + + + // Media + // + + // Audio + + // Disable measuring of audio levels. + // disableAudioLevels: false, + + // audioLevelsInterval: 200, + + // Enabling this will run the lib-jitsi-meet no audio detection module which + // will notify the user if the current selected microphone has no audio + // input and will suggest another valid device if one is present. + enableNoAudioDetection: true, + + // Enabling this will show a "Save Logs" link in the GSM popover that can be + // used to collect debug information (XMPP IQs, SDP offer/answer cycles) + // about the call. + // enableSaveLogs: false, + + // Enabling this will hide the "Show More" link in the GSM popover that can be + // used to display more statistics about the connection (IP, Port, protocol, etc). + // disableShowMoreStats: true, + + // Enabling this will run the lib-jitsi-meet noise detection module which will + // notify the user if there is noise, other than voice, coming from the current + // selected microphone. The purpose it to let the user know that the input could + // be potentially unpleasant for other meeting participants. + enableNoisyMicDetection: true, + + // Start the conference in audio only mode (no video is being received nor + // sent). + // startAudioOnly: false, + + // Every participant after the Nth will start audio muted. + // startAudioMuted: 10, + + // Start calls with audio muted. Unlike the option above, this one is only + // applied locally. FIXME: having these 2 options is confusing. + // startWithAudioMuted: false, + + // Enabling it (with #params) will disable local audio output of remote + // participants and to enable it back a reload is needed. + // startSilent: false, + + // Enables support for opus-red (redundancy for Opus). + // enableOpusRed: false, + + // Specify audio quality stereo and opusMaxAverageBitrate values in order to enable HD audio. + // Beware, by doing so, you are disabling echo cancellation, noise suppression and AGC. + // Specify enableOpusDtx to enable support for opus-dtx where + // audio packets won’t be transmitted while participant is silent or muted. + // audioQuality: { + // stereo: false, + // opusMaxAverageBitrate: null, // Value to fit the 6000 to 510000 range. + // enableOpusDtx: false, + // }, + + // Noise suppression configuration. By default rnnoise is used. Optionally Krisp + // can be used by enabling it below, but the Krisp JS SDK files must be supplied in your + // installation. Specifically, these files are needed: + // - https://meet.example.com/libs/krisp/krisp.mjs + // - https://meet.example.com/libs/krisp/models/model_8.kw + // - https://meet.example.com/libs/krisp/models/model_nc.kw + // - https://meet.example.com/libs/krisp/models/model_bvc.kw + // - https://meet.example.com/libs/krisp/assets/bvc-allowed.txt + // In case when you have known BVC supported devices and you want to extend allowed devices list + // - https://meet.example.com/libs/krisp/assets/bvc-allowed-ext.txt + // In case when you have known BVC supported devices and you want to extend allowed devices list + // - https://meet.example.com/libs/krisp/models/model_inbound_8.kw + // - https://meet.example.com/libs/krisp/models/model_inbound_16.kw + // In case when you want to use inbound noise suppression models + // NOTE: Krisp JS SDK v2.0.0 was tested. + // noiseSuppression: { + // krisp: { + // enabled: false, + // logProcessStats: false, + // debugLogs: false, + // useBVC: false, + // bufferOverflowMS: 1000, + // inboundModels: { + // modelInbound8: 'model_inbound_8.kef', + // modelInbound16: 'model_inbound_16.kef', + // }, + // preloadInboundModels: { + // modelInbound8: 'model_inbound_8.kef', + // modelInbound16: 'model_inbound_16.kef', + // }, + // preloadModels: { + // modelBVC: 'model_bvc.kef', + // model8: 'model_8.kef', + // modelNC: 'model_nc_mq.kef', + // }, + // models: { + // modelBVC: 'model_bvc.kef', + // model8: 'model_8.kef', + // modelNV: 'model_nc_mq.kef', + // }, + // bvc: { + // allowedDevices: 'bvc-allowed.txt', + // allowedDevicesExt: 'bvc-allowed-ext.txt', + // } + // }, + // }, + + // Video + + // Sets the default camera facing mode. + // cameraFacingMode: 'user', + + // Sets the preferred resolution (height) for local video. Defaults to 720. + // resolution: 720, + + // DEPRECATED. Please use raisedHands.disableRemoveRaisedHandOnFocus instead. + // Specifies whether the raised hand will hide when someone becomes a dominant speaker or not + // disableRemoveRaisedHandOnFocus: false, + + // Specifies which raised hand related config should be set. + // raisedHands: { + // // Specifies whether the raised hand can be lowered by moderator. + // disableLowerHandByModerator: false, + + // // Specifies whether there is a notification before hiding the raised hand + // // when someone becomes the dominant speaker. + // disableLowerHandNotification: true, + + // // Specifies whether there is a notification when you are the next speaker in line. + // disableNextSpeakerNotification: false, + + // // Specifies whether the raised hand will hide when someone becomes a dominant speaker or not. + // disableRemoveRaisedHandOnFocus: false, + // }, + + // speakerStats: { + // // Specifies whether the speaker stats is enable or not. + // disabled: false, + + // // Specifies whether there will be a search field in speaker stats or not. + // disableSearch: false, + + // // Specifies whether participants in speaker stats should be ordered or not, and with what priority. + // // 'role', <- Moderators on top. + // // 'name', <- Alphabetically by name. + // // 'hasLeft', <- The ones that have left in the bottom. + // order: [ + // 'role', + // 'name', + // 'hasLeft', + // ], + // }, + + // DEPRECATED. Please use speakerStats.disableSearch instead. + // Specifies whether there will be a search field in speaker stats or not + // disableSpeakerStatsSearch: false, + + // DEPRECATED. Please use speakerStats.order . + // Specifies whether participants in speaker stats should be ordered or not, and with what priority + // speakerStatsOrder: [ + // 'role', <- Moderators on top + // 'name', <- Alphabetically by name + // 'hasLeft', <- The ones that have left in the bottom + // ], <- the order of the array elements determines priority + + // How many participants while in the tile view mode, before the receiving video quality is reduced from HD to SD. + // Use -1 to disable. + // maxFullResolutionParticipants: 2, + + // w3c spec-compliant video constraints to use for video capture. Currently + // used by browsers that return true from lib-jitsi-meet's + // util#browser#usesNewGumFlow. The constraints are independent from + // this config's resolution value. Defaults to requesting an ideal + // resolution of 720p. + // constraints: { + // video: { + // height: { + // ideal: 720, + // max: 720, + // min: 240, + // }, + // }, + // }, + + // Enable / disable simulcast support. + // disableSimulcast: false, + + // Every participant after the Nth will start video muted. + // startVideoMuted: 10, + + // Start calls with video muted. Unlike the option above, this one is only + // applied locally. FIXME: having these 2 options is confusing. + // startWithVideoMuted: false, + + // Desktop sharing + + // Optional desktop sharing frame rate options. Default value: min:5, max:5. + // Setting higher min/max values will affect the resolution, it makes it worse. + // desktopSharingFrameRate: { + // min: 5, + // max: 5, + // }, + + // Optional screenshare settings that give more control over screen capture in the browser. + // screenShareSettings: { + // // Show users the current tab is the preferred capture source, default: false. + // desktopPreferCurrentTab: false, + // // Allow users to select system audio, default: include. + // desktopSystemAudio: 'include', + // // Allow users to seamlessly switch which tab they are sharing without having to select the tab again. + // desktopSurfaceSwitching: 'include', + // // Allow a user to be shown a preference for what screen is to be captured, default: unset. + // desktopDisplaySurface: undefined, + // // Allow users to select the current tab as a capture source, default: exclude. + // desktopSelfBrowserSurface: 'exclude' + // }, + + // Recording + + // Enable the dropbox integration. + // dropbox: { + // appKey: '', // Specify your app key here. + // // A URL to redirect the user to, after authenticating + // // by default uses: + // // 'https://jitsi-meet.example.com/static/oauth.html' + // redirectURI: + // 'https://jitsi-meet.example.com/subfolder/static/oauth.html', + // }, + + // configuration for all things recording related. Existing settings will be migrated here in the future. + // recordings: { + // // IF true (default) recording audio and video is selected by default in the recording dialog. + // // recordAudioAndVideo: true, + // // If true, shows a notification at the start of the meeting with a call to action button + // // to start recording (for users who can do so). + // // suggestRecording: true, + // // If true, shows a warning label in the prejoin screen to point out the possibility that + // // the call you're joining might be recorded. + // // showPrejoinWarning: true, + // // If true, the notification for recording start will display a link to download the cloud recording. + // // showRecordingLink: true, + // // If true, mutes audio and video when a recording begins and displays a dialog + // // explaining the effect of unmuting. + // // requireConsent: true, + // // If true consent will be skipped for users who are already in the meeting. + // // skipConsentInMeeting: true, + // // Link for the recording consent dialog's "Learn more" link. + // // consentLearnMoreLink: 'https://jitsi.org/meet/consent', + // }, + + // recordingService: { + // // When integrations like dropbox are enabled only that will be shown, + // // by enabling fileRecordingsServiceEnabled, we show both the integrations + // // and the generic recording service (its configuration and storage type + // // depends on jibri configuration) + // enabled: false, + + // // Whether to show the possibility to share file recording with other people + // // (e.g. meeting participants), based on the actual implementation + // // on the backend. + // sharingEnabled: false, + + // // Hide the warning that says we only store the recording for 24 hours. + // hideStorageWarning: false, + // }, + + // DEPRECATED. Use recordingService.enabled instead. + // fileRecordingsServiceEnabled: false, + + // DEPRECATED. Use recordingService.sharingEnabled instead. + // fileRecordingsServiceSharingEnabled: false, + + // Local recording configuration. + // localRecording: { + // // Whether to disable local recording or not. + // disable: false, + + // // Whether to notify all participants when a participant is recording locally. + // notifyAllParticipants: false, + + // // Whether to disable the self recording feature (only local participant streams). + // disableSelfRecording: false, + // }, + + // Customize the Live Streaming dialog. Can be modified for a non-YouTube provider. + // liveStreaming: { + // // Whether to enable live streaming or not. + // enabled: false, + // // Terms link + // termsLink: 'https://www.youtube.com/t/terms', + // // Data privacy link + // dataPrivacyLink: 'https://policies.google.com/privacy', + // // RegExp string that validates the stream key input field + // validatorRegExpString: '^(?:[a-zA-Z0-9]{4}(?:-(?!$)|$)){4}', + // // Documentation reference for the live streaming feature. + // helpLink: 'https://jitsi.org/live' + // }, + + // DEPRECATED. Use liveStreaming.enabled instead. + // liveStreamingEnabled: false, + + // DEPRECATED. Use transcription.enabled instead. + // transcribingEnabled: false, + + // DEPRECATED. Use transcription.useAppLanguage instead. + // transcribeWithAppLanguage: true, + + // DEPRECATED. Use transcription.preferredLanguage instead. + // preferredTranscribeLanguage: 'en-US', + + // DEPRECATED. Use transcription.autoTranscribeOnRecord instead. + // autoCaptionOnRecord: false, + + // Transcription options. + // transcription: { + // // Whether the feature should be enabled or not. + // enabled: false, + + // // Translation languages. + // // Available languages can be found in + // // ./lang/translation-languages.json. + // translationLanguages: ['en', 'es', 'fr', 'ro'], + + // // Important languages to show on the top of the language list. + // translationLanguagesHead: ['en'], + + // // If true transcriber will use the application language. + // // The application language is either explicitly set by participants in their settings or automatically + // // detected based on the environment, e.g. if the app is opened in a chrome instance which + // // is using french as its default language then transcriptions for that participant will be in french. + // // Defaults to true. + // useAppLanguage: true, + + // // Transcriber language. This settings will only work if "useAppLanguage" + // // is explicitly set to false. + // // Available languages can be found in + // // ./src/react/features/transcribing/transcriber-langs.json. + // preferredLanguage: 'en-US', + + // // Enables automatic turning on transcribing when recording is started + // autoTranscribeOnRecord: false, + + // // Enables automatic request of subtitles when transcriber is present in the meeting, uses the default + // // language that is set + // autoCaptionOnTranscribe: false, + // + // // Disables everything related to closed captions - the tab in the chat area, the button in the menu, + // // subtitles on stage and the "Show subtitles on stage" checkbox in the settings. + // // Note: Starting transcriptions from the recording dialog will still work. + // disableClosedCaptions: false, + + // }, + + // Misc + + // Default value for the channel "last N" attribute. -1 for unlimited. + channelLastN: -1, + + // Connection indicators + // connectionIndicators: { + // autoHide: true, + // autoHideTimeout: 5000, + // disabled: false, + // disableDetails: false, + // inactiveDisabled: false + // }, + + // Provides a way for the lastN value to be controlled through the UI. + // When startLastN is present, conference starts with a last-n value of startLastN and channelLastN + // value will be used when the quality level is selected using "Manage Video Quality" slider. + // startLastN: 1, + + // Specify the settings for video quality optimizations on the client. + // videoQuality: { + // + // // Provides a way to set the codec preference on desktop based endpoints. + // codecPreferenceOrder: [ 'AV1', 'VP9', 'VP8', 'H264' ], + // + // // Provides a way to set the codec for screenshare. + // screenshareCodec: 'AV1', + // mobileScreenshareCodec: 'VP8', + // + // // Enables the adaptive mode in the client that will make runtime adjustments to selected codecs and received + // // videos for a better user experience. This mode will kick in only when CPU overuse is reported in the + // // WebRTC statistics for the outbound video streams. + // enableAdaptiveMode: false, + // + // // Codec specific settings for scalability modes and max bitrates. + // av1: { + // maxBitratesVideo: { + // low: 100000, + // standard: 300000, + // high: 1000000, + // fullHd: 2000000, + // ultraHd: 4000000, + // ssHigh: 2500000 + // }, + // scalabilityModeEnabled: true, + // useSimulcast: false, + // useKSVC: true + // }, + // h264: { + // maxBitratesVideo: { + // low: 200000, + // standard: 500000, + // high: 1500000, + // fullHd: 3000000, + // ultraHd: 6000000, + // ssHigh: 2500000 + // }, + // scalabilityModeEnabled: true + // }, + // vp8: { + // maxBitratesVideo: { + // low: 200000, + // standard: 500000, + // high: 1500000, + // fullHd: 3000000, + // ultraHd: 6000000, + // ssHigh: 2500000 + // }, + // scalabilityModeEnabled: false + // }, + // vp9: { + // maxBitratesVideo: { + // low: 100000, + // standard: 300000, + // high: 1200000, + // fullHd: 2500000, + // ultraHd: 5000000, + // ssHigh: 2500000 + // }, + // scalabilityModeEnabled: true, + // useSimulcast: false, + // useKSVC: true + // }, + // + // // The options can be used to override default thresholds of video thumbnail heights corresponding to + // // the video quality levels used in the application. At the time of this writing the allowed levels are: + // // 'low' - for the low quality level (180p at the time of this writing) + // // 'standard' - for the medium quality level (360p) + // // 'high' - for the high quality level (720p) + // // The keys should be positive numbers which represent the minimal thumbnail height for the quality level. + // // + // // With the default config value below the application will use 'low' quality until the thumbnails are + // // at least 360 pixels tall. If the thumbnail height reaches 720 pixels then the application will switch to + // // the high quality. + // minHeightForQualityLvl: { + // 360: 'standard', + // 720: 'high', + // }, + // + // // Provides a way to set the codec preference on mobile devices, both on RN and mobile browser based endpoint + // mobileCodecPreferenceOrder: [ 'VP8', 'VP9', 'H264', 'AV1' ], + // }, + + // Notification timeouts + // notificationTimeouts: { + // short: 2500, + // medium: 5000, + // long: 10000, + // extraLong: 60000, + // sticky: 0, + // }, + + // // Options for the recording limit notification. + // recordingLimit: { + // + // // The recording limit in minutes. Note: This number appears in the notification text + // // but doesn't enforce the actual recording time limit. This should be configured in + // // jibri! + // limit: 60, + // + // // The name of the app with unlimited recordings. + // appName: 'Unlimited recordings APP', + // + // // The URL of the app with unlimited recordings. + // appURL: 'https://unlimited.recordings.app.com/', + // }, + + // Disables or enables RTX (RFC 4588) (defaults to false). + // disableRtx: false, + + // Moves all Jitsi Meet 'beforeunload' logic (cleanup, leaving, disconnecting, etc) to the 'unload' event. + // disableBeforeUnloadHandlers: true, + + // Disables or enables TCC support in this client (default: enabled). + // enableTcc: true, + + // Disables or enables REMB support in this client (default: enabled). + // enableRemb: true, + + // Enables forced reload of the client when the call is migrated as a result of + // the bridge going down. + // enableForcedReload: true, + + // Use TURN/UDP servers for the jitsi-videobridge connection (by default + // we filter out TURN/UDP because it is usually not needed since the + // bridge itself is reachable via UDP) + // useTurnUdp: false + + // Enable support for encoded transform in supported browsers. This allows + // E2EE to work in Safari if the corresponding flag is enabled in the browser. + // Experimental. + // enableEncodedTransformSupport: false, + + // UI + // + + // Disables responsive tiles. + // disableResponsiveTiles: false, + + // DEPRECATED. Please use `securityUi?.hideLobbyButton` instead. + // Hides lobby button. + // hideLobbyButton: false, + + // DEPRECATED. Please use `lobby?.autoKnock` instead. + // If Lobby is enabled starts knocking automatically. + // autoKnockLobby: false, + + // DEPRECATED. Please use `lobby?.enableChat` instead. + // Enable lobby chat. + // enableLobbyChat: true, + + // DEPRECATED! Use `breakoutRooms.hideAddRoomButton` instead. + // Hides add breakout room button + // hideAddRoomButton: false, + + // Require users to always specify a display name. + // requireDisplayName: true, + + // Enables webhid functionality for Audio. + // enableWebHIDFeature: false, + + // DEPRECATED! Use 'welcomePage.disabled' instead. + // Whether to use a welcome page or not. In case it's false a random room + // will be joined when no room is specified. + // enableWelcomePage: true, + + // Configs for welcome page. + // welcomePage: { + // // Whether to disable welcome page. In case it's disabled a random room + // // will be joined when no room is specified. + // disabled: false, + // // If set, landing page will redirect to this URL. + // customUrl: '' + // }, + + // Configs for the lobby screen. + // lobby: { + // // If Lobby is enabled, it starts knocking automatically. Replaces `autoKnockLobby`. + // autoKnock: false, + // // Enables the lobby chat. Replaces `enableLobbyChat`. + // enableChat: true, + // // Shows the hangup button in the lobby screen. + // showHangUp: true, + // }, + + // Configs for the security related UI elements. + // securityUi: { + // // Hides the lobby button. Replaces `hideLobbyButton`. + // hideLobbyButton: false, + // // Hides the possibility to set and enter a lobby password. + // disableLobbyPassword: false, + // }, + + // Disable app shortcuts that are registered upon joining a conference + // disableShortcuts: false, + + // Disable initial browser getUserMedia requests. + // This is useful for scenarios where users might want to start a conference for screensharing only + // disableInitialGUM: false, + + // Enabling the close page will ignore the welcome page redirection when + // a call is hangup. + // enableClosePage: false, + + // Disable hiding of remote thumbnails when in a 1-on-1 conference call. + // Setting this to null, will also disable showing the remote videos + // when the toolbar is shown on mouse movements + // disable1On1Mode: null | false | true, + + // Default local name to be displayed + // defaultLocalDisplayName: 'me', + + // Default remote name to be displayed + // defaultRemoteDisplayName: 'Fellow Jitster', + + // Hides the display name from the participant thumbnail + // hideDisplayName: false, + + // Hides the dominant speaker name badge that hovers above the toolbox + // hideDominantSpeakerBadge: false, + + // Default language for the user interface. Cannot be overwritten. + // For iframe integrations, use the `lang` option directly instead. + // defaultLanguage: 'en', + + // Disables profile and the edit of all fields from the profile settings (display name and email) + // disableProfile: false, + + // Hides the email section under profile settings. + // hideEmailInSettings: false, + + // When enabled the password used for locking a room is restricted to up to the number of digits specified + // default: roomPasswordNumberOfDigits: false, + // roomPasswordNumberOfDigits: 10, + + // Message to show the users. Example: 'The service will be down for + // maintenance at 01:00 AM GMT, + // noticeMessage: '', + + // Enables calendar integration, depends on googleApiApplicationClientID + // and microsoftApiApplicationClientID + // enableCalendarIntegration: false, + + // Whether to notify when the conference is terminated because it was destroyed. + // notifyOnConferenceDestruction: true, + + // The client id for the google APIs used for the calendar integration, youtube livestreaming, etc. + // googleApiApplicationClientID: '', + + // Configs for prejoin page. + // prejoinConfig: { + // // When 'true', it shows an intermediate page before joining, where the user can configure their devices. + // enabled: true, + // // Hides the participant name editing field in the prejoin screen. + // // If requireDisplayName is also set as true, a name should still be provided through + // // either the jwt or the userInfo from the iframe api init object in order for this to have an effect. + // hideDisplayName: false, + // // List of buttons to hide from the extra join options dropdown. + // hideExtraJoinButtons: ['no-audio', 'by-phone'], + // // Configuration for pre-call test + // // By setting preCallTestEnabled, you enable the pre-call test in the prejoin page. + // // ICE server credentials need to be provided over the preCallTestICEUrl + // preCallTestEnabled: false, + // preCallTestICEUrl: '', + // // Shows the hangup button in the lobby screen. + // showHangUp: true, + // }, + + // When 'true', the user cannot edit the display name. + // (Mainly useful when used in conjunction with the JWT so the JWT name becomes read only.) + // readOnlyName: false, + + // If etherpad integration is enabled, setting this to true will + // automatically open the etherpad when a participant joins. This + // does not affect the mobile app since opening an etherpad + // obscures the conference controls -- it's better to let users + // choose to open the pad on their own in that case. + // openSharedDocumentOnJoin: false, + + // If true, shows the unsafe room name warning label when a room name is + // deemed unsafe (due to the simplicity in the name) and a password is not + // set or the lobby is not enabled. + // enableInsecureRoomNameWarning: false, + + // Array with avatar URL prefixes that need to use CORS. + // corsAvatarURLs: [ 'https://www.gravatar.com/avatar/' ], + + // Base URL for a Gravatar-compatible service. Defaults to Gravatar. + // DEPRECATED! Use `gravatar.baseUrl` instead. + // gravatarBaseURL: 'https://www.gravatar.com/avatar/', + + // Setup for Gravatar-compatible services. + // gravatar: { + // // Defaults to Gravatar. + // baseUrl: 'https://www.gravatar.com/avatar/', + // // True if Gravatar should be disabled. + // disabled: false, + // }, + + // App name to be displayed in the invitation email subject, as an alternative to + // interfaceConfig.APP_NAME. + // inviteAppName: null, + + // Moved from interfaceConfig(TOOLBAR_BUTTONS). + // The name of the toolbar buttons to display in the toolbar, including the + // "More actions" menu. If present, the button will display. Exceptions are + // "livestreaming" and "recording" which also require being a moderator and + // some other values in config.js to be enabled. Also, the "profile" button will + // not display for users with a JWT. + // Notes: + // - it's possible to reorder the buttons in the maintoolbar by changing the order of the mainToolbarButtons + // - 'desktop' controls the "Share your screen" button + // - if `toolbarButtons` is undefined, we fallback to enabling all buttons on the UI + // toolbarButtons: [ + // 'camera', + // 'chat', + // 'closedcaptions', + // 'desktop', + // 'download', + // 'embedmeeting', + // 'etherpad', + // 'feedback', + // 'filmstrip', + // 'fullscreen', + // 'hangup', + // 'help', + // 'highlight', + // 'invite', + // 'linktosalesforce', + // 'livestreaming', + // 'microphone', + // 'noisesuppression', + // 'participants-pane', + // 'profile', + // 'raisehand', + // 'recording', + // 'security', + // 'select-background', + // 'settings', + // 'shareaudio', + // 'sharedvideo', + // 'shortcuts', + // 'stats', + // 'tileview', + // 'toggle-camera', + // 'videoquality', + // 'whiteboard', + // ], + + // Holds values related to toolbar visibility control. + // toolbarConfig: { + // // Moved from interfaceConfig.INITIAL_TOOLBAR_TIMEOUT + // // The initial number of milliseconds for the toolbar buttons to be visible on screen. + // initialTimeout: 20000, + // // Moved from interfaceConfig.TOOLBAR_TIMEOUT + // // Number of milliseconds for the toolbar buttons to be visible on screen. + // timeout: 4000, + // // Moved from interfaceConfig.TOOLBAR_ALWAYS_VISIBLE + // // Whether toolbar should be always visible or should hide after x milliseconds. + // alwaysVisible: false, + // // Indicates whether the toolbar should still autohide when chat is open + // autoHideWhileChatIsOpen: false, + // // Default background color for the main toolbar. Accepts any valid CSS color. + // // backgroundColor: '#ffffff', + // }, + + // Overrides the buttons displayed in the main toolbar. Depending on the screen size the number of displayed + // buttons varies from 2 buttons to 8 buttons. Every array in the mainToolbarButtons array will replace the + // corresponding default buttons configuration matched by the number of buttons specified in the array. Arrays with + // more than 8 buttons or less then 2 buttons will be ignored. When there there isn't an override for a certain + // configuration (for example when 3 buttons are displayed) the default jitsi-meet configuration will be used. + // The order of the buttons in the array is preserved. + // mainToolbarButtons: [ + // [ 'microphone', 'camera', 'desktop', 'chat', 'raisehand', 'reactions', 'participants-pane', 'tileview' ], + // [ 'microphone', 'camera', 'desktop', 'chat', 'raisehand', 'participants-pane', 'tileview' ], + // [ 'microphone', 'camera', 'desktop', 'chat', 'raisehand', 'participants-pane' ], + // [ 'microphone', 'camera', 'desktop', 'chat', 'participants-pane' ], + // [ 'microphone', 'camera', 'chat', 'participants-pane' ], + // [ 'microphone', 'camera', 'chat' ], + // [ 'microphone', 'camera' ] + // ], + + // Overrides the buttons displayed in the main toolbar for reduced UI. + // When there isn't an override for a certain configuration the default jitsi-meet configuration will be used. + // The order of the buttons in the array is preserved. + // reducedUImainToolbarButtons: [ 'microphone', 'camera' ], + + // Toolbar buttons which have their click/tap event exposed through the API on + // `toolbarButtonClicked`. Passing a string for the button key will + // prevent execution of the click/tap routine; passing an object with `key` and + // `preventExecution` flag on false will not prevent execution of the click/tap + // routine. Below array with mixed mode for passing the buttons. + // buttonsWithNotifyClick: [ + // 'camera', + // { + // key: 'chat', + // preventExecution: false + // }, + // { + // key: 'closedcaptions', + // preventExecution: true + // }, + // 'desktop', + // 'download', + // 'embedmeeting', + // 'end-meeting', + // 'etherpad', + // 'feedback', + // 'filmstrip', + // 'fullscreen', + // 'hangup', + // 'hangup-menu', + // 'help', + // { + // key: 'invite', + // preventExecution: false + // }, + // 'livestreaming', + // 'microphone', + // 'mute-everyone', + // 'mute-video-everyone', + // 'noisesuppression', + // 'participants-pane', + // 'profile', + // { + // key: 'raisehand', + // preventExecution: true + // }, + // 'recording', + // 'security', + // 'select-background', + // 'settings', + // 'shareaudio', + // 'sharedvideo', + // 'shortcuts', + // 'stats', + // 'tileview', + // 'toggle-camera', + // 'videoquality', + // // The add passcode button from the security dialog. + // { + // key: 'add-passcode', + // preventExecution: false + // }, + // 'whiteboard', + // ], + + // Participant context menu buttons which have their click/tap event exposed through the API on + // `participantMenuButtonClick`. Passing a string for the button key will + // prevent execution of the click/tap routine; passing an object with `key` and + // `preventExecution` flag on false will not prevent execution of the click/tap + // routine. Below array with mixed mode for passing the buttons. + // participantMenuButtonsWithNotifyClick: [ + // 'allow-video', + // { + // key: 'ask-unmute', + // preventExecution: false + // }, + // 'conn-status', + // 'flip-local-video', + // 'grant-moderator', + // { + // key: 'kick', + // preventExecution: true + // }, + // { + // key: 'hide-self-view', + // preventExecution: false + // }, + // 'mute', + // 'mute-others', + // 'mute-others-video', + // 'mute-video', + // 'pinToStage', + // 'privateMessage', + // { + // key: 'remote-control', + // preventExecution: false + // }, + // 'send-participant-to-room', + // 'verify', + // ], + + // List of pre meeting screens buttons to hide. The values must be one or more of the 5 allowed buttons: + // 'microphone', 'camera', 'select-background', 'invite', 'settings' + // hiddenPremeetingButtons: [], + + // An array with custom option buttons for the participant context menu + // type: Array<{ icon: string; id: string; text: string; }> + // customParticipantMenuButtons: [], + + // An array with custom option buttons for the toolbar + // type: Array<{ icon: string; id: string; text: string; backgroundColor?: string; }> + // customToolbarButtons: [], + + // Stats + // + + // Whether to enable stats collection or not in the TraceablePeerConnection. + // This can be useful for debugging purposes (post-processing/analysis of + // the webrtc stats) as it is done in the jitsi-meet-torture bandwidth + // estimation tests. + // gatherStats: false, + + // The interval at which PeerConnection.getStats() is called. Defaults to 10000 + // pcStatsInterval: 10000, + + // Enables sending participants' display names to stats + // enableDisplayNameInStats: false, + + // Enables sending participants' emails (if available) to stats and other analytics + // enableEmailInStats: false, + + // faceLandmarks: { + // // Enables sharing your face coordinates. Used for centering faces within a video. + // enableFaceCentering: false, + + // // Enables detecting face expressions and sharing data with other participants + // enableFaceExpressionsDetection: false, + + // // Enables displaying face expressions in speaker stats + // enableDisplayFaceExpressions: false, + + // // Enable rtc stats for face landmarks + // enableRTCStats: false, + + // // Minimum required face movement percentage threshold for sending new face centering coordinates data. + // faceCenteringThreshold: 10, + + // // Milliseconds for processing a new image capture in order to detect face coordinates if they exist. + // captureInterval: 1000, + // }, + + // Controls the percentage of automatic feedback shown to participants. + // The default value is 100%. If set to 0, no automatic feedback will be requested + // feedbackPercentage: 100, + + // Privacy + // + + // If third party requests are disabled, no other server will be contacted. + // This means avatars will be locally generated and external stats integration + // will not function. + // disableThirdPartyRequests: false, + + + // Peer-To-Peer mode: used (if enabled) when there are just 2 participants. + // + + p2p: { + // Enables peer to peer mode. When enabled the system will try to + // establish a direct connection when there are exactly 2 participants + // in the room. If that succeeds the conference will stop sending data + // through the JVB and use the peer to peer connection instead. When a + // 3rd participant joins the conference will be moved back to the JVB + // connection. + enabled: true, + + // Sets the ICE transport policy for the p2p connection. At the time + // of this writing the list of possible values are 'all' and 'relay', + // but that is subject to change in the future. The enum is defined in + // the WebRTC standard: + // https://www.w3.org/TR/webrtc/#rtcicetransportpolicy-enum. + // If not set, the effective value is 'all'. + // iceTransportPolicy: 'all', + + // Provides a way to set the codec preference on mobile devices, both on RN and mobile browser based + // endpoints. + // mobileCodecPreferenceOrder: [ 'H264', 'VP8', 'VP9', 'AV1' ], + // + // Provides a way to set the codec preference on desktop based endpoints. + // codecPreferenceOrder: [ 'AV1', 'VP9', 'VP8', 'H264 ], + + // Provides a way to set the codec for screenshare. + // screenshareCodec: 'AV1', + // mobileScreenshareCodec: 'VP8', + + // How long we're going to wait, before going back to P2P after the 3rd + // participant has left the conference (to filter out page reload). + // backToP2PDelay: 5, + + // The STUN servers that will be used in the peer to peer connections + stunServers: [ + + // { urls: 'stun:jitsi-meet.example.com:3478' }, + { urls: 'stun:meet-jit-si-turnrelay.jitsi.net:443' }, + ], + }, + + analytics: { + // True if the analytics should be disabled + // disabled: false, + + // Matomo configuration: + // matomoEndpoint: 'https://your-matomo-endpoint/', + // matomoSiteID: '42', + + // The Amplitude APP Key: + // amplitudeAPPKey: '', + + // Obfuscates room name sent to analytics (amplitude, rtcstats) + // Default value is false. + // obfuscateRoomName: false, + + // Configuration for the rtcstats server: + // By enabling rtcstats server every time a conference is joined the rtcstats + // module connects to the provided rtcstatsEndpoint and sends statistics regarding + // PeerConnection states along with getStats metrics polled at the specified + // interval. + // rtcstatsEnabled: false, + // rtcstatsStoreLogs: false, + + // In order to enable rtcstats one needs to provide a endpoint url. + // rtcstatsEndpoint: wss://rtcstats-server-pilot.jitsi.net/, + + // The interval at which rtcstats will poll getStats, defaults to 10000ms. + // If the value is set to 0 getStats won't be polled and the rtcstats client + // will only send data related to RTCPeerConnection events. + // rtcstatsPollInterval: 10000, + + // This determines if rtcstats sends the SDP to the rtcstats server or replaces + // all SDPs with an empty string instead. + // rtcstatsSendSdp: false, + + // Array of script URLs to load as lib-jitsi-meet "analytics handlers". + // scriptURLs: [ + // "https://example.com/my-custom-analytics.js", + // ], + + // By enabling watchRTCEnabled option you would want to use watchRTC feature + // This would also require to configure watchRTCConfigParams. + // Please remember to keep rtcstatsEnabled disabled for watchRTC to work. + // watchRTCEnabled: false, + }, + + // Logs that should go be passed through the 'log' event if a handler is defined for it + // apiLogLevels: ['warn', 'log', 'error', 'info', 'debug'], + + // Information about the jitsi-meet instance we are connecting to, including + // the user region as seen by the server. + // deploymentInfo: { + // shard: "shard1", + // region: "europe", + // userRegion: "asia", + // }, + + // Array of disabled sounds. + // Possible values: + // - 'ASKED_TO_UNMUTE_SOUND' + // - 'E2EE_OFF_SOUND' + // - 'E2EE_ON_SOUND' + // - 'INCOMING_MSG_SOUND' + // - 'KNOCKING_PARTICIPANT_SOUND' + // - 'LIVE_STREAMING_OFF_SOUND' + // - 'LIVE_STREAMING_ON_SOUND' + // - 'NO_AUDIO_SIGNAL_SOUND' + // - 'NOISY_AUDIO_INPUT_SOUND' + // - 'OUTGOING_CALL_EXPIRED_SOUND' + // - 'OUTGOING_CALL_REJECTED_SOUND' + // - 'OUTGOING_CALL_RINGING_SOUND' + // - 'OUTGOING_CALL_START_SOUND' + // - 'PARTICIPANT_JOINED_SOUND' + // - 'PARTICIPANT_LEFT_SOUND' + // - 'RAISE_HAND_SOUND' + // - 'REACTION_SOUND' + // - 'RECORDING_OFF_SOUND' + // - 'RECORDING_ON_SOUND' + // - 'TALK_WHILE_MUTED_SOUND' + // disabledSounds: [], + + // DEPRECATED! Use `disabledSounds` instead. + // Decides whether the start/stop recording audio notifications should play on record. + // disableRecordAudioNotification: false, + + // DEPRECATED! Use `disabledSounds` instead. + // Disables the sounds that play when other participants join or leave the + // conference (if set to true, these sounds will not be played). + // disableJoinLeaveSounds: false, + + // DEPRECATED! Use `disabledSounds` instead. + // Disables the sounds that play when a chat message is received. + // disableIncomingMessageSound: false, + + // Information for the chrome extension banner + // chromeExtensionBanner: { + // // The chrome extension to be installed address + // url: 'https://chrome.google.com/webstore/detail/jitsi-meetings/kglhbbefdnlheedjiejgomgmfplipfeb', + // edgeUrl: 'https://microsoftedge.microsoft.com/addons/detail/jitsi-meetings/eeecajlpbgjppibfledfihobcabccihn', + + // // Extensions info which allows checking if they are installed or not + // chromeExtensionsInfo: [ + // { + // id: 'kglhbbefdnlheedjiejgomgmfplipfeb', + // path: 'jitsi-logo-48x48.png', + // }, + // // Edge extension info + // { + // id: 'eeecajlpbgjppibfledfihobcabccihn', + // path: 'jitsi-logo-48x48.png', + // }, + // ] + // }, + + // e2ee: { + // labels: { + // description: '', + // label: '', + // tooltip: '', + // warning: '', + // }, + // externallyManagedKey: false, + // disabled: false, + // }, + + // Options related to end-to-end (participant to participant) ping. + // e2eping: { + // // Whether ene-to-end pings should be enabled. + // enabled: false, + // + // // The number of responses to wait for. + // numRequests: 5, + // + // // The max conference size in which e2e pings will be sent. + // maxConferenceSize: 200, + // + // // The maximum number of e2e ping messages per second for the whole conference to aim for. + // // This is used to control the pacing of messages in order to reduce the load on the backend. + // maxMessagesPerSecond: 250, + // }, + + // If set, will attempt to use the provided video input device label when + // triggering a screenshare, instead of proceeding through the normal flow + // for obtaining a desktop stream. + // NOTE: This option is experimental and is currently intended for internal + // use only. + // _desktopSharingSourceDevice: 'sample-id-or-label', + + // DEPRECATED! Use deeplinking.disabled instead. + // If true, any checks to handoff to another application will be prevented + // and instead the app will continue to display in the current browser. + // disableDeepLinking: false, + + // The deeplinking config. + // deeplinking: { + // + // // The desktop deeplinking config, disabled by default. + // desktop: { + // appName: 'Jitsi Meet', + // appScheme: 'jitsi-meet, + // download: { + // linux: + // 'https://github.com/jitsi/jitsi-meet-electron/releases/latest/download/jitsi-meet-x86_64.AppImage', + // macos: 'https://github.com/jitsi/jitsi-meet-electron/releases/latest/download/jitsi-meet.dmg', + // windows: 'https://github.com/jitsi/jitsi-meet-electron/releases/latest/download/jitsi-meet.exe' + // }, + // enabled: false + // }, + // // If true, any checks to handoff to another application will be prevented + // // and instead the app will continue to display in the current browser. + // disabled: false, + + // // whether to hide the logo on the deep linking pages. + // hideLogo: false, + + // // The ios deeplinking config. + // ios: { + // appName: 'Jitsi Meet', + // // Specify mobile app scheme for opening the app from the mobile browser. + // appScheme: 'org.jitsi.meet', + // // Custom URL for downloading ios mobile app. + // downloadLink: 'https://itunes.apple.com/us/app/jitsi-meet/id1165103905', + // }, + + // // The android deeplinking config. + // android: { + // appName: 'Jitsi Meet', + // // Specify mobile app scheme for opening the app from the mobile browser. + // appScheme: 'org.jitsi.meet', + // // Custom URL for downloading android mobile app. + // downloadLink: 'https://play.google.com/store/apps/details?id=org.jitsi.meet', + // // Android app package name. + // appPackage: 'org.jitsi.meet', + // fDroidUrl: 'https://f-droid.org/en/packages/org.jitsi.meet/', + // } + // }, + + // // The terms, privacy and help centre URL's. + // legalUrls: { + // helpCentre: 'https://web-cdn.jitsi.net/faq/meet-faq.html', + // privacy: 'https://jitsi.org/meet/privacy', + // terms: 'https://jitsi.org/meet/terms' + // }, + + // A property to disable the right click context menu for localVideo + // the menu has option to flip the locally seen video for local presentations + // disableLocalVideoFlip: false, + + // A property used to unset the default flip state of the local video. + // When it is set to 'true', the local(self) video will not be mirrored anymore. + // doNotFlipLocalVideo: false, + + // Mainly privacy related settings + + // Disables all invite functions from the app (share, invite, dial out...etc) + // disableInviteFunctions: true, + + // Disables storing the room name to the recents list. When in an iframe this is ignored and + // the room is never stored in the recents list. + // doNotStoreRoom: true, + + // Deployment specific URLs. + // deploymentUrls: { + // // If specified a 'Help' button will be displayed in the overflow menu with a link to the specified URL for + // // user documentation. + // userDocumentationURL: 'https://docs.example.com/video-meetings.html', + // // If specified a 'Download our apps' button will be displayed in the overflow menu with a link + // // to the specified URL for an app download page. + // downloadAppsUrl: 'https://docs.example.com/our-apps.html', + // }, + + // Options related to the remote participant menu. + // remoteVideoMenu: { + // // Whether the remote video context menu to be rendered or not. + // disabled: true, + // // If set to true the 'Switch to visitor' button will be disabled. + // disableDemote: true, + // // If set to true the 'Kick out' button will be disabled. + // disableKick: true, + // // If set to true the 'Grant moderator' button will be disabled. + // disableGrantModerator: true, + // // If set to 'all' the 'Private chat' button will be disabled for all participants. + // // If set to 'allow-moderator-chat' the 'Private chat' button will be available for chats with moderators. + // // If set to 'disable-visitor-chat' the 'Private chat' button will be disabled for visitor-main participant + // // conversations. + // disablePrivateChat: 'all' | 'allow-moderator-chat' | 'disable-visitor-chat', + // }, + + + // If set to true all muting operations of remote participants will be disabled. + // disableRemoteMute: true, + + /** + External API url used to receive branding specific information. + If there is no url set or there are missing fields, the defaults are applied. + The config file should be in JSON. + None of the fields are mandatory and the response must have the shape: + { + // Whether participant can only send group chat message if `send-groupchat` feature is enabled in jwt. + groupChatRequiresPermission: false, + // Whether participant can only create polls if `create-polls` feature is enabled in jwt. + pollCreationRequiresPermission: false, + // The domain url to apply (will replace the domain in the sharing conference link/embed section) + inviteDomain: 'example-company.org', + // The hex value for the colour used as background + backgroundColor: '#fff', + // The url for the image used as background + backgroundImageUrl: 'https://example.com/background-img.png', + // The anchor url used when clicking the logo image + logoClickUrl: 'https://example-company.org', + // The url used for the image used as logo + logoImageUrl: 'https://example.com/logo-img.png', + // Endpoint that enables support for salesforce integration with in-meeting resource linking + // This is required for: + // listing the most recent records - salesforceUrl/records/recents + // searching records - salesforceUrl/records?text=${text} + // retrieving record details - salesforceUrl/records/${id}?type=${type} + // and linking the meeting - salesforceUrl/sessions/${sessionId}/records/${id} + // salesforceUrl: 'https://api.example.com/', + // Overwrite for pool of background images for avatars + avatarBackgrounds: ['url(https://example.com/avatar-background-1.png)', '#FFF'], + // The lobby/prejoin screen background + premeetingBackground: 'url(https://example.com/premeeting-background.png)', + // A list of images that can be used as video backgrounds. + // When this field is present, the default images will be replaced with those provided. + virtualBackgrounds: ['https://example.com/img.jpg'], + // Object containing customized icons that should replace the default ones. + // The keys need to be the exact same icon names used in here: + // https://github.com/jitsi/jitsi-meet/blob/master/react/features/base/icons/svg/index.ts + // To avoid having the icons trimmed or displayed in an unexpected way, please provide svg + // files containing svg xml icons in the size that the default icons come in. + customIcons: { + IconArrowUp: 'https://example.com/arrow-up.svg', + IconDownload: 'https://example.com/download.svg', + IconRemoteControlStart: 'https://example.com/remote-start.svg', + }, + // Object containing a theme's properties. It also supports partial overwrites of the main theme. + // For a list of all possible theme tokens and their current defaults, please check: + // https://github.com/jitsi/jitsi-meet/tree/master/resources/custom-theme/custom-theme.json + // For a short explanations on each of the tokens, please check: + // https://github.com/jitsi/jitsi-meet/blob/master/react/features/base/ui/Tokens.ts + // IMPORTANT!: This is work in progress so many of the various tokens are not yet applied in code + // or they are partially applied. + customTheme: { + palette: { + ui01: "orange !important", + ui02: "maroon", + surface02: 'darkgreen', + ui03: "violet", + ui04: "magenta", + ui05: "blueviolet", + action01: 'green', + action01Hover: 'lightgreen', + disabled01: 'beige', + success02: 'cadetblue', + action02Hover: 'aliceblue', + }, + typography: { + labelRegular: { + fontSize: 25, + lineHeight: 30, + fontWeight: 500, + } + } + } + } + */ + // dynamicBrandingUrl: '', + + // A list of allowed URL domains for shared video. + // + // NOTE: + // '*' is allowed value and it will allow any URL to be used for shared video. We do not recommend using '*', + // use it at your own risk! + // sharedVideoAllowedURLDomains: [ ], + + // Options related to the participants pane. + // participantsPane: { + // // Enables feature + // enabled: true, + // // Hides the moderator settings tab. + // hideModeratorSettingsTab: false, + // // Hides the more actions button. + // hideMoreActionsButton: false, + // // Hides the mute all button. + // hideMuteAllButton: false, + // }, + + // Options related to the breakout rooms feature. + // breakoutRooms: { + // // Hides the add breakout room button. This replaces `hideAddRoomButton`. + // hideAddRoomButton: false, + // // Hides the auto assign participants button. + // hideAutoAssignButton: false, + // // Hides the join breakout room button. + // hideJoinRoomButton: false, + // }, + + // When true, virtual background feature will be disabled. + // disableVirtualBackground: false, + + // When true the user cannot add more images to be used as virtual background. + // Only the default ones from will be available. + // disableAddingBackgroundImages: false, + + // Sets the background transparency level. '0' is fully transparent, '1' is opaque. + // backgroundAlpha: 1, + + // The URL of the moderated rooms microservice, if available. If it + // is present, a link to the service will be rendered on the welcome page, + // otherwise the app doesn't render it. + // moderatedRoomServiceUrl: 'https://moderated.jitsi-meet.example.com', + + // If true, tile view will not be enabled automatically when the participants count threshold is reached. + // disableTileView: true, + + // If true, the tiles will be displayed contained within the available space rather than enlarged to cover it, + // with a 16:9 aspect ratio (old behaviour). + // disableTileEnlargement: true, + + // Controls the visibility and behavior of the top header conference info labels. + // If a label's id is not in any of the 2 arrays, it will not be visible at all on the header. + // conferenceInfo: { + // // those labels will not be hidden in tandem with the toolbox. + // alwaysVisible: ['recording', 'raised-hands-count'], + // // those labels will be auto-hidden in tandem with the toolbox buttons. + // autoHide: [ + // 'subject', + // 'conference-timer', + // 'participants-count', + // 'e2ee', + // 'video-quality', + // 'insecure-room', + // 'highlight-moment', + // 'top-panel-toggle', + // ] + // }, + + // Hides the conference subject + // hideConferenceSubject: false, + + // Hides the conference timer. + // hideConferenceTimer: false, + + // Hides the recording label + // hideRecordingLabel: false, + + // Hides the participants stats + // hideParticipantsStats: true, + + // Sets the conference subject + // subject: 'Conference Subject', + + // Sets the conference local subject + // localSubject: 'Conference Local Subject', + + // This property is related to the use case when jitsi-meet is used via the IFrame API. When the property is true + // jitsi-meet will use the local storage of the host page instead of its own. This option is useful if the browser + // is not persisting the local storage inside the iframe. + // useHostPageLocalStorage: true, + + // Etherpad ("shared document") integration. + // + // If set, add a "Open shared document" link to the bottom right menu that + // will open an etherpad document. + // etherpad_base: 'https://your-etherpad-installati.on/p/', + + // To enable information about dial-in access to meetings you need to provide + // dialInNumbersUrl and dialInConfCodeUrl. + // dialInNumbersUrl returns a json array of numbers that can be used for dial-in. + // {"countryCode":"US","tollFree":false,"formattedNumber":"+1 123-456-7890"} + // dialInConfCodeUrl is the conference mapper converting a meeting id to a PIN used for dial-in + // or the other way around (more info in resources/cloud-api.swagger) + + // You can use external service for authentication that will redirect back passing a jwt token + // You can use tokenAuthUrl config to point to a URL of such service. + // The URL for the service supports few params which will be filled in by the code. + // tokenAuthUrl: + // 'https://myservice.com/auth/{room}?code_challenge_method=S256&code_challenge={code_challenge}&state={state}' + // Supported parameters in tokenAuthUrl: + // {room} - will be replaced with the room name + // {code_challenge} - (A web only). A oauth 2.0 code challenge that will be sent to the service. See: + // https://datatracker.ietf.org/doc/html/rfc7636. The code verifier will be saved in the sessionStorage + // under key: 'code_verifier'. + // {state} - A json with the current state before redirecting. Keys that are included in the state: + // - room (The current room name as shown in the address bar) + // - roomSafe (the backend safe room name to use (lowercase), that is passed to the backend) + // - tenant (The tenant if any) + // - config.xxx (all config overrides) + // - interfaceConfig.xxx (all interfaceConfig overrides) + // - ios=true (in case ios mobile app is used) + // - android=true (in case android mobile app is used) + // - electron=true (when web is loaded in electron app) + // If there is a logout service you can specify its URL with: + // tokenLogoutUrl: 'https://myservice.com/logout' + // You can enable tokenAuthUrlAutoRedirect which will detect that you have logged in successfully before + // and will automatically redirect to the token service to get the token for the meeting. + // tokenAuthUrlAutoRedirect: false + // An option to respect the context.tenant jwt field compared to the current tenant from the url + // tokenRespectTenant: false, + // An option to get for user info (name, picture, email) in the token outside the user context. + // Can be used with Firebase tokens. + // tokenGetUserInfoOutOfContext: false, + + // You can put an array of values to target different entity types in the invite dialog. + // Valid values are "phone", "room", "sip", "user", "videosipgw" and "email" + // peopleSearchQueryTypes: ["user", "email"], + // Directory endpoint which is called for invite dialog autocomplete + // peopleSearchUrl: "https://myservice.com/api/people", + // Endpoint which is called to send invitation requests + // inviteServiceUrl: "https://myservice.com/api/invite", + + // For external entities (e. g. email), the localStorage key holding the token value for directory authentication + // peopleSearchTokenLocation: "mytoken", + + + // Options related to visitors. + // visitors: { + // // Starts audio/video when the participant is promoted from visitor. + // enableMediaOnPromote: { + // audio: true, + // video: true + // }, + // // Hides the visitor count for visitors. + // // hideVisitorCountForVisitors: false, + // // Whether to show the join meeting dialog when joining as a visitor. + // // showJoinMeetingDialog: true, + // }, + // The default type of desktop sharing sources that will be used in the electron app. + // desktopSharingSources: ['screen', 'window'], + + // Disables the echo cancelation for local audio tracks. + // disableAEC: true, + + // Disables the auto gain control for local audio tracks. + // disableAGC: true, + + // Disables the audio processing (echo cancelation, auto gain control and noise suppression) for local audio tracks. + // disableAP: true, + + // Disables the anoise suppression for local audio tracks. + // disableNS: true, + + // Replaces the display name with the JID of the participants. + // displayJids: true, + + // Enables disables talk while muted detection. + // enableTalkWhileMuted: true, + + // Sets the peer connection ICE transport policy to "relay". + // forceTurnRelay: true, + + // List of undocumented settings used in jitsi-meet + /** + _immediateReloadThreshold + deploymentInfo + dialOutAuthUrl + dialOutCodesUrl + dialOutRegionUrl + disableRemoteControl + iAmRecorder + iAmSipGateway + microsoftApiApplicationClientID + */ + + /** + * This property can be used to alter the generated meeting invite links (in combination with a branding domain + * which is retrieved internally by jitsi meet) (e.g. https://meet.jit.si/someMeeting + * can become https://brandedDomain/roomAlias) + */ + // brandingRoomAlias: null, + + // List of undocumented settings used in lib-jitsi-meet + /** + _peerConnStatusOutOfLastNTimeout + _peerConnStatusRtcMuteTimeout + avgRtpStatsN + desktopSharingSources + disableLocalStats + hiddenDomain + hiddenFromRecorderFeatureEnabled + ignoreStartMuted + websocketKeepAlive + websocketKeepAliveUrl + */ + + /** + * Default interval (milliseconds) for triggering mouseMoved iframe API event + */ + mouseMoveCallbackInterval: 1000, + + /** + Use this array to configure which notifications will be shown to the user + The items correspond to the title or description key of that notification + Some of these notifications also depend on some other internal logic to be displayed or not, + so adding them here will not ensure they will always be displayed + + A falsy value for this prop will result in having all notifications enabled (e.g null, undefined, false) + */ + // notifications: [ + // 'connection.CONNFAIL', // shown when the connection fails, + // 'dialog.cameraConstraintFailedError', // shown when the camera failed + // 'dialog.cameraNotSendingData', // shown when there's no feed from user's camera + // 'dialog.kickTitle', // shown when user has been kicked + // 'dialog.liveStreaming', // livestreaming notifications (pending, on, off, limits) + // 'dialog.lockTitle', // shown when setting conference password fails + // 'dialog.maxUsersLimitReached', // shown when maximmum users limit has been reached + // 'dialog.micNotSendingData', // shown when user's mic is not sending any audio + // 'dialog.passwordNotSupportedTitle', // shown when setting conference password fails due to password format + // 'dialog.recording', // recording notifications (pending, on, off, limits) + // 'dialog.remoteControlTitle', // remote control notifications (allowed, denied, start, stop, error) + // 'dialog.reservationError', + // 'dialog.screenSharingFailedTitle', // shown when the screen sharing failed + // 'dialog.serviceUnavailable', // shown when server is not reachable + // 'dialog.sessTerminated', // shown when there is a failed conference session + // 'dialog.sessionRestarted', // show when a client reload is initiated because of bridge migration + // 'dialog.tokenAuthFailed', // show when an invalid jwt is used + // 'dialog.tokenAuthFailedWithReasons', // show when an invalid jwt is used with the reason behind the error + // 'dialog.transcribing', // transcribing notifications (pending, off) + // 'dialOut.statusMessage', // shown when dial out status is updated. + // 'liveStreaming.busy', // shown when livestreaming service is busy + // 'liveStreaming.failedToStart', // shown when livestreaming fails to start + // 'liveStreaming.unavailableTitle', // shown when livestreaming service is not reachable + // 'lobby.joinRejectedMessage', // shown when while in a lobby, user's request to join is rejected + // 'lobby.notificationTitle', // shown when lobby is toggled and when join requests are allowed / denied + // 'notify.audioUnmuteBlockedTitle', // shown when mic unmute blocked + // 'notify.chatMessages', // shown when receiving chat messages while the chat window is closed + // 'notify.connectedOneMember', // show when a participant joined + // 'notify.connectedThreePlusMembers', // show when more than 2 participants joined simultaneously + // 'notify.connectedTwoMembers', // show when two participants joined simultaneously + // 'notify.dataChannelClosed', // shown when the bridge channel has been disconnected + // 'notify.hostAskedUnmute', // shown to participant when host asks them to unmute + // 'notify.invitedOneMember', // shown when 1 participant has been invited + // 'notify.invitedThreePlusMembers', // shown when 3+ participants have been invited + // 'notify.invitedTwoMembers', // shown when 2 participants have been invited + // 'notify.kickParticipant', // shown when a participant is kicked + // 'notify.leftOneMember', // show when a participant left + // 'notify.leftThreePlusMembers', // show when more than 2 participants left simultaneously + // 'notify.leftTwoMembers', // show when two participants left simultaneously + // 'notify.linkToSalesforce', // shown when joining a meeting with salesforce integration + // 'notify.localRecordingStarted', // shown when the local recording has been started + // 'notify.localRecordingStopped', // shown when the local recording has been stopped + // 'notify.moderationInEffectCSTitle', // shown when user attempts to share content during AV moderation + // 'notify.moderationInEffectTitle', // shown when user attempts to unmute audio during AV moderation + // 'notify.moderationInEffectVideoTitle', // shown when user attempts to enable video during AV moderation + // 'notify.moderator', // shown when user gets moderator privilege + // 'notify.mutedRemotelyTitle', // shown when user is muted by a remote party + // 'notify.mutedTitle', // shown when user has been muted upon joining, + // 'notify.newDeviceAudioTitle', // prompts the user to use a newly detected audio device + // 'notify.newDeviceCameraTitle', // prompts the user to use a newly detected camera + // 'notify.noiseSuppressionFailedTitle', // shown when failed to start noise suppression + // 'notify.participantWantsToJoin', // shown when lobby is enabled and participant requests to join meeting + // 'notify.participantsWantToJoin', // shown when lobby is enabled and participants request to join meeting + // 'notify.passwordRemovedRemotely', // shown when a password has been removed remotely + // 'notify.passwordSetRemotely', // shown when a password has been set remotely + // 'notify.raisedHand', // shown when a participant used raise hand, + // 'notify.screenShareNoAudio', // shown when the audio could not be shared for the selected screen + // 'notify.screenSharingAudioOnlyTitle', // shown when the best performance has been affected by screen sharing + // 'notify.selfViewTitle', // show "You can always un-hide the self-view from settings" + // 'notify.startSilentTitle', // shown when user joined with no audio + // 'notify.suboptimalExperienceTitle', // show the browser warning + // 'notify.unmute', // shown to moderator when user raises hand during AV moderation + // 'notify.videoMutedRemotelyTitle', // shown when user's video is muted by a remote party, + // 'notify.videoUnmuteBlockedTitle', // shown when camera unmute and desktop sharing are blocked + // 'prejoin.errorDialOut', + // 'prejoin.errorDialOutDisconnected', + // 'prejoin.errorDialOutFailed', + // 'prejoin.errorDialOutStatus', + // 'prejoin.errorStatusCode', + // 'prejoin.errorValidation', + // 'recording.busy', // shown when recording service is busy + // 'recording.failedToStart', // shown when recording fails to start + // 'recording.unavailableTitle', // shown when recording service is not reachable + // 'toolbar.noAudioSignalTitle', // shown when a broken mic is detected + // 'toolbar.noisyAudioInputTitle', // shown when noise is detected for the current microphone + // 'toolbar.talkWhileMutedPopup', // shown when user tries to speak while muted + // 'transcribing.failed', // shown when transcribing fails + // ], + + // List of notifications to be disabled. Works in tandem with the above setting. + // disabledNotifications: [], + + // Prevent the filmstrip from autohiding when screen width is under a certain threshold + // disableFilmstripAutohiding: false, + + // filmstrip: { + // // Disable the vertical/horizontal filmstrip. + // disabled: false, + // // Disables user resizable filmstrip. Also, allows configuration of the filmstrip + // // (width, tiles aspect ratios) through the interfaceConfig options. + // disableResizable: false, + + // // Disables the stage filmstrip + // // (displaying multiple participants on stage besides the vertical filmstrip) + // disableStageFilmstrip: false, + + // // Default number of participants that can be displayed on stage. + // // The user can change this in settings. Number must be between 1 and 6. + // stageFilmstripParticipants: 1, + + // // Disables the top panel (only shown when a user is sharing their screen). + // disableTopPanel: false, + + // // The minimum number of participants that must be in the call for + // // the top panel layout to be used. + // minParticipantCountForTopPanel: 50, + + // // The width of the filmstrip on joining meeting. Can be resized afterwards. + // initialWidth: 400, + + // // Whether the draggable resize bar of the filmstrip is always visible. Setting this to true will make + // // the filmstrip always visible in case `disableResizable` is false. + // alwaysShowResizeBar: true, + // }, + + // Tile view related config options. + // tileView: { + // // Whether tileview should be disabled. + // disabled: false, + // // The optimal number of tiles that are going to be shown in tile view. Depending on the screen size it may + // // not be possible to show the exact number of participants specified here. + // numberOfVisibleTiles: 25, + // }, + + // Specifies whether the chat emoticons are disabled or not + // disableChatSmileys: false, + + // Settings for the GIPHY integration. + // giphy: { + // // Whether the feature is enabled or not. + // enabled: false, + // // SDK API Key from Giphy. + // sdkKey: '', + // // Display mode can be one of: + // // - tile: show the GIF on the tile of the participant that sent it. + // // - chat: show the GIF as a message in chat + // // - all: all of the above. This is the default option + // displayMode: 'all', + // // How long the GIF should be displayed on the tile (in milliseconds). + // tileTime: 5000, + // // Limit results by rating: g, pg, pg-13, r. Default value: g. + // rating: 'pg', + // }, + + // Logging + // logging: { + // // Default log level for the app and lib-jitsi-meet. + // defaultLogLevel: 'trace', + // // Option to disable LogCollector. + // //disableLogCollector: true, + // // Individual loggers are customizable. + // loggers: { + // // The following are too verbose in their logging with the default level. + // 'modules/RTC/TraceablePeerConnection.js': 'info', + // 'modules/xmpp/strophe.util.js': 'log', + // }, + // }, + + // Application logo url + // defaultLogoUrl: 'images/watermark.svg', + + // Settings for the Excalidraw whiteboard integration. + // whiteboard: { + // // Whether the feature is enabled or not. + // enabled: true, + // // The server used to support whiteboard collaboration. + // // https://github.com/jitsi/excalidraw-backend + // collabServerBaseUrl: 'https://excalidraw-backend.example.com', + // // The user access limit to the whiteboard, introduced as a means + // // to control the performance. + // userLimit: 25, + // // The url for more info about the whiteboard and its usage limitations. + // limitUrl: 'https://example.com/blog/whiteboard-limits', + // }, + + // The watchRTC initialize config params as described : + // https://testrtc.com/docs/installing-the-watchrtc-javascript-sdk/#h-set-up-the-sdk + // https://www.npmjs.com/package/@testrtc/watchrtc-sdk + // watchRTCConfigParams: { + // /** Watchrtc api key */ + // rtcApiKey: string; + // /** Identifier for the session */ + // rtcRoomId?: string; + // /** Identifier for the current peer */ + // rtcPeerId?: string; + // /** + // * ["tag1", "tag2", "tag3"] + // * @deprecated use 'keys' instead + // */ + // rtcTags?: string[]; + // /** { "key1": "value1", "key2": "value2"} */ + // keys?: any; + // /** Enables additional logging */ + // debug?: boolean; + // rtcToken?: string; + // /** + // * @deprecated No longer needed. Use "proxyUrl" instead. + // */ + // wsUrl?: string; + // proxyUrl?: string; + // console?: { + // level: string; + // override: boolean; + // }; + // allowBrowserLogCollection?: boolean; + // collectionInterval?: number; + // logGetStats?: boolean; + // }, + + // Hide login button on auth dialog, you may want to enable this if you are using JWT tokens to authenticate users + // hideLoginButton: true, + + // If true remove the tint foreground on focused user camera in filmstrip + // disableCameraTintForeground: false, + + // File sharign service. + // fileSharing: { + // // The URL of the file sharing service API. See resources/file-sharing.yaml for more details. + // apiUrl: 'https://example.com', + // // Whether the file sharing service is enabled or not. + // enabled: true, + // // Maximum file size limit (-1 value disables any file size limit check) + // maxFileSize: 50, + // }, +}; + +// Set the default values for JaaS customers +if (enableJaaS) { + config.dialInNumbersUrl = 'https://conference-mapper.jitsi.net/v1/access/dids'; + config.dialInConfCodeUrl = 'https://conference-mapper.jitsi.net/v1/access'; + config.roomPasswordNumberOfDigits = 10; // skip re-adding it (do not remove comment) +} diff --git a/css/404.scss b/css/404.scss new file mode 100644 index 0000000..9c2a956 --- /dev/null +++ b/css/404.scss @@ -0,0 +1,15 @@ +.error_page { + width: 60%; + margin: 20% auto; + text-align: center; + + h2 { + font-size: 3rem; + color : #f2f2f2; + } + + &__message { + font-size: 1.5rem; + margin-top: 20px; + } +} diff --git a/css/_base.scss b/css/_base.scss new file mode 100644 index 0000000..ea5a83d --- /dev/null +++ b/css/_base.scss @@ -0,0 +1,190 @@ +/** + * Safari will limit input in input elements to one character when user-select + * none is applied. Other browsers already support selecting within inputs while + * user-select is none. As such, disallow user-select except on inputs. + */ +* { + -webkit-user-select: none; + user-select: none; + + // Firefox only + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, .5) transparent; +} + +input, +textarea { + -webkit-user-select: text; + user-select: text; +} + +html { + height: 100%; + width: 100%; + overflow: hidden; +} + +body { + margin: 0px; + width: 100%; + height: 100%; + font-size: 0.75rem; + font-weight: 400; + overflow: hidden; + color: #F1F1F1; + background: #040404; // should match DEFAULT_BACKGROUND from interface_config +} + +/** + * This will hide the focus indicator if an element receives focus via the mouse, + * but it will still show up on keyboard focus, thus preserving accessibility. + */ +.js-focus-visible :focus:not(.focus-visible) { + outline: none; +} + +.jitsi-icon { + &-default svg { + fill: white; + } +} + +.disabled .jitsi-icon svg { + fill: #929292; +} + +.jitsi-icon.gray svg { + fill: #5E6D7A; + cursor: pointer; +} + +p { + margin: 0; +} + +body, input, textarea, keygen, select, button { + font-family: $baseFontFamily !important; +} + +button, input, select, textarea { + margin: 0; + vertical-align: baseline; + font-size: 1em; +} + +button, select, input[type="button"], +input[type="reset"], input[type="submit"] { + cursor: pointer; +} + +textarea { + word-wrap: break-word; + resize: none; + line-height: 1.5em; +} + +input[type='text'], input[type='password'], textarea { + outline: none; /* removes the default outline */ + resize: none; /* prevents the user-resizing, adjust to taste */ +} + +button { + color: #FFF; + background-color: #44A5FF; + border-radius: $borderRadius; + + &.no-icon { + padding: 0 1em; + } +} + +button, +form { + display: block; +} + +.watermark { + display: block; + position: absolute; + top: 15; + width: $watermarkWidth; + height: $watermarkHeight; + background-size: contain; + background-repeat: no-repeat; + z-index: $watermarkZ; +} + +.leftwatermark { + max-width: 140px; + max-height:70px; + left: 32px; + top: 32px; + background-position: center left; + background-repeat: no-repeat; + background-size: contain; + + &.no-margin { + left:0; + top:0; + } +} + +.rightwatermark { + right: 32px; + top: 32px; + background-position: center right; +} + +.poweredby { + position: absolute; + left: 25; + bottom: 7; + font-size: 0.875rem; + color: rgba(255,255,255,.50); + text-decoration: none; + z-index: $watermarkZ; +} + +/** + * Re-style default OS scrollbar. + */ +::-webkit-scrollbar { + background: transparent; + width: 7px; + height: $scrollHeight; +} + +::-webkit-scrollbar-button { + display: none; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-track-piece { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #3D3D3D; + border-radius: 4px; +} + +/* Necessary for the new icons to work properly. */ +.jitsi-icon svg path { + fill: inherit !important; +} + +.sr-only { + border: 0 !important; + clip: rect(1px, 1px, 1px, 1px) !important; + clip-path: inset(50%) !important; + height: 1px !important; + margin: -1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; +} diff --git a/css/_chat.scss b/css/_chat.scss new file mode 100644 index 0000000..b59db4b --- /dev/null +++ b/css/_chat.scss @@ -0,0 +1,220 @@ +@use './variables'; + +#chat-conversation-container { + // extract message input height + box-sizing: border-box; + height: calc(100% - 64px); + overflow: hidden; + position: relative; +} + +#chatconversation { + box-sizing: border-box; + flex: 1; + font-size: 0.75rem; + height: calc(100% - 10px); + line-height: 1.25rem; + overflow: auto; + padding: 16px; + text-align: left; + word-wrap: break-word; + + display: flex; + flex-direction: column; + + &.focus-visible { + outline: 0; + margin: 4px; + border-radius: 0 0 variables.$borderRadius variables.$borderRadius; + box-shadow: 0px 0px 0px 2px #4687ed; // focus01/primary07 + } + + & > :first-child { + margin-top: auto; + } + + a { + display: block; + } + + a:link { + color: rgb(184, 184, 184); + } + + a:visited { + color: white; + } + + a:hover { + color: rgb(213, 213, 213); + } + + a:active { + color: black; + } +} + +.chat-input-container { + padding: 0 16px 24px; +} + +#chat-input { + display: flex; + align-items: flex-end; + position: relative; +} + +.chat-input { + flex: 1; + margin-right: 8px; +} + +#nickname { + text-align: center; + color: #9d9d9d; + font-size: 1rem; + margin: auto 0; + padding: 0 16px; + + label[for="nickinput"] { + > div > span { + color: #B8C7E0; + } + } + input { + height: 40px; + } + + label { + line-height: 1.5rem; + } +} + +.mobile-browser { + #nickname { + input { + height: 48px; + } + } + + .chatmessage .usermessage { + font-size: 1rem; + } +} + +.chatmessage { + &.error { + border-radius: 0px; + + .timestamp, + .display-name { + display: none; + } + + .usermessage { + color: #ffffff; + padding: 0; + } + } + + .messagecontent { + max-width: 100%; + overflow: hidden; + } +} + +#smileys { + font-size: 1.625rem; + margin: auto; + cursor: pointer; +} + +#smileys img { + width: 22px; + padding: 2px; +} + +.smiley-input { + display: flex; + position: absolute; + top: 0; + left: 0; +} + +#smileysContainer .smiley { + font-size: 1.625rem; +} + +.smileyContainer { + width: 40px; + height: 40px; + display: inline-block; + text-align: center; +} + +.smileyContainer:hover { + background-color: rgba(255, 255, 255, 0.15); + border-radius: 5px; + cursor: pointer; +} + +.chat-message-group { + &.local { + align-items: flex-end; + + .display-name { + display: none; + } + + .timestamp { + text-align: right; + } + } + + &.error { + .display-name { + display: none; + } + } +} + +.chat-dialog { + display: flex; + flex-direction: column; + height: 100%; + margin-top: -5px; // Margin set by atlaskit. + + &-header { + display: flex; + justify-content: space-between; + align-items: center; + margin: 16px; + width: calc(100% - 32px); + box-sizing: border-box; + color: #fff; + font-weight: 600; + font-size: 1.5rem; + line-height: 2rem; + + .jitsi-icon { + cursor: pointer; + } + } + + #chatconversation { + width: 100%; + } +} + + +/** + * Make header close button more easily tappable on mobile. + */ +.mobile-browser .chat-dialog-header .jitsi-icon { + display: grid; + place-items: center; + height: 48px; + width: 48px; + background: #36383C; + border-radius: 3px; +} diff --git a/css/_chrome-extension-banner.scss b/css/_chrome-extension-banner.scss new file mode 100644 index 0000000..f65568f --- /dev/null +++ b/css/_chrome-extension-banner.scss @@ -0,0 +1,93 @@ +.chrome-extension-banner { + position: fixed; + width: 406px; + height: $chromeExtensionBannerHeight; + background: #FFF; + box-shadow: 0px 2px 48px rgba(0, 0, 0, 0.25); + border-radius: 4px; + z-index: 1000; + float: right; + display: flex; + flex-direction: column; + padding: 20px 20px; + top: $chromeExtensionBannerTop; + right: $chromeExtensionBannerRight; + &__pos_in_meeting { + top: $chromeExtensionBannerTopInMeeting; + right: $chromeExtensionBannerRightInMeeeting; + } + + &__container { + display: flex; + justify-content: space-between; + margin-bottom: 16px; + } + + &__button-container { + display: flex; + } + + &__checkbox-container { + display: $chromeExtensionBannerDontShowAgainDisplay; + margin-left: 45px; + margin-top: 16px; + } + + &__checkbox-label { + font-size: 0.875rem; + line-height: 1.125rem; + display: flex; + align-items: center; + letter-spacing: -0.006em; + color: #1C2025; + } + + &__icon-container { + display: flex; + background: url('../images/chromeLogo.svg'); + background-repeat: no-repeat; + width: 27px; + height: 27px; + } + + &__text-container { + font-size: 0.875rem; + line-height: 1.125rem; + display: flex; + align-items: center; + letter-spacing: -0.006em; + color: #151531; + width: 329px; + } + + &__close-container { + display: flex; + width: 12px; + height: 12px; + } + + &__gray-close-icon { + fill: #5E6D7A; + width: 12px; + height: 12px; + cursor: pointer; + } + + &__button-open-url { + background: #0A57EB; + border-radius: 24px; + margin-left: 45px; + width: 236px; + height: 40px; + cursor: pointer; + } + + &__button-text { + font-weight: 600; + font-size: 0.875rem; + line-height: 2.5rem; + text-align: center; + letter-spacing: -0.006em; + color: #FFFFFF; + } +} \ No newline at end of file diff --git a/css/_functions.scss b/css/_functions.scss new file mode 100644 index 0000000..8075204 --- /dev/null +++ b/css/_functions.scss @@ -0,0 +1,6 @@ +/* Functions */ + +/* Pixels to Ems function */ +@function em($value, $base: 16) { + @return #{$value / $base}em; +} \ No newline at end of file diff --git a/css/_inlay.scss b/css/_inlay.scss new file mode 100644 index 0000000..f6593b0 --- /dev/null +++ b/css/_inlay.scss @@ -0,0 +1,30 @@ +.inlay { + margin-top: 14%; + @include border-radius(4px); + padding: 40px 38px 44px; + color: #fff; + background: lighten(#474747, 20%); + text-align: center; + + &__title { + margin: 17px 0; + padding-bottom: 17px; + color: #ffffff; + font-size: 1.25rem; + letter-spacing: 0.3px; + border-bottom: 1px solid lighten(#FFFFFF, 10%); + } + + &__text { + color: #ffffff; + display: block; + margin-top: 22px; + font-size: 1rem; + } + + &__icon { + margin: 0 10px; + font-size: 3.125rem; + } + +} diff --git a/css/_login_menu.scss b/css/_login_menu.scss new file mode 100644 index 0000000..2326081 --- /dev/null +++ b/css/_login_menu.scss @@ -0,0 +1,4 @@ +a.disabled { + color: gray !important; + pointer-events: none; +} diff --git a/css/_meetings_list.scss b/css/_meetings_list.scss new file mode 100644 index 0000000..9de962e --- /dev/null +++ b/css/_meetings_list.scss @@ -0,0 +1,185 @@ +.meetings-list { + font-size: 0.875rem; + color: #253858; + line-height: 1.25rem; + text-align: left; + text-overflow: ellipsis; + display: flex; + flex-direction: column; + position: relative; + overflow-y: auto; + flex-grow: 1; + + .meetings-list-empty { + text-align: center; + align-items: center; + justify-content: center; + display: flex; + flex-grow: 1; + flex-direction: column; + + .description { + color: #2f3237; + font-size: 0.875rem; + line-height: 1.125rem; + margin-bottom: 16px; + max-width: 436px; + } + } + + .meetings-list-empty-image { + text-align: center; + margin: 24px 0 20px 0; + } + + .meetings-list-empty-button { + align-items: center; + color: #0163FF; + cursor: pointer; + display: flex; + font-size: 0.875rem; + line-height: 1.125rem; + margin: 24px 0 32px 0; + } + + .meetings-list-empty-icon { + display: inline-block; + margin-right: 8px; + } + + .button { + background: #0074E0; + border-radius: 4px; + color: #FFFFFF; + display: flex; + justify-content: center; + align-items: center; + padding: 8px; + cursor: pointer; + } + + .calendar-action-buttons { + .button { + margin: 0px 10px; + } + } + + .item { + background: #fff; + box-sizing: border-box; + border-radius: 4px; + display: inline-flex; + margin: 4px 4px 0 4px; + min-height: 60px; + width: calc(100% - 8px); + word-break: break-word; + display: flex; + flex-direction: row; + text-align: left; + + &:first-child { + margin-top: 0px; + } + + .left-column { + order: -1; + display: flex; + flex-direction: column; + flex-grow: 0; + padding-left: 16px; + padding-top: 13px; + } + + .right-column { + display: flex; + flex-direction: column; + align-items: flex-start; + flex-grow: 1; + padding-left: 16px; + padding-top: 13px; + position: relative; + } + + .title { + font-size: 0.75rem; + font-weight: 600; + line-height: 1rem; + margin-bottom: 4px; + } + + .subtitle { + color: #5E6D7A; + font-weight: normal; + font-size: 0.75rem; + line-height: 1rem; + } + + + .actions { + display: flex; + align-items: center; + justify-content: center; + flex-grow: 0; + margin-right: 16px; + } + + &.with-click-handler { + cursor: pointer; + } + + &.with-click-handler:hover, + &.with-click-handler:focus { + background-color: #c7ddff; + } + + .add-button { + width: 30px; + height: 30px; + padding: 0px; + } + + i { + cursor: inherit; + } + + .join-button { + display: none; + } + + &:hover .join-button { + display: block + } + } + + .delete-meeting { + display: none; + margin-right: 16px; + position: absolute; + + &>svg { + fill: #0074e0; + } + } + + .item:hover, + .item:focus, + .item:focus-within { + .delete-meeting { + display: block; + } + + .delete-meeting:hover { + &>svg { + fill: #4687ED; + } + } + } + + @media (max-width: 1024px) { /* Targets iPads and smaller devices */ + .item { + .delete-meeting { + display: block !important; + } + } + } +} \ No newline at end of file diff --git a/css/_meter.scss b/css/_meter.scss new file mode 100644 index 0000000..57b4281 --- /dev/null +++ b/css/_meter.scss @@ -0,0 +1,30 @@ +.jitsi-icon { + &.metr { + display: inline-block; + + & > svg { + fill: #525252; + width: 38px; + } + } + + &.metr--disabled { + & > svg { + fill: #525252; + } + } +} + +.metr-l-0 { + rect:first-child { + fill: #1EC26A; + } +} + +@for $i from 1 through 7 { + .metr-l-#{$i} { + rect:nth-child(-n+#{$i+1}) { + fill: #1EC26A; + } + } +} diff --git a/css/_mini_toolbox.scss b/css/_mini_toolbox.scss new file mode 100644 index 0000000..14f8737 --- /dev/null +++ b/css/_mini_toolbox.scss @@ -0,0 +1,30 @@ +.always-on-top-toolbox { + background-color: $newToolbarBackgroundColor; + border-radius: 3px; + display: flex; + z-index: $toolbarZ; + + .toolbox-icon { + cursor: pointer; + padding: 7px; + width: 22px; + height : 22px; + + &.toggled { + background: none; + } + + &.disabled { + cursor: initial; + } + } +} + +.always-on-top-toolbox { + flex-direction: row; + left: 50%; + position: absolute; + bottom: 10px; + transform: translateX(-50%); + padding: 3px !important; +} diff --git a/css/_mixins.scss b/css/_mixins.scss new file mode 100644 index 0000000..673cd54 --- /dev/null +++ b/css/_mixins.scss @@ -0,0 +1,209 @@ +/** + * Animation mixin. + */ +@mixin animation($animate...) { + $max: length($animate); + $animations: ''; + + @for $i from 1 through $max { + $animations: #{$animations + nth($animate, $i)}; + + @if $i < $max { + $animations: #{$animations + ", "}; + } + } + -webkit-animation: $animations; + -moz-animation: $animations; + -o-animation: $animations; + animation: $animations; +} + +@mixin flex() { + display: -webkit-box; + display: -moz-box; + display: -ms-flexbox; + display: -webkit-flex; + display: flex; +} + +/** + * Keyframes mixin. + */ +@mixin keyframes($animationName) { + @-webkit-keyframes #{$animationName} { + @content; + } + @-moz-keyframes #{$animationName} { + @content; + } + @-o-keyframes #{$animationName} { + @content; + } + @keyframes #{$animationName} { + @content; + } +} + +@mixin circle($diameter) { + width: $diameter; + height: $diameter; + border-radius: 50%; +} + +/** +* Absolute position the element at the top left corner +**/ +@mixin topLeft() { + position: absolute; + top: 0; + left: 0; +} + +@mixin absoluteAligning() { + top: 50%; + left: 50%; + position: absolute; + @include transform(translate(-50%, -50%)); +} + +/** +* Defines the maximum width and height +**/ +@mixin maxSize($value) { + max-width: $value; + max-height: $value; +} + +@mixin transform($func) { + -moz-transform: $func; + -ms-transform: $func; + -webkit-transform: $func; + -o-transform: $func; + transform: $func; +} + +@mixin transition($transition...) { + -moz-transition: $transition; + -o-transition: $transition; + -webkit-transition: $transition; + transition: $transition; +} + +/** + * Mixin styling a placeholder. + **/ +@mixin placeholder() { + $selectors: ( + "::-webkit-input-placeholder", + "::-moz-placeholder", + ":-moz-placeholder", + ":-ms-input-placeholder" + ); + + @each $selector in $selectors { + #{$selector} { + @content; + } + } +} + +/** + * Mixin styling a slider track for different browsers. + **/ +@mixin slider() { + $selectors: ( + "input[type=range]::-webkit-slider-runnable-track", + "input[type=range]::-moz-range-track", + "input[type=range]::-ms-track" + ); + + @each $selector in $selectors { + #{$selector} { + @content; + } + } +} + +/** + * Mixin styling a slider thumb for different browsers. + **/ +@mixin slider-thumb() { + $selectors: ( + "input[type=range]::-webkit-slider-thumb", + "input[type=range]::-moz-range-thumb", + "input[type=range]::-ms-thumb" + ); + + @each $selector in $selectors { + #{$selector} { + @content; + } + } +} + +@mixin box-shadow($h, $y, $blur, $color, $inset: false) { + @if $inset { + -webkit-box-shadow: inset $h $y $blur $color; + -moz-box-shadow: inset $h $y $blur $color; + box-shadow: inset $h $y $blur $color; + } @else { + -webkit-box-shadow: $h $y $blur $color; + -moz-box-shadow: $h $y $blur $color; + box-shadow: $h $y $blur $color; + } +} + +@mixin no-box-shadow { + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} + +@mixin box-sizing($box-model) { + -webkit-box-sizing: $box-model; // Safari <= 5 + -moz-box-sizing: $box-model; // Firefox <= 19 + box-sizing: $box-model; +} + +@mixin border-radius($radius) { + -webkit-border-radius: $radius; + border-radius: $radius; + /* stops bg color from leaking outside the border: */ + background-clip: padding-box; +} + +@mixin opacity($opacity) { + opacity: $opacity; + $opacity-ie: $opacity * 100; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=$opacity-ie); + filter: alpha(opacity=$opacity-ie); //IE8 +} + +@mixin text-truncate { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/** + * Creates a semi-transparent background with the given color and alpha + * (opacity) value. + */ +@mixin transparentBg($color, $alpha) { + background-color: rgba(red($color), green($color), blue($color), $alpha); +} + +/** + * Change the direction of the current element to LTR, but do not change the direction + * of its children; Keep them RTL. + */ +@mixin ltr { + body[dir=rtl] & { + direction: ltr; + + & > * { + direction: rtl; + } + } +} diff --git a/css/_navigate_section_list.scss b/css/_navigate_section_list.scss new file mode 100644 index 0000000..ba1b454 --- /dev/null +++ b/css/_navigate_section_list.scss @@ -0,0 +1,77 @@ +%navigate-section-list-text { + width: 100%; + font-size: 0.875rem; + line-height: 1.25rem; + color: $welcomePageTitleColor; + text-align: left; + font-family: 'open_sanslight', Helvetica, sans-serif; +} +%navigate-section-list-tile-text { + @extend %navigate-section-list-text; + overflow: hidden; + text-overflow: ellipsis; + float: left; +} +.navigate-section-list-tile { + background-color: #1754A9; + border-radius: 4px; + box-sizing: border-box; + display: inline-flex; + margin-bottom: 8px; + margin-right: 8px; + min-height: 100px; + padding: 16px; + width: 100%; + + &.with-click-handler { + cursor: pointer; + } + + &.with-click-handler:hover { + background-color: #1a5dbb; + } + + i { + cursor: inherit; + } + + .element-after { + display: flex; + align-items: center; + justify-content: center; + } + + .join-button { + display: none; + } + + &:hover .join-button { + display: block + } +} +.navigate-section-tile-body { + @extend %navigate-section-list-tile-text; + font-weight: normal; + line-height: 1.5rem; +} +.navigate-section-list-tile-info { + flex: 1; + word-break: break-word; +} +.navigate-section-tile-title { + @extend %navigate-section-list-tile-text; + font-weight: bold; + line-height: 1.5rem; +} +.navigate-section-section-header { + @extend %navigate-section-list-text; + font-weight: bold; + margin-bottom: 16px; + display: block; +} +.navigate-section-list { + position: relative; + margin-top: 36px; + margin-bottom: 36px; + width: 100%; +} diff --git a/css/_participants-pane.scss b/css/_participants-pane.scss new file mode 100644 index 0000000..4ef4d3b --- /dev/null +++ b/css/_participants-pane.scss @@ -0,0 +1,6 @@ +.jitsi-icon { + &-dominant-speaker { + background-color: #1EC26A; + border-radius: 3px; + } +} diff --git a/css/_plan-limit.scss b/css/_plan-limit.scss new file mode 100644 index 0000000..e69de29 diff --git a/css/_policy.scss b/css/_policy.scss new file mode 100644 index 0000000..a370f46 --- /dev/null +++ b/css/_policy.scss @@ -0,0 +1,15 @@ +.policy { + &__logo { + display: block; + width: 200px; + height: 50px; + margin: 30px auto 0; + } + + &__text { + text-align: center; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 300; + } +} \ No newline at end of file diff --git a/css/_popover.scss b/css/_popover.scss new file mode 100644 index 0000000..c152590 --- /dev/null +++ b/css/_popover.scss @@ -0,0 +1,35 @@ +.popover { + z-index: 8; + + .popover-content { + position: relative; + } + + &.hover { + margin: -16px -24px; + + .popover-content { + margin: 16px 24px; + + &.top { + bottom: 8px; + } + + &.bottom { + top: 4px; + } + + &.left { + right: 4px; + } + + &.right { + left: 4px; + } + } + } +} + +.excalidraw .popover { + margin: 0; +} diff --git a/css/_popup_menu.scss b/css/_popup_menu.scss new file mode 100644 index 0000000..17d1ca9 --- /dev/null +++ b/css/_popup_menu.scss @@ -0,0 +1,19 @@ +/** +* Initialize +**/ + +.popupmenu__contents { + .popupmenu__volume-slider { + &::-webkit-slider-runnable-track { + background-color: #246FE5; + } + + &::-moz-range-track { + background-color: #246FE5; + } + + &::-ms-fill-lower { + background-color: #246FE5; + } + } +} diff --git a/css/_promotional-footer.scss b/css/_promotional-footer.scss new file mode 100644 index 0000000..16aa956 --- /dev/null +++ b/css/_promotional-footer.scss @@ -0,0 +1 @@ +/** Insert custom CSS for any additional content in the promotional footer **/ diff --git a/css/_reactions-menu.scss b/css/_reactions-menu.scss new file mode 100644 index 0000000..3406c25 --- /dev/null +++ b/css/_reactions-menu.scss @@ -0,0 +1,214 @@ +@use 'sass:math'; + +.reactions-menu { + width: 330px; + background: #242528; + box-shadow: 0px 3px 16px rgba(0, 0, 0, 0.6), 0px 0px 4px 1px rgba(0, 0, 0, 0.25); + border-radius: 6px; + padding: 16px; + + &.with-gif { + width: 380px; + + .reactions-row .toolbox-button:last-of-type { + top: 3px; + + & .toolbox-icon.toggled { + background-color: #000000; + } + } + } + + &.overflow { + width: 100%; + + .toolbox-icon { + width: 48px; + height: 48px; + + span.emoji { + width: 48px; + height: 48px; + } + } + + .reactions-row { + display: flex; + flex-direction: row; + justify-content: space-around; + + .toolbox-button { + margin-right: 0; + } + + .toolbox-button:last-of-type { + top: 0; + } + } + } + + .toolbox-icon { + width: 40px; + height: 40px; + border-radius: 6px; + + span.emoji { + width: 40px; + height: 40px; + font-size: 1.375rem; + display: flex; + align-items: center; + justify-content: center; + transition: font-size ease .1s; + + @for $i from 1 through 12 { + &.increase-#{$i}{ + font-size: calc(1.25rem + #{$i}px); + } + } + } + } + + .reactions-row { + .toolbox-button { + margin-right: 8px; + touch-action: manipulation; + position: relative; + } + + .toolbox-button:last-of-type { + margin-right: 0; + } + } + + .raise-hand-row { + margin-top: 16px; + + .toolbox-button { + width: 100%; + } + + .toolbox-icon { + width: 100%; + flex-direction: row; + align-items: center; + + span.text { + font-style: normal; + font-weight: 600; + font-size: 0.875rem; + line-height: 1.5rem; + margin-left: 8px; + } + } + } +} + +.reactions-animations-overflow-container { + position: absolute; + width: 20%; + bottom: 0; + left: 40%; + height: 0; +} + +.reactions-menu-popup-container { + display: inline-block; + position: relative; +} + +.reactions-animations-container { + left: 50%; + bottom: 0px; + display: inline-block; + position: absolute; +} + +$reactionCount: 20; + +@function random($min, $max) { + @return math.random() * ($max - $min) + $min; +} + +.reaction-emoji { + position: absolute; + font-size: 1.5rem; + line-height: 2rem; + width: 32px; + height: 32px; + top: 0; + left: 20px; + opacity: 0; + z-index: 1; + + &.reaction-0 { + animation: flowToRight 5s forwards ease-in-out; + } + + @for $i from 1 through $reactionCount { + &.reaction-#{$i} { + animation: animation-#{$i} 5s forwards ease-in-out; + top: #{random(-40, 10)}px; + left: #{random(0, 30)}px; + } +} +} + +@keyframes flowToRight { + 0% { + transform: translate(0px, 0px) scale(0.6); + opacity: 1; + } + + 70% { + transform: translate(40px, -70dvh) scale(1.5); + opacity: 1; + } + + 75% { + transform: translate(40px, -70dvh) scale(1.5); + opacity: 1; + } + + 100% { + transform: translate(140px, -50dvh) scale(1); + opacity: 0; + } +} + +@mixin animation-list { + @for $i from 1 through $reactionCount { + $topX: random(-100, 100); + $topY: random(65, 75); + $bottomX: random(150, 200); + $bottomY: random(40, 50); + + @if $topX < 0 { + $bottomX: -$bottomX; + } + + @keyframes animation-#{$i} { + 0% { + transform: translate(0, 0) scale(0.6); + opacity: 1; + } + + 70% { + transform: translate(#{$topX}px, -#{$topY}dvh) scale(1.5); + opacity: 1; + } + + 75% { + transform: translate(#{$topX}px, -#{$topY}dvh) scale(1.5); + opacity: 1; + } + + 100% { + transform: translate(#{$bottomX}px, -#{$bottomY}dvh) scale(1); + opacity: 0; + } + } + } +} + +@include animation-list; diff --git a/css/_recording.scss b/css/_recording.scss new file mode 100644 index 0000000..681c407 --- /dev/null +++ b/css/_recording.scss @@ -0,0 +1,199 @@ +.recording-dialog { + flex: 0; + flex-direction: column; + + .recording-header { + align-items: center; + display: flex; + flex: 0; + flex-direction: row; + justify-content: space-between; + + .recording-title { + display: inline-flex; + align-items: center; + font-size: 0.875rem; + margin-left: 16px; + max-width: 70%; + + &-no-space { + margin-left: 0; + } + } + + &.space-top { + margin-top: 10px; + } + } + + .recording-header-line { + border-top: 1px solid #5e6d7a; + padding-top: 16px; + margin-top: 16px; + } + + .local-recording-warning { + margin-top: 8px; + display: block; + font-size: 0.875rem; + line-height: 1.25rem; + padding: 8px 16px; + + &.text { + color: #fff; + background-color: #3D3D3D; + } + + &.notification { + color: #040404; + background-color: #F8AE1A; + } + } + + .recording-icon-container { + display: inline-flex; + align-items: center; + } + + .file-sharing-icon-container { + background-color: #525252; + border-radius: 4px; + height: 40px; + justify-content: center; + width: 42px; + } + + .cloud-content-recording-icon-container { + background-color: #FFFFFF; + border-radius: 4px; + height: 40px; + justify-content: center; + width: 40px; + } + + .jitsi-recording-header { + margin-bottom: 16px; + } + + .jitsi-content-recording-icon-container-with-switch { + background-color: #FFFFFF; + border-radius: 4px; + height: 40px; + width: 40px; + } + + .jitsi-content-recording-icon-container-without-switch { + background-color: #FFFFFF; + border-radius: 4px; + height: 40px; + width: 46px; + } + + .recording-icon { + height: 40px; + object-fit: contain; + width: 40px; + } + + .content-recording-icon { + height: 18px; + margin: 10px 0 0 10px; + object-fit: contain; + width: 18px; + } + + .recording-file-sharing-icon { + height: 18px; + object-fit: contain; + width: 18px; + } + + .recording-info{ + background-color: #FFD740; + color: black; + display: inline-flex; + margin: 32px 0; + width: 100%; + } + + .recording-info-icon { + align-self: center; + height: 14px; + margin: 0 24px 0 16px; + object-fit: contain; + width: 14px; + } + + .recording-info-title { + display: inline-flex; + font-size: 0.875rem; + width: 290px + } + + .recording-switch { + margin-left: auto; + } + + .authorization-panel { + display: flex; + flex-direction: column; + margin: 0 40px 10px 40px; + padding-bottom: 10px; + + .logged-in-panel { + padding: 10px; + } + } +} + +.live-stream-dialog { + /** + * Set font-size to be consistent with Atlaskit FieldText. + */ + font-size: 0.875rem; + + .broadcast-dropdown { + text-align: left; + } + + .form-footer { + display: flex; + margin-top: 5px; + text-align: right; + flex-direction: column; + + .help-container { + display: flex; + } + } + + .live-stream-cta { + a { + cursor: pointer; + } + } + + .google-api { + margin-top: 10px; + min-height: 36px; + text-align: center; + width: 100%; + } + + .google-error { + color: #c61600; + } + + .google-panel { + align-items: center; + border-bottom: 2px solid rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + padding-bottom: 10px; + } + + .warning-text { + color:#FFD740; + font-size: 0.75rem; + } +} diff --git a/css/_redirect_page.scss b/css/_redirect_page.scss new file mode 100644 index 0000000..1fbe474 --- /dev/null +++ b/css/_redirect_page.scss @@ -0,0 +1,40 @@ +.redirectPageMessage { + width: 30%; + margin: 20% auto; + text-align: center; + font-size: 1.5rem; + + .thanks-msg { + border-bottom: 1px solid #FFFFFF; + padding-left: 30px; + padding-right: 30px; + p { + margin: 30px auto; + font-size: 1.5rem; + line-height: 1.5rem; + } + } + .hint-msg { + p { + margin: 26px auto; + font-weight: 600; + font-size: 1rem; + line-height: 1.125rem; + .hint-msg__holder{ + font-weight: 200; + } + } + .happy-software{ + width: 120px; + height: 86px; + margin: 0 auto; + background: transparent; + } + } + .forbidden-msg { + p { + font-size: 1rem; + margin-top: 15px; + } + } +} diff --git a/css/_reset.scss b/css/_reset.scss new file mode 100644 index 0000000..ae1fe50 --- /dev/null +++ b/css/_reset.scss @@ -0,0 +1,231 @@ +/* Fonts and line heights */ +/** + * RESET + */ +html, +body, +p, +div, +h1, +h2, +h3, +h4, +h5, +h6, +img, +pre, +form, +fieldset { + margin: 0; + padding: 0; +} +ul, +ol, +dl { + margin: 0; +} +img, +fieldset { + border: 0; +} +@-moz-document url-prefix() { + img { + font-size: 0; + } + img:-moz-broken { + font-size: inherit; + } +} +/* https://github.com/necolas/normalize.css */ +/* Customised to remove styles for unsupported browsers */ +details, +main, +summary { + display: block; +} +audio, +canvas, +progress, +video { + display: inline-block; + transition: object-position 0.5s ease 0s; + vertical-align: baseline; +} +audio:not([controls]) { + display: none; + height: 0; +} +[hidden], +template { + display: none; +} +input[type="button"], +input[type="submit"], +input[type="reset"] { + -webkit-appearance: button; +} +/** + * TYPOGRAPHY - 14px base font size, agnostic font stack + */ +body { + color: #333; + font-family: Arial, sans-serif; + font-size: 0.875rem; + line-height: 1.42857142857143; +} +/* International Font Stacks*/ +[lang|=en] { + font-family: Arial, sans-serif; +} +[lang|=ja] { + font-family: "Hiragino Kaku Gothic Pro", "ヒラギノ角ゴ Pro W3", "メイリオ", Meiryo, "MS Pゴシック", Verdana, Arial, sans-serif; +} +/* Default margins */ +p, +ul, +ol, +dl, +h1, +h2, +h3, +h4, +h5, +h6, +blockquote, +pre { + margin: 10px 0 0 0; +} +/* No top margin to interfere with box padding */ +p:first-child, +ul:first-child, +ol:first-child, +dl:first-child, +h1:first-child, +h2:first-child, +h3:first-child, +h4:first-child, +h5:first-child, +h6:first-child, +blockquote:first-child, +pre:first-child { + margin-top: 0; +} +/* Headings: desired line height in px / font size = unitless line height */ +h1 { + color: #333; + font-size: 2rem; + font-weight: normal; + line-height: 1.25; + text-transform: none; + margin: 30px 0 0 0; +} +h2 { + color: #333; + font-size: 1.5rem; + font-weight: normal; + line-height: 1.25; + text-transform: none; + margin: 30px 0 0 0; +} +h3 { + color: #333; + font-size: 1.25rem; + font-weight: normal; + line-height: 1.5; + text-transform: none; + margin: 30px 0 0 0; +} +h4 { + font-size: 1rem; + font-weight: bold; + line-height: 1.25; + text-transform: none; + margin: 20px 0 0 0; +} +h5 { + color: #333; + font-size: 0.875rem; + font-weight: bold; + line-height: 1.42857143; + text-transform: none; + margin: 20px 0 0 0; +} +h6 { + color: #707070; + font-size: 0.75rem; + font-weight: bold; + line-height: 1.66666667; + text-transform: uppercase; + margin: 20px 0 0 0; +} +h1:first-child, +h2:first-child, +h3:first-child, +h4:first-child, +h5:first-child, +h6:first-child { + margin-top: 0; +} +/* Nice styles for using subheadings */ +h1 + h2, +h2 + h3, +h3 + h4, +h4 + h5, +h5 + h6 { + margin-top: 10px; +} + + +/* Other typographical elements */ +small { + color: #707070; + font-size: 0.75rem; + line-height: 1.33333333333333; +} +code, +kbd { + font-family: monospace; +} +var, +address, +dfn, +cite { + font-style: italic; +} +cite:before { + content: "\2014 \2009"; +} +blockquote { + border-left: 1px solid #ccc; + color: #707070; + margin-left: 19px; + padding: 10px 20px; +} +blockquote > cite { + display: block; + margin-top: 10px; +} +q { + color: #707070; +} +q:before { + content: open-quote; +} +q:after { + content: close-quote; +} +abbr { + border-bottom: 1px #707070 dotted; + cursor: help; +} + +a { + color: #44A5FF; + text-decoration: none; + font-weight: bold; +} +a:focus, +a:hover, +a:active { + text-decoration: underline; +} diff --git a/css/_responsive.scss b/css/_responsive.scss new file mode 100644 index 0000000..626af69 --- /dev/null +++ b/css/_responsive.scss @@ -0,0 +1,62 @@ +@media only screen and (max-width: $verySmallScreen) { + .welcome { + display: block; + + #enter_room { + .welcome-page-button { + font-size: 1rem; + left: 0; + text-align: center; + width: 100%; + } + } + + .header { + background-color: #002637; + + .insecure-room-name-warning { + width: 100%; + } + + #enter_room { + width: 100%; + + .join-meeting-container { + padding: 0; + flex-direction: column; + background: transparent; + } + + .enter-room-input-container { + padding-right: 0; + margin-bottom: 10px; + } + } + } + + .header-text-title { + text-align: center; + } + + .welcome-cards-container { + padding: 0; + } + + &.without-content { + .header { + height: 100%; + } + } + + #moderated-meetings { + display: none; + } + + .welcome-footer-row-block { + display: flex; + flex-direction: column; + gap:12px; + align-items: center; + } + } +} diff --git a/css/_settings-button.scss b/css/_settings-button.scss new file mode 100644 index 0000000..aa68f8b --- /dev/null +++ b/css/_settings-button.scss @@ -0,0 +1,74 @@ +.settings-button-container { + position: relative; + + .toolbox-icon { + align-items: center; + border-radius: 3px; + cursor: pointer; + display: flex; + justify-content: center; + + &.disabled, .disabled & { + cursor: initial; + color: #929292; + background-color: #36383c; + + &:hover { + background-color: #36383c; + } + } + } +} + +.settings-button-small-icon { + background: #36383C; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25), 0px 0px 0px 1px rgba(0, 0, 0, 0.1); + border-radius: 3px; + cursor: pointer; + padding: 1px; + position: absolute; + right: -4px; + top: -3px; + + &:hover { + background: #F2F3F4; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25), 0px 0px 0px 1px rgba(0, 0, 0, 0.1); + + & svg { + fill: #040404; + } + + &.settings-button-small-icon--disabled { + background: #36383C; + + & svg { + fill: #929292; + } + } + } + + & svg { + fill: #fff; + } + + &--disabled { + background-color: #36383c; + cursor: default; + + & svg { + fill: #929292; + } + } +} + +.settings-button-small-icon-container { + position: absolute; + right: -4px; + top: -3px; + + & .settings-button-small-icon { + position: relative; + top: 0; + right: 0; + } +} diff --git a/css/_subject.scss b/css/_subject.scss new file mode 100644 index 0000000..5efb9b6 --- /dev/null +++ b/css/_subject.scss @@ -0,0 +1,64 @@ +.subject { + color: #fff; + transition: opacity .6s ease-in-out; + z-index: $toolbarZ + 2; + margin-top: 20px; + opacity: 0; + + &.visible { + opacity: 1; + } + + &#autoHide.with-always-on { + overflow: hidden; + animation: hideSubject forwards .6s ease-out; + + & > .subject-info-container { + justify-content: flex-start; + } + + &.visible { + animation: showSubject forwards .6s ease-out; + } + } +} + +.subject-info-container { + display: flex; + justify-content: center; + margin: 0 auto; + height: 28px; + + @media (max-width: 500px) { + flex-wrap: wrap; + } +} + +.details-container { + width: 100%; + display: flex; + justify-content: center; + position: absolute; + top: 0; + height: 48px; +} + +@keyframes hideSubject { + 0% { + max-width: 100%; + } + + 100% { + max-width: 0; + } +} + +@keyframes showSubject { + 0% { + max-width: 0%; + } + + 100% { + max-width: 100%; + } +} diff --git a/css/_toolbars.scss b/css/_toolbars.scss new file mode 100644 index 0000000..d1327a9 --- /dev/null +++ b/css/_toolbars.scss @@ -0,0 +1,204 @@ +/** + * Round badge. + */ +.badge-round { + background-color: #165ECC; + border-radius: 50%; + box-sizing: border-box; + color: #FFFFFF; + // Do not inherit the font-family from the toolbar button, because it's an + // icon style. + font-family: $baseFontFamily; + font-size: 0.5rem; + font-weight: 700; + line-height: 0.75rem; + min-width: 13px; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + vertical-align: middle; +} + +/** + * TODO: when the old filmstrip has been removed, remove the "new-" prefix. + */ +.new-toolbox { + bottom: calc((#{$newToolbarSize} * 2) * -1); + left: 0; + position: absolute; + right: 0; + transition: bottom .3s ease-in; + width: 100%; + pointer-events: none; + z-index: $toolbarZ + 2; + + &.shift-up { + bottom: calc(((#{$newToolbarSize} + 30px) * 2) * -1); + + .toolbox-content { + margin-bottom: 46px; + } + } + + &.visible { + bottom: 0; + } + + &.no-buttons { + display: none; + } +} + +.toolbox-content { + align-items: center; + box-sizing: border-box; + display: flex; + margin-bottom: 16px; + position: relative; + z-index: $toolbarZ; + pointer-events: none; + + .toolbox-button-wth-dialog { + display: inline-block; + } +} + +.toolbar-button-with-badge { + display: inline-block; + position: relative; + + .badge-round { + bottom: -5px; + font-size: 0.75rem; + line-height: 1.25rem; + min-width: 20px; + pointer-events: none; + position: absolute; + right: -5px; + } +} + +.toolbox-content-wrapper { + display: flex; + flex-direction: column; + margin: 0 auto; + max-width: 100%; + pointer-events: all; + border-radius: 6px; + + .toolbox-content-items { + @include ltr; + } +} + +.toolbox-content-wrapper::after { + content: ''; + background: $newToolbarBackgroundColor; + padding-bottom: env(safe-area-inset-bottom, 0); +} + +.overflow-menu-hr { + border-top: 1px solid #4C4D50; + border-bottom: 0; + margin: 8px 0; +} + +div.hangup-button { + background-color: #CB2233; + + @media (hover: hover) and (pointer: fine) { + &:hover { + background-color: #E04757; + } + + &:active { + background-color: #A21B29; + } + } + + svg { + fill: #fff; + } +} + +div.hangup-menu-button { + background-color: #CB2233; + + @media (hover: hover) and (pointer: fine) { + &:hover { + background-color: #E04757; + } + + &:active { + background-color: #A21B29; + } + } + + svg { + fill: #fff; + } +} + +.profile-button-avatar { + align-items: center; +} + +/** + * START of fade in animation for main toolbar + */ +.fadeIn { + opacity: 1; + + @include transition(all .3s ease-in); +} + +.fadeOut { + opacity: 0; + + @include transition(all .3s ease-out); +} + +/** + * Audio and video buttons do not have toggled state. + */ +.audio-preview, +.video-preview { + .toolbox-icon.toggled { + background: none; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + } + +} + +/** + * On small mobile devices make the toolbar full width and pad the invite prompt. + */ +.toolbox-content-mobile { + @media (max-width: 500px) { + margin-bottom: 0; + + .toolbox-content-wrapper { + width: 100%; + } + + .toolbox-content-items { + @include ltr; + border-radius: 0; + display: flex; + justify-content: space-evenly; + padding: 8px 0; + width: 100%; + } + + .invite-more-container { + margin: 0 16px 8px; + } + + .invite-more-container.elevated { + margin-bottom: 52px; + } + } +} diff --git a/css/_utils.scss b/css/_utils.scss new file mode 100644 index 0000000..ce26463 --- /dev/null +++ b/css/_utils.scss @@ -0,0 +1,58 @@ +.flip-x { + transform: scaleX(-1); +} + +.hidden { + display: none; +} + +/** + * Hides an element. + */ +.hide { + display: none !important; +} + +.invisible { + visibility: hidden; +} + +/** + * Shows an element. + */ +.show { + display: block !important; +} + +/** + * resets default button styles, + * mostly intended to be used on interactive elements that + * differ from their default styles (e.g. ) or have custom styles + */ +.invisible-button { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; +} + + +/** + * style an element the same as an + * useful on some cases where we visually have a link but it's actually a