diff --git a/.git-hooks/check-commit-message.sh b/.git-hooks/check-commit-message.sh new file mode 100755 index 000000000..8d0f57a6d --- /dev/null +++ b/.git-hooks/check-commit-message.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +commit_msg_file=$1 +commit_msg=$(cat "$commit_msg_file") + + +if perl -e ' + binmode(STDIN, ":utf8"); + $/ = undef; + $text = <>; + if ($text =~ /[\x{4e00}-\x{9fff}]/) { + exit(1); + } else { + exit(0); + }' < "$commit_msg_file" +then + exit 0 +else + echo "Error: Commit message contains Chinese characters." + echo "Please use English only in commit messages." + exit 1 +fi \ No newline at end of file diff --git a/.git-hooks/install-hooks.sh b/.git-hooks/install-hooks.sh new file mode 100755 index 000000000..5c63f9e92 --- /dev/null +++ b/.git-hooks/install-hooks.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +# Functions for colored text output +print_green() { + echo -e "\033[0;32m$1\033[0m" +} + +print_yellow() { + echo -e "\033[0;33m$1\033[0m" +} + +print_red() { + echo -e "\033[0;31m$1\033[0m" +} + +# Function to add executable permissions +ensure_executable() { + if [ -f "$1" ] && [ ! -x "$1" ]; then + chmod +x "$1" + print_green "Added executable permission to $1" + fi +} + +# Ensure script runs from project root directory +if [ ! -d ".git" ]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + cd "$(dirname "$SCRIPT_DIR")" || { print_red "Cannot find project root directory"; exit 1; } + + if [ ! -d ".git" ]; then + print_red "Please run this script from the project root directory" + exit 1 + fi +fi + +# Check if pre-commit is installed +if ! command -v pre-commit &> /dev/null; then + print_yellow "pre-commit not found, attempting to install..." + if command -v pip3 &> /dev/null; then + pip3 install pre-commit + elif command -v pip &> /dev/null; then + pip install pre-commit + else + print_red "pip not found, please install Python and pip first, then run this script again" + exit 1 + fi + + if [ $? -ne 0 ]; then + print_red "Failed to install pre-commit, please install manually: pip install pre-commit" + exit 1 + fi + print_green "pre-commit installed successfully!" +else + print_green "pre-commit is already installed!" +fi + +# Check if gitleaks is installed +if ! command -v gitleaks &> /dev/null; then + print_yellow "gitleaks not found, please install it..." + print_yellow "Installation guide: https://github.com/gitleaks/gitleaks#installing" + + # Attempt automatic installation (based on OS) + if [[ "$OSTYPE" == "darwin"* ]]; then + print_yellow "Detected macOS, attempting to install gitleaks via Homebrew..." + if command -v brew &> /dev/null; then + brew install gitleaks + if [ $? -eq 0 ]; then + print_green "gitleaks installed successfully!" + else + print_red "Cannot automatically install gitleaks, please install manually" + exit 1 + fi + else + print_red "Homebrew not found, please install Homebrew or install gitleaks manually" + exit 1 + fi + else + print_red "Please install gitleaks manually and try again" + exit 1 + fi +fi + +# Check required files and directories +if [ ! -d ".git-hooks" ]; then + print_red "Cannot find .git-hooks directory, please ensure you're in the correct project" + exit 1 +fi + +if [ ! -f ".gitleaks.toml" ]; then + print_red "Cannot find .gitleaks.toml configuration file, please ensure it exists" + exit 1 +fi + +if [ ! -f ".git-hooks/check-commit-message.sh" ]; then + print_red "Cannot find .git-hooks/check-commit-message.sh file, please ensure it exists" + exit 1 +fi + +# Ensure all scripts have executable permissions +print_yellow "Granting executable permissions to hook scripts..." +ensure_executable ".git-hooks/check-commit-message.sh" +ensure_executable ".git-hooks/post-commit" +ensure_executable ".git-hooks/pre-commit" + +# Install pre-commit hook +print_yellow "Installing pre-commit hook..." +pre-commit install +if [ $? -ne 0 ]; then + print_red "Failed to install pre-commit hook!" + exit 1 +fi +print_green "pre-commit hook installed successfully!" + +# Install commit-msg hook +print_yellow "Installing commit-msg hook..." +pre-commit install --hook-type commit-msg +if [ $? -ne 0 ]; then + print_red "Failed to install commit-msg hook!" + exit 1 +fi +print_green "pre-commit commit-msg hook installed successfully!" + +# Copy and set up custom hooks +print_yellow "Setting up custom hooks..." +# Copy commit-msg hook +cp .git-hooks/check-commit-message.sh .git/hooks/commit-msg +chmod +x .git/hooks/commit-msg + +# Copy post-commit hook (if exists) +if [ -f ".git-hooks/post-commit" ]; then + cp .git-hooks/post-commit .git/hooks/post-commit + chmod +x .git/hooks/post-commit +fi + +# Copy pre-commit hook (if exists) +if [ -f ".git-hooks/pre-commit" ]; then + # Backup pre-commit hook + if [ -f ".git/hooks/pre-commit" ]; then + cp .git/hooks/pre-commit .git/hooks/pre-commit.bak + fi + + cp .git-hooks/pre-commit .git/hooks/pre-commit.custom + chmod +x .git/hooks/pre-commit.custom + + # Add custom pre-commit to existing hook chain + if [ -f ".git/hooks/pre-commit" ]; then + HOOK_CONTENT=$(cat .git/hooks/pre-commit) + if ! grep -q "pre-commit.custom" .git/hooks/pre-commit; then + echo -e "\n# Run custom pre-commit hook\n.git/hooks/pre-commit.custom || exit 1" >> .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + fi + else + echo -e "#!/bin/bash\n\n# Run custom pre-commit hook\n.git/hooks/pre-commit.custom" > .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + fi +fi + +pre-commit clean && pre-commit install && pre-commit install --hook-type commit-msg + +print_green "================================================================" +print_green "🎉 Git hooks setup complete! Your repository now has:" +print_green " - Sensitive information leak detection using gitleaks" +print_green " - Chinese character detection in commit messages" +print_green "================================================================" diff --git a/.git-hooks/post-commit b/.git-hooks/post-commit new file mode 100755 index 000000000..74ae8b053 --- /dev/null +++ b/.git-hooks/post-commit @@ -0,0 +1,27 @@ +#!/bin/bash + +# Check if required hooks are installed +if [ ! -f ".git/hooks/commit-msg" ] || [ ! -x ".git/hooks/commit-msg" ]; then + echo "============================================================" + echo "Note: Git hooks for checking Chinese characters in commit messages are not installed." + echo "Please run the following commands to install:" + echo "" + echo " 1. Install pre-commit:" + echo " pip install pre-commit" + echo "" + echo " 2. Install pre-commit hook:" + echo " pre-commit install" + echo "" + echo " 3. Install commit-msg hook:" + echo " pre-commit install --hook-type commit-msg" + echo " cp .git-hooks/check-commit-message.sh .git/hooks/commit-msg" + echo " chmod +x .git/hooks/commit-msg" + echo "" + echo "These hooks will help detect sensitive information leaks and Chinese characters in commit messages." + echo "============================================================" +fi + +# Ensure the hook itself is executable +if [ -f ".git-hooks/check-commit-message.sh" ] && [ ! -x ".git-hooks/check-commit-message.sh" ]; then + chmod +x .git-hooks/check-commit-message.sh +fi \ No newline at end of file diff --git a/.git-hooks/pre-commit b/.git-hooks/pre-commit new file mode 100755 index 000000000..57221d159 --- /dev/null +++ b/.git-hooks/pre-commit @@ -0,0 +1,25 @@ +#!/bin/bash + +# Check if gitleaks is configured +if ! command -v gitleaks &> /dev/null; then + echo "============================================================" + echo "Gitleaks not detected. This is a required tool to prevent sensitive information leaks." + echo "Please install gitleaks first: https://github.com/gitleaks/gitleaks#installing" + echo "After installation, run: ./.git-hooks/install-hooks.sh" + echo "============================================================" + exit 1 +fi + +# Check for sensitive information +if [ -f ".gitleaks.toml" ]; then + gitleaks detect --source . --config .gitleaks.toml + if [ $? -ne 0 ]; then + echo "Gitleaks detected sensitive information. Commit rejected." + echo "Please review the output above and remove sensitive information." + exit 1 + fi +else + echo "No .gitleaks.toml configuration file found, skipping sensitive information check." +fi + +exit 0 \ No newline at end of file diff --git a/.github/ci/Jenkinsfile_bitbucket.groovy b/.github/ci/Jenkinsfile_bitbucket.groovy new file mode 100644 index 000000000..28df4bfe9 --- /dev/null +++ b/.github/ci/Jenkinsfile_bitbucket.groovy @@ -0,0 +1,3 @@ +@Library('agora-build-pipeline-library') _ + +pipelineLoad(this, "ApiExample", "workflow", "", "", "api-examples") diff --git a/.github/ci/build/build_android.groovy b/.github/ci/build/build_android.groovy new file mode 100644 index 000000000..8d3c152de --- /dev/null +++ b/.github/ci/build/build_android.groovy @@ -0,0 +1,66 @@ +// -*- mode: groovy -*- +// vim: set filetype=groovy : +@Library('agora-build-pipeline-library') _ +import groovy.transform.Field + +buildUtils = new agora.build.BuildUtils() + +compileConfig = [ + "sourceDir": "api-examples", + "non-publish": [ + "command": "./.github/ci/build/build_android.sh", + "extraArgs": "", + ], + "publish": [ + "command": "./.github/ci/build/build_android.sh", + "extraArgs": "", + ] +] + +def doBuild(buildVariables) { + type = params.Package_Publish ? "publish" : "non-publish" + command = compileConfig.get(type).command + preCommand = compileConfig.get(type).get("preCommand", "") + postCommand = compileConfig.get(type).get("postCommand", "") + docker = compileConfig.docker + extraArgs = compileConfig.get(type).extraArgs + extraArgs += " " + params.getOrDefault("extra_args", "") + commandConfig = [ + "command": command, + "sourceRoot": "${compileConfig.sourceDir}", + "extraArgs": extraArgs, + "docker": docker, + ] + loadResources(["config.json", "artifactory_utils.py"]) + buildUtils.customBuild(commandConfig, preCommand, postCommand) +} + +def doPublish(buildVariables) { + if (!params.Package_Publish) { + return + } + (shortVersion, releaseVersion) = buildUtils.getBranchVersion() + def archiveInfos = [ + [ + "type": "ARTIFACTORY", + "archivePattern": "*.zip", + "serverPath": "ApiExample/${shortVersion}/${buildVariables.buildDate}/${env.platform}", + "serverRepo": "SDK_repo" + ], + [ + "type": "ARTIFACTORY", + "archivePattern": "*.apk", + "serverPath": "ApiExample/${shortVersion}/${buildVariables.buildDate}/${env.platform}", + "serverRepo": "SDK_repo" + ] + ] + archiveUrls = archive.archiveFiles(archiveInfos) ?: [] + archiveUrls = archiveUrls as Set + if (archiveUrls) { + def content = archiveUrls.join("\n") + writeFile(file: 'package_urls', text: content, encoding: "utf-8") + } + sh "rm -rf *.zip *.apk || true" +} + +pipelineLoad(this, "ApiExample", "build", "android", "RTC-Sample") diff --git a/.github/ci/build/build_android.sh b/.github/ci/build/build_android.sh new file mode 100644 index 000000000..b8bd71de6 --- /dev/null +++ b/.github/ci/build/build_android.sh @@ -0,0 +1,111 @@ +################################## +# --- Guidelines: --- +# +# Common Environment Variable Injected: +# 'Package_Publish:boolean:true', +# 'Clean_Clone:boolean:false', +# 'is_tag_fetch:boolean:false', +# 'is_offical_build:boolean:false', +# 'repo:string', +# 'base:string', +# 'arch:string' +# 'output:string' +# 'short_version:string' +# 'release_version:string' +# 'build_date:string(yyyyMMdd)', +# 'build_timestamp:string (yyyyMMdd_hhmm)', +# 'platform: string', +# 'BUILD_NUMBER: string', +# 'WORKSPACE: string' +# +# --- Test Related: --- +# PR build, zip test related to test.zip +# Package build, zip package related to package.zip +# --- Artifactory Related: --- +# download artifactory: +# python3 ${WORKSPACE}/artifactory_utils.py --action=download_file --file=ARTIFACTORY_URL +# upload file to artifactory: +# python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=FILEPATTERN --server_path=SERVERPATH --server_repo=SERVER_REPO --with_pattern +# for example: python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=*.zip --server_path=windows/ --server_repo=ACCS_repo --with_pattern +# upload folder to artifactory +# python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=FILEPATTERN --server_path=SERVERPATH --server_repo=SERVER_REPO --with_folder +# for example: python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=*.zip --server_path=windows/ --server_repo=ACCS_repo --with_folder +# --- Input: ---- +# sourcePath: see jenkins console for details. +# WORKSPACE: ${WORKSPACE} +# --- Output: ---- +# pr: output test.zip to workspace dir +# others: Rename the zip package name yourself, But need copy it to workspace dir +################################## +export PATH=$PATH:/opt/homebrew/bin + +echo Package_Publish: $Package_Publish +echo is_tag_fetch: $is_tag_fetch +echo arch: $arch +echo source_root: %source_root% +echo output: /tmp/jenkins/${project}_out +echo build_date: $build_date +echo build_time: $build_time +echo release_version: $release_version +echo short_version: $short_version +echo pwd: `pwd` +echo sdk_url: $sdk_url +echo android_direction: $android_direction + +unzip_name=Agora_Native_SDK_for_Android_FULL_DEFAULT +zip_name=Agora_Native_SDK_for_Android_FULL_DEFAULT.zip +if [ -z "$sdk_url" ] || [ "$sdk_url" = "none" ]; then + echo "sdk_url is empty" + echo unzip_name: $unzip_name + echo zip_name: $zip_name +else + zip_name=${sdk_url##*/} + echo zip_name: $zip_name + + # env LC_ALL=en_US.UTF-8 python3 $WORKSPACE/artifactory_utils.py --action=download_file --file=$sdk_url || exit 1 + curl -o $zip_name $sdk_url || exit 1 + 7za x ./$zip_name -y > log.txt + + # Support top-level directory name containing 'Agora' or 'Shengwang' + unzip_name=`ls -S -d */ | grep -E 'Agora|Shengwang' | head -n 1 | sed 's/\///g'` + if [ -z "$unzip_name" ]; then + echo "Error: Unzipped directory not found. The SDK package structure may be invalid or the top-level directory does not contain 'Agora' or 'Shengwang'" + exit 1 + fi + echo unzip_name: $unzip_name + + rm -rf ./$unzip_name/rtc/bin + rm -rf ./$unzip_name/rtc/demo + rm -f ./$unzip_name/.commits + rm -f ./$unzip_name/spec + rm -rf ./$unzip_name/pom +fi + +mkdir -p ./$unzip_name/rtc/samples/${android_direction} || exit 1 +rm -rf ./$unzip_name/rtc/samples/${android_direction}/* + +if [ -d "./Android/${android_direction}" ]; then + cp -rf ./Android/${android_direction}/* ./$unzip_name/rtc/samples/${android_direction}/ || exit 1 +else + echo "Error: Source directory ./Android/${android_direction} does not exist" + exit 1 +fi + +7za a -tzip result.zip -r $unzip_name > log.txt +mv result.zip $WORKSPACE/withAPIExample_${BUILD_NUMBER}_$zip_name + +if [ $compress_apiexample = true ]; then + onlyCodeZipName=${android_direction}_onlyCode.zip + 7za a -tzip $onlyCodeZipName -r ./$unzip_name/rtc/samples/${android_direction} >> log.txt + mv $onlyCodeZipName $WORKSPACE/APIExample_onlyCode_${BUILD_NUMBER}_$zip_name +fi + +if [ $compile_project = true ]; then + cd ./$unzip_name/rtc/samples/${android_direction} || exit 1 + if [ -z "$sdk_url" ] || [ "$sdk_url" = "none" ]; then + ./cloud_build.sh false || exit 1 + else + ./cloud_build.sh true || exit 1 + fi +fi + diff --git a/.github/ci/build/build_ios.groovy b/.github/ci/build/build_ios.groovy new file mode 100644 index 000000000..2a772435e --- /dev/null +++ b/.github/ci/build/build_ios.groovy @@ -0,0 +1,59 @@ +// -*- mode: groovy -*- +// vim: set filetype=groovy : +@Library('agora-build-pipeline-library') _ +import groovy.transform.Field + +buildUtils = new agora.build.BuildUtils() + +compileConfig = [ + "sourceDir": "api-examples", + "non-publish": [ + "command": "./.github/ci/build/build_ios.sh", + "extraArgs": "", + ], + "publish": [ + "command": "./.github/ci/build/build_ios.sh", + "extraArgs": "", + ] +] + +def doBuild(buildVariables) { + type = params.Package_Publish ? "publish" : "non-publish" + command = compileConfig.get(type).command + preCommand = compileConfig.get(type).get("preCommand", "") + postCommand = compileConfig.get(type).get("postCommand", "") + extraArgs = compileConfig.get(type).extraArgs + extraArgs += " " + params.getOrDefault("extra_args", "") + commandConfig = [ + "command": command, + "sourceRoot": "${compileConfig.sourceDir}", + "extraArgs": extraArgs + ] + loadResources(["config.json", "artifactory_utils.py"]) + buildUtils.customBuild(commandConfig, preCommand, postCommand) +} + +def doPublish(buildVariables) { + if (!params.Package_Publish) { + return + } + (shortVersion, releaseVersion) = buildUtils.getBranchVersion() + def archiveInfos = [ + [ + "type": "ARTIFACTORY", + "archivePattern": "*.zip", + "serverPath": "ApiExample/${shortVersion}/${buildVariables.buildDate}/${env.platform}", + "serverRepo": "SDK_repo" // ATTENTIONS: Update the artifactoryRepo if needed. + ], + [ + "type": "ARTIFACTORY", + "archivePattern": "*.ipa", + "serverPath": "ApiExample/${shortVersion}/${buildVariables.buildDate}/${env.platform}", + "serverRepo": "SDK_repo" // ATTENTIONS: Update the artifactoryRepo if needed. + ] + ] + archive.archiveFiles(archiveInfos) + sh "rm -rf *.zip *.ipa || true" +} + +pipelineLoad(this, "ApiExample", "build", "ios", "RTC-Sample") \ No newline at end of file diff --git a/.github/ci/build/build_ios.sh b/.github/ci/build/build_ios.sh new file mode 100644 index 000000000..64ef58c85 --- /dev/null +++ b/.github/ci/build/build_ios.sh @@ -0,0 +1,133 @@ +#!/bin/bash -ilex +################################## +# --- Guidelines: --- +# +# Common Environment Variable: +# 'Package_Publish:boolean:true', +# 'Clean_Clone:boolean:false', +# 'is_tag_fetch:boolean:false', +# 'is_offical_build:boolean:false', +# 'repo:string', +# 'base:string', +# 'arch:string' +# 'output:string' +# 'short_version:string' +# 'release_version:string' +# 'build_date:string(yyyyMMdd)', +# 'build_timestamp:string (yyyyMMdd_hhmm)', +# 'platform: string', +# 'BUILD_NUMBER: string', +# 'WORKSPACE: string' +# +# --- Test Related: --- +# PR build, zip test related to test.zip +# Package build, zip package related to package.zip +# --- Artifactory Related: --- +# download artifactory: +# python3 ${WORKSPACE}/artifactory_utils.py --action=download_file --file=ARTIFACTORY_URL +# upload file to artifactory: +# python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=FILEPATTERN --server_path=SERVERPATH --server_repo=SERVER_REPO --with_pattern +# for example: python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=*.zip --server_path=windows/ --server_repo=ACCS_repo --with_pattern +# upload folder to artifactory +# python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=FILEPATTERN --server_path=SERVERPATH --server_repo=SERVER_REPO --with_folder +# for example: python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=*.zip --server_path=windows/ --server_repo=ACCS_repo --with_folder +# --- Input: ---- +# sourcePath: see jenkins console for details. +# WORKSPACE: ${WORKSPACE} +# --- Output: ---- +# pr: output test.zip to workspace dir +# others: Rename the zip package name yourself, But need copy it to workspace dir +################################## +export PATH=$PATH:/opt/homebrew/bin + +xcode_version=$(xcodebuild -version | grep Xcode | awk '{print $2}') +echo "Xcode Version: $xcode_version" +echo ios_direction: $ios_direction +echo Package_Publish: $Package_Publish +echo is_tag_fetch: $is_tag_fetch +echo arch: $arch +echo source_root: %source_root% +echo output: /tmp/jenkins/${project}_out +echo build_date: $build_date +echo build_time: $build_time +echo release_version: $release_version +echo short_version: $short_version +echo pwd: `pwd` +echo sdk_url: $sdk_url + +export https_proxy=10.10.114.55:1080 +export http_proxy=10.10.114.55:1080 +export all_proxy=10.10.114.55:1080 +export LANG=en_US.UTF-8 + +unzip_name=Agora_Native_SDK_for_iOS_FULL +zip_name=output.zip +sdk_url_flag=false +apiexample_cn_name=Shengwang_Native_SDK_for_iOS +apiexample_global_name=Agora_Native_SDK_for_iOS +global_dir=Global + +if [ -z "$sdk_url" -o "$sdk_url" = "none" ]; then + sdk_url_flag=false + echo "sdk_url is empty" + echo unzip_name: $unzip_name + mkdir -p ./$unzip_name/samples + cp -rf ./iOS/${ios_direction} ./$unzip_name/samples/${ios_direction} || exit 1 + ls -al ./$unzip_name/samples/${ios_direction}/ +else + sdk_url_flag=true + zip_name=${sdk_url##*/} + echo zip_name: $zip_name + curl -o $zip_name $sdk_url || exit 1 + 7za x ./$zip_name -y > log.txt + unzip_name=`ls -S -d */ | egrep 'Agora|Shengwang' | sed 's/\///g'` + echo unzip_name: $unzip_name + rm -rf ./$unzip_name/bin + rm -f ./$unzip_name/commits + rm -f ./$unzip_name/package_size_report.txt + + rm -f ./$unzip_name/.commits + rm -f ./$unzip_name/AgoraInfra_iOS.swift + rm -f ./$unzip_name/AgoraRtcEngine_iOS.podspec + rm -f ./$unzip_name/AgoraAudio_iOS.podspec + rm -f ./$unzip_name/Package.swift + mkdir -p ./$unzip_name/samples + cp -rf ./iOS/${ios_direction} ./$unzip_name/samples/${ios_direction} || exit 1 + ls -al ./$unzip_name/samples/${ios_direction}/ + mv ./$unzip_name/samples/${ios_direction}/sdk.podspec ./$unzip_name/ || exit 1 +fi + +python3 ./.github/ci/build/modify_podfile.py ./$unzip_name/samples/${ios_direction}/Podfile $sdk_url_flag || exit 1 + +echo "start compress" +7za a -tzip result.zip -r $unzip_name > log.txt +echo "start move to" +echo $WORKSPACE/with${ios_direction}_${BUILD_NUMBER}_$zip_name +mv result.zip $WORKSPACE/with${ios_direction}_${BUILD_NUMBER}_$zip_name + +if [ $compress_apiexample = true ]; then + sdk_version=$(grep "pod 'AgoraRtcEngine_iOS'" ./iOS/${ios_direction}/Podfile | sed -n "s/.*'\([0-9.]*\)'.*/\1/p") + echo "sdk_version: $sdk_version" + + cp -rf ./iOS/${ios_direction} $global_dir/ + + echo "start compress api example" + 7za a -tzip global_result.zip $global_dir + echo "complete compress api example" + echo "current path: `pwd`" + ls -al + global_des_path=$WORKSPACE/${apiexample_global_name}_${sdk_version}_${BUILD_NUMBER}_APIExample.zip + + echo "global_des_path: $global_des_path" + echo "Moving global_result.zip to $global_des_path" + mv global_result.zip $global_des_path + + ls -al $WORKSPACE/ +fi + +if [ $compile_project = true ]; then + cd ./$unzip_name/samples/${ios_direction} + ./cloud_build.sh || exit 1 + cd - +fi + diff --git a/.github/ci/build/build_ios_ipa.sh b/.github/ci/build/build_ios_ipa.sh new file mode 100755 index 000000000..e63a4f94f --- /dev/null +++ b/.github/ci/build/build_ios_ipa.sh @@ -0,0 +1,169 @@ +CURRENT_PATH=$PWD + +# 获取项目目录 +PROJECT_PATH="$( cd "$1" && pwd )" +IS_OBJECTIVE_C=false +if [ "$ios_direction" = "APIExample-OC" ]; then + IS_OBJECTIVE_C=true +fi + +cd ${PROJECT_PATH} && pod install + +if [ $? -eq 0 ]; then + echo "success" +else + echo "failed" + exit 1 +fi + +# 项目target名 +TARGET_NAME=$ios_direction + +KEYCENTER_PATH=${PROJECT_PATH}"/"${TARGET_NAME}"/Common/KeyCenter.swift" +if [ $IS_OBJECTIVE_C = true ]; then + KEYCENTER_PATH=${PROJECT_PATH}"/"${TARGET_NAME}"/Common/KeyCenter.m" +fi + +# 打包环境 +CONFIGURATION="Debug" + +#工程文件路径 +APP_PATH="${PROJECT_PATH}/${TARGET_NAME}.xcworkspace" + +#工程配置路径 +PBXPROJ_PATH="${PROJECT_PATH}/${TARGET_NAME}.xcodeproj/project.pbxproj" +echo PBXPROJ_PATH: $PBXPROJ_PATH + +# 主项目工程配置 +if [ $IS_OBJECTIVE_C = true ]; then + # Debug + /usr/libexec/PlistBuddy -c "Set :objects:E70ADE062A5D0050009947CF:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E70ADE062A5D0050009947CF:buildSettings:DEVELOPMENT_TEAM 'GM72UGLGZW'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E70ADE062A5D0050009947CF:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'App'" $PBXPROJ_PATH + # Release + /usr/libexec/PlistBuddy -c "Set :objects:E70ADE072A5D0050009947CF:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E70ADE072A5D0050009947CF:buildSettings:DEVELOPMENT_TEAM 'GM72UGLGZW'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E70ADE072A5D0050009947CF:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'App'" $PBXPROJ_PATH + + # 屏幕共享Extension + # Debug + /usr/libexec/PlistBuddy -c "Set :objects:E72F61D42A7256D500C963D2:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E72F61D42A7256D500C963D2:buildSettings:DEVELOPMENT_TEAM 'GM72UGLGZW'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E72F61D42A7256D500C963D2:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'App'" $PBXPROJ_PATH + # Release + /usr/libexec/PlistBuddy -c "Set :objects:E72F61D52A7256D500C963D2:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E72F61D52A7256D500C963D2:buildSettings:DEVELOPMENT_TEAM 'GM72UGLGZW'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E72F61D52A7256D500C963D2:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'App'" $PBXPROJ_PATH + + # SimpleFilter + # Debug + /usr/libexec/PlistBuddy -c "Set :objects:E7361F932A6E6E7100925BD6:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E7361F932A6E6E7100925BD6:buildSettings:DEVELOPMENT_TEAM ''" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E7361F932A6E6E7100925BD6:buildSettings:PROVISIONING_PROFILE_SPECIFIER ''" $PBXPROJ_PATH + # Release + /usr/libexec/PlistBuddy -c "Set :objects:E7361F942A6E6E7100925BD6:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E7361F942A6E6E7100925BD6:buildSettings:DEVELOPMENT_TEAM ''" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:E7361F942A6E6E7100925BD6:buildSettings:PROVISIONING_PROFILE_SPECIFIER ''" $PBXPROJ_PATH + + #修改build number + # Debug + /usr/libexec/PlistBuddy -c "Set :objects:E70ADE062A5D0050009947CF:buildSettings:CURRENT_PROJECT_VERSION ${BUILD_NUMBER}" $PBXPROJ_PATH + # Release + /usr/libexec/PlistBuddy -c "Set :objects:E70ADE072A5D0050009947CF:buildSettings:CURRENT_PROJECT_VERSION ${BUILD_NUMBER}" $PBXPROJ_PATH +else + # Debug + /usr/libexec/PlistBuddy -c "Set :objects:03D13BF72448758C00B599B3:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:03D13BF72448758C00B599B3:buildSettings:DEVELOPMENT_TEAM 'GM72UGLGZW'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:03D13BF72448758C00B599B3:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'App'" $PBXPROJ_PATH + # Release + /usr/libexec/PlistBuddy -c "Set :objects:03D13BF82448758C00B599B3:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:03D13BF82448758C00B599B3:buildSettings:DEVELOPMENT_TEAM 'GM72UGLGZW'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:03D13BF82448758C00B599B3:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'App'" $PBXPROJ_PATH + + # 屏幕共享Extension + # Debug + /usr/libexec/PlistBuddy -c "Set :objects:0339BEB825205B80007D4FDD:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:0339BEB825205B80007D4FDD:buildSettings:DEVELOPMENT_TEAM 'GM72UGLGZW'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:0339BEB825205B80007D4FDD:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'App'" $PBXPROJ_PATH + # Release + /usr/libexec/PlistBuddy -c "Set :objects:0339BEB925205B80007D4FDD:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:0339BEB925205B80007D4FDD:buildSettings:DEVELOPMENT_TEAM 'GM72UGLGZW'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:0339BEB925205B80007D4FDD:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'App'" $PBXPROJ_PATH + + # SimpleFilter + # Debug + /usr/libexec/PlistBuddy -c "Set :objects:8B10BE1726AFFFA6002E1373:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:8B10BE1726AFFFA6002E1373:buildSettings:DEVELOPMENT_TEAM ''" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:8B10BE1726AFFFA6002E1373:buildSettings:PROVISIONING_PROFILE_SPECIFIER ''" $PBXPROJ_PATH + # Release + /usr/libexec/PlistBuddy -c "Set :objects:8B10BE1826AFFFA6002E1373:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:8B10BE1826AFFFA6002E1373:buildSettings:DEVELOPMENT_TEAM ''" $PBXPROJ_PATH + /usr/libexec/PlistBuddy -c "Set :objects:8B10BE1826AFFFA6002E1373:buildSettings:PROVISIONING_PROFILE_SPECIFIER ''" $PBXPROJ_PATH + + #修改build number + # Debug + /usr/libexec/PlistBuddy -c "Set :objects:03D13BF72448758C00B599B3:buildSettings:CURRENT_PROJECT_VERSION ${BUILD_NUMBER}" $PBXPROJ_PATH + # Release + /usr/libexec/PlistBuddy -c "Set :objects:03D13BF82448758C00B599B3:buildSettings:CURRENT_PROJECT_VERSION ${BUILD_NUMBER}" $PBXPROJ_PATH +fi + +# 读取APPID环境变量 +echo AGORA_APP_ID:$APP_ID +echo $AGORA_APP_ID + +echo PROJECT_PATH: $PROJECT_PATH +echo TARGET_NAME: $TARGET_NAME +echo KEYCENTER_PATH: $KEYCENTER_PATH +echo APP_PATH: $APP_PATH + +#修改Keycenter文件 +python3 /tmp/jenkins/api-examples/.github/ci/build/modify_ios_keycenter.py $KEYCENTER_PATH 0 + +# Xcode clean +xcodebuild clean -workspace "${APP_PATH}" -configuration "${CONFIGURATION}" -scheme "${TARGET_NAME}" + +# 时间戳 +CURRENT_TIME=$(date "+%Y-%m-%d %H-%M-%S") + +# 归档路径 +ARCHIVE_PATH="${WORKSPACE}/${TARGET_NAME}_${BUILD_NUMBER}.xcarchive" +# 编译环境 + +# plist路径 +PLIST_PATH="${PROJECT_PATH}/ExportOptions.plist" + +echo PLIST_PATH: $PLIST_PATH + +# archive 这边使用的工作区间 也可以使用project +xcodebuild CODE_SIGN_STYLE="Manual" archive -workspace "${APP_PATH}" -scheme "${TARGET_NAME}" clean CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO -configuration "${CONFIGURATION}" -archivePath "${ARCHIVE_PATH}" -destination 'generic/platform=iOS' -quiet || exit + +cd ${WORKSPACE} + +# 压缩archive +7za a -tzip "${TARGET_NAME}_${BUILD_NUMBER}.xcarchive.zip" "${ARCHIVE_PATH}" + +# 签名 +# sh sign "${TARGET_NAME}_${BUILD_NUMBER}.xcarchive.zip" --type xcarchive --plist "${PLIST_PATH}" +sh export "${TARGET_NAME}_${BUILD_NUMBER}.xcarchive.zip" --plist "${PLIST_PATH}" + +SDK_VERSION=$(echo $sdk_url | cut -d "/" -f 5) +# 上传IPA +PAYLOAD_PATH="${TARGET_NAME}_SDK_${SDK_VERSION}_CI_${BUILD_NUMBER}_Payload" +mkdir "${PAYLOAD_PATH}" +# mv "${TARGET_NAME}_${BUILD_NUMBER}_iOS.ipa" "${PAYLOAD_PATH}" +mv "${TARGET_NAME}_${BUILD_NUMBER}.ipa" "${PAYLOAD_PATH}" + +7za a "${TARGET_NAME}_SDK_${SDK_VERSION}_CI_${BUILD_NUMBER}_IPA.zip" -r "${PAYLOAD_PATH}" +python3 artifactory_utils.py --action=upload_file --file="${TARGET_NAME}_SDK_${SDK_VERSION}_CI_${BUILD_NUMBER}_IPA.zip" --project + +# 删除IPA文件夹 +rm -rf ${TARGET_NAME}_SDK_${SDK_VERSION}_CI_${BUILD_NUMBER}.xcarchive +rm -rf *.zip +rm -rf ${PAYLOAD_PATH} + +#复原Keycenter文件 +python3 /tmp/jenkins/api-examples/.github/ci/build/modify_ios_keycenter.py $KEYCENTER_PATH 1 + + + + diff --git a/.github/ci/build/build_mac.groovy b/.github/ci/build/build_mac.groovy new file mode 100644 index 000000000..93923911b --- /dev/null +++ b/.github/ci/build/build_mac.groovy @@ -0,0 +1,53 @@ +// -*- mode: groovy -*- +// vim: set filetype=groovy : +@Library('agora-build-pipeline-library') _ +import groovy.transform.Field + +buildUtils = new agora.build.BuildUtils() + +compileConfig = [ + "sourceDir": "api-examples", + "non-publish": [ + "command": "./.github/ci/build/build_mac.sh", + "extraArgs": "", + ], + "publish": [ + "command": "./.github/ci/build/build_mac.sh", + "extraArgs": "", + ] +] + +def doBuild(buildVariables) { + type = params.Package_Publish ? "publish" : "non-publish" + command = compileConfig.get(type).command + preCommand = compileConfig.get(type).get("preCommand", "") + postCommand = compileConfig.get(type).get("postCommand", "") + extraArgs = compileConfig.get(type).extraArgs + extraArgs += " " + params.getOrDefault("extra_args", "") + commandConfig = [ + "command": command, + "sourceRoot": "${compileConfig.sourceDir}", + "extraArgs": extraArgs + ] + loadResources(["config.json", "artifactory_utils.py"]) + buildUtils.customBuild(commandConfig, preCommand, postCommand) +} + +def doPublish(buildVariables) { + if (!params.Package_Publish) { + return + } + (shortVersion, releaseVersion) = buildUtils.getBranchVersion() + def archiveInfos = [ + [ + "type": "ARTIFACTORY", + "archivePattern": "*.zip", + "serverPath": "ApiExample/${shortVersion}/${buildVariables.buildDate}/${env.platform}", + "serverRepo": "SDK_repo" // ATTENTIONS: Update the artifactoryRepo if needed. + ] + ] + archive.archiveFiles(archiveInfos) + sh "rm -rf *.zip || true" +} + +pipelineLoad(this, "ApiExample", "build", "mac", "RTC-Sample") \ No newline at end of file diff --git a/.github/ci/build/build_mac.sh b/.github/ci/build/build_mac.sh new file mode 100644 index 000000000..c45a0d365 --- /dev/null +++ b/.github/ci/build/build_mac.sh @@ -0,0 +1,128 @@ +################################## +# --- Guidelines: --- +# +# Common Environment Variable: +# 'Package_Publish:boolean:true', +# 'Clean_Clone:boolean:false', +# 'is_tag_fetch:boolean:false', +# 'is_offical_build:boolean:false', +# 'repo:string', +# 'base:string', +# 'arch:string' +# 'output:string' +# 'short_version:string' +# 'release_version:string' +# 'build_date:string(yyyyMMdd)', +# 'build_timestamp:string (yyyyMMdd_hhmm)', +# 'platform: string', +# 'BUILD_NUMBER: string', +# 'WORKSPACE: string' +# +# --- Test Related: --- +# PR build, zip test related to test.zip +# Package build, zip package related to package.zip +# --- Artifactory Related: --- +# download artifactory: +# python3 ${WORKSPACE}/artifactory_utils.py --action=download_file --file=ARTIFACTORY_URL +# upload file to artifactory: +# python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=FILEPATTERN --server_path=SERVERPATH --server_repo=SERVER_REPO --with_pattern +# for example: python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=*.zip --server_path=windows/ --server_repo=ACCS_repo --with_pattern +# upload folder to artifactory +# python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=FILEPATTERN --server_path=SERVERPATH --server_repo=SERVER_REPO --with_folder +# for example: python3 ${WORKSPACE}/artifactory_utils.py --action=upload_file --file=*.zip --server_path=windows/ --server_repo=ACCS_repo --with_folder +# --- Input: ---- +# sourcePath: see jenkins console for details. +# WORKSPACE: ${WORKSPACE} +# --- Output: ---- +# pr: output test.zip to workspace dir +# others: Rename the zip package name yourself, But need copy it to workspace dir +################################## +export PATH=$PATH:/opt/homebrew/bin + +echo compile_project:$compile_project +echo Package_Publish: $Package_Publish +echo is_tag_fetch: $is_tag_fetch +echo arch: $arch +echo source_root: %source_root% +echo output: /tmp/jenkins/${project}_out +echo build_date: $build_date +echo build_time: $build_time +echo release_version: $release_version +echo short_version: $short_version +echo pwd: `pwd` +echo sdk_url: $sdk_url + +export https_proxy=10.10.114.55:1080 +export http_proxy=10.10.114.55:1080 +export all_proxy=10.10.114.55:1080 +export LANG=en_US.UTF-8 + +unzip_name=Agora_Native_SDK_for_iOS_FULL +zip_name=output.zip +sdk_url_flag=false +apiexample_cn_name=Shengwang_Native_SDK_for_Mac +apiexample_global_name=Agora_Native_SDK_for_Mac +cn_dir=CN +global_dir=Global + +echo zip_name: $zip_name +if [ -z "$sdk_url" -o "$sdk_url" = "none" ]; then + sdk_url_flag=false + echo "sdk_url is empty" + echo unzip_name: $unzip_name + mkdir -p ./$unzip_name/samples + cp -rf ./macOS ./$unzip_name/samples/APIExample || exit 1 + ls -al ./$unzip_name/samples/API-Example/ +else + sdk_url_flag=true + zip_name=${sdk_url##*/} + echo unzip_name: $unzip_name + curl -o $zip_name $sdk_url || exit 1 + 7za x ./$zip_name -y > log.txt + unzip_name=`ls -S -d */ | egrep 'Agora|Shengwang' | sed 's/\///g'` + echo unzip_name: $unzip_name + + rm -rf ./$unzip_name/bin + rm -f ./$unzip_name/commits + rm -f ./$unzip_name/package_size_report.txt + rm -f ./$unzip_name/.commits + rm -f ./$unzip_name/AgoraInfra_macOS.swift + rm -f ./$unzip_name/AgoraRtcEngine_macOS.podspec + rm -f ./$unzip_name/Package.swift + + mkdir ./$unzip_name/samples + cp -rf ./macOS ./$unzip_name/samples/APIExample || exit 1 + ls -al ./$unzip_name/samples/API-Example/ + mv ./$unzip_name/samples/APIExample/sdk.podspec ./$unzip_name/ +fi + +python3 ./.github/ci/build/modify_podfile.py ./$unzip_name/samples/APIExample/Podfile $sdk_url_flag + +echo "start compress" +7za a -tzip result.zip -r $unzip_name > log.txt +echo "start move to" +echo $WORKSPACE/with${BUILD_NUMBER}_$zip_name +mv result.zip $WORKSPACE/with_${BUILD_NUMBER}_$zip_name + +if [ $compress_apiexample = true ]; then + sdk_version=$(grep "pod 'AgoraRtcEngine_macOS'" ./macOS/Podfile | sed -n "s/.*'\([0-9.]*\)'.*/\1/p") + echo "sdk_version: $sdk_version" + + cp -rf ./macOS $global_dir/ + + echo "start compress api example" + 7za a -tzip global_result.zip $global_dir + echo "complete compress api example" + echo "current path: `pwd`" + ls -al + global_des_path=$WORKSPACE/${apiexample_global_name}_${sdk_version}_${BUILD_NUMBER}_APIExample.zip + + echo "global_des_path: $global_des_path" + echo "Moving global_result.zip to $global_des_path" + mv global_result.zip $global_des_path + + ls -al $WORKSPACE/ +fi + + + diff --git a/.github/ci/build/build_mac_ipa.sh b/.github/ci/build/build_mac_ipa.sh new file mode 100755 index 000000000..7c295439c --- /dev/null +++ b/.github/ci/build/build_mac_ipa.sh @@ -0,0 +1,108 @@ +CURRENT_PATH=$PWD + +# 获取项目目录 +PROJECT_PATH="$( cd "$1" && pwd )" + +cd ${PROJECT_PATH} && pod install +if [ $? -eq 0 ]; then + echo " pod install success" +else + echo " pod install failed" + exit 1 +fi + +# 项目target名 +TARGET_NAME=${PROJECT_PATH##*/} + +KEYCENTER_PATH=${PROJECT_PATH}"/"${TARGET_NAME}"/Common/KeyCenter.swift" + +# 打包环境 +CONFIGURATION=Release + +#工程文件路径 +APP_PATH="${PROJECT_PATH}/${TARGET_NAME}.xcworkspace" + +#工程配置路径 +PBXPROJ_PATH="${PROJECT_PATH}/${TARGET_NAME}.xcodeproj/project.pbxproj" +echo PBXPROJ_PATH: $PBXPROJ_PATH + +# 主项目工程配置 +# Debug +/usr/libexec/PlistBuddy -c "Set :objects:03896D5324F8A011008593CD:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH +/usr/libexec/PlistBuddy -c "Set :objects:03896D5324F8A011008593CD:buildSettings:CODE_SIGN_IDENTITY 'Developer ID Application'" $PBXPROJ_PATH +/usr/libexec/PlistBuddy -c "Set :objects:03896D5324F8A011008593CD:buildSettings:DEVELOPMENT_TEAM 'YS397FG5PA'" $PBXPROJ_PATH +/usr/libexec/PlistBuddy -c "Set :objects:03896D5324F8A011008593CD:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'apiexamplemac'" $PBXPROJ_PATH +# Release +/usr/libexec/PlistBuddy -c "Set :objects:03896D5424F8A011008593CD:buildSettings:CODE_SIGN_STYLE 'Manual'" $PBXPROJ_PATH +/usr/libexec/PlistBuddy -c "Set :objects:03896D5424F8A011008593CD:buildSettings:CODE_SIGN_IDENTITY 'Developer ID Application'" $PBXPROJ_PATH +/usr/libexec/PlistBuddy -c "Set :objects:03896D5424F8A011008593CD:buildSettings:DEVELOPMENT_TEAM 'YS397FG5PA'" $PBXPROJ_PATH +/usr/libexec/PlistBuddy -c "Set :objects:03896D5424F8A011008593CD:buildSettings:PROVISIONING_PROFILE_SPECIFIER 'apiexamplemac'" $PBXPROJ_PATH + +#修改build number +# Debug +/usr/libexec/PlistBuddy -c "Set :objects:03896D5324F8A011008593CD:buildSettings:CURRENT_PROJECT_VERSION ${BUILD_NUMBER}" $PBXPROJ_PATH +# Release +/usr/libexec/PlistBuddy -c "Set :objects:03896D5424F8A011008593CD:buildSettings:CURRENT_PROJECT_VERSION ${BUILD_NUMBER}" $PBXPROJ_PATH + +# 读取APPID环境变量 +echo AGORA_APP_ID:$APP_ID +echo $AGORA_APP_ID + +echo PROJECT_PATH: $PROJECT_PATH +echo TARGET_NAME: $TARGET_NAME +echo KEYCENTER_PATH: $KEYCENTER_PATH +echo APP_PATH: $APP_PATH + +#修改Keycenter文件 + +python3 /tmp/jenkins/api-examples/.github/ci/build/modify_ios_keycenter.py $KEYCENTER_PATH 0 +if [ $? -eq 0 ]; then + echo "修改Keycenter文件 success" +else + echo "修改Keycenter文件 failed" + exit 1 +fi +# Xcode clean +xcodebuild clean -workspace "${APP_PATH}" -configuration "${CONFIGURATION}" -scheme "${TARGET_NAME}" + +# 时间戳 +CURRENT_TIME=$(date "+%Y-%m-%d %H-%M-%S") + +SDK_VERSION=$(echo $sdk_url | cut -d "/" -f 5) + +# 归档路径 +ARCHIVE_PATH="${WORKSPACE}/${TARGET_NAME}_${BUILD_NUMBER}.xcarchive" +# 编译环境 + +# plist路径 +PLIST_PATH="${PROJECT_PATH}/ExportOptions.plist" + +echo PLIST_PATH: $PLIST_PATH + +# archive 这边使用的工作区间 也可以使用project +xcodebuild archive -workspace "${APP_PATH}" -scheme "${TARGET_NAME}" -configuration "${CONFIGURATION}" -archivePath "${ARCHIVE_PATH}" + +cd ${WORKSPACE} + +# 压缩archive +7za a -slp "${TARGET_NAME}_${BUILD_NUMBER}.xcarchive.zip" "${ARCHIVE_PATH}" + +# 签名 +sh sign "${WORKSPACE}/${TARGET_NAME}_${BUILD_NUMBER}.xcarchive.zip" --type xcarchive --plist "${PLIST_PATH}" --application macApp + +# 重命名 +cp "${TARGET_NAME}_${BUILD_NUMBER}.app.zip" "${TARGET_NAME}_SDK_${SDK_VERSION}_CI_${BUILD_NUMBER}.app.zip" + +# 上传IPA +python3 artifactory_utils.py --action=upload_file --file="${TARGET_NAME}_SDK_${SDK_VERSION}_CI_${BUILD_NUMBER}.app.zip" --project + +# 删除archive文件 +rm -rf ${TARGET_NAME}_${BUILD_NUMBER}.xcarchive +rm -rf *.zip + +#复原Keycenter文件 +python3 /tmp/jenkins/api-examples/.github/ci/build/modify_ios_keycenter.py $KEYCENTER_PATH 1 + + + + diff --git a/.github/ci/build/build_windows.bat b/.github/ci/build/build_windows.bat new file mode 100644 index 000000000..2d0f20a69 --- /dev/null +++ b/.github/ci/build/build_windows.bat @@ -0,0 +1,99 @@ +REM ################################## +REM --- Guidelines: --- +REM +REM Common Environment Variable: +REM 'Package_Publish:boolean:true', +REM 'Clean_Clone:boolean:false', +REM 'is_tag_fetch:boolean:false', +REM 'is_offical_build:boolean:false', +REM 'repo:string', +REM 'base:string', +REM 'arch:string' +REM 'output:string' +REM 'short_version:string' +REM 'release_version:string' +REM 'build_date:string(yyyyMMdd)', +REM 'build_timestamp:string (yyyyMMdd_hhmm)', +REM 'platform: string', +REM 'BUILD_NUMBER: string', +REM 'WORKSPACE: string' +REM +REM --- Test Related: --- +REM For PR build, zip test related to test.zip +REM For Package build, zip package related to package.zip +REM --- Artifactory Related: --- +REM download artifactory: +REM python %WORKSPACE%\\artifactory_utils.py --action=download_file --file=ARTIFACTORY_URL +REM upload file to artifactory: +REM python %WORKSPACE%\\artifactory_utils.py --action=upload_file --file=FILEPATTERN --server_path=SERVERPATH --server_repo=SERVER_REPO --with_pattern +REM for example: python %WORKSPACE%\\artifactory_utils.py --action=upload_file --file=*.zip --server_path=windows/ --server_repo=ACCS_repo --with_pattern +REM upload folder to artifactory +REM python %WORKSPACE%\\artifactory_utils.py --action=upload_file --file=FILEPATTERN --server_path=SERVERPATH --server_repo=SERVER_REPO --with_folder +REM for example: python %WORKSPACE%\\artifactory_utils.py --action=upload_file --file=publish --server_path=windows/ --server_repo=ACCS_repo --with_folder +REM --- Input: ---- +REM sourcePath: see jenkins console for details. +REM WORKSPACE: %WORKSPACE% +REM --- Output: ---- +REM pr: output test.zip to workspace dir +REM others: Rename the zip package name yourself, But need copy it to workspace dir +REM ################################## + +echo compile_project: %compile_project% +echo Package_Publish: %Package_Publish% +echo is_tag_fetch: %is_tag_fetch% +echo arch: %arch% +echo source_root: %source_root% +echo output: C:\\tmp\\%project%_out +echo build_date: %build_date% +echo build_time: %build_time% +echo release_version: %release_version% +echo short_version: %short_version% +echo pwd: %cd% +echo sdk_url: %sdk_url% + + +set zip_name=Agora_Native_SDK_for_Windows_FULL_DEFAULT.zip +if %compile_project% EQU false goto SKIP_DOWNLOAD +set zip_name=%sdk_url% +:LOOP +for /f "tokens=1* delims=" %%a in ("%zip_name%") do ( + set zip_name=%%a + set part2=%%b +) +if "%part2%" EQU "" goto END +set zip_name=%part2% +goto LOOP +:END +echo on +echo zip_name: %zip_name% + +dir + +curl %sdk_url% -o %zip_name% +REM python %WORKSPACE%\\artifactory_utils.py --action=download_file --file=%sdk_url% +7z x ./%zip_name% -y +dir +rmdir /S /Q Agora_Native_SDK_for_Windows_FULL\demo +del /F /Q Agora_Native_SDK_for_Windows_FULL\commits +del /F /Q Agora_Native_SDK_for_Windows_FULL\package_size_report.txt +:SKIP_DOWNLOAD + + +mkdir Agora_Native_SDK_for_Windows_FULL\samples +mkdir Agora_Native_SDK_for_Windows_FULL\samples\API-example +rmdir /S /Q windows\cicd +del /F /Q windows\APIExample\ci.py +xcopy /Y /E windows\APIExample Agora_Native_SDK_for_Windows_FULL\samples\API-example +xcopy /Y /E windows\README.md Agora_Native_SDK_for_Windows_FULL\samples\API-example +xcopy /Y /E windows\README.zh.md Agora_Native_SDK_for_Windows_FULL\samples\API-example +rmdir /S /Q Agora_Native_SDK_for_Windows_FULL\samples\API-example\APIExample\APIExample +dir Agora_Native_SDK_for_Windows_FULL\samples\API-example\APIExample +7z a -tzip result.zip -r Agora_Native_SDK_for_Windows_FULL +copy result.zip %WORKSPACE%\\withAPIExample_%BUILD_NUMBER%_%zip_name% +del /F result.zip +del /F %WORKSPACE%\\%zip_name% + +if %compile_project% EQU false goto FINAL +cd Agora_Native_SDK_for_Windows_FULL\samples\API-example +call cloud_build.bat +:FINAL diff --git a/.github/ci/build/build_windows.groovy b/.github/ci/build/build_windows.groovy new file mode 100644 index 000000000..1f7463e05 --- /dev/null +++ b/.github/ci/build/build_windows.groovy @@ -0,0 +1,53 @@ +// -*- mode: groovy -*- +// vim: set filetype=groovy : +@Library('agora-build-pipeline-library') _ +import groovy.transform.Field + +buildUtils = new agora.build.BuildUtils() + +compileConfig = [ + "sourceDir": "api-examples", + "non-publish": [ + "command": "./.github/ci/build/build_windows.bat", + "extraArgs": "", + ], + "publish": [ + "command": "./.github/ci/build/build_windows.bat", + "extraArgs": "", + ] +] + +def doBuild(buildVariables) { + type = params.Package_Publish ? "publish" : "non-publish" + command = compileConfig.get(type).command + preCommand = compileConfig.get(type).get("preCommand", "") + postCommand = compileConfig.get(type).get("postCommand", "") + extraArgs = compileConfig.get(type).extraArgs + extraArgs += " " + params.getOrDefault("extra_args", "") + commandConfig = [ + "command": command, + "sourceRoot": "${compileConfig.sourceDir}", + "extraArgs": extraArgs + ] + loadResources(["config.json", "artifactory_utils.py"]) + buildUtils.customBuild(commandConfig, preCommand, postCommand) +} + +def doPublish(buildVariables) { + if (!params.Package_Publish) { + return + } + (shortVersion, releaseVersion) = buildUtils.getBranchVersion() + def archiveInfos = [ + [ + "type": "ARTIFACTORY", + "archivePattern": "*.zip", + "serverPath": "ApiExample/${shortVersion}/${buildVariables.buildDate}/${env.platform}", + "serverRepo": "SDK_repo" // ATTENTIONS: Update the artifactoryRepo if needed. + ] + ] + archive.archiveFiles(archiveInfos) + bat "del /f /Q *.zip" +} + +pipelineLoad(this, "ApiExample", "build", "windows", "apiexample_windows") \ No newline at end of file diff --git a/.github/ci/build/modify_ios_keycenter.py b/.github/ci/build/modify_ios_keycenter.py new file mode 100644 index 000000000..d80c8c23d --- /dev/null +++ b/.github/ci/build/modify_ios_keycenter.py @@ -0,0 +1,51 @@ +import os, sys + +def modfiy(path, isReset): + appId = os.environ.get('APP_ID') + faceCaptureLicense = os.environ.get('FACE_CAPTURE_LICENSE') + with open(path, 'r', encoding='utf-8') as file: + contents = [] + for num, line in enumerate(file): + line = line.strip() + if "static let AppId" in line: + if isReset: + line = "static let AppId: String = <#YOUR APPID#>" + else: + line = f'static let AppId: String = "{appId}"' + elif "static let Certificate" in line: + if isReset: + line = "static let Certificate: String? = <#YOUR Certificate#>" + else: + line = 'static let Certificate: String? = nil' + elif "static let FaceCaptureLicense" in line: + if isReset: + line = "static let FaceCaptureLicense: String? = nil" + else: + line = f'static let FaceCaptureLicense: String? = "{faceCaptureLicense}"' + elif "static NSString * const APPID" in line: + if isReset: + line = "static NSString * const APPID = <#YOUR APPID#>" + else: + line = f'static NSString * const APPID = @"{appId}";' + elif "static NSString * const Certificate" in line: + if isReset: + line = "static NSString * const Certificate = <#YOUR Certificate#>" + else: + line = 'static NSString * const Certificate = nil;' + contents.append(line) + file.close() + + with open(path, 'w', encoding='utf-8') as fw: + for content in contents: + if "{" in content or "}" in content: + fw.write(content + "\n") + else: + fw.write('\t'+content + "\n") + fw.close() + + +if __name__ == '__main__': + print(f'argv === {sys.argv[1:]}') + path = sys.argv[1:][0] + isReset = eval(sys.argv[1:][1]) + modfiy(path.strip(), isReset) diff --git a/.github/ci/build/modify_podfile.py b/.github/ci/build/modify_podfile.py new file mode 100644 index 000000000..787d9c7a8 --- /dev/null +++ b/.github/ci/build/modify_podfile.py @@ -0,0 +1,28 @@ +import os, sys + +def modfiy(path, sdk_flag): + with open(path, 'r', encoding='utf-8') as file: + contents = [] + for num, line in enumerate(file): + if "pod 'Agora" in line: + if sdk_flag: + line = '\t'+"pod 'sdk', :path => '../../sdk.podspec'" + "\n" + elif "pod 'sdk" in line: + if sdk_flag: + line = "" + elif 'sh .download_script' in line: + line = line.replace('true', 'false') + "\n" + contents.append(line) + file.close() + + with open(path, 'w', encoding='utf-8') as fw: + for content in contents: + fw.write(content) + fw.close() + + + +if __name__ == '__main__': + path = sys.argv[1] + sdk_url_is_not_empty = sys.argv[2].lower() == 'true' + modfiy(path.strip(), sdk_url_is_not_empty) diff --git a/.gitignore b/.gitignore index 2785a6808..606b91bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ xcuserdata .DS_Store AgoraRtcKit.framework +*/libs +/sdk diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..856063fee --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,145 @@ +title = "gitleaks config" + +# Gitleaks rules are defined by regular expressions and entropy ranges. +# Some secrets have unique signatures which make detecting those secrets easy. +# Examples of those secrets would be GitLab Personal Access Tokens, AWS keys, and GitHub Access Tokens. +# All these examples have defined prefixes like `glpat`, `AKIA`, `ghp_`, etc. +# +# Other secrets might just be a hash which means we need to write more complex rules to verify +# that what we are matching is a secret. +# +# Here is an example of a semi-generic secret +# +# discord_client_secret = "8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ" +# +# We can write a regular expression to capture the variable name (identifier), +# the assignment symbol (like '=' or ':='), and finally the actual secret. +# The structure of a rule to match this example secret is below: +# +# Beginning string +# quotation +# │ End string quotation +# │ │ +# ▼ ▼ +# (?i)(discord[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9=_\-]{32})['\"] +# +# ▲ ▲ ▲ +# │ │ │ +# │ │ │ +# identifier assignment symbol +# Secret +# + +[extend] +useDefault = true + +[[rules]] +id = "chinese-characters" +description = "Detecting Chinese characters" +regex = '''[\p{Han}]+''' +tags = ["chinese"] + +[[rules]] +id = "chinese-comments" +description = "Detect Chinese comments" +regex = '''(//|#|/\*|\*).*[\p{Han}]+''' +tags = ["chinese", "comments"] + +[[rules]] +id = "agora-app-id-pattern" +description = "Agora App ID Pattern" +regex = '''(?i)(AGORA_APP_ID|AG_APP_ID|static\s+let\s+AppId:\s+String|static\s+let\s+AG_APP_ID:\s+String)(\s*=\s*)(?:['"]?([0-9a-zA-Z]{1,32})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "agora-app-certificate-pattern" +description = "Agora App Certificate Pattern" +regex = '''(?i)(AGORA_APP_CERTIFICATE|AG_APP_CERTIFICATE|static\s+let\s+Certificate:\s+String\?|static\s+let\s+AG_APP_CERTIFICATE:\s+String)(\s*=\s*)(?:['"]?([0-9a-zA-Z]{1,32})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "basic-auth-key" +description = "Basic Auth Key" +regex = '''(?i)(BASIC_AUTH_KEY|static\s+let\s+BASIC_AUTH_KEY:\s+String)(\s*=\s*)(?:['"]?([0-9a-zA-Z\-_=]{1,64})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "basic-auth-secret" +description = "Basic Auth Secret" +regex = '''(?i)(BASIC_AUTH_SECRET|static\s+let\s+BASIC_AUTH_SECRET:\s+String)(\s*=\s*)(?:['"]?([0-9a-zA-Z\-_=]{1,64})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "llm-api-key" +description = "LLM API Key" +regex = '''(?i)(LLM_API_KEY|static\s+let\s+LLM_API_KEY:\s+String)(\s*=\s*)(?:['"]?([a-zA-Z0-9\-_]{1,100})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "llm-url-with-key" +description = "LLM URL with API Key" +regex = '''(?i)(LLM_URL|static\s+let\s+LLM_URL:\s+String)(\s*=\s*)['"]?(https?:\/\/[^\s'"]+?(?:api_key|apikey|token|secret|password|key)=[^\s'"&]+)['"]?''' +secretGroup = 3 + +[[rules]] +id = "tts-key-pattern" +description = "TTS API Key in Parameters" +regex = '''(?i)(TTS_PARAMS|static\s+let\s+TTS_PARAMS)(\s*=\s*)(?:['"]?.*["']key["']:\s*["']([a-zA-Z0-9\-_]{1,64})["'].*['"]?)''' +secretGroup = 3 + +[[rules]] +id = "im-app-key-pattern" +description = "IM App Key Pattern" +regex = '''(?i)(IM_APP_KEY|static\s+var\s+IMAppKey:\s+String\?)(\s*=\s*)(?:['"]?([0-9a-zA-Z#]{1,64})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "im-client-id-pattern" +description = "IM Client ID Pattern" +regex = '''(?i)(IM_APP_CLIENT_ID|static\s+var\s+IMClientId:\s+String\?)(\s*=\s*)(?:['"]?([0-9a-zA-Z]{1,64})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "im-client-secret-pattern" +description = "IM Client Secret Pattern" +regex = '''(?i)(IM_APP_CLIENT_SECRET|static\s+var\s+IMClientSecret:\s+String\?)(\s*=\s*)(?:['"]?([0-9a-zA-Z\-_=]{1,64})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "restful-api-key-pattern" +description = "Restful API Key Pattern" +regex = '''(?i)(RESTFUL_API_KEY|static\s+let\s+RestfulApiKey:\s+String\?)(\s*=\s*)(?:['"]?([0-9a-zA-Z\-_=]{1,64})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "restful-api-secret-pattern" +description = "Restful API Secret Pattern" +regex = '''(?i)(RESTFUL_API_SECRET|static\s+let\s+RestfulApiSecret:\s+String\?)(\s*=\s*)(?:['"]?([0-9a-zA-Z\-_=]{1,64})['"]?)''' +secretGroup = 3 + +[[rules]] +id = "openai-api-key" +description = "OpenAI API Key Pattern" +regex = '''(?i)sk-(live|test|proj)-[0-9a-zA-Z]{24,48}''' + +[allowlist] +description = "global allow lists" +regexes = ['''219-09-9999''', '''078-05-1120''', '''(9[0-9]{2}|666)-\d{2}-\d{4}'''] +paths = [ + '''gitleaks.toml''', + '''(.*?)(jpg|gif|doc|pdf|bin|svg|socket)$''', + '''(go.mod|go.sum)$''', + '''iOS/.*\.strings''', + '''iOS/.*\.lproj/.*''', + '''iOS/Scenes/ConvoAI/.*''', + '''.*\.strings$''', + '''.*\.strings''', + '''.*\/zh-Hans\.lproj\/.*''', + '''.*\/zh-Hant\.lproj\/.*''', + '''.*\/zh\.lproj\/.*''', + '''iOS/Pods/.*''', + '''.*\.bundle''', + '''README\.md''', + '''.*\.md''', + '''Android/.*/res/values(-zh)?/(strings|arrays)\.xml''' +] \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..8cca4020e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: local + hooks: + - id: gitleaks + name: Detect hardcoded secrets + description: Ensures no secrets are committed + entry: gitleaks protect + args: ["--config=.gitleaks.toml", "--staged", "--verbose"] + language: system + pass_filenames: false + stages: [pre-commit] + + - id: check-commit-message + name: Check commit message for Chinese characters + description: Ensures commit messages do not contain Chinese characters + entry: .git-hooks/check-commit-message.sh + language: script + stages: [commit-msg] diff --git a/Android/APIExample-Audio/.gitignore b/Android/APIExample-Audio/.gitignore new file mode 100644 index 000000000..b0f139bf0 --- /dev/null +++ b/Android/APIExample-Audio/.gitignore @@ -0,0 +1,24 @@ +*.so +*.iml +.gradle +/local.properties +/.idea +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +androidTest/ +Test/ + +# sdk files +*.so +agora-rtc-sdk.jar +AgoraScreenShareExtension.aar +/release \ No newline at end of file diff --git a/Android/APIExample-Audio/README.md b/Android/APIExample-Audio/README.md new file mode 100644 index 000000000..6cbdf868a --- /dev/null +++ b/Android/APIExample-Audio/README.md @@ -0,0 +1,53 @@ +# API Example Android + +*English | [中文](README.zh.md)* + +This project presents you a set of API examples to help you understand how to use Agora APIs. + +## Prerequisites + +- Android Studio 3.0+ +- Physical Android device +- Android simulator is supported + +## Quick Start + +This section shows you how to prepare, build, and run the sample application. + +### Obtain an App Id + +To build and run the sample application, get an App Id: + +1. Create a developer account at [agora.io](https://dashboard.agora.io/signin/). Once you finish the signup process, you will be redirected to the Dashboard. +2. Navigate in the Dashboard tree on the left to **Projects** > **Project List**. +3. Save the **App Id** from the Dashboard for later use. +4. Save the **App Certificate** from the Dashboard for later use. +5. Generate a temp **Access Token** (valid for 24 hours) from dashboard page with given channel name, save for later use. + +6. Open `Android/APIExample` and edit the `app/src/main/res/values/string-config.xml` file. Update `YOUR APP ID` with your App Id, update `YOUR APP CERTIFICATE` with the main app certificate from dashboard, and update `YOUR ACCESS TOKEN` with the temp Access Token generated from dashboard. Note you can leave the token and certificate variable `null` if your project has not turned on security token. + + ``` + YOUR APP ID + // assign token and certificate to null if you have not enabled app certificate + YOUR APP CERTIFICATE + // assign token and certificate to null if you have not enabled app certificate or you have set the certificate above. + // PS:It is unsafe to place the App Certificate on the client side, it is recommended to place it on the server side to ensure that the App Certificate is not leaked. + YOUR ACCESS TOKEN + ``` + +You are all set. Now connect your Android device and run the project. + + +## Contact Us + +- For potential issues, take a look at our [FAQ](https://docs.agora.io/en/faq) first +- Dive into [Agora SDK Samples](https://github.com/AgoraIO) to see more tutorials +- Take a look at [Agora Use Case](https://github.com/AgoraIO-usecase) for more complicated real use case +- Repositories managed by developer communities can be found at [Agora Community](https://github.com/AgoraIO-Community) +- You can find full API documentation at [Document Center](https://docs.agora.io/en/) +- If you encounter problems during integration, you can ask question in [Stack Overflow](https://stackoverflow.com/questions/tagged/agora.io) +- You can file bugs about this sample at [issue](https://github.com/AgoraIO/API-Examples/issues) + +## License + +The MIT License (MIT) diff --git a/Android/APIExample-Audio/README.zh.md b/Android/APIExample-Audio/README.zh.md new file mode 100644 index 000000000..6828cc087 --- /dev/null +++ b/Android/APIExample-Audio/README.zh.md @@ -0,0 +1,54 @@ +# API Example Android + +*[English](README.md) | 中文* + +这个开源示例项目演示了Agora视频SDK的部分API使用示例,以帮助开发者更好地理解和运用Agora视频SDK的API。 + +## 环境准备 + +- Android Studio 3.0+ +- Android 真机设备 +- 支持模拟器 + +## 运行示例程序 + +这个段落主要讲解了如何编译和运行实例程序。 + +### 创建Agora账号并获取AppId + +在编译和启动实例程序前,你需要首先获取一个可用的App Id: + +1. 在[agora.io](https://dashboard.agora.io/signin/)创建一个开发者账号 +2. 前往后台页面,点击左部导航栏的 **项目 > 项目列表** 菜单 +3. 复制后台的 **App Id** 并备注,稍后启动应用时会用到它 +4. 复制后台的 **App Certificate** 并备注,稍后启动应用时会用到它 +5. 在项目页面生成临时 **Access Token** (24小时内有效)并备注,注意生成的Token只能适用于对应的频道名。 + +6. 打开 `Android/APIExample` 并编辑 `app/src/main/res/values/string-config.xml`,将你的 AppID 、App主证书、 临时Token 分别替换到 `Your App Id` 、 `YOUR ACCESS TOKEN` 和 `YOUR APP CERTIFICATE` + + ``` + YOUR APP ID + // 如果你没有打开Token功能,certificate可以直接不填 + YOUR APP CERTIFICATE + // 如果你没有打开Token功能或者已经配置了certificate,token可以直接不填 + // 注意:App证书放在客户端不安全,推荐放在服务端以确保 App 证书不会泄露。 + YOUR ACCESS TOKEN + + ``` + +然后你就可以编译并运行项目了。 + +## 联系我们 + +- 如果你遇到了困难,可以先参阅 [常见问题](https://docs.agora.io/cn/faq) +- 如果你想了解更多官方示例,可以参考 [官方SDK示例](https://github.com/AgoraIO) +- 如果你想了解声网SDK在复杂场景下的应用,可以参考 [官方场景案例](https://github.com/AgoraIO-usecase) +- 如果你想了解声网的一些社区开发者维护的项目,可以查看 [社区](https://github.com/AgoraIO-Community) +- 完整的 API 文档见 [文档中心](https://docs.agora.io/cn/) +- 若遇到问题需要开发者帮助,你可以到 [开发者社区](https://rtcdeveloper.com/) 提问 +- 如果需要售后技术支持, 你可以在 [Agora Dashboard](https://dashboard.agora.io) 提交工单 +- 如果发现了示例代码的 bug,欢迎提交 [issue](https://github.com/AgoraIO/API-Examples/issues) + +## 代码许可 + +The MIT License (MIT) diff --git a/Android/APIExample-Audio/app/.gitignore b/Android/APIExample-Audio/app/.gitignore new file mode 100644 index 000000000..da62f148c --- /dev/null +++ b/Android/APIExample-Audio/app/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +/local.properties +/.idea +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +gradle +gradlew +gradlew.bat +.externalNativeBuild +.cxx +androidTest/ +Test/ diff --git a/Android/APIExample-Audio/app/build.gradle b/Android/APIExample-Audio/app/build.gradle new file mode 100644 index 000000000..02c61dd68 --- /dev/null +++ b/Android/APIExample-Audio/app/build.gradle @@ -0,0 +1,122 @@ +apply plugin: 'com.android.application' +apply plugin: 'org.jetbrains.kotlin.android' + +def sdkVersionFile = file("../gradle.properties") +def properties = new Properties() +sdkVersionFile.withInputStream { stream -> + properties.load(stream) +} +def agoraSdkVersion = properties.getProperty("rtc_sdk_version") +println("${rootProject.project.name} agoraSdkVersion: ${agoraSdkVersion}") +def localSdkPath= "${rootProject.projectDir.absolutePath}/../../sdk" + +android { + namespace "io.agora.api.example" + compileSdk 35 + + defaultConfig { + applicationId "io.agora.api.example.audio" + minSdkVersion 24 + targetSdkVersion 35 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + myConfig { + storeFile new File(rootProject.rootDir.absolutePath + "/keystore.key") + storePassword "965606" + keyAlias "agora" + keyPassword "965606" + v1SigningEnabled true + v2SigningEnabled true + } + } + + buildTypes { + debug { + minifyEnabled false + signingConfig signingConfigs.myConfig + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + + release { + minifyEnabled true + signingConfig signingConfigs.myConfig + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main { + jniLibs.srcDirs += 'src/main/jniLibs' + if(new File("${localSdkPath}").exists()){ + jniLibs.srcDirs += "${localSdkPath}" + } + } + } + + buildFeatures{ + viewBinding true + buildConfig true + } + + applicationVariants.all { + variant -> + variant.outputs.all { output -> + outputFileName = new File(rootProject.name + + "_" + agoraSdkVersion + + "_" + new Date().format("yyyyMMddHHmm") + ".apk") + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + if(new File("${localSdkPath}").exists()){ + implementation fileTree(dir: "${localSdkPath}", include: ['*.jar', '*.aar']) + } + else{ + // case 1: full single lib with voice only + implementation "io.agora.rtc:voice-sdk:${agoraSdkVersion}" + // case 2: partial libs with voice only + // implementation "io.agora.rtc:voice-rtc-basic:${agoraSdkVersion}" + // implementation "io.agora.rtc:spatial-audio:${agoraSdkVersion}" + // implementation "io.agora.rtc:audio-beauty:${agoraSdkVersion}" + // implementation "io.agora.rtc:aiaec:${agoraSdkVersion}" + // implementation "io.agora.rtc:drm-loader:${agoraSdkVersion}" + // implementation "io.agora.rtc:drm:${agoraSdkVersion}" + } + + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation "org.jetbrains.kotlin:kotlin-stdlib:1.8.22" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3" + + // Java language implementation + implementation "androidx.navigation:navigation-fragment:2.7.0" + implementation "androidx.navigation:navigation-ui:2.7.0" + + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + + implementation 'io.github.luizgrp.sectionedrecyclerviewadapter:sectionedrecyclerviewadapter:1.2.0' + implementation 'de.javagl:obj:0.2.1' + + implementation "com.squareup.okhttp3:okhttp:4.10.0" + implementation "com.squareup.okhttp3:logging-interceptor:4.10.0" +} diff --git a/Android/APIExample-Audio/app/proguard-rules.pro b/Android/APIExample-Audio/app/proguard-rules.pro new file mode 100644 index 000000000..bcb0ce342 --- /dev/null +++ b/Android/APIExample-Audio/app/proguard-rules.pro @@ -0,0 +1,32 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class io.agora.**{*;} +-dontwarn javax.** +-dontwarn com.google.devtools.build.android.** + +# OkHttp +-dontwarn org.bouncycastle.jsse.** +-dontwarn org.conscrypt.** +-dontwarn org.openjsse.** +-dontwarn okhttp3.internal.platform.** +-dontwarn org.codehaus.mojo.animal_sniffer.** \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/AndroidManifest.xml b/Android/APIExample-Audio/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..52ef5659f --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/assets/agora-logo.png b/Android/APIExample-Audio/app/src/main/assets/agora-logo.png new file mode 100644 index 000000000..0b93e8b9f Binary files /dev/null and b/Android/APIExample-Audio/app/src/main/assets/agora-logo.png differ diff --git a/Android/APIExample-Audio/app/src/main/assets/effectA.wav b/Android/APIExample-Audio/app/src/main/assets/effectA.wav new file mode 100644 index 000000000..dc31fdb68 Binary files /dev/null and b/Android/APIExample-Audio/app/src/main/assets/effectA.wav differ diff --git a/Android/APIExample-Audio/app/src/main/assets/music_1.m4a b/Android/APIExample-Audio/app/src/main/assets/music_1.m4a new file mode 100644 index 000000000..3fb0b5ba5 Binary files /dev/null and b/Android/APIExample-Audio/app/src/main/assets/music_1.m4a differ diff --git a/Android/APIExample-Audio/app/src/main/assets/output.raw b/Android/APIExample-Audio/app/src/main/assets/output.raw new file mode 100644 index 000000000..e252ed263 Binary files /dev/null and b/Android/APIExample-Audio/app/src/main/assets/output.raw differ diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainActivity.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainActivity.java new file mode 100644 index 000000000..5f59203cc --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainActivity.java @@ -0,0 +1,46 @@ +package io.agora.api.example; + +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.navigation.NavController; +import androidx.navigation.Navigation; +import androidx.navigation.ui.AppBarConfiguration; +import androidx.navigation.ui.NavigationUI; + +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.Constant; +import io.agora.api.example.common.model.ExampleBean; + +/** + * @author cjw + */ +public class MainActivity extends AppCompatActivity implements MainFragment.OnListFragmentInteractionListener { + private AppBarConfiguration appBarConfiguration; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment); + appBarConfiguration = new AppBarConfiguration.Builder(navController.getGraph()).build(); + NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return false; + } + + @Override + public void onListFragmentInteraction(Example item) { + ExampleBean exampleBean = new ExampleBean(item.index(), item.group(), item.name(), item.actionId(), item.tipsId()); + Bundle bundle = new Bundle(); + bundle.putParcelable(Constant.DATA, exampleBean); + Navigation.findNavController(this, R.id.nav_host_fragment) + .navigate(R.id.action_mainFragment_to_Ready, bundle); + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainApplication.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainApplication.java new file mode 100644 index 000000000..0a057c895 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainApplication.java @@ -0,0 +1,49 @@ +package io.agora.api.example; + +import android.app.Application; + +import java.lang.annotation.Annotation; +import java.util.Set; + +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.model.Examples; +import io.agora.api.example.common.model.GlobalSettings; +import io.agora.api.example.utils.ClassUtils; + +public class MainApplication extends Application { + + private GlobalSettings globalSettings; + + @Override + public void onCreate() { + super.onCreate(); + initExamples(); + } + + private void initExamples() { + try { + Set packageName = ClassUtils.getFileNameByPackageName(this, "io.agora.api.example.examples"); + for (String name : packageName) { + Class aClass = Class.forName(name); + Annotation[] declaredAnnotations = aClass.getAnnotations(); + for (Annotation annotation : declaredAnnotations) { + if (annotation instanceof Example) { + Example example = (Example) annotation; + Examples.addItem(example); + } + } + } + Examples.sortItem(); + } + catch (Exception e) { + e.printStackTrace(); + } + } + + public GlobalSettings getGlobalSettings() { + if(globalSettings == null){ + globalSettings = new GlobalSettings(); + } + return globalSettings; + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainFragment.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainFragment.java new file mode 100644 index 000000000..3b843ef71 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainFragment.java @@ -0,0 +1,137 @@ +package io.agora.api.example; + +import static io.agora.api.example.common.model.Examples.ADVANCED; +import static io.agora.api.example.common.model.Examples.BASIC; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.adapter.ExampleSection; +import io.agora.api.example.common.model.Examples; +import io.github.luizgrp.sectionedrecyclerviewadapter.SectionedRecyclerViewAdapter; + +/** + * A fragment representing a list of Items. + *

+ * Activities containing this fragment MUST implement the {@link OnListFragmentInteractionListener} + * interface. + */ +public class MainFragment extends Fragment { + // TODO: Customize parameter argument names + private static final String ARG_COLUMN_COUNT = "column-count"; + // TODO: Customize parameters + private int mColumnCount = 1; + private OnListFragmentInteractionListener mListener; + + /** + * Mandatory empty constructor for the fragment manager to instantiate the + * fragment (e.g. upon screen orientation changes). + */ + public MainFragment() { + } + + // TODO: Customize parameter initialization + @SuppressWarnings("unused") + public static MainFragment newInstance(int columnCount) { + MainFragment fragment = new MainFragment(); + Bundle args = new Bundle(); + args.putInt(ARG_COLUMN_COUNT, columnCount); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (getArguments() != null) { + mColumnCount = getArguments().getInt(ARG_COLUMN_COUNT); + } + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_main, container, false); + + // Set the adapter + if (view instanceof RecyclerView) { + Context context = view.getContext(); + RecyclerView recyclerView = (RecyclerView) view; + if (mColumnCount <= 1) { + recyclerView.setLayoutManager(new LinearLayoutManager(context)); + } else { + recyclerView.setLayoutManager(new GridLayoutManager(context, mColumnCount)); + } + SectionedRecyclerViewAdapter sectionedAdapter = new SectionedRecyclerViewAdapter(); + sectionedAdapter.addSection(new ExampleSection(BASIC, Examples.ITEM_MAP.get(BASIC), mListener)); + sectionedAdapter.addSection(new ExampleSection(ADVANCED, Examples.ITEM_MAP.get(ADVANCED), mListener)); + recyclerView.setAdapter(sectionedAdapter); + } + return view; + } + + + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (context instanceof OnListFragmentInteractionListener) { + mListener = (OnListFragmentInteractionListener) context; + } else { + throw new RuntimeException(context.toString() + + " must implement OnListFragmentInteractionListener"); + } + } + + @Override + public void onDetach() { + super.onDetach(); + mListener = null; + } + + /** + * This interface must be implemented by activities that contain this + * fragment to allow an interaction in this fragment to be communicated + * to the activity and potentially other fragments contained in that + * activity. + *

+ * See the Android Training lesson Communicating with Other Fragments for more information. + */ + public interface OnListFragmentInteractionListener { + // TODO: Update argument type and name + void onListFragmentInteraction(Example item); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.menu_main_activity, menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.setting) { + startActivity(new Intent(getContext(), SettingActivity.class)); + return true; + } + return super.onOptionsItemSelected(item); + + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/ReadyFragment.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/ReadyFragment.java new file mode 100644 index 000000000..689fe4905 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/ReadyFragment.java @@ -0,0 +1,87 @@ +package io.agora.api.example; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.navigation.NavController; +import androidx.navigation.NavDestination; +import androidx.navigation.Navigation; + +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.Constant; +import io.agora.api.example.common.model.ExampleBean; +import io.agora.api.example.utils.PermissonUtils; + +/** + * @author cjw + */ +public class ReadyFragment extends BaseFragment { + private static final String TAG = ReadyFragment.class.getSimpleName(); + + private AppCompatTextView tips; + private ExampleBean exampleBean; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + exampleBean = getArguments().getParcelable(Constant.DATA); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_ready_layout, container, false); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(exampleBean.getName()); + actionBar.setHomeButtonEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + tips = view.findViewById(R.id.tips); + tips.setText(getString(exampleBean.getTipsId())); + view.findViewById(R.id.next).setOnClickListener(v -> { + checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { + @Override + public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { + if (allPermissionsGranted) { + navigationDest(); + } else { + showLongToast(getString(R.string.permission)); + } + } + }); + }); + } + + private void navigationDest() { + NavController navController = Navigation.findNavController(requireView()); + navController.navigate(exampleBean.getActionId()); + navController.addOnDestinationChangedListener(new NavController.OnDestinationChangedListener() { + @Override + public void onDestinationChanged(@NonNull NavController controller, + @NonNull NavDestination destination, + @Nullable Bundle arguments) { + if (destination.getId() == R.id.Ready) { + controller.navigateUp(); + controller.removeOnDestinationChangedListener(this); + } + } + }); + } +} + diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/SettingActivity.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/SettingActivity.java new file mode 100644 index 000000000..42dd12ef4 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/SettingActivity.java @@ -0,0 +1,84 @@ +package io.agora.api.example; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import io.agora.api.example.common.model.GlobalSettings; +import io.agora.api.example.databinding.ActivitySettingLayoutBinding; +import io.agora.rtc2.RtcEngine; + +/** + * @author cjw + */ +public class SettingActivity extends AppCompatActivity{ + private static final String TAG = SettingActivity.class.getSimpleName(); + + private ActivitySettingLayoutBinding mBinding; + private MenuItem saveMenu; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mBinding = ActivitySettingLayoutBinding.inflate(LayoutInflater.from(this)); + setContentView(mBinding.getRoot()); + mBinding.sdkVersion.setText(String.format(getString(R.string.sdkversion1), RtcEngine.getSdkVersion())); + fetchGlobalSettings(); + } + + private void fetchGlobalSettings(){ + GlobalSettings globalSettings = ((MainApplication)getApplication()).getGlobalSettings(); + + String[] mItems = getResources().getStringArray(R.array.areaCode); + String selectedItem = globalSettings.getAreaCodeStr(); + int i = 0; + if(selectedItem!=null){ + for(String item : mItems){ + if(selectedItem.equals(item)){ + break; + } + i++; + } + } + mBinding.areaSpinner.setSelection(i); + + + mBinding.privateCloudLayout.etIpAddress.setText(globalSettings.privateCloudIp); + mBinding.privateCloudLayout.swLogReport.setChecked(globalSettings.privateCloudLogReportEnable); + mBinding.privateCloudLayout.etLogServerDomain.setText(globalSettings.privateCloudLogServerDomain); + mBinding.privateCloudLayout.etLogServerPort.setText(globalSettings.privateCloudLogServerPort + ""); + mBinding.privateCloudLayout.etLogServerPath.setText(globalSettings.privateCloudLogServerPath); + mBinding.privateCloudLayout.swUseHttps.setChecked(globalSettings.privateCloudUseHttps); + } + + @Override + public boolean onCreateOptionsMenu(@NonNull Menu menu) { + saveMenu = menu.add(R.string.save); + saveMenu.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == saveMenu.getItemId()) { + GlobalSettings globalSettings = ((MainApplication)getApplication()).getGlobalSettings(); + globalSettings.privateCloudIp = mBinding.privateCloudLayout.etIpAddress.getText().toString(); + globalSettings.privateCloudLogReportEnable = mBinding.privateCloudLayout.swLogReport.isChecked(); + globalSettings.privateCloudLogServerDomain = mBinding.privateCloudLayout.etLogServerDomain.getText().toString(); + globalSettings.privateCloudLogServerPort = Integer.parseInt(mBinding.privateCloudLayout.etLogServerPort.getText().toString()); + globalSettings.privateCloudLogServerPath = mBinding.privateCloudLayout.etLogServerPath.getText().toString(); + globalSettings.privateCloudUseHttps = mBinding.privateCloudLayout.swUseHttps.isChecked(); + + globalSettings.setAreaCodeStr(getResources().getStringArray(R.array.areaCode)[mBinding.areaSpinner.getSelectedItemPosition()]); + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/annotation/Example.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/annotation/Example.java new file mode 100644 index 000000000..1e790bb1d --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/annotation/Example.java @@ -0,0 +1,37 @@ +package io.agora.api.example.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @author + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Example { + /** + * @return example index + */ + int index(); + /** + * @return group name + */ + String group() default ""; + + /** + * @return example name + */ + int name(); + + /** + * @return action ID + */ + int actionId(); + + /** + * @return tips ID + * */ + int tipsId(); +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/BaseFragment.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/BaseFragment.java new file mode 100644 index 000000000..cc64acdeb --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/BaseFragment.java @@ -0,0 +1,233 @@ +package io.agora.api.example.common; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.view.View; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.navigation.Navigation; + +import java.util.Map; + +import io.agora.api.example.utils.PermissonUtils; + +/** + * The type Base fragment. + */ +public class BaseFragment extends Fragment { + /** + * The Handler. + */ + protected Handler handler; + private AlertDialog mAlertDialog; + private String mAlertMessage; + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + onBackPressed(); + } + }; + private String[] permissionArray; + private PermissonUtils.PermissionResultCallback permissionResultCallback; + private ActivityResultLauncher permissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), + result -> { + if (permissionResultCallback != null) { + boolean allPermissionsGranted = true; + for (Map.Entry entry : result.entrySet()) { + if (!entry.getValue()) { + allPermissionsGranted = false; + break; + } + } + int[] grantResults = new int[permissionArray.length]; + for (int i = 0; i < permissionArray.length; i++) { + grantResults[i] = result.containsKey(permissionArray[i]) && result.get(permissionArray[i]) ? PackageManager.PERMISSION_GRANTED : PackageManager.PERMISSION_DENIED; + } + if (permissionResultCallback != null) { + permissionResultCallback.onPermissionsResult(allPermissionsGranted, permissionArray, grantResults); + } + } + } + ); + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handler = new Handler(Looper.getMainLooper()); + requireActivity().getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + onBackPressedCallback.setEnabled(true); + } + + @Override + public void onDetach() { + super.onDetach(); + onBackPressedCallback.setEnabled(false); + } + + /** + * Show alert. + * + * @param message the message + */ + protected void showAlert(String message) { + this.showAlert(message, true); + } + + /** + * Show alert. + * + * @param message the message + * @param showRepeatMsg the show repeat msg + */ + protected void showAlert(String message, boolean showRepeatMsg) { + runOnUIThread(() -> { + Context context = getContext(); + if (context == null) { + return; + } + if (mAlertDialog == null) { + mAlertDialog = new AlertDialog.Builder(context).setTitle("Tips") + .setPositiveButton("OK", (dialog, which) -> dialog.dismiss()) + .create(); + } + if (!showRepeatMsg && !TextUtils.isEmpty(mAlertMessage) && mAlertMessage.equals(message)) { + return; + } + mAlertMessage = message; + mAlertDialog.setMessage(message); + mAlertDialog.show(); + }); + } + + /** + * Reset alert. + */ + protected void resetAlert() { + runOnUIThread(() -> mAlertMessage = ""); + } + + /** + * Show long toast. + * + * @param msg the msg + */ + protected final void showLongToast(final String msg) { + runOnUIThread(() -> { + Context context = getContext(); + if (context == null) { + return; + } + Toast.makeText(context, msg, Toast.LENGTH_LONG).show(); + }); + } + + /** + * Show short toast. + * + * @param msg the msg + */ + protected final void showShortToast(final String msg) { + runOnUIThread(() -> { + Context context = getContext(); + if (context == null) { + return; + } + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); + }); + } + + /** + * Run on ui thread. + * + * @param runnable the runnable + */ + protected final void runOnUIThread(Runnable runnable) { + this.runOnUIThread(runnable, 0); + } + + /** + * Run on ui thread. + * + * @param runnable the runnable + * @param delay the delay + */ + protected final void runOnUIThread(Runnable runnable, long delay) { + if (handler != null && runnable != null && getContext() != null) { + if (delay <= 0 && handler.getLooper().getThread() == Thread.currentThread()) { + runnable.run(); + } else { + handler.postDelayed(() -> { + if (getContext() != null) { + runnable.run(); + } + }, delay); + } + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + handler.removeCallbacksAndMessages(null); + if (mAlertDialog != null) { + mAlertDialog.dismiss(); + mAlertDialog = null; + } + } + + /** + * On back pressed. + */ + protected void onBackPressed() { + View view = getView(); + if (view != null) { + Navigation.findNavController(view).navigateUp(); + } + } + + /** + * @param permissions + * @param callback + */ + protected void checkOrRequestPermisson(String[] permissions, PermissonUtils.PermissionResultCallback callback) { + if (permissions != null && permissions.length > 0) { + permissionArray = permissions; + permissionResultCallback = callback; + if (PermissonUtils.checkPermissions(getContext(), permissionArray)) { + int[] grantResults = new int[permissionArray.length]; + for (int i = 0; i < permissionArray.length; i++) { + grantResults[i] = PackageManager.PERMISSION_GRANTED; + } + permissionResultCallback.onPermissionsResult(true, permissionArray, grantResults); + } else { + permissionLauncher.launch(permissionArray); + } + } + } + + /** + * request permisson with common permissions + * + * @param callback + */ + protected void checkOrRequestPermisson(PermissonUtils.PermissionResultCallback callback) { + checkOrRequestPermisson(PermissonUtils.getCommonPermission(), callback); + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/Constant.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/Constant.java new file mode 100644 index 000000000..5fb4c2f4f --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/Constant.java @@ -0,0 +1,30 @@ +package io.agora.api.example.common; + +import android.view.TextureView; + +import io.agora.rtc2.RtcEngine; + +public class Constant { + public static TextureView TEXTUREVIEW; + + public static RtcEngine ENGINE; + + public static String TIPS = "tips"; + + public static String DATA = "data"; + + public static final String MIX_FILE_PATH = "/assets/music_1.m4a"; + + public static final String EFFECT_FILE_PATH = "https://webdemo.agora.io/ding.mp3"; + + public static final String WATER_MARK_FILE_PATH = "/assets/agora-logo.png"; + + public static final String URL_PLAY_AUDIO_FILES_MULTI_TRACK = "https://webdemo.agora.io/mTrack.m4a"; + + public static final String URL_PLAY_AUDIO_FILES = "https://webdemo.agora.io/audiomixing.mp3"; + + public static final String URL_UPBEAT = "https://webdemo.agora.io/ding.mp3"; + + public static final String URL_DOWNBEAT = "https://webdemo.agora.io/dang.mp3"; + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/adapter/ExampleSection.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/adapter/ExampleSection.java new file mode 100644 index 000000000..31505545d --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/adapter/ExampleSection.java @@ -0,0 +1,79 @@ +package io.agora.api.example.common.adapter; + +import android.view.View; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +import io.agora.api.example.MainFragment; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.github.luizgrp.sectionedrecyclerviewadapter.Section; +import io.github.luizgrp.sectionedrecyclerviewadapter.SectionParameters; + +public class ExampleSection extends Section { + private final String mTitle; + private final List mValues; + private final MainFragment.OnListFragmentInteractionListener mListener; + + public ExampleSection(String title, List items, MainFragment.OnListFragmentInteractionListener listener) { + super(SectionParameters.builder().headerResourceId(R.layout.layout_main_list_section).itemResourceId(R.layout.layout_main_list_item).build()); + mTitle = title; + mValues = items; + mListener = listener; + } + + @Override + public int getContentItemsTotal() { + return mValues.size(); + } + + @Override + public RecyclerView.ViewHolder getItemViewHolder(View view) { + return new ViewHolder(view); + } + + @Override + public void onBindItemViewHolder(RecyclerView.ViewHolder viewHolder, int position) { + if (viewHolder instanceof ViewHolder) { + ViewHolder holder = (ViewHolder) viewHolder; + holder.mItem = mValues.get(position); + holder.mNameView.setText(holder.mView.getContext().getString(holder.mItem.name())); + + holder.mView.setOnClickListener(v -> { + if (null != mListener) { + // Notify the active callbacks interface (the activity, if the + // fragment is attached to one) that an item has been selected. + mListener.onListFragmentInteraction(holder.mItem); + } + }); + } + } + + @Override + public RecyclerView.ViewHolder getHeaderViewHolder(View view) { + return new ViewHolder(view); + } + + @Override + public void onBindHeaderViewHolder(RecyclerView.ViewHolder viewHolder) { + if (viewHolder instanceof ViewHolder) { + ViewHolder holder = (ViewHolder) viewHolder; + holder.mNameView.setText(mTitle); + } + } + + static class ViewHolder extends RecyclerView.ViewHolder { + final View mView; + final TextView mNameView; + Example mItem; + + ViewHolder(View view) { + super(view); + mView = view; + mNameView = view.findViewById(R.id.item_name); + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/Drawable2dFull.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/Drawable2dFull.java new file mode 100644 index 000000000..8db5cb290 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/Drawable2dFull.java @@ -0,0 +1,50 @@ +/* + * Copyright 2014 Google Inc. All rights reserved. + * + * 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 io.agora.api.example.common.gles; + + +import io.agora.api.example.common.gles.core.Drawable2d; + +/** + * Base class for stuff we like to draw. + */ +public class Drawable2dFull extends Drawable2d { + + /** + * A "full" square, extending from -1 to +1 in both dimensions. When the model/view/projection + * matrix is identity, this will exactly cover the viewport. + *

+ * The texture coordinates are Y-inverted relative to RECTANGLE. (This seems to work out + * right with external textures from SurfaceTexture.) + */ + private static final float FULL_RECTANGLE_COORDS[] = { + -1.0f, -1.0f, // 0 bottom left + 1.0f, -1.0f, // 1 bottom right + -1.0f, 1.0f, // 2 top left + 1.0f, 1.0f, // 3 top right + }; + private static final float FULL_RECTANGLE_TEX_COORDS[] = { + 0.0f, 0.0f, // 0 bottom left + 1.0f, 0.0f, // 1 bottom right + 0.0f, 1.0f, // 2 top left + 1.0f, 1.0f // 3 top right + }; + + public Drawable2dFull() { + super(FULL_RECTANGLE_COORDS, FULL_RECTANGLE_TEX_COORDS); + } +} diff --git a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/Drawable2dLandmarks.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/Drawable2dLandmarks.java similarity index 89% rename from Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/Drawable2dLandmarks.java rename to Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/Drawable2dLandmarks.java index 539f7809e..29d798b12 100644 --- a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/Drawable2dLandmarks.java +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/Drawable2dLandmarks.java @@ -14,10 +14,10 @@ * limitations under the License. */ -package io.agora.api.component.gles; +package io.agora.api.example.common.gles; -import io.agora.api.component.gles.core.Drawable2d; +import io.agora.api.example.common.gles.core.Drawable2d; /** * Base class for stuff we like to draw. diff --git a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/ProgramLandmarks.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramLandmarks.java similarity index 96% rename from Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/ProgramLandmarks.java rename to Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramLandmarks.java index a2fef5d63..541d6e3c2 100644 --- a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/ProgramLandmarks.java +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramLandmarks.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.agora.api.component.gles; +package io.agora.api.example.common.gles; import android.hardware.Camera; import android.opengl.GLES20; @@ -21,9 +21,9 @@ import java.util.Arrays; -import io.agora.api.component.gles.core.Drawable2d; -import io.agora.api.component.gles.core.GlUtil; -import io.agora.api.component.gles.core.Program; +import io.agora.api.example.common.gles.core.Drawable2d; +import io.agora.api.example.common.gles.core.GlUtil; +import io.agora.api.example.common.gles.core.Program; public class ProgramLandmarks extends Program { diff --git a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/ProgramTexture2d.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramTexture2d.java similarity index 95% rename from Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/ProgramTexture2d.java rename to Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramTexture2d.java index 7bd8d34a9..73221726b 100644 --- a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/ProgramTexture2d.java +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramTexture2d.java @@ -14,13 +14,13 @@ * limitations under the License. */ -package io.agora.api.component.gles; +package io.agora.api.example.common.gles; import android.opengl.GLES20; -import io.agora.api.component.gles.core.Drawable2d; -import io.agora.api.component.gles.core.GlUtil; -import io.agora.api.component.gles.core.Program; +import io.agora.api.example.common.gles.core.Drawable2d; +import io.agora.api.example.common.gles.core.GlUtil; +import io.agora.api.example.common.gles.core.Program; public class ProgramTexture2d extends Program { diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramTextureOES.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramTextureOES.java new file mode 100644 index 000000000..9eaff0787 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramTextureOES.java @@ -0,0 +1,130 @@ +/* + * Copyright 2014 Google Inc. All rights reserved. + * + * 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 io.agora.api.example.common.gles; + +import android.opengl.GLES11Ext; +import android.opengl.GLES20; + +import io.agora.api.example.common.gles.core.Drawable2d; +import io.agora.api.example.common.gles.core.GlUtil; +import io.agora.api.example.common.gles.core.Program; + + +public class ProgramTextureOES extends Program { + + // Simple vertex shader, used for all programs. + private static final String VERTEX_SHADER = + "uniform mat4 uMVPMatrix;\n" + + "uniform mat4 uTexMatrix;\n" + + "attribute vec4 aPosition;\n" + + "attribute vec4 aTextureCoord;\n" + + "varying vec2 vTextureCoord;\n" + + "void main() {\n" + + " gl_Position = uMVPMatrix * aPosition;\n" + + " vTextureCoord = (uTexMatrix * aTextureCoord).xy;\n" + + "}\n"; + + // Simple fragment shader for use with external 2D textures (e.g. what we get from + // SurfaceTexture). + private static final String FRAGMENT_SHADER_EXT = + "#extension GL_OES_EGL_image_external : require\n" + + "precision mediump float;\n" + + "varying vec2 vTextureCoord;\n" + + "uniform samplerExternalOES sTexture;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(sTexture, vTextureCoord);\n" + + "}\n"; + + private int muMVPMatrixLoc; + private int muTexMatrixLoc; + private int maPositionLoc; + private int maTextureCoordLoc; + + /** + * Prepares the program in the current EGL context. + */ + public ProgramTextureOES() { + super(VERTEX_SHADER, FRAGMENT_SHADER_EXT); + } + + @Override + protected Drawable2d getDrawable2d() { + return new Drawable2dFull(); + } + + @Override + protected void getLocations() { + maPositionLoc = GLES20.glGetAttribLocation(mProgramHandle, "aPosition"); + GlUtil.checkLocation(maPositionLoc, "aPosition"); + maTextureCoordLoc = GLES20.glGetAttribLocation(mProgramHandle, "aTextureCoord"); + GlUtil.checkLocation(maTextureCoordLoc, "aTextureCoord"); + muMVPMatrixLoc = GLES20.glGetUniformLocation(mProgramHandle, "uMVPMatrix"); + GlUtil.checkLocation(muMVPMatrixLoc, "uMVPMatrix"); + muTexMatrixLoc = GLES20.glGetUniformLocation(mProgramHandle, "uTexMatrix"); + GlUtil.checkLocation(muTexMatrixLoc, "uTexMatrix"); + } + + @Override + public void drawFrame(int textureId, float[] texMatrix, float[] mvpMatrix) { + GlUtil.checkGlError("draw start"); + + // Select the program. + GLES20.glUseProgram(mProgramHandle); + GlUtil.checkGlError("glUseProgram"); + + // Set the texture. + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId); + + // Copy the model / view / projection matrix over. + GLES20.glUniformMatrix4fv(muMVPMatrixLoc, 1, false, mvpMatrix, 0); + GlUtil.checkGlError("glUniformMatrix4fv"); + + // Copy the texture transformation matrix over. + GLES20.glUniformMatrix4fv(muTexMatrixLoc, 1, false, texMatrix, 0); + GlUtil.checkGlError("glUniformMatrix4fv"); + + // Enable the "aPosition" vertex attribute. + GLES20.glEnableVertexAttribArray(maPositionLoc); + GlUtil.checkGlError("glEnableVertexAttribArray"); + + // Connect vertexBuffer to "aPosition". + GLES20.glVertexAttribPointer(maPositionLoc, Drawable2d.COORDS_PER_VERTEX, + GLES20.GL_FLOAT, false, Drawable2d.VERTEXTURE_STRIDE, mDrawable2d.vertexArray()); + GlUtil.checkGlError("glVertexAttribPointer"); + + // Enable the "aTextureCoord" vertex attribute. + GLES20.glEnableVertexAttribArray(maTextureCoordLoc); + GlUtil.checkGlError("glEnableVertexAttribArray"); + + // Connect texBuffer to "aTextureCoord". + GLES20.glVertexAttribPointer(maTextureCoordLoc, 2, + GLES20.GL_FLOAT, false, Drawable2d.TEXTURE_COORD_STRIDE, mDrawable2d.texCoordArray()); + GlUtil.checkGlError("glVertexAttribPointer"); + + // Draw the rect. + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, mDrawable2d.vertexCount()); + GlUtil.checkGlError("glDrawArrays"); + + // Done -- disable vertex array, texture, and program. + GLES20.glDisableVertexAttribArray(maPositionLoc); + GLES20.glDisableVertexAttribArray(maTextureCoordLoc); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0); + GLES20.glUseProgram(0); + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/Drawable2d.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/Drawable2d.java new file mode 100644 index 000000000..c3d2abe68 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/Drawable2d.java @@ -0,0 +1,82 @@ +/* + * Copyright 2014 Google Inc. All rights reserved. + * + * 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 io.agora.api.example.common.gles.core; + +import java.nio.FloatBuffer; + +/** + * Base class for stuff we like to draw. + */ +public class Drawable2d { + + public static final int SIZEOF_FLOAT = 4; + public static final int COORDS_PER_VERTEX = 2; + public static final int TEXTURE_COORD_STRIDE = 2 * SIZEOF_FLOAT; + public static final int VERTEXTURE_STRIDE = COORDS_PER_VERTEX * SIZEOF_FLOAT; + + private FloatBuffer mTexCoordArray; + private FloatBuffer mVertexArray; + private int mVertexCount; + + public Drawable2d() { + } + + /** + * Prepares a drawable from a "pre-fabricated" shape definition. + *

+ * Does no EGL/GL operations, so this can be done at any time. + */ + public Drawable2d(float[] FULL_RECTANGLE_COORDS, float[] FULL_RECTANGLE_TEX_COORDS) { + updateVertexArray(FULL_RECTANGLE_COORDS); + updateTexCoordArray(FULL_RECTANGLE_TEX_COORDS); + } + + public void updateVertexArray(float[] FULL_RECTANGLE_COORDS) { + mVertexArray = GlUtil.createFloatBuffer(FULL_RECTANGLE_COORDS); + mVertexCount = FULL_RECTANGLE_COORDS.length / COORDS_PER_VERTEX; + } + + public void updateTexCoordArray(float[] FULL_RECTANGLE_TEX_COORDS) { + mTexCoordArray = GlUtil.createFloatBuffer(FULL_RECTANGLE_TEX_COORDS); + } + + /** + * Returns the array of vertices. + *

+ * To avoid allocations, this returns internal state. The caller must not modify it. + */ + public FloatBuffer vertexArray() { + return mVertexArray; + } + + /** + * Returns the array of texture coordinates. + *

+ * To avoid allocations, this returns internal state. The caller must not modify it. + */ + public FloatBuffer texCoordArray() { + return mTexCoordArray; + } + + /** + * Returns the number of vertices stored in the vertex array. + */ + public int vertexCount() { + return mVertexCount; + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/EglCore.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/EglCore.java new file mode 100644 index 000000000..59ce2f986 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/EglCore.java @@ -0,0 +1,385 @@ +/* + * Copyright 2013 Google Inc. All rights reserved. + * + * 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 io.agora.api.example.common.gles.core; + +import android.graphics.SurfaceTexture; +import android.opengl.EGL14; +import android.opengl.EGLConfig; +import android.opengl.EGLContext; +import android.opengl.EGLDisplay; +import android.opengl.EGLExt; +import android.opengl.EGLSurface; +import android.util.Log; +import android.view.Surface; + +/** + * Core EGL state (display, context, config). + *

+ * The EGLContext must only be attached to one thread at a time. This class is not thread-safe. + */ +public final class EglCore { + private static final String TAG = GlUtil.TAG; + + /** + * Constructor flag: surface must be recordable. This discourages EGL from using a + * pixel format that cannot be converted efficiently to something usable by the video + * encoder. + */ + public static final int FLAG_RECORDABLE = 0x01; + + /** + * Constructor flag: ask for GLES3, fall back to GLES2 if not available. Without this + * flag, GLES2 is used. + */ + public static final int FLAG_TRY_GLES3 = 0x02; + + // Android-specific extension. + private static final int EGL_RECORDABLE_ANDROID = 0x3142; + + private EGLDisplay mEGLDisplay = EGL14.EGL_NO_DISPLAY; + private EGLContext mEGLContext = EGL14.EGL_NO_CONTEXT; + private EGLConfig mEGLConfig = null; + private int mGlVersion = -1; + + + /** + * Prepares EGL display and context. + *

+ * Equivalent to EglCore(null, 0). + */ + public EglCore() { + this(null, 0); + } + + /** + * Prepares EGL display and context. + *

+ * + * @param sharedContext The context to share, or null if sharing is not desired. + * @param flags Configuration bit flags, e.g. FLAG_RECORDABLE. + */ + public EglCore(EGLContext sharedContext, int flags) { + if (mEGLDisplay != EGL14.EGL_NO_DISPLAY) { + throw new RuntimeException("EGL already set up"); + } + + if (sharedContext == null) { + sharedContext = EGL14.EGL_NO_CONTEXT; + } + + mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) { + throw new RuntimeException("unable to get EGL14 display"); + } + int[] version = new int[2]; + if (!EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)) { + mEGLDisplay = null; + throw new RuntimeException("unable to initialize EGL14"); + } + + // Try to get a GLES3 context, if requested. + if ((flags & FLAG_TRY_GLES3) != 0) { + //Log.d(TAG, "Trying GLES 3"); + EGLConfig config = getConfig(flags, 3); + if (config != null) { + int[] attrib3_list = { + EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, + EGL14.EGL_NONE + }; + EGLContext context = EGL14.eglCreateContext(mEGLDisplay, config, sharedContext, + attrib3_list, 0); + + if (EGL14.eglGetError() == EGL14.EGL_SUCCESS) { + //Log.d(TAG, "Got GLES 3 config"); + mEGLConfig = config; + mEGLContext = context; + mGlVersion = 3; + } + } + } + if (mEGLContext == EGL14.EGL_NO_CONTEXT) { // GLES 2 only, or GLES 3 attempt failed + //Log.d(TAG, "Trying GLES 2"); + EGLConfig config = getConfig(flags, 2); + if (config == null) { + throw new RuntimeException("Unable to find a suitable EGLConfig"); + } + int[] attrib2_list = { + EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, + EGL14.EGL_NONE + }; + EGLContext context = EGL14.eglCreateContext(mEGLDisplay, config, sharedContext, + attrib2_list, 0); + checkEglError("eglCreateContext"); + mEGLConfig = config; + mEGLContext = context; + mGlVersion = 2; + } + + // Confirm with query. + int[] values = new int[1]; + EGL14.eglQueryContext(mEGLDisplay, mEGLContext, EGL14.EGL_CONTEXT_CLIENT_VERSION, + values, 0); + Log.d(TAG, "EGLContext created, client version " + values[0]); + } + + /** + * Finds a suitable EGLConfig. + * + * @param flags Bit flags from constructor. + * @param version Must be 2 or 3. + */ + private EGLConfig getConfig(int flags, int version) { + int renderableType = EGL14.EGL_OPENGL_ES2_BIT; + if (version >= 3) { + renderableType |= EGLExt.EGL_OPENGL_ES3_BIT_KHR; + } + + // The actual surface is generally RGBA or RGBX, so situationally omitting alpha + // doesn't really help. It can also lead to a huge performance hit on glReadPixels() + // when reading into a GL_RGBA buffer. + int[] attribList = { + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + //EGL14.EGL_DEPTH_SIZE, 16, + //EGL14.EGL_STENCIL_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, renderableType, + EGL14.EGL_NONE, 0, // placeholder for recordable [@-3] + EGL14.EGL_NONE + }; + if ((flags & FLAG_RECORDABLE) != 0) { + attribList[attribList.length - 3] = EGL_RECORDABLE_ANDROID; + attribList[attribList.length - 2] = 1; + } + EGLConfig[] configs = new EGLConfig[1]; + int[] numConfigs = new int[1]; + if (!EGL14.eglChooseConfig(mEGLDisplay, attribList, 0, configs, 0, configs.length, + numConfigs, 0)) { + Log.w(TAG, "unable to find RGB8888 / " + version + " EGLConfig"); + return null; + } + return configs[0]; + } + + /** + * Discards all resources held by this class, notably the EGL context. This must be + * called from the thread where the context was created. + *

+ * On completion, no context will be current. + */ + public void release() { + if (mEGLDisplay != EGL14.EGL_NO_DISPLAY) { + // Android is unusual in that it uses a reference-counted EGLDisplay. So for + // every eglInitialize() we need an eglTerminate(). + EGL14.eglMakeCurrent(mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_CONTEXT); + EGL14.eglDestroyContext(mEGLDisplay, mEGLContext); + EGL14.eglReleaseThread(); + EGL14.eglTerminate(mEGLDisplay); + } + + mEGLDisplay = EGL14.EGL_NO_DISPLAY; + mEGLContext = EGL14.EGL_NO_CONTEXT; + mEGLConfig = null; + } + + @Override + protected void finalize() throws Throwable { + try { + if (mEGLDisplay != EGL14.EGL_NO_DISPLAY) { + // We're limited here -- finalizers don't run on the thread that holds + // the EGL state, so if a surface or context is still current on another + // thread we can't fully release it here. Exceptions thrown from here + // are quietly discarded. Complain in the log file. + Log.w(TAG, "WARNING: EglCore was not explicitly released -- state may be leaked"); + release(); + } + } finally { + super.finalize(); + } + } + + public EGLContext getEGLContext() { + return mEGLContext; + } + + /** + * Destroys the specified surface. Note the EGLSurface won't actually be destroyed if it's + * still current in a context. + */ + public void releaseSurface(EGLSurface eglSurface) { + EGL14.eglDestroySurface(mEGLDisplay, eglSurface); + } + + /** + * Creates an EGL surface associated with a Surface. + *

+ * If this is destined for MediaCodec, the EGLConfig should have the "recordable" attribute. + */ + public EGLSurface createWindowSurface(Object surface) { + if (!(surface instanceof Surface) && !(surface instanceof SurfaceTexture)) { + throw new RuntimeException("invalid surface: " + surface); + } + + // Create a window surface, and attach it to the Surface we received. + int[] surfaceAttribs = { + EGL14.EGL_NONE + }; + EGLSurface eglSurface = EGL14.eglCreateWindowSurface(mEGLDisplay, mEGLConfig, surface, + surfaceAttribs, 0); + checkEglError("eglCreateWindowSurface"); + if (eglSurface == null) { + throw new RuntimeException("surface was null"); + } + return eglSurface; + } + + /** + * Creates an EGL surface associated with an offscreen buffer. + */ + public EGLSurface createOffscreenSurface(int width, int height) { + int[] surfaceAttribs = { + EGL14.EGL_WIDTH, width, + EGL14.EGL_HEIGHT, height, + EGL14.EGL_NONE + }; + EGLSurface eglSurface = EGL14.eglCreatePbufferSurface(mEGLDisplay, mEGLConfig, + surfaceAttribs, 0); + checkEglError("eglCreatePbufferSurface"); + if (eglSurface == null) { + throw new RuntimeException("surface was null"); + } + return eglSurface; + } + + /** + * Makes our EGL context current, using the supplied surface for both "draw" and "read". + */ + public void makeCurrent(EGLSurface eglSurface) { + if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) { + // called makeCurrent() before create? + Log.d(TAG, "NOTE: makeCurrent w/o display"); + } + if (!EGL14.eglMakeCurrent(mEGLDisplay, eglSurface, eglSurface, mEGLContext)) { + throw new RuntimeException("eglMakeCurrent failed"); + } + } + + /** + * Makes our EGL context current, using the supplied "draw" and "read" surfaces. + */ + public void makeCurrent(EGLSurface drawSurface, EGLSurface readSurface) { + if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) { + // called makeCurrent() before create? + Log.d(TAG, "NOTE: makeCurrent w/o display"); + } + if (!EGL14.eglMakeCurrent(mEGLDisplay, drawSurface, readSurface, mEGLContext)) { + throw new RuntimeException("eglMakeCurrent(draw,read) failed"); + } + } + + /** + * Makes no context current. + */ + public void makeNothingCurrent() { + if (!EGL14.eglMakeCurrent(mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_CONTEXT)) { + throw new RuntimeException("eglMakeCurrent failed"); + } + } + + /** + * Calls eglSwapBuffers. Use this to "publish" the current frame. + * + * @return false on failure + */ + public boolean swapBuffers(EGLSurface eglSurface) { + return EGL14.eglSwapBuffers(mEGLDisplay, eglSurface); + } + + /** + * Sends the presentation time stamp to EGL. Time is expressed in nanoseconds. + */ + public void setPresentationTime(EGLSurface eglSurface, long nsecs) { + EGLExt.eglPresentationTimeANDROID(mEGLDisplay, eglSurface, nsecs); + } + + /** + * Returns true if our context and the specified surface are current. + */ + public boolean isCurrent(EGLSurface eglSurface) { + return mEGLContext.equals(EGL14.eglGetCurrentContext()) && + eglSurface.equals(EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW)); + } + + public EGLSurface getCurrentDrawingSurface() { + EGLSurface surface = null; + if (mEGLContext.equals(EGL14.eglGetCurrentContext())) { + surface = EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW); + } + return surface; + } + + /** + * Performs a simple surface query. + */ + public int querySurface(EGLSurface eglSurface, int what) { + int[] value = new int[1]; + EGL14.eglQuerySurface(mEGLDisplay, eglSurface, what, value, 0); + return value[0]; + } + + /** + * Queries a string value. + */ + public String queryString(int what) { + return EGL14.eglQueryString(mEGLDisplay, what); + } + + /** + * Returns the GLES version this context is configured for (currently 2 or 3). + */ + public int getGlVersion() { + return mGlVersion; + } + + /** + * Writes the current display, context, and surface to the log. + */ + public static void logCurrent(String msg) { + EGLDisplay display; + EGLContext context; + EGLSurface surface; + + display = EGL14.eglGetCurrentDisplay(); + context = EGL14.eglGetCurrentContext(); + surface = EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW); + Log.i(TAG, "Current EGL (" + msg + "): display=" + display + ", context=" + context + + ", surface=" + surface); + } + + /** + * Checks for EGL errors. Throws an exception if an error has been raised. + */ + private void checkEglError(String msg) { + int error; + if ((error = EGL14.eglGetError()) != EGL14.EGL_SUCCESS) { + throw new RuntimeException(msg + ": EGL error: 0x" + Integer.toHexString(error)); + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/EglSurfaceBase.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/EglSurfaceBase.java new file mode 100644 index 000000000..98f776ecf --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/EglSurfaceBase.java @@ -0,0 +1,198 @@ +/* + * Copyright 2013 Google Inc. All rights reserved. + * + * 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 io.agora.api.example.common.gles.core; + +import android.graphics.Bitmap; +import android.opengl.EGL14; +import android.opengl.EGLSurface; +import android.opengl.GLES20; +import android.util.Log; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Common base class for EGL surfaces. + *

+ * There can be multiple surfaces associated with a single context. + */ +public class EglSurfaceBase { + protected static final String TAG = GlUtil.TAG; + + // EglCore object we're associated with. It may be associated with multiple surfaces. + protected EglCore mEglCore; + + private EGLSurface mEGLSurface = EGL14.EGL_NO_SURFACE; + private int mWidth = -1; + private int mHeight = -1; + + protected EglSurfaceBase(EglCore eglCore) { + mEglCore = eglCore; + } + + /** + * Creates a window surface. + *

+ * + * @param surface May be a Surface or SurfaceTexture. + */ + public void createWindowSurface(Object surface) { + if (mEGLSurface != EGL14.EGL_NO_SURFACE) { + throw new IllegalStateException("surface already created"); + } + mEGLSurface = mEglCore.createWindowSurface(surface); + + // Don't cache width/height here, because the size of the underlying surface can change + // out from under us (see e.g. HardwareScalerActivity). + //mWidth = mEglCore.querySurface(mEGLSurface, EGL14.EGL_WIDTH); + //mHeight = mEglCore.querySurface(mEGLSurface, EGL14.EGL_HEIGHT); + } + + /** + * Creates an off-screen surface. + */ + public void createOffscreenSurface(int width, int height) { + if (mEGLSurface != EGL14.EGL_NO_SURFACE) { + throw new IllegalStateException("surface already created"); + } + mEGLSurface = mEglCore.createOffscreenSurface(width, height); + mWidth = width; + mHeight = height; + } + + /** + * Returns the surface's width, in pixels. + *

+ * If this is called on a window surface, and the underlying surface is in the process + * of changing size, we may not see the new size right away (e.g. in the "surfaceChanged" + * callback). The size should match after the next buffer swap. + */ + public int getWidth() { + if (mWidth < 0) { + return mEglCore.querySurface(mEGLSurface, EGL14.EGL_WIDTH); + } else { + return mWidth; + } + } + + /** + * Returns the surface's height, in pixels. + */ + public int getHeight() { + if (mHeight < 0) { + return mEglCore.querySurface(mEGLSurface, EGL14.EGL_HEIGHT); + } else { + return mHeight; + } + } + + /** + * Release the EGL surface. + */ + public void releaseEglSurface() { + mEglCore.releaseSurface(mEGLSurface); + mEGLSurface = EGL14.EGL_NO_SURFACE; + mWidth = mHeight = -1; + } + + /** + * Makes our EGL context and surface current. + */ + public void makeCurrent() { + mEglCore.makeCurrent(mEGLSurface); + } + + /** + * Makes our EGL context and surface current for drawing, using the supplied surface + * for reading. + */ + public void makeCurrentReadFrom(EglSurfaceBase readSurface) { + mEglCore.makeCurrent(mEGLSurface, readSurface.mEGLSurface); + } + + /** + * Calls eglSwapBuffers. Use this to "publish" the current frame. + * + * @return false on failure + */ + public boolean swapBuffers() { + boolean result = mEglCore.swapBuffers(mEGLSurface); + if (!result) { + Log.d(TAG, "WARNING: swapBuffers() failed"); + } + return result; + } + + /** + * Sends the presentation time stamp to EGL. + * + * @param nsecs Timestamp, in nanoseconds. + */ + public void setPresentationTime(long nsecs) { + mEglCore.setPresentationTime(mEGLSurface, nsecs); + } + + /** + * Saves the EGL surface to a file. + *

+ * Expects that this object's EGL surface is current. + */ + public void saveFrame(File file) throws IOException { + if (!mEglCore.isCurrent(mEGLSurface)) { + throw new RuntimeException("Expected EGL context/surface is not current"); + } + + // glReadPixels fills in a "direct" ByteBuffer with what is essentially big-endian RGBA + // data (i.e. a byte of red, followed by a byte of green...). While the Bitmap + // constructor that takes an int[] wants little-endian ARGB (blue/red swapped), the + // Bitmap "copy pixels" method wants the same format GL provides. + // + // Ideally we'd have some way to re-use the ByteBuffer, especially if we're calling + // here often. + // + // Making this even more interesting is the upside-down nature of GL, which means + // our output will look upside down relative to what appears on screen if the + // typical GL conventions are used. + + String filename = file.toString(); + + int width = getWidth(); + int height = getHeight(); + ByteBuffer buf = ByteBuffer.allocateDirect(width * height * 4); + buf.order(ByteOrder.LITTLE_ENDIAN); + GLES20.glReadPixels(0, 0, width, height, + GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buf); + GlUtil.checkGlError("glReadPixels"); + buf.rewind(); + + BufferedOutputStream bos = null; + try { + bos = new BufferedOutputStream(new FileOutputStream(filename)); + Bitmap bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bmp.copyPixelsFromBuffer(buf); + bmp.compress(Bitmap.CompressFormat.PNG, 90, bos); + bmp.recycle(); + } finally { + if (bos != null) bos.close(); + } + Log.d(TAG, "Saved " + width + "x" + height + " frame as '" + filename + "'"); + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/Extensions.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/Extensions.java new file mode 100644 index 000000000..06f33b1cd --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/Extensions.java @@ -0,0 +1,38 @@ +package io.agora.api.example.common.gles.core; + +import android.content.Context; +import android.content.res.AssetManager; + +import java.io.IOException; +import java.io.InputStream; + +public abstract class Extensions { + + public static byte[] getBytes(InputStream inputStream) { + try { + byte[] bytes = new byte[inputStream.available()]; + inputStream.read(bytes); + inputStream.close(); + return bytes; + } catch (IOException e) { + e.printStackTrace(); + } + + return new byte[0]; + } + + public static byte[] getBytes(AssetManager assetManager, String fileName) { + try { + return getBytes(assetManager.open(fileName)); + } catch (IOException e) { + e.printStackTrace(); + } + + return new byte[0]; + } + + public static String readTextFileFromResource(Context context, int resourceId) { + return new String(Extensions.getBytes(context.getResources().openRawResource(resourceId))); + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/GlUtil.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/GlUtil.java new file mode 100644 index 000000000..13cb63889 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/GlUtil.java @@ -0,0 +1,282 @@ +/* + * Copyright 2014 Google Inc. All rights reserved. + * + * 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 io.agora.api.example.common.gles.core; + +import android.graphics.Bitmap; +import android.opengl.GLES11Ext; +import android.opengl.GLES20; +import android.opengl.GLES30; +import android.opengl.GLUtils; +import android.opengl.Matrix; +import android.util.Log; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; + +/** + * Some OpenGL utility functions. + */ +public abstract class GlUtil { + //public static final String TAG = "Grafika"; + public static final String TAG = "mqi"; + /** + * Identity matrix for general use. Don't modify or life will get weird. + */ + public static final float[] IDENTITY_MATRIX; + + static { + IDENTITY_MATRIX = new float[16]; + Matrix.setIdentityM(IDENTITY_MATRIX, 0); + } + + private static final int SIZEOF_FLOAT = 4; + + + private GlUtil() { + } // do not instantiate + + /** + * Creates a new program from the supplied vertex and fragment shaders. + * + * @return A handle to the program, or 0 on failure. + */ + public static int createProgram(String vertexSource, String fragmentSource) { + int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource); + if (vertexShader == 0) { + return 0; + } + int pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource); + if (pixelShader == 0) { + return 0; + } + + int program = GLES20.glCreateProgram(); + checkGlError("glCreateProgram"); + if (program == 0) { + Log.e(TAG, "Could not create program"); + } + GLES20.glAttachShader(program, vertexShader); + checkGlError("glAttachShader"); + GLES20.glAttachShader(program, pixelShader); + checkGlError("glAttachShader"); + GLES20.glLinkProgram(program); + int[] linkStatus = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0); + if (linkStatus[0] != GLES20.GL_TRUE) { + Log.e(TAG, "Could not link program: "); + Log.e(TAG, GLES20.glGetProgramInfoLog(program)); + GLES20.glDeleteProgram(program); + program = 0; + } + return program; + } + + /** + * Compiles the provided shader source. + * + * @return A handle to the shader, or 0 on failure. + */ + public static int loadShader(int shaderType, String source) { + int shader = GLES20.glCreateShader(shaderType); + checkGlError("glCreateShader type=" + shaderType); + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + int[] compiled = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0); + if (compiled[0] == 0) { + Log.e(TAG, "Could not compile shader " + shaderType + ":"); + Log.e(TAG, " " + GLES20.glGetShaderInfoLog(shader)); + GLES20.glDeleteShader(shader); + shader = 0; + } + return shader; + } + + /** + * Checks to see if a GLES error has been raised. + */ + public static void checkGlError(String op) { + int error = GLES20.glGetError(); + if (error != GLES20.GL_NO_ERROR) { + String msg = op + ": glError 0x" + Integer.toHexString(error); + Log.e(TAG, msg); + } + } + + /** + * Checks to see if the location we obtained is valid. GLES returns -1 if a label + * could not be found, but does not set the GL error. + *

+ * Throws a RuntimeException if the location is invalid. + */ + public static void checkLocation(int location, String label) { + if (location < 0) { + Log.e(TAG, "Unable to locate '" + label + "' in program"); + } + } + + /** + * Creates a texture from raw data. + * + * @param data Image data, in a "direct" ByteBuffer. + * @param width Texture width, in pixels (not bytes). + * @param height Texture height, in pixels. + * @param format Image data format (use constant appropriate for glTexImage2D(), e.g. GL_RGBA). + * @return Handle to texture. + */ + public static int createImageTexture(ByteBuffer data, int width, int height, int format) { + int[] textureHandles = new int[1]; + int textureHandle; + + GLES20.glGenTextures(1, textureHandles, 0); + textureHandle = textureHandles[0]; + GlUtil.checkGlError("glGenTextures"); + + // Bind the texture handle to the 2D texture target. + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle); + + // Configure min/mag filtering, i.e. what scaling method do we use if what we're rendering + // is smaller or larger than the source image. + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, + GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, + GLES20.GL_LINEAR); + GlUtil.checkGlError("loadImageTexture"); + + // Load the data from the buffer into the texture handle. + GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, /*level*/ 0, format, + width, height, /*border*/ 0, format, GLES20.GL_UNSIGNED_BYTE, data); + GlUtil.checkGlError("loadImageTexture"); + + return textureHandle; + } + + /** + * Creates a texture from bitmap. + * + * @param bmp bitmap data + * @return Handle to texture. + */ + public static int createImageTexture(Bitmap bmp) { + int[] textureHandles = new int[1]; + int textureHandle; + + GLES20.glGenTextures(1, textureHandles, 0); + textureHandle = textureHandles[0]; + GlUtil.checkGlError("glGenTextures"); + + // Bind the texture handle to the 2D texture target. + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle); + + // Configure min/mag filtering, i.e. what scaling method do we use if what we're rendering + // is smaller or larger than the source image. + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, + GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, + GLES20.GL_LINEAR); + GlUtil.checkGlError("loadImageTexture"); + + // Load the data from the buffer into the texture handle. + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, /*level*/ 0, bmp, 0); + GlUtil.checkGlError("loadImageTexture"); + + return textureHandle; + } + + /** + * Allocates a direct float buffer, and populates it with the float array data. + */ + public static FloatBuffer createFloatBuffer(float[] coords) { + // Allocate a direct ByteBuffer, using 4 bytes per float, and copy coords into it. + ByteBuffer bb = ByteBuffer.allocateDirect(coords.length * SIZEOF_FLOAT); + bb.order(ByteOrder.nativeOrder()); + FloatBuffer fb = bb.asFloatBuffer(); + fb.put(coords); + fb.position(0); + return fb; + } + + /** + * Writes GL version info to the log. + */ + public static void logVersionInfo() { + Log.i(TAG, "vendor : " + GLES20.glGetString(GLES20.GL_VENDOR)); + Log.i(TAG, "renderer: " + GLES20.glGetString(GLES20.GL_RENDERER)); + Log.i(TAG, "version : " + GLES20.glGetString(GLES20.GL_VERSION)); + + if (false) { + int[] values = new int[1]; + GLES30.glGetIntegerv(GLES30.GL_MAJOR_VERSION, values, 0); + int majorVersion = values[0]; + GLES30.glGetIntegerv(GLES30.GL_MINOR_VERSION, values, 0); + int minorVersion = values[0]; + if (GLES30.glGetError() == GLES30.GL_NO_ERROR) { + Log.i(TAG, "iversion: " + majorVersion + "." + minorVersion); + } + } + } + + + /** + * Creates a texture object suitable for use with this program. + *

+ * On exit, the texture will be bound. + */ + public static int createTextureObject(int textureTarget) { + int[] textures = new int[1]; + GLES20.glGenTextures(1, textures, 0); + GlUtil.checkGlError("glGenTextures"); + + int texId = textures[0]; + GLES20.glBindTexture(textureTarget, texId); + GlUtil.checkGlError("glBindTexture " + texId); + + GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, + GLES20.GL_NEAREST); + GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, + GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, + GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, + GLES20.GL_CLAMP_TO_EDGE); + GlUtil.checkGlError("glTexParameter"); + + return texId; + } + + public static void deleteTextureObject(int textureId) { + int[] textures = new int[1]; + textures[0] = textureId; + GLES20.glDeleteTextures(1, textures, 0); + GlUtil.checkGlError("glDeleteTextures"); + } + + public static float[] changeMVPMatrix(float[] mvpMatrix, float viewWidth, float viewHeight, float textureWidth, float textureHeight) { + float scale = viewWidth * textureHeight / viewHeight / textureWidth; + if (scale == 1) { + return mvpMatrix; + } else { + float[] mvp = new float[16]; + float[] tmp = new float[16]; + Matrix.setIdentityM(tmp, 0); + Matrix.scaleM(tmp, 0, scale > 1 ? 1F : (1F / scale), scale > 1 ? scale : 1F, 1F); + Matrix.multiplyMM(mvp, 0, tmp, 0, mvpMatrix, 0); + return mvp; + } + } +} diff --git a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/core/OffscreenSurface.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/OffscreenSurface.java similarity index 96% rename from Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/core/OffscreenSurface.java rename to Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/OffscreenSurface.java index 1481f5b6a..447a1416c 100644 --- a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/core/OffscreenSurface.java +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/OffscreenSurface.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.agora.api.component.gles.core; +package io.agora.api.example.common.gles.core; /** * Off-screen EGL surface (pbuffer). diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/Program.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/Program.java new file mode 100644 index 000000000..537e5d18d --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/Program.java @@ -0,0 +1,74 @@ +package io.agora.api.example.common.gles.core; + +import android.content.Context; +import android.opengl.GLES20; + +/** + * Created by tujh on 2018/1/24. + */ + +public abstract class Program { + private static final String TAG = GlUtil.TAG; + + // Handles to the GL program and various components of it. + protected int mProgramHandle; + + + protected Drawable2d mDrawable2d; + + /** + * Prepares the program in the current EGL context. + */ + public Program(String VERTEX_SHADER, String FRAGMENT_SHADER_2D) { + mProgramHandle = GlUtil.createProgram(VERTEX_SHADER, FRAGMENT_SHADER_2D); + mDrawable2d = getDrawable2d(); + getLocations(); + } + + public Program(Context context, int vertexShaderResourceId, int fragmentShaderResourceId) { + this(Extensions.readTextFileFromResource(context, vertexShaderResourceId), Extensions.readTextFileFromResource(context, fragmentShaderResourceId)); + } + + public void updateVertexArray(float[] FULL_RECTANGLE_COORDS) { + mDrawable2d.updateVertexArray(FULL_RECTANGLE_COORDS); + } + + public void updateTexCoordArray(float[] FULL_RECTANGLE_TEX_COORDS) { + mDrawable2d.updateTexCoordArray(FULL_RECTANGLE_TEX_COORDS); + } + + protected abstract Drawable2d getDrawable2d(); + + /** + * get locations of attributes and uniforms + */ + protected abstract void getLocations(); + + /** + * Issues the draw call. Does the full setup on every call. + */ + public abstract void drawFrame(int textureId, float[] texMatrix, float[] mvpMatrix); + + public void drawFrame(int textureId, float[] texMatrix) { + drawFrame(textureId, texMatrix, GlUtil.IDENTITY_MATRIX); + } + + public void drawFrame(int textureId, float[] texMatrix, float[] mvpMatrix, int x, int y, int width, int height) { + int[] originalViewport = new int[4]; + GLES20.glGetIntegerv(GLES20.GL_VIEWPORT, originalViewport, 0); + GLES20.glViewport(x, y, width, height); + drawFrame(textureId, texMatrix, mvpMatrix); + GLES20.glViewport(originalViewport[0], originalViewport[1], originalViewport[2], originalViewport[3]); + } + + /** + * Releases the program. + *

+ * The appropriate EGL context must be current (i.e. the one that was used to create + * the program). + */ + public void release() { + GLES20.glDeleteProgram(mProgramHandle); + mProgramHandle = -1; + } +} diff --git a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/core/WindowSurface.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/WindowSurface.java similarity index 98% rename from Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/core/WindowSurface.java rename to Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/WindowSurface.java index 1831f6f96..2c784f6ab 100644 --- a/Android/APIExample/lib-component/src/main/java/io/agora/api/component/gles/core/WindowSurface.java +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/WindowSurface.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.agora.api.component.gles.core; +package io.agora.api.example.common.gles.core; import android.graphics.SurfaceTexture; import android.view.Surface; diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/ExampleBean.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/ExampleBean.java new file mode 100644 index 000000000..331ae96a7 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/ExampleBean.java @@ -0,0 +1,99 @@ +package io.agora.api.example.common.model; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * @author cjw + */ +public class ExampleBean implements Parcelable { + private int index; + private String group; + private int name; + private int actionId; + private int tipsId; + + public ExampleBean(int index, String group, int name, int actionId, int tipsId) { + this.index = index; + this.group = group; + this.name = name; + this.actionId = actionId; + this.tipsId = tipsId; + } + + public int getIndex() { + return index; + } + + public void setIndex(int index) { + this.index = index; + } + + public String getGroup() { + return group; + } + + public void setGroup(String group) { + this.group = group; + } + + public int getName() { + return name; + } + + public void setName(int name) { + this.name = name; + } + + public int getActionId() { + return actionId; + } + + public void setActionId(int actionId) { + this.actionId = actionId; + } + + public int getTipsId() { + return tipsId; + } + + public void setTipsId(int tipsId) { + this.tipsId = tipsId; + } + + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(this.group); + dest.writeInt(this.name); + dest.writeInt(this.actionId); + dest.writeInt(this.tipsId); + } + + public ExampleBean() { + } + + protected ExampleBean(Parcel in) { + this.group = in.readString(); + this.name = in.readInt(); + this.actionId = in.readInt(); + this.tipsId = in.readInt(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public ExampleBean createFromParcel(Parcel source) { + return new ExampleBean(source); + } + + @Override + public ExampleBean[] newArray(int size) { + return new ExampleBean[size]; + } + }; +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/Examples.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/Examples.java new file mode 100644 index 000000000..80ec5092b --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/Examples.java @@ -0,0 +1,41 @@ +package io.agora.api.example.common.model; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.agora.api.example.annotation.Example; + +public class Examples { + public static final String BASIC = "BASIC"; + public static final String ADVANCED = "ADVANCED"; + + public static final Map> ITEM_MAP = new HashMap<>(); + + public static void addItem(@NonNull Example item) { + String group = item.group(); + List list = ITEM_MAP.get(group); + if (list == null) { + list = new ArrayList<>(); + ITEM_MAP.put(group, list); + } + list.add(item); + } + + public static void sortItem() { + for (Map.Entry> entry : ITEM_MAP.entrySet()) { + List exampleList = ITEM_MAP.get(entry.getKey()); + Collections.sort(exampleList, new Comparator() { + @Override + public int compare(Example o1, Example o2) { + return o1.index() - o2.index(); + } + }); + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/GlobalSettings.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/GlobalSettings.java new file mode 100644 index 000000000..47733e200 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/GlobalSettings.java @@ -0,0 +1,83 @@ +package io.agora.api.example.common.model; + +import android.text.TextUtils; + +import java.util.ArrayList; + +import io.agora.rtc2.Constants; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +public class GlobalSettings { + private String areaCodeStr = "GLOBAL"; + + // private cloud config + public String privateCloudIp = ""; + public boolean privateCloudLogReportEnable = false; + public String privateCloudLogServerDomain = ""; + public int privateCloudLogServerPort = 80; + public String privateCloudLogServerPath = ""; + public boolean privateCloudUseHttps = false; + // public String privateCloudIp = "10.62.0.85"; + // public boolean privateCloudLogReportEnable = true; + // public String privateCloudLogServerDomain = "10.72.0.29"; + // public int privateCloudLogServerPort = 442; + // public String privateCloudLogServerPath = "/kafka/log/upload/v1"; + // public boolean privateCloudUseHttps = true; + + public LocalAccessPointConfiguration getPrivateCloudConfig() { + LocalAccessPointConfiguration config = new LocalAccessPointConfiguration(); + if(TextUtils.isEmpty(privateCloudIp)){ + return null; + } + config.ipList = new ArrayList<>(); + config.ipList.add(privateCloudIp); + config.domainList = new ArrayList<>(); + config.mode = Constants.LOCAL_RPOXY_LOCAL_ONLY; + if (privateCloudLogReportEnable) { + LocalAccessPointConfiguration.AdvancedConfigInfo advancedConfig = new LocalAccessPointConfiguration.AdvancedConfigInfo(); + LocalAccessPointConfiguration.LogUploadServerInfo logUploadServer = new LocalAccessPointConfiguration.LogUploadServerInfo(); + logUploadServer.serverDomain = privateCloudLogServerDomain; + logUploadServer.serverPort = privateCloudLogServerPort; + logUploadServer.serverPath = privateCloudLogServerPath; + logUploadServer.serverHttps = privateCloudUseHttps; + + advancedConfig.logUploadServer = logUploadServer; + config.advancedConfig = advancedConfig; + } + return config; + } + + + public String getAreaCodeStr() { + return areaCodeStr; + } + + public void setAreaCodeStr(String areaCodeStr) { + this.areaCodeStr = areaCodeStr; + } + + public int getAreaCode(){ + if("CN".equals(areaCodeStr)){ + return RtcEngineConfig.AreaCode.AREA_CODE_CN; + } + else if("NA".equals(areaCodeStr)){ + return RtcEngineConfig.AreaCode.AREA_CODE_NA; + } + else if("EU".equals(areaCodeStr)){ + return RtcEngineConfig.AreaCode.AREA_CODE_EU; + } + else if("AS".equals(areaCodeStr)){ + return RtcEngineConfig.AreaCode.AREA_CODE_AS; + } + else if("JP".equals(areaCodeStr)){ + return RtcEngineConfig.AreaCode.AREA_CODE_JP; + } + else if("IN".equals(areaCodeStr)){ + return RtcEngineConfig.AreaCode.AREA_CODE_IN; + } + else{ + return RtcEngineConfig.AreaCode.AREA_CODE_GLOB; + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/Peer.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/Peer.java new file mode 100644 index 000000000..676f7c7ec --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/Peer.java @@ -0,0 +1,16 @@ +package io.agora.api.example.common.model; + +import java.nio.ByteBuffer; + +/** + * Created by wyylling@gmail.com on 03/01/2018. + */ + +public class Peer { + public int uid; + public ByteBuffer data; + public int width; + public int height; + public int rotation; + public long ts; +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/StatisticsInfo.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/StatisticsInfo.java new file mode 100644 index 000000000..0b41aa1eb --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/StatisticsInfo.java @@ -0,0 +1,167 @@ +package io.agora.api.example.common.model; + +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.IRtcEngineEventHandler.LocalVideoStats; +import io.agora.rtc2.IRtcEngineEventHandler.RemoteAudioStats; +import io.agora.rtc2.IRtcEngineEventHandler.RemoteVideoStats; + +public class StatisticsInfo { + private LocalVideoStats localVideoStats = new LocalVideoStats(); + private IRtcEngineEventHandler.LocalAudioStats localAudioStats = new IRtcEngineEventHandler.LocalAudioStats(); + private RemoteVideoStats remoteVideoStats = new RemoteVideoStats(); + private RemoteAudioStats remoteAudioStats = new RemoteAudioStats(); + private IRtcEngineEventHandler.RtcStats rtcStats = new IRtcEngineEventHandler.RtcStats(); + private int quality; + private IRtcEngineEventHandler.LastmileProbeResult lastMileProbeResult; + + public void setLocalVideoStats(LocalVideoStats localVideoStats) { + this.localVideoStats = localVideoStats; + } + + public void setLocalAudioStats(IRtcEngineEventHandler.LocalAudioStats localAudioStats) { + this.localAudioStats = localAudioStats; + } + + public void setRemoteVideoStats(RemoteVideoStats remoteVideoStats) { + this.remoteVideoStats = remoteVideoStats; + } + + public void setRemoteAudioStats(RemoteAudioStats remoteAudioStats) { + this.remoteAudioStats = remoteAudioStats; + } + + public void setRtcStats(IRtcEngineEventHandler.RtcStats rtcStats) { + this.rtcStats = rtcStats; + } + + public String getLocalVideoStats() { + StringBuilder builder = new StringBuilder(); + return builder + .append(""+localVideoStats.encodedFrameWidth) + .append("×") + .append(localVideoStats.encodedFrameHeight) + .append(",") + .append(localVideoStats.encoderOutputFrameRate) + .append("fps") + .append("\n") + .append("LM Delay: ") + .append(rtcStats.lastmileDelay) + .append("ms") + .append("\n") + .append("VSend: ") + .append(localVideoStats.sentBitrate) + .append("kbps") + .append("\n") + .append("ASend: ") + .append(localAudioStats.sentBitrate) + .append("kbps") + .append("\n") + .append("CPU: ") + .append(rtcStats.cpuAppUsage) + .append("%/") + .append(rtcStats.cpuTotalUsage) + .append("%/") + .append("\n") + .append("VSend Loss: ") + .append(rtcStats.txPacketLossRate) + .append("%") + .toString(); + } + + public String getRemoteVideoStats() { + StringBuilder builder = new StringBuilder(); + return builder + .append(remoteVideoStats.width) + .append("×") + .append(remoteVideoStats.height) + .append(",") + .append(remoteVideoStats.rendererOutputFrameRate) + .append("fps") + .append("\n") + .append("VRecv: ") + .append(remoteVideoStats.receivedBitrate) + .append("kbps") + .append("\n") + .append("ARecv: ") + .append(remoteAudioStats.receivedBitrate) + .append("kbps") + .append("\n") + .append("VLoss: ") + .append(remoteVideoStats.packetLossRate) + .append("%") + .append("\n") + .append("ALoss: ") + .append(remoteAudioStats.audioLossRate) + .append("%") + .append("\n") + .append("AQuality: ") + .append(remoteAudioStats.quality) + .toString(); + } + + public void setLastMileQuality(int quality) { + this.quality = quality; + } + + public String getLastMileQuality(){ + switch (quality){ + case 1: + return "EXCELLENT"; + case 2: + return "GOOD"; + case 3: + return "POOR"; + case 4: + return "BAD"; + case 5: + return "VERY BAD"; + case 6: + return "DOWN"; + case 7: + return "UNSUPPORTED"; + case 8: + return "DETECTING"; + default: + return "UNKNOWN"; + } + } + + public String getLastMileResult() { + if(lastMileProbeResult == null) + return null; + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("Rtt: ") + .append(lastMileProbeResult.rtt) + .append("ms") + .append("\n") + .append("DownlinkAvailableBandwidth: ") + .append(lastMileProbeResult.downlinkReport.availableBandwidth) + .append("Kbps") + .append("\n") + .append("DownlinkJitter: ") + .append(lastMileProbeResult.downlinkReport.jitter) + .append("ms") + .append("\n") + .append("DownlinkLoss: ") + .append(lastMileProbeResult.downlinkReport.packetLossRate) + .append("%") + .append("\n") + .append("UplinkAvailableBandwidth: ") + .append(lastMileProbeResult.uplinkReport.availableBandwidth) + .append("Kbps") + .append("\n") + .append("UplinkJitter: ") + .append(lastMileProbeResult.uplinkReport.jitter) + .append("ms") + .append("\n") + .append("UplinkLoss: ") + .append(lastMileProbeResult.uplinkReport.packetLossRate) + .append("%"); + return stringBuilder.toString(); + } + + public void setLastMileProbeResult(IRtcEngineEventHandler.LastmileProbeResult lastmileProbeResult) { + this.lastMileProbeResult = lastmileProbeResult; + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/AudioOnlyLayout.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/AudioOnlyLayout.java new file mode 100644 index 000000000..30c60870c --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/AudioOnlyLayout.java @@ -0,0 +1,71 @@ +package io.agora.api.example.common.widget; + +import android.content.Context; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TableLayout; +import android.widget.TableRow; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Map; + +import io.agora.api.example.R; + +public class AudioOnlyLayout extends FrameLayout { + + private TextView tvUserType, tvUserId; + private TableLayout tlState; + + public AudioOnlyLayout(@NonNull Context context) { + this(context, null); + } + + public AudioOnlyLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public AudioOnlyLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initView(); + } + + + private void initView(){ + View rootView = View.inflate(getContext(), R.layout.widget_audio_only_layout, this); + tvUserType = rootView.findViewById(R.id.tv_user_type); + tvUserId = rootView.findViewById(R.id.tv_user_id); + tlState = rootView.findViewById(R.id.table_layout_state); + } + + public void updateUserInfo(String uid, boolean isLocal){ + tvUserId.setText(uid + ""); + tvUserType.setText(isLocal ? "Local": "Remote"); + } + + public void updateStats(Map states){ + tlState.removeAllViews(); + if(states == null || states.size() <= 0){ + return; + } + + for (Map.Entry entry : states.entrySet()) { + TableRow row = new TableRow(getContext()); + TextView keyTv = new TextView(getContext()); + keyTv.setTextSize(10); + keyTv.setTextColor(Color.BLACK); + keyTv.setText(entry.getKey()); + row.addView(keyTv); + TextView valueTv = new TextView(getContext()); + valueTv.setTextSize(10); + valueTv.setTextColor(Color.BLACK); + valueTv.setText(entry.getValue()); + row.addView(valueTv); + tlState.addView(row); + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/AudioSeatManager.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/AudioSeatManager.java new file mode 100644 index 000000000..0e1292787 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/AudioSeatManager.java @@ -0,0 +1,131 @@ +package io.agora.api.example.common.widget; + +import android.view.View; + +import java.util.ArrayList; + +/** + * The type Audio seat manager. + */ +public class AudioSeatManager { + + private final AudioOnlyLayout[] audioOnlyLayouts; + + /** + * Instantiates a new Audio seat manager. + * + * @param seats the seats + */ + public AudioSeatManager(AudioOnlyLayout... seats) { + audioOnlyLayouts = new AudioOnlyLayout[seats.length]; + for (int i = 0; i < audioOnlyLayouts.length; i++) { + audioOnlyLayouts[i] = seats[i]; + } + } + + /** + * Up local seat. + * + * @param uid the uid + */ + public void upLocalSeat(int uid) { + AudioOnlyLayout localSeat = audioOnlyLayouts[0]; + localSeat.setTag(uid); + localSeat.setVisibility(View.VISIBLE); + localSeat.updateUserInfo(uid + "", true); + } + + /** + * Up remote seat. + * + * @param uid the uid + */ + public void upRemoteSeat(int uid) { + AudioOnlyLayout idleSeat = null; + for (AudioOnlyLayout audioOnlyLayout : audioOnlyLayouts) { + if (audioOnlyLayout.getTag() == null) { + idleSeat = audioOnlyLayout; + break; + } + } + if (idleSeat != null) { + idleSeat.setTag(uid); + idleSeat.setVisibility(View.VISIBLE); + idleSeat.updateUserInfo(uid + "", false); + } + } + + /** + * Get seat remote uid list array list. + * + * @return the array list + */ + public ArrayList getSeatRemoteUidList() { + ArrayList uidList = new ArrayList<>(); + for (int i = 1; i < audioOnlyLayouts.length; i++) { + AudioOnlyLayout audioOnlyLayout = audioOnlyLayouts[i]; + Object tag = audioOnlyLayout.getTag(); + if (tag instanceof Integer) { + uidList.add((Integer) tag); + } + } + return uidList; + } + + /** + * Down seat. + * + * @param uid the uid + */ + public void downSeat(int uid) { + AudioOnlyLayout seat = null; + for (AudioOnlyLayout audioOnlyLayout : audioOnlyLayouts) { + Object tag = audioOnlyLayout.getTag(); + if (tag instanceof Integer && (Integer) tag == uid) { + seat = audioOnlyLayout; + break; + } + } + if (seat != null) { + seat.setTag(null); + seat.setVisibility(View.INVISIBLE); + } + } + + /** + * Get local seat audio only layout. + * + * @return the audio only layout + */ + public AudioOnlyLayout getLocalSeat() { + return audioOnlyLayouts[0]; + } + + /** + * Get remote seat audio only layout. + * + * @param uid the uid + * @return the audio only layout + */ + public AudioOnlyLayout getRemoteSeat(int uid) { + AudioOnlyLayout seat = null; + for (AudioOnlyLayout audioOnlyLayout : audioOnlyLayouts) { + Object tag = audioOnlyLayout.getTag(); + if (tag instanceof Integer && (Integer) tag == uid) { + seat = audioOnlyLayout; + break; + } + } + return seat; + } + + /** + * Down all seats. + */ + public void downAllSeats() { + for (AudioOnlyLayout audioOnlyLayout : audioOnlyLayouts) { + audioOnlyLayout.setTag(null); + audioOnlyLayout.setVisibility(View.INVISIBLE); + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/VideoReportLayout.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/VideoReportLayout.java new file mode 100644 index 000000000..5210f4560 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/VideoReportLayout.java @@ -0,0 +1,111 @@ +package io.agora.api.example.common.widget; + +import android.content.Context; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.agora.api.example.common.model.StatisticsInfo; +import io.agora.rtc2.IRtcEngineEventHandler; + +public class VideoReportLayout extends FrameLayout { + + private final StatisticsInfo statisticsInfo = new StatisticsInfo(); + private TextView reportTextView; + private int reportUid = -1; + + public VideoReportLayout(@NonNull Context context) { + super(context); + } + + public VideoReportLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public VideoReportLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + super.addView(child, index, params); + + if (child instanceof SurfaceView || child instanceof TextureView) { + reportTextView = new TextView(getContext()); + reportTextView.addOnAttachStateChangeListener(new OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + + } + + @Override + public void onViewDetachedFromWindow(View v) { + reportTextView.removeOnAttachStateChangeListener(this); + reportTextView = null; + } + }); + reportTextView.setTextColor(Color.parseColor("#eeeeee")); + LayoutParams reportParams = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + reportParams.topMargin = reportParams.leftMargin = 16; + addView(reportTextView, reportParams); + } + } + + public void setReportUid(int uid) { + this.reportUid = uid; + } + + public int getReportUid() { + return reportUid; + } + + public void setLocalAudioStats(IRtcEngineEventHandler.LocalAudioStats stats){ + statisticsInfo.setLocalAudioStats(stats); + setReportText(statisticsInfo.getLocalVideoStats()); + } + + public void setLocalVideoStats(IRtcEngineEventHandler.LocalVideoStats stats){ + if (stats.uid != reportUid) { + return; + } + statisticsInfo.setLocalVideoStats(stats); + setReportText(statisticsInfo.getLocalVideoStats()); + } + + public void setRemoteAudioStats(IRtcEngineEventHandler.RemoteAudioStats stats){ + if (stats.uid != reportUid) { + return; + } + statisticsInfo.setRemoteAudioStats(stats); + setReportText(statisticsInfo.getRemoteVideoStats()); + } + + public void setRemoteVideoStats(IRtcEngineEventHandler.RemoteVideoStats stats){ + if (stats.uid != reportUid) { + return; + } + statisticsInfo.setRemoteVideoStats(stats); + setReportText(statisticsInfo.getRemoteVideoStats()); + } + + + private void setReportText(String reportText) { + if(reportTextView != null){ + reportTextView.post(new Runnable() { + @Override + public void run() { + reportTextView.setText(reportText); + } + }); + } + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/WaveformView.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/WaveformView.java new file mode 100644 index 000000000..6b7bb9062 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/WaveformView.java @@ -0,0 +1,209 @@ +package io.agora.api.example.common.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; + +import io.agora.api.example.R; + +public class WaveformView extends View { + private ArrayList datas = new ArrayList<>(); + private short max = 100; + private float mWidth; + private float mHeight; + private float space =1f; + private Paint mWavePaint; + private Paint baseLinePaint; + private int mWaveColor = Color.WHITE; + private int mBaseLineColor = Color.WHITE; + private float waveStrokeWidth = 4f; + private int invalidateTime = 1000 / 100; + private long drawTime; + private boolean isMaxConstant = false; + + public WaveformView(Context context) { + this(context, null); + } + + public WaveformView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public WaveformView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs, defStyleAttr); + } + + private void init(AttributeSet attrs, int defStyle) { + final TypedArray a = getContext().obtainStyledAttributes( + attrs, R.styleable.WaveView, defStyle, 0); + mWaveColor = a.getColor( + R.styleable.WaveView_waveColor, + mWaveColor); + mBaseLineColor = a.getColor( + R.styleable.WaveView_baselineColor, + mBaseLineColor); + + waveStrokeWidth = a.getDimension( + R.styleable.WaveView_waveStokeWidth, + waveStrokeWidth); + + max = (short) a.getInt(R.styleable.WaveView_maxValue, max); + invalidateTime = a.getInt(R.styleable.WaveView_invalidateTime, invalidateTime); + + space = a.getDimension(R.styleable.WaveView_space, space); + a.recycle(); + initPainters(); + + } + + private void initPainters() { + mWavePaint = new Paint(); + mWavePaint.setColor(mWaveColor);// Set paint color + mWavePaint.setStrokeWidth(waveStrokeWidth);// Set paint stroke width + mWavePaint.setAntiAlias(true); + mWavePaint.setFilterBitmap(true); + mWavePaint.setStrokeCap(Paint.Cap.ROUND); + mWavePaint.setStyle(Paint.Style.FILL); + Shader shader = new LinearGradient(0, 0, 1000, 0, 0xffffffff, 0xFFe850ee, Shader.TileMode.CLAMP); + mWavePaint.setShader(shader); + baseLinePaint = new Paint(); + baseLinePaint.setColor(mBaseLineColor);// Set paint color + baseLinePaint.setStrokeWidth(1f);// Set paint stroke width + baseLinePaint.setAntiAlias(true); + baseLinePaint.setFilterBitmap(true); + baseLinePaint.setStyle(Paint.Style.FILL); + } + + public short getMax() { + return max; + } + + public void setMax(short max) { + this.max = max; + } + + public float getSpace() { + return space; + } + + public void setSpace(float space) { + this.space = space; + } + + public int getmWaveColor() { + return mWaveColor; + } + + public void setmWaveColor(int mWaveColor) { + this.mWaveColor = mWaveColor; + invalidateNow(); + } + + public int getmBaseLineColor() { + return mBaseLineColor; + } + + public void setmBaseLineColor(int mBaseLineColor) { + this.mBaseLineColor = mBaseLineColor; + invalidateNow(); + } + + public float getWaveStrokeWidth() { + return waveStrokeWidth; + } + + public void setWaveStrokeWidth(float waveStrokeWidth) { + this.waveStrokeWidth = waveStrokeWidth; + invalidateNow(); + } + + public int getInvalidateTime() { + return invalidateTime; + } + + public void setInvalidateTime(int invalidateTime) { + this.invalidateTime = invalidateTime; + } + + public boolean isMaxConstant() { + return isMaxConstant; + } + + public void setMaxConstant(boolean maxConstant) { + isMaxConstant = maxConstant; + } + + /** + * If you change the corresponding configuration, you need to refresh the paint settings + */ + public void invalidateNow() { + initPainters(); + invalidate(); + } + + public void addData(short data) { + + if (data < 0) { + data = (short) -data; + } + if (data > max && !isMaxConstant) { + max = data; + } + if (datas.size() > mWidth / space) { + synchronized (this) { + datas.remove(0); + datas.add(data); + } + } else { + datas.add(data); + } + if (System.currentTimeMillis() - drawTime > invalidateTime) { + invalidate(); + drawTime = System.currentTimeMillis(); + } + + } + + public void clear() { + datas.clear(); + invalidateNow(); + } + + + @Override + protected void onDraw(Canvas canvas) { + canvas.translate(0, mHeight / 2); + drawBaseLine(canvas); + drawWave(canvas); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + mWidth = w; + mHeight = h; + } + + private void drawWave(Canvas mCanvas) { + for (int i = 0; i < datas.size(); i++) { + float x = (i) * space; + float y = (float) datas.get(i) / max * mHeight / 2; + mCanvas.drawLine(x, -y, x, y, mWavePaint); + } + + } + + private void drawBaseLine(Canvas mCanvas) { + mCanvas.drawLine(0, 0, mWidth, 0, baseLinePaint); + } +} \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java new file mode 100644 index 000000000..40ea64b86 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java @@ -0,0 +1,546 @@ +package io.agora.api.example.examples.advanced; + +import static io.agora.api.example.common.model.Examples.ADVANCED; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.EditText; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.LinkedHashMap; +import java.util.Map; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.Constant; +import io.agora.api.example.common.widget.AudioSeatManager; +import io.agora.api.example.utils.CommonUtil; +import io.agora.api.example.utils.PermissonUtils; +import io.agora.api.example.utils.TokenUtils; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.Constants; +import io.agora.rtc2.IAudioEffectManager; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +@Example( + index = 15, + group = ADVANCED, + name = R.string.item_playaudiofiles, + actionId = R.id.action_mainFragment_to_PlayAudioFiles, + tipsId = R.string.playaudiofiles +) +public class PlayAudioFiles extends BaseFragment implements View.OnClickListener, + SeekBar.OnSeekBarChangeListener, AdapterView.OnItemSelectedListener { + private static final String TAG = PlayAudioFiles.class.getSimpleName(); + private static final int EFFECT_SOUND_ID = 0; + private EditText et_channel; + private Button join; + private Spinner audioProfile, audioScenario; + private TextView mixingStart, mixingResume, mixingPause, mixingStop, + effectStart, effectResume, effectPause, effectStop; + private SeekBar mixingPublishVolBar, mixingPlayoutVolBar, mixingVolBar, effectVolBar; + private RtcEngine engine; + private int myUid; + private boolean joined = false; + private IAudioEffectManager audioEffectManager; + + private AudioSeatManager audioSeatManager; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handler = new Handler(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_play_audio_files, container, false); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + join = view.findViewById(R.id.btn_join); + et_channel = view.findViewById(R.id.et_channel); + join.setOnClickListener(this); + audioProfile = view.findViewById(R.id.audio_profile_spinner); + audioScenario = view.findViewById(R.id.audio_scenario_spinner); + audioScenario.setOnItemSelectedListener(this); + + // mixing + mixingStart = view.findViewById(R.id.mixing_start); + mixingResume = view.findViewById(R.id.mixing_resume); + mixingPause = view.findViewById(R.id.mixing_pause); + mixingStop = view.findViewById(R.id.mixing_stop); + mixingVolBar = view.findViewById(R.id.mixingVolBar); + mixingPlayoutVolBar = view.findViewById(R.id.mixingPlayoutVolBar); + mixingPublishVolBar = view.findViewById(R.id.mixingPublishVolBar); + + mixingStart.setOnClickListener(this); + mixingResume.setOnClickListener(this); + mixingPause.setOnClickListener(this); + mixingStop.setOnClickListener(this); + mixingVolBar.setOnSeekBarChangeListener(this); + mixingPlayoutVolBar.setOnSeekBarChangeListener(this); + mixingPublishVolBar.setOnSeekBarChangeListener(this); + + // effect + effectStart = view.findViewById(R.id.effect_start); + effectResume = view.findViewById(R.id.effect_resume); + effectPause = view.findViewById(R.id.effect_pause); + effectStop = view.findViewById(R.id.effect_stop); + effectVolBar = view.findViewById(R.id.effectVolBar); + + effectStart.setOnClickListener(this); + effectResume.setOnClickListener(this); + effectPause.setOnClickListener(this); + effectStop.setOnClickListener(this); + effectVolBar.setOnSeekBarChangeListener(this); + + audioSeatManager = new AudioSeatManager( + view.findViewById(R.id.audio_place_01), + view.findViewById(R.id.audio_place_02) + ); + + resetLayoutByJoin(); + } + + private void resetLayoutByJoin() { + audioProfile.setEnabled(!joined); + + mixingStart.setClickable(joined); + mixingResume.setClickable(joined); + mixingPause.setClickable(joined); + mixingStop.setClickable(joined); + mixingVolBar.setEnabled(joined); + mixingPlayoutVolBar.setEnabled(joined); + mixingPublishVolBar.setEnabled(joined); + + effectStart.setClickable(joined); + effectResume.setClickable(joined); + effectPause.setClickable(joined); + effectStop.setClickable(joined); + effectVolBar.setEnabled(joined); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null) { + return; + } + try { + RtcEngineConfig config = new RtcEngineConfig(); + /** + * The context of Android Activity + */ + config.mContext = context.getApplicationContext(); + /** + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = getString(R.string.agora_app_id); + /** Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT); + config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = RtcEngine.create(config); + /** + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + preloadAudioEffect(); + } catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + } + + /** + * To ensure smooth communication, limit the size of the audio effect file. + * We recommend using this method to preload the audio effect before calling the joinChannel method. + */ + private void preloadAudioEffect() { + // Gets the global audio effect manager. + audioEffectManager = engine.getAudioEffectManager(); + // Preloads the audio effect (recommended). Note the file size, and preload the file before joining the channel. + // Only mp3, aac, m4a, 3gp, and wav files are supported. + // You may need to record the sound IDs and their file paths. + audioEffectManager.preloadEffect(EFFECT_SOUND_ID, Constant.EFFECT_FILE_PATH); + } + + @Override + public void onDestroy() { + super.onDestroy(); + /**leaveChannel and Destroy the RtcEngine instance*/ + if (engine != null) { + engine.leaveChannel(); + } + handler.post(RtcEngine::destroy); + engine = null; + } + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (parent == audioScenario) { + engine.setAudioScenario(Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString()))); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + + @Override + public void onClick(View v) { + if (v == join) { + if (!joined) { + CommonUtil.hideInputBoard(getActivity(), et_channel); + // call when join button hit + String channelId = et_channel.getText().toString(); + // Check permission + checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { + @Override + public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { + if (allPermissionsGranted) { + // Permissions Granted + joinChannel(channelId); + } + } + }); + } else { + joined = false; + /**After joining a channel, the user must call the leaveChannel method to end the + * call before joining another channel. This method returns 0 if the user leaves the + * channel and releases all resources related to the call. This method call is + * asynchronous, and the user has not exited the channel when the method call returns. + * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback. + * A successful leaveChannel method call triggers the following callbacks: + * 1:The local client: onLeaveChannel. + * 2:The remote client: onUserOffline, if the user leaving the channel is in the + * Communication channel, or is a BROADCASTER in the Live Broadcast profile. + * @returns 0: Success. + * < 0: Failure. + * PS: + * 1:If you call the destroy method immediately after calling the leaveChannel + * method, the leaveChannel process interrupts, and the SDK does not trigger + * the onLeaveChannel callback. + * 2:If you call the leaveChannel method during CDN live streaming, the SDK + * triggers the removeInjectStreamUrl method.*/ + engine.leaveChannel(); + join.setText(getString(R.string.join)); + resetLayoutByJoin(); + audioSeatManager.downAllSeats(); + } + } else if (v == mixingStart) { + int ret = engine.startAudioMixing(Constant.MIX_FILE_PATH, false, -1, 0); + Log.i(TAG, "startAudioMixing >> ret=" + ret); + } else if (v == mixingResume) { + int ret = engine.resumeAudioMixing(); + Log.i(TAG, "resumeAudioMixing >> ret=" + ret); + } else if (v == mixingPause) { + int ret = engine.pauseAudioMixing(); + Log.i(TAG, "pauseAudioMixing >> ret=" + ret); + } else if (v == mixingStop) { + int ret = engine.stopAudioMixing(); + Log.i(TAG, "stopAudioMixing >> ret=" + ret); + } else if (v == effectStart) { + /** Plays an audio effect file. + * Returns + * 0: Success. + * < 0: Failure. + */ + int playRet = audioEffectManager.playEffect( + EFFECT_SOUND_ID, // The sound ID of the audio effect file to be played. + Constant.EFFECT_FILE_PATH, // The file path of the audio effect file. + -1, // The number of playback loops. -1 means an infinite loop. + 1, // pitch The pitch of the audio effect. The value ranges between 0.5 and 2. The default value is 1 (no change to the pitch). The lower the value, the lower the pitch. + 0.0, // Sets the spatial position of the effect. 0 means the effect shows ahead. + 100, // Sets the volume. The value ranges between 0 and 100. 100 is the original volume. + true // Sets whether to publish the audio effect. + ); + Log.i(TAG, "result playRet:" + playRet); + } else if (v == effectResume) { + int ret = engine.resumeEffect(EFFECT_SOUND_ID); + Log.i(TAG, "resumeEffect >> ret=" + ret); + } else if (v == effectPause) { + int ret = engine.pauseEffect(EFFECT_SOUND_ID); + Log.i(TAG, "resumeEffect >> ret=" + ret); + } else if (v == effectStop) { + int ret = engine.stopEffect(EFFECT_SOUND_ID); + Log.i(TAG, "resumeEffect >> ret=" + ret); + } + } + + /** + * @param channelId Specify the channel name that you want to join. + * Users that input the same channel name join the same channel. + */ + private void joinChannel(String channelId) { + /**In the demo, the default is to enter as the anchor.*/ + engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); + engine.setAudioProfile( + Constants.AudioProfile.getValue(Constants.AudioProfile.valueOf(audioProfile.getSelectedItem().toString())), + Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString())) + ); + + /**Please configure accessToken in the string_config file. + * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see + * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token + * A token generated at the server. This applies to scenarios with high-security requirements. For details, see + * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ + TokenUtils.gen(requireContext(), channelId, 0, ret -> { + /** Allows a user to join a channel. + if you do not specify the uid, we will generate the uid for you*/ + ChannelMediaOptions option = new ChannelMediaOptions(); + option.autoSubscribeAudio = true; + option.autoSubscribeVideo = true; + int res = engine.joinChannel(ret, channelId, 0, option); + if (res != 0) { + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + Log.e(TAG, RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + join.setEnabled(false); + }); + } + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { + /** + * Error code description can be found at: + * en: https://api-ref.agora.io/en/voice-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/voice-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int err) { + Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + } + + /**Occurs when a user leaves the channel. + * @param stats With this callback, the application retrieves the channel information, + * such as the call duration and statistics.*/ + @Override + public void onLeaveChannel(RtcStats stats) { + super.onLeaveChannel(stats); + Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); + showLongToast(String.format("local user %d leaveChannel!", myUid)); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + myUid = uid; + joined = true; + handler.post(new Runnable() { + @Override + public void run() { + join.setEnabled(true); + join.setText(getString(R.string.leave)); + resetLayoutByJoin(); + audioSeatManager.upLocalSeat(uid); + } + }); + } + + @Override + public void onLocalAudioStats(LocalAudioStats stats) { + super.onLocalAudioStats(stats); + runOnUIThread(() -> { + Map _stats = new LinkedHashMap<>(); + _stats.put("sentSampleRate", stats.sentSampleRate + ""); + _stats.put("sentBitrate", stats.sentBitrate + " kbps"); + _stats.put("internalCodec", stats.internalCodec + ""); + _stats.put("audioDeviceDelay", stats.audioDeviceDelay + " ms"); + audioSeatManager.getLocalSeat().updateStats(_stats); + }); + } + + @Override + public void onRemoteAudioStats(RemoteAudioStats stats) { + super.onRemoteAudioStats(stats); + runOnUIThread(() -> { + Map _stats = new LinkedHashMap<>(); + _stats.put("numChannels", stats.numChannels + ""); + _stats.put("receivedBitrate", stats.receivedBitrate + " kbps"); + _stats.put("audioLossRate", stats.audioLossRate + ""); + _stats.put("jitterBufferDelay", stats.jitterBufferDelay + " ms"); + audioSeatManager.getRemoteSeat(stats.uid).updateStats(_stats); + }); + } + + /**Since v2.9.0. + * This callback indicates the state change of the remote audio stream. + * PS: This callback does not work properly when the number of users (in the Communication profile) or + * broadcasters (in the Live-broadcast profile) in the channel exceeds 17. + * @param uid ID of the user whose audio state changes. + * @param state State of the remote audio + * REMOTE_AUDIO_STATE_STOPPED(0): The remote audio is in the default state, probably due + * to REMOTE_AUDIO_REASON_LOCAL_MUTED(3), REMOTE_AUDIO_REASON_REMOTE_MUTED(5), + * or REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7). + * REMOTE_AUDIO_STATE_STARTING(1): The first remote audio packet is received. + * REMOTE_AUDIO_STATE_DECODING(2): The remote audio stream is decoded and plays normally, + * probably due to REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2), + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4) or REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6). + * REMOTE_AUDIO_STATE_FROZEN(3): The remote audio is frozen, probably due to + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1). + * REMOTE_AUDIO_STATE_FAILED(4): The remote audio fails to start, probably due to + * REMOTE_AUDIO_REASON_INTERNAL(0). + * @param reason The reason of the remote audio state change. + * REMOTE_AUDIO_REASON_INTERNAL(0): Internal reasons. + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1): Network congestion. + * REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2): Network recovery. + * REMOTE_AUDIO_REASON_LOCAL_MUTED(3): The local user stops receiving the remote audio + * stream or disables the audio module. + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote audio + * stream or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_MUTED(5): The remote user stops sending the audio stream or + * disables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the audio stream + * or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel. + * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method + * until the SDK triggers this callback.*/ + @Override + public void onRemoteAudioStateChanged(int uid, int state, int reason, int elapsed) { + super.onRemoteAudioStateChanged(uid, state, reason, elapsed); + Log.i(TAG, "onRemoteAudioStateChanged->" + uid + ", state->" + state + ", reason->" + reason); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. + * @param uid ID of the user whose audio state changes. + * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole + * until this callback is triggered.*/ + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + Log.i(TAG, "onUserJoined->" + uid); + showLongToast(String.format("user %d joined!", uid)); + runOnUIThread(() -> audioSeatManager.upRemoteSeat(uid)); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. + * @param uid ID of the user whose audio state changes. + * @param reason Reason why the user goes offline: + * USER_OFFLINE_QUIT(0): The user left the current channel. + * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data + * packet was received within a certain period of time. If a user quits the + * call and the message is not passed to the SDK (due to an unreliable channel), + * the SDK assumes the user dropped offline. + * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from + * the host to the audience.*/ + @Override + public void onUserOffline(int uid, int reason) { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); + showLongToast(String.format("user %d offline! reason:%d", uid, reason)); + runOnUIThread(() -> audioSeatManager.downSeat(uid)); + } + + @Override + public void onAudioMixingStateChanged(int state, int errorCode) { + showLongToast(String.format("onAudioMixingStateChanged %d error code:%d", state, errorCode)); + } + + @Override + public void onAudioMixingFinished() { + super.onAudioMixingFinished(); + } + }; + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (seekBar.getId() == R.id.mixingPublishVolBar) { + /** + * Adjusts the volume of audio mixing for publishing (sending to other users). + * @param volume: Audio mixing volume for publishing. The value ranges between 0 and 100 (default). + */ + engine.adjustAudioMixingPublishVolume(progress); + } else if (seekBar.getId() == R.id.mixingPlayoutVolBar) { + /** + * Adjusts the volume of audio mixing for local playback. + * @param volume: Audio mixing volume for local playback. The value ranges between 0 and 100 (default). + */ + engine.adjustAudioMixingPlayoutVolume(progress); + } else if (seekBar.getId() == R.id.mixingVolBar) { + /** + * Adjusts the volume of audio mixing. + * Call this method when you are in a channel. + * @param volume: Audio mixing volume. The value ranges between 0 and 100 (default). + */ + engine.adjustAudioMixingVolume(progress); + } else if (seekBar.getId() == R.id.effectVolBar) { + engine.setEffectsVolume(progress); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java new file mode 100644 index 000000000..ec1b50481 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java @@ -0,0 +1,349 @@ +package io.agora.api.example.examples.advanced; + +import static io.agora.api.example.common.model.Examples.ADVANCED; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Random; +import java.util.Timer; +import java.util.TimerTask; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.model.StatisticsInfo; +import io.agora.api.example.utils.TokenUtils; +import io.agora.rtc2.Constants; +import io.agora.rtc2.EchoTestConfiguration; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.internal.LastmileProbeConfig; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +@Example( + index = 16, + group = ADVANCED, + name = R.string.item_precalltest, + actionId = R.id.action_mainFragment_to_PreCallTest, + tipsId = R.string.precalltest +) +public class PreCallTest extends BaseFragment implements View.OnClickListener { + private static final String TAG = PreCallTest.class.getSimpleName(); + + private RtcEngine engine; + private int myUid; + private Button btn_lastmile, btn_echo; + private StatisticsInfo statisticsInfo; + private TextView lastmileQuality, lastmileResult; + private static final Integer MAX_COUNT_DOWN = 8; + private int num; + private Timer echoTimer; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handler = new Handler(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_precall_test, container, false); + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null) { + return; + } + try { + RtcEngineConfig config = new RtcEngineConfig(); + /** + * The context of Android Activity + */ + config.mContext = context.getApplicationContext(); + /** + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = getString(R.string.agora_app_id); + /** Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime evepnts. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT); + config.mAreaCode = ((MainApplication)getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = RtcEngine.create(config); + /** + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + } + catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + statisticsInfo = new StatisticsInfo(); + btn_echo = view.findViewById(R.id.btn_echo); + btn_echo.setOnClickListener(this); + btn_lastmile = view.findViewById(R.id.btn_lastmile); + btn_lastmile.setOnClickListener(this); + lastmileQuality = view.findViewById(R.id.lastmile_quality); + lastmileResult = view.findViewById(R.id.lastmile_result); + } + + @Override + public void onDestroy() { + super.onDestroy(); + RtcEngine.destroy(); + } + + @Override + public void onClick(View v) { + if (v.getId() == R.id.btn_lastmile) + { + // Configure a LastmileProbeConfig instance. + LastmileProbeConfig config = new LastmileProbeConfig(){}; + // Probe the uplink network quality. + config.probeUplink = true; + // Probe the downlink network quality. + config.probeDownlink = true; + // The expected uplink bitrate (bps). The value range is [100000, 5000000]. + config.expectedUplinkBitrate = 100000; + // The expected downlink bitrate (bps). The value range is [100000, 5000000]. + config.expectedDownlinkBitrate = 100000; + // Start the last-mile network test before joining the channel. + engine.startLastmileProbeTest(config); + btn_lastmile.setEnabled(false); + btn_lastmile.setText("Testing ..."); + } + else if (v.getId() == R.id.btn_echo){ + String channelId = "AudioEchoTest" + (new Random().nextInt(1000) + 10000); + TokenUtils.genToken(requireContext(), channelId, 0, ret -> { + if (ret == null) { + showAlert("Gen token error"); + return; + } + num = 0; + engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); + EchoTestConfiguration config = new EchoTestConfiguration(); + config.enableVideo = false; + config.enableAudio = true; + config.intervalInSeconds = MAX_COUNT_DOWN; + config.channelId = channelId; + config.token = ret; + engine.startEchoTest(config); + btn_echo.setEnabled(false); + btn_echo.setText("Recording on Microphone ..."); + echoTimer = new Timer(true); + echoTimer.schedule(new TimerTask(){ + public void run() { + num++; + if(num >= MAX_COUNT_DOWN * 2){ + handler.post(() -> { + btn_echo.setEnabled(true); + btn_echo.setText(R.string.start); + }); + engine.stopEchoTest(); + echoTimer.cancel(); + } + else if(num >= MAX_COUNT_DOWN) { + handler.post(() -> btn_echo.setText("PLaying with " + (MAX_COUNT_DOWN * 2 - num) + "Seconds")); + } + else{ + handler.post(() -> btn_echo.setText("Recording with " + (MAX_COUNT_DOWN - num) + "Seconds")); + } + } + }, 1000, 1000); + }); + } + } + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { + /** + * Error code description can be found at: + * en: https://api-ref.agora.io/en/voice-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/voice-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int err) { + Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + } + + /**Occurs when a user leaves the channel. + * @param stats With this callback, the application retrieves the channel information, + * such as the call duration and statistics.*/ + @Override + public void onLeaveChannel(RtcStats stats) { + super.onLeaveChannel(stats); + Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); + showLongToast(String.format("local user %d leaveChannel!", myUid)); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + myUid = uid; + } + + /**Since v2.9.0. + * This callback indicates the state change of the remote audio stream. + * PS: This callback does not work properly when the number of users (in the Communication profile) or + * broadcasters (in the Live-broadcast profile) in the channel exceeds 17. + * @param uid ID of the user whose audio state changes. + * @param state State of the remote audio + * REMOTE_AUDIO_STATE_STOPPED(0): The remote audio is in the default state, probably due + * to REMOTE_AUDIO_REASON_LOCAL_MUTED(3), REMOTE_AUDIO_REASON_REMOTE_MUTED(5), + * or REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7). + * REMOTE_AUDIO_STATE_STARTING(1): The first remote audio packet is received. + * REMOTE_AUDIO_STATE_DECODING(2): The remote audio stream is decoded and plays normally, + * probably due to REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2), + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4) or REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6). + * REMOTE_AUDIO_STATE_FROZEN(3): The remote audio is frozen, probably due to + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1). + * REMOTE_AUDIO_STATE_FAILED(4): The remote audio fails to start, probably due to + * REMOTE_AUDIO_REASON_INTERNAL(0). + * @param reason The reason of the remote audio state change. + * REMOTE_AUDIO_REASON_INTERNAL(0): Internal reasons. + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1): Network congestion. + * REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2): Network recovery. + * REMOTE_AUDIO_REASON_LOCAL_MUTED(3): The local user stops receiving the remote audio + * stream or disables the audio module. + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote audio + * stream or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_MUTED(5): The remote user stops sending the audio stream or + * disables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the audio stream + * or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel. + * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method + * until the SDK triggers this callback.*/ + @Override + public void onRemoteAudioStateChanged(int uid, int state, int reason, int elapsed) { + super.onRemoteAudioStateChanged(uid, state, reason, elapsed); + Log.i(TAG, "onRemoteAudioStateChanged->" + uid + ", state->" + state + ", reason->" + reason); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. + * @param uid ID of the user whose audio state changes. + * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole + * until this callback is triggered.*/ + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + Log.i(TAG, "onUserJoined->" + uid); + showLongToast(String.format("user %d joined!", uid)); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. + * @param uid ID of the user whose audio state changes. + * @param reason Reason why the user goes offline: + * USER_OFFLINE_QUIT(0): The user left the current channel. + * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data + * packet was received within a certain period of time. If a user quits the + * call and the message is not passed to the SDK (due to an unreliable channel), + * the SDK assumes the user dropped offline. + * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from + * the host to the audience.*/ + @Override + public void onUserOffline(int uid, int reason) { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); + showLongToast(String.format("user %d offline! reason:%d", uid, reason)); + } + + /** + * Implemented in the global IRtcEngineEventHandler class. + * Triggered 2 seconds after starting the last-mile test. + * @param quality + */ + @Override + public void onLastmileQuality(int quality){ + statisticsInfo.setLastMileQuality(quality); + updateLastMileResult(); + } + + /** + * Implemented in the global IRtcEngineEventHandler class. + * Triggered 30 seconds after starting the last-mile test. + * @param lastmileProbeResult + */ + @Override + public void onLastmileProbeResult(LastmileProbeResult lastmileProbeResult) { + // (1) Stop the test. Agora recommends not calling any other API method before the test ends. + engine.stopLastmileProbeTest(); + statisticsInfo.setLastMileProbeResult(lastmileProbeResult); + updateLastMileResult(); + handler.post(() -> { + btn_lastmile.setEnabled(true); + btn_lastmile.setText(R.string.start); + }); + } + + }; + + private void updateLastMileResult() { + handler.post(() -> { + if(statisticsInfo.getLastMileQuality() != null){ + lastmileQuality.setText("Quality: " + statisticsInfo.getLastMileQuality()); + } + if(statisticsInfo.getLastMileResult() != null){ + lastmileResult.setText(statisticsInfo.getLastMileResult()); + } + }); + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java new file mode 100644 index 000000000..fa2112fe8 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java @@ -0,0 +1,450 @@ +package io.agora.api.example.examples.advanced; + +import static io.agora.api.example.common.model.Examples.ADVANCED; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.Switch; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.widget.AudioSeatManager; +import io.agora.api.example.utils.CommonUtil; +import io.agora.api.example.utils.PermissonUtils; +import io.agora.api.example.utils.TokenUtils; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.Constants; +import io.agora.rtc2.IAudioFrameObserver; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.audio.AudioParams; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +/** + * This demo demonstrates how to make a one-to-one voice call + * + * @author cjw + */ +@Example( + index = 9, + group = ADVANCED, + name = R.string.item_raw_audio, + actionId = R.id.action_mainFragment_raw_audio, + tipsId = R.string.rawaudio +) +public class ProcessAudioRawData extends BaseFragment implements View.OnClickListener, CompoundButton.OnCheckedChangeListener { + private static final String TAG = ProcessAudioRawData.class.getSimpleName(); + private EditText et_channel; + private Button join; + private Switch writeBackAudio; + private RtcEngine engine; + private int myUid; + private boolean joined = false; + private volatile boolean isWriteBackAudio = false; + private static final Integer SAMPLE_RATE = 44100; + private static final Integer SAMPLE_NUM_OF_CHANNEL = 1; + private static final Integer SAMPLES = 1024; + private static final String AUDIO_FILE = "output.raw"; + private InputStream inputStream; + + private AudioSeatManager audioSeatManager; + + private void openAudioFile(){ + try { + inputStream = this.getResources().getAssets().open(AUDIO_FILE); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void closeAudioFile(){ + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private byte[] readBuffer(){ + int byteSize = SAMPLES * SAMPLE_NUM_OF_CHANNEL * 2; + byte[] buffer = new byte[byteSize]; + try { + if(inputStream.read(buffer) < 0){ + inputStream.reset(); + return readBuffer(); + } + } catch (IOException e) { + e.printStackTrace(); + } + return buffer; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handler = new Handler(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_raw_audio, container, false); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + join = view.findViewById(R.id.btn_join); + et_channel = view.findViewById(R.id.et_channel); + view.findViewById(R.id.btn_join).setOnClickListener(this); + writeBackAudio = view.findViewById(R.id.writebackAudio); + writeBackAudio.setOnCheckedChangeListener(this); + + audioSeatManager = new AudioSeatManager( + view.findViewById(R.id.audio_place_01), + view.findViewById(R.id.audio_place_02), + view.findViewById(R.id.audio_place_03), + view.findViewById(R.id.audio_place_04), + view.findViewById(R.id.audio_place_05), + view.findViewById(R.id.audio_place_06), + view.findViewById(R.id.audio_place_07), + view.findViewById(R.id.audio_place_08), + view.findViewById(R.id.audio_place_09) + ); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null) { + return; + } + try { + RtcEngineConfig config = new RtcEngineConfig(); + /** + * The context of Android Activity + */ + config.mContext = context.getApplicationContext(); + /** + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = getString(R.string.agora_app_id); + /** Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAreaCode = ((MainApplication)getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = RtcEngine.create(config); + /** + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + engine.registerAudioFrameObserver(iAudioFrameObserver); + engine.setRecordingAudioFrameParameters(SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL, Constants.RAW_AUDIO_FRAME_OP_MODE_READ_WRITE, SAMPLES); + engine.setPlaybackAudioFrameParameters(SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL, Constants.RAW_AUDIO_FRAME_OP_MODE_READ_WRITE, SAMPLES); + openAudioFile(); + } + catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + } + + private byte[] audioAggregate(byte[] origin, byte[] buffer) { + byte[] output = new byte[buffer.length]; + for (int i = 0; i < origin.length; i++) { + output[i] = (byte) ((long) origin[i] / 2 + (long) buffer[i] / 2); + if(i == 2){ + Log.i(TAG, "origin :" + (int) origin[i] + " audio: " + (int) buffer[i]); + } + } + return output; + } + + @Override + public void onDestroy() { + super.onDestroy(); + /**leaveChannel and Destroy the RtcEngine instance*/ + if (engine != null) { + engine.leaveChannel(); + } + handler.post(RtcEngine::destroy); + engine = null; + closeAudioFile(); + } + + @Override + public void onClick(View v) { + if (v.getId() == R.id.btn_join) { + if (!joined) { + CommonUtil.hideInputBoard(getActivity(), et_channel); + // call when join button hit + String channelId = et_channel.getText().toString(); + // Check permission + checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { + @Override + public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { + if (allPermissionsGranted) { + // Permissions Granted + joinChannel(channelId); + } + } + }); + } else { + joined = false; + /**After joining a channel, the user must call the leaveChannel method to end the + * call before joining another channel. This method returns 0 if the user leaves the + * channel and releases all resources related to the call. This method call is + * asynchronous, and the user has not exited the channel when the method call returns. + * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback. + * A successful leaveChannel method call triggers the following callbacks: + * 1:The local client: onLeaveChannel. + * 2:The remote client: onUserOffline, if the user leaving the channel is in the + * Communication channel, or is a BROADCASTER in the Live Broadcast profile. + * @returns 0: Success. + * < 0: Failure. + * PS: + * 1:If you call the destroy method immediately after calling the leaveChannel + * method, the leaveChannel process interrupts, and the SDK does not trigger + * the onLeaveChannel callback. + * 2:If you call the leaveChannel method during CDN live streaming, the SDK + * triggers the removeInjectStreamUrl method.*/ + engine.leaveChannel(); + join.setText(getString(R.string.join)); + audioSeatManager.downAllSeats(); + } + } + } + + /** + * @param channelId Specify the channel name that you want to join. + * Users that input the same channel name join the same channel. + */ + private void joinChannel(String channelId) { + /**In the demo, the default is to enter as the anchor.*/ + engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); + engine.setDefaultAudioRoutetoSpeakerphone(true); + + /**Please configure accessToken in the string_config file. + * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see + * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token + * A token generated at the server. This applies to scenarios with high-security requirements. For details, see + * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ + TokenUtils.gen(requireContext(), channelId, 0, ret -> { + ChannelMediaOptions option = new ChannelMediaOptions(); + option.autoSubscribeAudio = true; + option.autoSubscribeVideo = true; + int res = engine.joinChannel(ret, channelId, 0, option); + if (res != 0) { + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + Log.e(TAG, RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + join.setEnabled(false); + }); + + + } + + private final IAudioFrameObserver iAudioFrameObserver = new IAudioFrameObserver() { + + @Override + public boolean onRecordAudioFrame(String channel, int audioFrameType, int samples, int bytesPerSample, int channels, int samplesPerSec, ByteBuffer byteBuffer, long renderTimeMs, int bufferLength) { + if(isWriteBackAudio){ + int length = byteBuffer.remaining(); +// byteBuffer.flip(); + byte[] buffer = readBuffer(); + byte[] origin = new byte[length]; + byteBuffer.get(origin); + byteBuffer.flip(); + byteBuffer.put(audioAggregate(origin, buffer), 0, byteBuffer.remaining()); + byteBuffer.flip(); + } + return true; + } + + + @Override + public boolean onPlaybackAudioFrame(String channel, int audioFrameType, int samples, int bytesPerSample, int channels, int samplesPerSec, ByteBuffer byteBuffer, long renderTimeMs, int bufferLength) { + return false; + } + + @Override + public boolean onMixedAudioFrame(String channel, int audioFrameType, int samples, int bytesPerSample, int channels, int samplesPerSec, ByteBuffer byteBuffer, long renderTimeMs, int bufferLength) { + return false; + } + + @Override + public boolean onEarMonitoringAudioFrame(int type, int samplesPerChannel, int bytesPerSample, int channels, int samplesPerSec, ByteBuffer buffer, long renderTimeMs, int avsync_type) { + return false; + } + + @Override + public boolean onPlaybackAudioFrameBeforeMixing(String channelId, int uid, int type, int samplesPerChannel, int bytesPerSample, int channels, int samplesPerSec, ByteBuffer buffer, long renderTimeMs, int avsync_type, int rtpTimestamp, long presentationMs) { + return false; + } + + @Override + public int getObservedAudioFramePosition() { + return 0; + } + + @Override + public AudioParams getRecordAudioParams() { + return null; + } + + @Override + public AudioParams getPlaybackAudioParams() { + return null; + } + + @Override + public AudioParams getMixedAudioParams() { + return null; + } + + @Override + public AudioParams getEarMonitoringAudioParams() { + return null; + } + + }; + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { + /** + * Error code description can be found at: + * en: https://api-ref.agora.io/en/voice-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/voice-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int err) { + Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + } + + /**Occurs when a user leaves the channel. + * @param stats With this callback, the application retrieves the channel information, + * such as the call duration and statistics.*/ + @Override + public void onLeaveChannel(RtcStats stats) { + super.onLeaveChannel(stats); + Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); + showLongToast(String.format("local user %d leaveChannel!", myUid)); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + myUid = uid; + joined = true; + handler.post(new Runnable() { + @Override + public void run() { + join.setEnabled(true); + join.setText(getString(R.string.leave)); + writeBackAudio.setEnabled(true); + audioSeatManager.upLocalSeat(uid); + } + }); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. + * @param uid ID of the user whose audio state changes. + * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole + * until this callback is triggered.*/ + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + Log.i(TAG, "onUserJoined->" + uid); + showLongToast(String.format("user %d joined!", uid)); + runOnUIThread(() -> audioSeatManager.upRemoteSeat(uid)); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. + * @param uid ID of the user whose audio state changes. + * @param reason Reason why the user goes offline: + * USER_OFFLINE_QUIT(0): The user left the current channel. + * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data + * packet was received within a certain period of time. If a user quits the + * call and the message is not passed to the SDK (due to an unreliable channel), + * the SDK assumes the user dropped offline. + * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from + * the host to the audience.*/ + @Override + public void onUserOffline(int uid, int reason) { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); + showLongToast(String.format("user %d offline! reason:%d", uid, reason)); + runOnUIThread(() -> audioSeatManager.downSeat(uid)); + } + + @Override + public void onActiveSpeaker(int uid) { + super.onActiveSpeaker(uid); + Log.i(TAG, String.format("onActiveSpeaker:%d", uid)); + } + }; + + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean b) { + isWriteBackAudio = b; + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java new file mode 100644 index 000000000..1f6b9ebf8 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java @@ -0,0 +1,403 @@ +package io.agora.api.example.examples.advanced; + +import static io.agora.api.example.common.Constant.URL_DOWNBEAT; +import static io.agora.api.example.common.Constant.URL_UPBEAT; +import static io.agora.api.example.common.model.Examples.ADVANCED; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.SeekBar; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.utils.CommonUtil; +import io.agora.api.example.utils.PermissonUtils; +import io.agora.api.example.utils.TokenUtils; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.Constants; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.audio.AgoraRhythmPlayerConfig; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +/** + * This demo demonstrates how to make a VideoProcessExtension + */ +@Example( + index = 19, + group = ADVANCED, + name = R.string.item_rhythmplayer, + actionId = R.id.action_mainFragment_rhythm_player, + tipsId = R.string.rhythmplayer +) +public class RhythmPlayer extends BaseFragment implements View.OnClickListener, SeekBar.OnSeekBarChangeListener { + + private static final String TAG = RhythmPlayer.class.getSimpleName(); + private EditText et_channel; + private Button join, play, stop; + private RtcEngine engine; + private int myUid; + private boolean joined = false; + private boolean isPlaying = false; + private SeekBar beatPerMinute, beatPerMeasure; + private AgoraRhythmPlayerConfig agoraRhythmPlayerConfig = new AgoraRhythmPlayerConfig(); + private ChannelMediaOptions mChannelMediaOptions; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handler = new Handler(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_rhythm_player, container, false); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + join = view.findViewById(R.id.btn_join); + play = view.findViewById(R.id.play); + stop = view.findViewById(R.id.stop); + et_channel = view.findViewById(R.id.et_channel); + beatPerMeasure = view.findViewById(R.id.beatsPerMeasure); + beatPerMinute = view.findViewById(R.id.beatsPerMinute); + view.findViewById(R.id.btn_join).setOnClickListener(this); + play.setOnClickListener(this); + stop.setOnClickListener(this); + beatPerMinute.setOnSeekBarChangeListener(this); + beatPerMeasure.setOnSeekBarChangeListener(this); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null) { + return; + } + try { + RtcEngineConfig config = new RtcEngineConfig(); + /** + * The context of Android Activity + */ + config.mContext = context.getApplicationContext(); + /** + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = getString(R.string.agora_app_id); + /** Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT); + config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = RtcEngine.create(config); + /** + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + } catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + /**leaveChannel and Destroy the RtcEngine instance*/ + if (engine != null) { + engine.stopRhythmPlayer(); + engine.leaveChannel(); + } + handler.post(RtcEngine::destroy); + engine = null; + } + + + @Override + public void onClick(View v) { + if (v.getId() == R.id.btn_join) { + if (!joined) { + CommonUtil.hideInputBoard(getActivity(), et_channel); + // call when join button hit + String channelId = et_channel.getText().toString(); + // Check permission + checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { + @Override + public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { + if (allPermissionsGranted) { + // Permissions Granted + joinChannel(channelId); + } + } + }); + } else { + joined = false; + /**After joining a channel, the user must call the leaveChannel method to end the + * call before joining another channel. This method returns 0 if the user leaves the + * channel and releases all resources related to the call. This method call is + * asynchronous, and the user has not exited the channel when the method call returns. + * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback. + * A successful leaveChannel method call triggers the following callbacks: + * 1:The local client: onLeaveChannel. + * 2:The remote client: onUserOffline, if the user leaving the channel is in the + * Communication channel, or is a BROADCASTER in the Live Broadcast profile. + * @returns 0: Success. + * < 0: Failure. + * PS: + * 1:If you call the destroy method immediately after calling the leaveChannel + * method, the leaveChannel process interrupts, and the SDK does not trigger + * the onLeaveChannel callback. + * 2:If you call the leaveChannel method during CDN live streaming, the SDK + * triggers the removeInjectStreamUrl method.*/ + engine.leaveChannel(); + join.setText(getString(R.string.join)); + } + } else if (v.getId() == R.id.play) { + if (!isPlaying) { + int ret = engine.startRhythmPlayer(URL_DOWNBEAT, URL_UPBEAT, agoraRhythmPlayerConfig); + if (joined) { + mChannelMediaOptions.publishRhythmPlayerTrack = true; + engine.updateChannelMediaOptions(mChannelMediaOptions); + } + Log.i(TAG, "startRhythmPlayer result:" + ret); + isPlaying = true; + beatPerMeasure.setEnabled(false); + beatPerMinute.setEnabled(false); + } + } else if (v.getId() == R.id.stop) { + engine.stopRhythmPlayer(); + if (joined) { + mChannelMediaOptions.publishRhythmPlayerTrack = false; + engine.updateChannelMediaOptions(mChannelMediaOptions); + } + isPlaying = false; + beatPerMeasure.setEnabled(true); + beatPerMinute.setEnabled(true); + } + } + + /** + * @param channelId Specify the channel name that you want to join. + * Users that input the same channel name join the same channel. + */ + private void joinChannel(String channelId) { + /**In the demo, the default is to enter as the anchor.*/ + engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); + engine.enableAudioVolumeIndication(1000, 3, true); + + /**Please configure accessToken in the string_config file. + * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see + * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token + * A token generated at the server. This applies to scenarios with high-security requirements. For details, see + * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ + TokenUtils.gen(requireContext(), channelId, 0, accessToken -> { + /** Allows a user to join a channel. + if you do not specify the uid, we will generate the uid for you*/ + + mChannelMediaOptions = new ChannelMediaOptions(); + mChannelMediaOptions.autoSubscribeAudio = true; + mChannelMediaOptions.autoSubscribeVideo = true; + mChannelMediaOptions.publishMicrophoneTrack = true; + /** + * config this for whether need push rhythem player to remote + */ + mChannelMediaOptions.publishRhythmPlayerTrack = isPlaying; + int res = engine.joinChannel(accessToken, channelId, 0, mChannelMediaOptions); + if (res != 0) { + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + Log.e(TAG, RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + join.setEnabled(false); + }); + } + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { + /** + * Error code description can be found at: + * en: https://api-ref.agora.io/en/voice-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/voice-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int err) { + Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + } + + /**Occurs when a user leaves the channel. + * @param stats With this callback, the application retrieves the channel information, + * such as the call duration and statistics.*/ + @Override + public void onLeaveChannel(RtcStats stats) { + super.onLeaveChannel(stats); + Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); + showLongToast(String.format("local user %d leaveChannel!", myUid)); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + myUid = uid; + joined = true; + handler.post(new Runnable() { + @Override + public void run() { + join.setEnabled(true); + join.setText(getString(R.string.leave)); + play.setEnabled(true); + stop.setEnabled(true); + beatPerMeasure.setEnabled(true); + beatPerMinute.setEnabled(true); + } + }); + } + + /**Since v2.9.0. + * This callback indicates the state change of the remote audio stream. + * PS: This callback does not work properly when the number of users (in the Communication profile) or + * broadcasters (in the Live-broadcast profile) in the channel exceeds 17. + * @param uid ID of the user whose audio state changes. + * @param state State of the remote audio + * REMOTE_AUDIO_STATE_STOPPED(0): The remote audio is in the default state, probably due + * to REMOTE_AUDIO_REASON_LOCAL_MUTED(3), REMOTE_AUDIO_REASON_REMOTE_MUTED(5), + * or REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7). + * REMOTE_AUDIO_STATE_STARTING(1): The first remote audio packet is received. + * REMOTE_AUDIO_STATE_DECODING(2): The remote audio stream is decoded and plays normally, + * probably due to REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2), + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4) or REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6). + * REMOTE_AUDIO_STATE_FROZEN(3): The remote audio is frozen, probably due to + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1). + * REMOTE_AUDIO_STATE_FAILED(4): The remote audio fails to start, probably due to + * REMOTE_AUDIO_REASON_INTERNAL(0). + * @param reason The reason of the remote audio state change. + * REMOTE_AUDIO_REASON_INTERNAL(0): Internal reasons. + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1): Network congestion. + * REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2): Network recovery. + * REMOTE_AUDIO_REASON_LOCAL_MUTED(3): The local user stops receiving the remote audio + * stream or disables the audio module. + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote audio + * stream or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_MUTED(5): The remote user stops sending the audio stream or + * disables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the audio stream + * or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel. + * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method + * until the SDK triggers this callback.*/ + @Override + public void onRemoteAudioStateChanged(int uid, int state, int reason, int elapsed) { + super.onRemoteAudioStateChanged(uid, state, reason, elapsed); + Log.i(TAG, "onRemoteAudioStateChanged->" + uid + ", state->" + state + ", reason->" + reason); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. + * @param uid ID of the user whose audio state changes. + * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole + * until this callback is triggered.*/ + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + Log.i(TAG, "onUserJoined->" + uid); + showLongToast(String.format("user %d joined!", uid)); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. + * @param uid ID of the user whose audio state changes. + * @param reason Reason why the user goes offline: + * USER_OFFLINE_QUIT(0): The user left the current channel. + * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data + * packet was received within a certain period of time. If a user quits the + * call and the message is not passed to the SDK (due to an unreliable channel), + * the SDK assumes the user dropped offline. + * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from + * the host to the audience.*/ + @Override + public void onUserOffline(int uid, int reason) { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); + showLongToast(String.format("user %d offline! reason:%d", uid, reason)); + } + + @Override + public void onActiveSpeaker(int uid) { + super.onActiveSpeaker(uid); + Log.i(TAG, String.format("onActiveSpeaker:%d", uid)); + } + }; + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (seekBar.getId() == R.id.beatsPerMeasure) { + agoraRhythmPlayerConfig.beatsPerMeasure = seekBar.getProgress() < 1 ? 1 : seekBar.getProgress(); + } else if (seekBar.getId() == R.id.beatsPerMinute) { + agoraRhythmPlayerConfig.beatsPerMinute = seekBar.getProgress() < 60 ? 60 : seekBar.getProgress(); + } + Log.i(TAG, "agoraRhythmPlayerConfig beatsPerMeasure:" + agoraRhythmPlayerConfig.beatsPerMeasure + ", beatsPerMinute:" + agoraRhythmPlayerConfig.beatsPerMinute); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java new file mode 100644 index 000000000..36e5fd7c3 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java @@ -0,0 +1,696 @@ +package io.agora.api.example.examples.advanced; + +import static io.agora.api.example.common.model.Examples.ADVANCED; +import static io.agora.mediaplayer.Constants.MediaPlayerState.PLAYER_STATE_OPEN_COMPLETED; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.SeekBar; +import android.widget.Switch; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.bottomsheet.BottomSheetDialog; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.Constant; +import io.agora.api.example.utils.CommonUtil; +import io.agora.api.example.utils.TokenUtils; +import io.agora.mediaplayer.Constants; +import io.agora.mediaplayer.IMediaPlayer; +import io.agora.mediaplayer.IMediaPlayerObserver; +import io.agora.mediaplayer.data.CacheStatistics; +import io.agora.mediaplayer.data.PlayerPlaybackStats; +import io.agora.mediaplayer.data.PlayerUpdatedInfo; +import io.agora.mediaplayer.data.SrcInfo; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.RtcEngineEx; +import io.agora.rtc2.SpatialAudioParams; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; +import io.agora.spatialaudio.ILocalSpatialAudioEngine; +import io.agora.spatialaudio.LocalSpatialAudioConfig; +import io.agora.spatialaudio.RemoteVoicePositionInfo; +import io.agora.spatialaudio.SpatialAudioZone; + +/** + * The type Spatial sound. + */ +@Example( + index = 22, + group = ADVANCED, + name = R.string.item_spatial_sound, + actionId = R.id.action_mainFragment_to_spatial_sound, + tipsId = R.string.spatial_sound +) +public class SpatialSound extends BaseFragment { + private static final String TAG = SpatialSound.class.getSimpleName(); + + private static final int AXIS_MAX_DISTANCE = 10; + + private View rootView; + private ImageView localIv, mediaPlayerLeftIv, mediaPlayerRightIv; + private TextView tipTv, remoteLeftTv, remoteRightTv, zoneTv; + private Button joinBtn; + private EditText channelIdEt; + private Switch switchMic, switchZone; + + + private RtcEngineEx engine; + private IMediaPlayer mediaPlayerLeft, mediaPlayerRight; + private volatile boolean isJoined; + private ILocalSpatialAudioEngine localSpatial; + private final InnerRtcEngineEventHandler iRtcEngineEventHandler = new InnerRtcEngineEventHandler(); + private final Map cacheDialogs = new HashMap<>(); + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_spatial_sound, container, false); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null) { + return; + } + try { + /*Creates an RtcEngine instance. + * @param context The context of Android Activity + * @param appId The App ID issued to you by Agora. See + * How to get the App ID + * @param handler IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events.*/ + String appId = getString(R.string.agora_app_id); + RtcEngineConfig config = new RtcEngineConfig(); + config.mContext = getContext().getApplicationContext(); + config.mAppId = appId; + config.mEventHandler = iRtcEngineEventHandler; + config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = (RtcEngineEx) RtcEngine.create(config); + /* + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + engine.enableAudio(); + + localSpatial = ILocalSpatialAudioEngine.create(); + LocalSpatialAudioConfig localSpatialAudioConfig = new LocalSpatialAudioConfig(); + localSpatialAudioConfig.mRtcEngine = engine; + localSpatial.initialize(localSpatialAudioConfig); + + localSpatial.setMaxAudioRecvCount(2); + localSpatial.setAudioRecvRange(AXIS_MAX_DISTANCE); + localSpatial.setDistanceUnit(1); + + engine.setChannelProfile(io.agora.rtc2.Constants.CHANNEL_PROFILE_LIVE_BROADCASTING); + + } catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + rootView = view.findViewById(R.id.root_view); + channelIdEt = view.findViewById(R.id.et_channel); + joinBtn = view.findViewById(R.id.btn_join); + tipTv = view.findViewById(R.id.tv_tip); + mediaPlayerLeftIv = view.findViewById(R.id.iv_mediaplayer_left); + mediaPlayerRightIv = view.findViewById(R.id.iv_mediaplayer_right); + localIv = view.findViewById(R.id.iv_local); + remoteLeftTv = view.findViewById(R.id.iv_remote_left); + remoteRightTv = view.findViewById(R.id.iv_remote_right); + tipTv.setText(R.string.spatial_sound_tip); + switchMic = view.findViewById(R.id.switch_microphone); + switchZone = view.findViewById(R.id.switch_zone); + zoneTv = view.findViewById(R.id.tv_zone); + zoneTv.setVisibility(View.INVISIBLE); + + joinBtn.setOnClickListener(v -> { + CommonUtil.hideInputBoard(requireActivity(), channelIdEt); + if (!isJoined) { + joinChannel(); + } else { + leftChannel(); + } + }); + localIv.setOnTouchListener(new ListenerOnTouchListener() { + @Override + protected void onPositionChanged() { + float[] pos = getVoicePosition(localIv); + float[] forward = new float[]{1.0F, 0.0F, 0.0F}; + float[] right = new float[]{0.0F, 1.0F, 0.0F}; + float[] up = new float[]{0.0F, 0.0F, 1.0F}; + Log.d(TAG, "updateSelfPosition >> pos=" + Arrays.toString(pos)); + localSpatial.updateSelfPosition(pos, forward, right, up); + } + }); + switchMic.setOnCheckedChangeListener((buttonView, isChecked) -> + localSpatial.muteLocalAudioStream(!isChecked)); + switchZone.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + zoneTv.setVisibility(View.VISIBLE); + + // create room + SpatialAudioZone mediaPlayerLeftZone = new SpatialAudioZone(); + mediaPlayerLeftZone.zoneSetId = 1; + mediaPlayerLeftZone.audioAttenuation = 1f; + float[] voicePosition = getVoicePosition(zoneTv); + float[] viewRelativeSizeInAxis = getViewRelativeSizeInAxis(zoneTv); + mediaPlayerLeftZone.position = new float[]{voicePosition[0], voicePosition[1], 0}; + mediaPlayerLeftZone.forward = new float[]{1.f, 0, 0}; + mediaPlayerLeftZone.right = new float[]{0, 1.f, 0}; + mediaPlayerLeftZone.up = new float[]{0, 0, 1.f}; + mediaPlayerLeftZone.forwardLength = viewRelativeSizeInAxis[1]; + mediaPlayerLeftZone.rightLength = viewRelativeSizeInAxis[0]; + mediaPlayerLeftZone.upLength = AXIS_MAX_DISTANCE; + localSpatial.setZones(new SpatialAudioZone[]{mediaPlayerLeftZone}); + } else { + zoneTv.setVisibility(View.INVISIBLE); + localSpatial.setZones(null); + } + }); + } + + private void joinChannel() { + String channelId = channelIdEt.getText().toString(); + + engine.setDefaultAudioRoutetoSpeakerphone(true); + + engine.setClientRole(io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER); + + /*Please configure accessToken in the string_config file. + * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see + * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token + * A token generated at the server. This applies to scenarios with high-security requirements. For details, see + * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ + TokenUtils.gen(requireContext(), channelId, 0, ret -> { + + ChannelMediaOptions option = new ChannelMediaOptions(); + option.autoSubscribeAudio = true; + + /* Allows a user to join a channel. + if you do not specify the uid, we will generate the uid for you*/ + int res = engine.joinChannel(ret, channelId, 0, option); + if (res != 0) { + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + Log.e(TAG, RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + joinBtn.setEnabled(false); + }); + + localSpatial.muteLocalAudioStream(!switchMic.isChecked()); + + float[] pos = getVoicePosition(localIv); + float[] forward = new float[]{1.0F, 0.0F, 0.0F}; + float[] right = new float[]{0.0F, 1.0F, 0.0F}; + float[] up = new float[]{0.0F, 0.0F, 1.0F}; + localSpatial.updateSelfPosition(pos, forward, right, up); + } + + private void leftChannel() { + isJoined = false; + + engine.leaveChannel(); + localSpatial.clearRemotePositions(); + + isJoined = false; + joinBtn.setText(R.string.join); + + mediaPlayerLeftIv.setVisibility(View.GONE); + mediaPlayerRightIv.setVisibility(View.GONE); + localIv.setTranslationX(0); + localIv.setTranslationY(0); + localIv.setVisibility(View.GONE); + remoteLeftTv.setTag(null); + remoteLeftTv.setVisibility(View.GONE); + remoteRightTv.setTag(null); + remoteRightTv.setVisibility(View.GONE); + tipTv.setVisibility(View.GONE); + zoneTv.setVisibility(View.GONE); + switchZone.setVisibility(View.GONE); + switchZone.setChecked(false); + + cacheDialogs.clear(); + + unInitMediaPlayers(); + } + + + private void initMediaPlayers() { + mediaPlayerLeft = createLoopMediaPlayer(); + mediaPlayerLeft.open(Constant.URL_PLAY_AUDIO_FILES, 0); + localSpatial.updatePlayerPositionInfo(mediaPlayerLeft.getMediaPlayerId(), getVoicePositionInfo(mediaPlayerLeftIv)); + + mediaPlayerRight = createLoopMediaPlayer(); + mediaPlayerRight.open(Constant.URL_DOWNBEAT, 0); + localSpatial.updatePlayerPositionInfo(mediaPlayerRight.getMediaPlayerId(), getVoicePositionInfo(mediaPlayerRightIv)); + + mediaPlayerLeftIv.setOnClickListener(v -> showMediaPlayerSettingDialog(mediaPlayerLeft)); + mediaPlayerRightIv.setOnClickListener(v -> showMediaPlayerSettingDialog(mediaPlayerRight)); + } + + private void showMediaPlayerSettingDialog(IMediaPlayer mediaPlayer) { + String key = "MediaPlayer_" + mediaPlayer.getMediaPlayerId(); + BottomSheetDialog dialog = cacheDialogs.get(key); + if (dialog != null) { + dialog.show(); + return; + } + boolean isPlaying = mediaPlayer.getState() == Constants.MediaPlayerState.PLAYER_STATE_PAUSED; + SpatialAudioParams spatialAudioParams = new SpatialAudioParams(); + dialog = showCommonSettingDialog( + isPlaying, + spatialAudioParams, + (buttonView, isChecked) -> { + if (isChecked) { + mediaPlayer.pause(); + } else { + mediaPlayer.resume(); + } + }, + (buttonView, isChecked) -> { + spatialAudioParams.enable_blur = isChecked; + mediaPlayer.setSpatialAudioParams(spatialAudioParams); + }, + (buttonView, isChecked) -> { + spatialAudioParams.enable_air_absorb = isChecked; + mediaPlayer.setSpatialAudioParams(spatialAudioParams); + }, + new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + localSpatial.setPlayerAttenuation(mediaPlayer.getMediaPlayerId(), (double) (progress * 1.0f / seekBar.getMax()), false); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + } + ); + cacheDialogs.put(key, dialog); + } + + private void showRemoteUserSettingDialog(int uid) { + String key = "RemoteUser_" + uid; + BottomSheetDialog dialog = cacheDialogs.get(key); + if (dialog != null) { + dialog.show(); + return; + } + SpatialAudioParams spatialAudioParams = new SpatialAudioParams(); + dialog = showCommonSettingDialog( + false, + spatialAudioParams, + (buttonView, isChecked) -> { + localSpatial.muteRemoteAudioStream(uid, isChecked); + }, + (buttonView, isChecked) -> { + spatialAudioParams.enable_blur = isChecked; + engine.setRemoteUserSpatialAudioParams(uid, spatialAudioParams); + }, + (buttonView, isChecked) -> { + spatialAudioParams.enable_air_absorb = isChecked; + engine.setRemoteUserSpatialAudioParams(uid, spatialAudioParams); + }, + new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + localSpatial.setRemoteAudioAttenuation(uid, (double) (progress * 1.0f / seekBar.getMax()), false); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + } + ); + cacheDialogs.put(key, dialog); + } + + + private void unInitMediaPlayers() { + mediaPlayerLeft.destroy(); + mediaPlayerLeft = null; + + mediaPlayerRight.destroy(); + mediaPlayerRight = null; + } + + private IMediaPlayer createLoopMediaPlayer() { + IMediaPlayer mediaPlayer = engine.createMediaPlayer(); + mediaPlayer.registerPlayerObserver(new IMediaPlayerObserver() { + @Override + public void onPlayerStateChanged(Constants.MediaPlayerState state, Constants.MediaPlayerReason reason) { + if (state.equals(PLAYER_STATE_OPEN_COMPLETED)) { + mediaPlayer.setLoopCount(-1); + mediaPlayer.play(); + } + } + + @Override + public void onPositionChanged(long positionMs, long timestampMs) { + + } + + @Override + public void onPlayerEvent(Constants.MediaPlayerEvent eventCode, long elapsedTime, String message) { + + } + + @Override + public void onMetaData(Constants.MediaPlayerMetadataType type, byte[] data) { + + } + + @Override + public void onPlayBufferUpdated(long playCachedBuffer) { + + } + + @Override + public void onPreloadEvent(String src, Constants.MediaPlayerPreloadEvent event) { + + } + + @Override + public void onAgoraCDNTokenWillExpire() { + + } + + @Override + public void onPlayerSrcInfoChanged(SrcInfo from, SrcInfo to) { + + } + + @Override + public void onPlayerInfoUpdated(PlayerUpdatedInfo info) { + + } + + @Override + public void onPlayerCacheStats(CacheStatistics stats) { + + } + + @Override + public void onPlayerPlaybackStats(PlayerPlaybackStats stats) { + + } + + @Override + public void onAudioVolumeIndication(int volume) { + + } + }); + return mediaPlayer; + } + + private RemoteVoicePositionInfo getVoicePositionInfo(View view) { + RemoteVoicePositionInfo positionInfo = new RemoteVoicePositionInfo(); + positionInfo.forward = new float[]{1.0F, 0.0F, 0.0F}; + positionInfo.position = getVoicePosition(view); + return positionInfo; + } + + private float[] getVoicePosition(View view) { + float transX = view.getTranslationX(); + float transY = view.getTranslationY(); + double posForward = -1 * AXIS_MAX_DISTANCE * transY / ((rootView.getHeight()) / 2.0f); + double posRight = AXIS_MAX_DISTANCE * transX / ((rootView.getWidth()) / 2.0f); + //Log.d(TAG, "VoicePosition posForward=" + posForward + ", posRight=" + posRight); + return new float[]{(float) posForward, (float) posRight, 0.0F}; + } + + private float[] getViewRelativeSizeInAxis(View view) { + return new float[]{ + AXIS_MAX_DISTANCE * view.getWidth() * 1.0f / (rootView.getWidth() / 2.0f), + AXIS_MAX_DISTANCE * view.getHeight() * 1.0f / (rootView.getHeight() / 2.0f), + }; + } + + private BottomSheetDialog showCommonSettingDialog(boolean isMute, SpatialAudioParams params, + CompoundButton.OnCheckedChangeListener muteCheckListener, + CompoundButton.OnCheckedChangeListener blurCheckListener, + CompoundButton.OnCheckedChangeListener airborneCheckListener, + SeekBar.OnSeekBarChangeListener attenuationSeekChangeListener + ) { + BottomSheetDialog dialog = new BottomSheetDialog(requireContext()); + View dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_spatial_sound, null); + Switch muteSwitch = dialogView.findViewById(R.id.switch_mute); + muteSwitch.setChecked(isMute); + muteSwitch.setOnCheckedChangeListener(muteCheckListener); + Switch blurSwitch = dialogView.findViewById(R.id.switch_blur); + blurSwitch.setChecked(params.enable_blur != null && params.enable_blur); + blurSwitch.setOnCheckedChangeListener(blurCheckListener); + Switch airborneSwitch = dialogView.findViewById(R.id.switch_airborne); + airborneSwitch.setChecked(params.enable_air_absorb != null && params.enable_air_absorb); + airborneSwitch.setOnCheckedChangeListener(airborneCheckListener); + TextView attenuationTv = dialogView.findViewById(R.id.tv_attenuation); + SeekBar attenuationSb = dialogView.findViewById(R.id.sb_attenuation); + attenuationTv.setText(String.valueOf(params.speaker_attenuation == null ? 0.5 : params.speaker_attenuation)); + attenuationSb.setProgress((int) ((params.speaker_attenuation == null ? 0.5 : params.speaker_attenuation) * attenuationSb.getMax())); + attenuationSb.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + float value = progress * 1.0f / seekBar.getMax(); + attenuationTv.setText(String.valueOf(value)); + if (attenuationSeekChangeListener != null) { + attenuationSeekChangeListener.onProgressChanged(seekBar, progress, fromUser); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }); + + dialog.setContentView(dialogView); + dialog.setCanceledOnTouchOutside(true); + dialog.show(); + return dialog; + } + + @Override + public void onDestroy() { + super.onDestroy(); + handler.removeCallbacksAndMessages(null); + handler.post(RtcEngine::destroy); + engine = null; + } + + private abstract static class ListenerOnTouchListener implements View.OnTouchListener { + private float startX, startY, tranX, tranY, curX, curY, maxX, maxY, minX, minY; + + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + startX = event.getRawX(); + startY = event.getRawY(); + tranX = v.getTranslationX(); + tranY = v.getTranslationY(); + if (v.getParent() instanceof ViewGroup) { + maxX = (((ViewGroup) v.getParent()).getWidth() - v.getWidth() + 1) / 2; + maxY = (((ViewGroup) v.getParent()).getHeight() - v.getHeight() + 1) / 2; + minX = -maxX; + minY = -maxY; + } + break; + case MotionEvent.ACTION_MOVE: + curX = event.getRawX(); + curY = event.getRawY(); + float newTranX = tranX + curX - startX; + if (minX != 0 && newTranX < minX) { + newTranX = minX; + } + if (maxX != 0 && newTranX > maxX) { + newTranX = maxX; + } + v.setTranslationX(newTranX); + float newTranY = tranY + curY - startY; + if (minY != 0 && newTranY < minY) { + newTranY = minY; + } + if (maxY != 0 && newTranY > maxY) { + newTranY = maxY; + } + v.setTranslationY(newTranY); + onPositionChanged(); + break; + default: + break; + } + return true; + } + + /** + * On position changed. + */ + protected abstract void onPositionChanged(); + } + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final class InnerRtcEngineEventHandler extends IRtcEngineEventHandler { + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + super.onJoinChannelSuccess(channel, uid, elapsed); + + isJoined = true; + + runOnUIThread(() -> { + joinBtn.setEnabled(true); + joinBtn.setText(R.string.leave); + + mediaPlayerLeftIv.setVisibility(View.VISIBLE); + mediaPlayerRightIv.setVisibility(View.VISIBLE); + localIv.setVisibility(View.VISIBLE); + tipTv.setVisibility(View.VISIBLE); + switchZone.setVisibility(View.VISIBLE); + + initMediaPlayers(); + }); + } + + /** + * Error code description can be found at: + * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int err) { + Log.e(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + showAlert(String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + } + + /** + * Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. + * + * @param uid ID of the user whose audio state changes. + * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole + * until this callback is triggered. + */ + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + Log.i(TAG, "onUserJoined->" + uid); + showLongToast(String.format("user %d joined!", uid)); + runOnUIThread(() -> { + if (remoteLeftTv.getTag() == null) { + remoteLeftTv.setTag(uid); + remoteLeftTv.setVisibility(View.VISIBLE); + remoteLeftTv.setText(uid + ""); + RemoteVoicePositionInfo info = getVoicePositionInfo(remoteLeftTv); + Log.d(TAG, "left remote user >> pos=" + Arrays.toString(info.position)); + localSpatial.updateRemotePosition(uid, info); + + remoteLeftTv.setOnClickListener(v -> showRemoteUserSettingDialog(uid)); + } else if (remoteRightTv.getTag() == null) { + remoteRightTv.setTag(uid); + remoteRightTv.setVisibility(View.VISIBLE); + remoteRightTv.setText(uid + ""); + localSpatial.updateRemotePosition(uid, getVoicePositionInfo(remoteRightTv)); + + remoteRightTv.setOnClickListener(v -> showRemoteUserSettingDialog(uid)); + } + }); + } + + /** + * Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. + * + * @param uid ID of the user whose audio state changes. + * @param reason Reason why the user goes offline: + * USER_OFFLINE_QUIT(0): The user left the current channel. + * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data + * packet was received within a certain period of time. If a user quits the + * call and the message is not passed to the SDK (due to an unreliable channel), + * the SDK assumes the user dropped offline. + * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from + * the host to the audience. + */ + @Override + public void onUserOffline(int uid, int reason) { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); + showLongToast(String.format("user %d offline! reason:%d", uid, reason)); + runOnUIThread(() -> { + if (remoteLeftTv.getTag() instanceof Integer && (int) remoteLeftTv.getTag() == uid) { + remoteLeftTv.setTag(null); + remoteLeftTv.setVisibility(View.GONE); + localSpatial.removeRemotePosition(uid); + } else if (remoteRightTv.getTag() instanceof Integer && (int) remoteRightTv.getTag() == uid) { + remoteRightTv.setTag(null); + remoteRightTv.setVisibility(View.GONE); + localSpatial.removeRemotePosition(uid); + } + }); + } + + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java new file mode 100644 index 000000000..39780ae2c --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java @@ -0,0 +1,860 @@ +package io.agora.api.example.examples.advanced; + +import static io.agora.api.example.common.model.Examples.ADVANCED; +import static io.agora.rtc2.Constants.AUDIO_EFFECT_OFF; +import static io.agora.rtc2.Constants.CHAT_BEAUTIFIER_FRESH; +import static io.agora.rtc2.Constants.CHAT_BEAUTIFIER_MAGNETIC; +import static io.agora.rtc2.Constants.CHAT_BEAUTIFIER_VITALITY; +import static io.agora.rtc2.Constants.PITCH_CORRECTION; +import static io.agora.rtc2.Constants.ROOM_ACOUSTICS_3D_VOICE; +import static io.agora.rtc2.Constants.ROOM_ACOUSTICS_ETHEREAL; +import static io.agora.rtc2.Constants.ROOM_ACOUSTICS_KTV; +import static io.agora.rtc2.Constants.ROOM_ACOUSTICS_PHONOGRAPH; +import static io.agora.rtc2.Constants.ROOM_ACOUSTICS_SPACIAL; +import static io.agora.rtc2.Constants.ROOM_ACOUSTICS_STUDIO; +import static io.agora.rtc2.Constants.ROOM_ACOUSTICS_VIRTUAL_STEREO; +import static io.agora.rtc2.Constants.ROOM_ACOUSTICS_VOCAL_CONCERT; +import static io.agora.rtc2.Constants.STYLE_TRANSFORMATION_POPULAR; +import static io.agora.rtc2.Constants.STYLE_TRANSFORMATION_RNB; +import static io.agora.rtc2.Constants.TIMBRE_TRANSFORMATION_CLEAR; +import static io.agora.rtc2.Constants.TIMBRE_TRANSFORMATION_DEEP; +import static io.agora.rtc2.Constants.TIMBRE_TRANSFORMATION_FALSETTO; +import static io.agora.rtc2.Constants.TIMBRE_TRANSFORMATION_FULL; +import static io.agora.rtc2.Constants.TIMBRE_TRANSFORMATION_MELLOW; +import static io.agora.rtc2.Constants.TIMBRE_TRANSFORMATION_RESOUNDING; +import static io.agora.rtc2.Constants.TIMBRE_TRANSFORMATION_RINGING; +import static io.agora.rtc2.Constants.TIMBRE_TRANSFORMATION_VIGOROUS; +import static io.agora.rtc2.Constants.ULTRA_HIGH_QUALITY_VOICE; +import static io.agora.rtc2.Constants.VOICE_BEAUTIFIER_OFF; +import static io.agora.rtc2.Constants.VOICE_CHANGER_BASS; +import static io.agora.rtc2.Constants.VOICE_CHANGER_CARTOON; +import static io.agora.rtc2.Constants.VOICE_CHANGER_CHILDLIKE; +import static io.agora.rtc2.Constants.VOICE_CHANGER_CHIPMUNK; +import static io.agora.rtc2.Constants.VOICE_CHANGER_DARTH_VADER; +import static io.agora.rtc2.Constants.VOICE_CHANGER_EFFECT_BOY; +import static io.agora.rtc2.Constants.VOICE_CHANGER_EFFECT_GIRL; +import static io.agora.rtc2.Constants.VOICE_CHANGER_EFFECT_HULK; +import static io.agora.rtc2.Constants.VOICE_CHANGER_EFFECT_OLDMAN; +import static io.agora.rtc2.Constants.VOICE_CHANGER_EFFECT_PIGKING; +import static io.agora.rtc2.Constants.VOICE_CHANGER_EFFECT_SISTER; +import static io.agora.rtc2.Constants.VOICE_CHANGER_EFFECT_UNCLE; +import static io.agora.rtc2.Constants.VOICE_CHANGER_GIRLISH_MAN; +import static io.agora.rtc2.Constants.VOICE_CHANGER_GROOT; +import static io.agora.rtc2.Constants.VOICE_CHANGER_IRON_LADY; +import static io.agora.rtc2.Constants.VOICE_CHANGER_MONSTER; +import static io.agora.rtc2.Constants.VOICE_CHANGER_NEUTRAL; +import static io.agora.rtc2.Constants.VOICE_CHANGER_PHONE_OPERATOR; +import static io.agora.rtc2.Constants.VOICE_CHANGER_SHIN_CHAN; +import static io.agora.rtc2.Constants.VOICE_CHANGER_SOLID; +import static io.agora.rtc2.Constants.VOICE_CHANGER_SWEET; +import static io.agora.rtc2.Constants.VOICE_CHANGER_TRANSFORMERS; +import static io.agora.rtc2.Constants.VOICE_CONVERSION_OFF; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.SeekBar; +import android.widget.Spinner; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.widget.AudioSeatManager; +import io.agora.api.example.utils.CommonUtil; +import io.agora.api.example.utils.PermissonUtils; +import io.agora.api.example.utils.TokenUtils; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.Constants; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +/** + * The type Voice effects. + */ +@Example( + index = 4, + group = ADVANCED, + name = R.string.item_voiceeffects, + actionId = R.id.action_mainFragment_to_VoiceEffects, + tipsId = R.string.voiceeffects +) +public class VoiceEffects extends BaseFragment implements View.OnClickListener, AdapterView.OnItemSelectedListener, CompoundButton.OnCheckedChangeListener, SeekBar.OnSeekBarChangeListener { + private static final String TAG = VoiceEffects.class.getSimpleName(); + + private RtcEngine engine; + private int myUid; + private boolean joined = false; + + private EditText et_channel; + private Button join; + private Spinner audioProfile, audioScenario, + chatBeautifier, timbreTransformation, voiceChanger, styleTransformation, roomAcoustics, pitchCorrection, _pitchModeOption, _pitchValueOption, voiceConversion, ainsMode, voiceAITuner, + customBandFreq, customReverbKey; + private ViewGroup _voice3DLayout, _pitchModeLayout, _pitchValueLayout; + private SeekBar _voice3DCircle, customPitch, customBandGain, customReverbValue, customVoiceFormant; + + private AudioSeatManager audioSeatManager; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handler = new Handler(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_voice_effects, container, false); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + // Join layout + join = view.findViewById(R.id.btn_join); + audioProfile = view.findViewById(R.id.audio_profile_spinner); + audioScenario = view.findViewById(R.id.audio_scenario_spinner); + et_channel = view.findViewById(R.id.et_channel); + + audioScenario.setOnItemSelectedListener(this); + join.setOnClickListener(this); + + // Voice Beautifier / Effects Preset layout + chatBeautifier = view.findViewById(R.id.audio_chat_beautifier); + timbreTransformation = view.findViewById(R.id.audio_timbre_transformation); + voiceChanger = view.findViewById(R.id.audio_voice_changer); + styleTransformation = view.findViewById(R.id.audio_style_transformation); + roomAcoustics = view.findViewById(R.id.audio_room_acoustics); + _voice3DLayout = view.findViewById(R.id.audio_3d_voice_layout); + _voice3DCircle = view.findViewById(R.id.audio_3d_voice_circle); + pitchCorrection = view.findViewById(R.id.audio_pitch_correction); + _pitchModeLayout = view.findViewById(R.id.audio_pitch_mode_layout); + _pitchModeOption = view.findViewById(R.id.audio_pitch_mode_option); + _pitchValueLayout = view.findViewById(R.id.audio_pitch_value_layout); + _pitchValueOption = view.findViewById(R.id.audio_pitch_value_option); + voiceConversion = view.findViewById(R.id.audio_voice_conversion); + ainsMode = view.findViewById(R.id.audio_ains_mode); + voiceAITuner = view.findViewById(R.id.voice_ai_tuner); + + chatBeautifier.setOnItemSelectedListener(this); + timbreTransformation.setOnItemSelectedListener(this); + voiceChanger.setOnItemSelectedListener(this); + styleTransformation.setOnItemSelectedListener(this); + roomAcoustics.setOnItemSelectedListener(this); + pitchCorrection.setOnItemSelectedListener(this); + voiceConversion.setOnItemSelectedListener(this); + _voice3DCircle.setOnSeekBarChangeListener(this); + _pitchModeOption.setOnItemSelectedListener(this); + _pitchValueOption.setOnItemSelectedListener(this); + ainsMode.setOnItemSelectedListener(this); + voiceAITuner.setOnItemSelectedListener(this); + + // Customize Voice Effects Layout + customPitch = view.findViewById(R.id.audio_custom_pitch); // engine.setLocalVoicePitch() + customBandFreq = view.findViewById(R.id.audio_custom_band_freq); // engine.setLocalVoiceEqualization() + customBandGain = view.findViewById(R.id.audio_custom_band_gain); // engine.setLocalVoiceEqualization() + customReverbKey = view.findViewById(R.id.audio_custom_reverb_key); + customReverbValue = view.findViewById(R.id.audio_custom_reverb_value); //engine.setLocalVoiceReverb() + customVoiceFormant = view.findViewById(R.id.audio_voice_formant_value); //engine.setLocalVoiceFormant() + + customPitch.setOnSeekBarChangeListener(this); + customBandGain.setOnSeekBarChangeListener(this); + customReverbValue.setOnSeekBarChangeListener(this); + customVoiceFormant.setOnSeekBarChangeListener(this); + customBandFreq.setOnItemSelectedListener(this); + customReverbKey.setOnItemSelectedListener(this); + + + audioSeatManager = new AudioSeatManager( + view.findViewById(R.id.audio_place_01), + view.findViewById(R.id.audio_place_02) + ); + + resetControlLayoutByJoined(); + } + + private void resetControlLayoutByJoined() { + audioProfile.setEnabled(!joined); + + chatBeautifier.setEnabled(joined); + timbreTransformation.setEnabled(joined); + voiceChanger.setEnabled(joined); + styleTransformation.setEnabled(joined); + roomAcoustics.setEnabled(joined); + _voice3DLayout.setVisibility(View.GONE); + pitchCorrection.setEnabled(joined); + _pitchModeLayout.setVisibility(View.GONE); + _pitchValueLayout.setVisibility(View.GONE); + voiceConversion.setEnabled(joined); + ainsMode.setEnabled(joined); + voiceAITuner.setEnabled(joined); + + customPitch.setEnabled(joined); + customBandFreq.setEnabled(joined); + customBandGain.setEnabled(joined); + customReverbKey.setEnabled(joined); + customReverbValue.setEnabled(joined); + customVoiceFormant.setEnabled(joined); + + + chatBeautifier.setSelection(0); + voiceChanger.setSelection(0); + timbreTransformation.setSelection(0); + roomAcoustics.setSelection(0); + pitchCorrection.setSelection(0); + voiceConversion.setSelection(0); + ainsMode.setSelection(0); + + customPitch.setProgress(0); + customBandGain.setProgress(0); + customReverbValue.setProgress(0); + customVoiceFormant.setProgress(50); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null) { + return; + } + try { + RtcEngineConfig config = new RtcEngineConfig(); + /* + * The context of Android Activity + */ + config.mContext = context.getApplicationContext(); + /* + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = getString(R.string.agora_app_id); + /* Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /* + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT); + config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = RtcEngine.create(config); + /* + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + } catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + /*leaveChannel and Destroy the RtcEngine instance*/ + if (engine != null) { + engine.leaveChannel(); + } + handler.post(RtcEngine::destroy); + engine = null; + } + + @Override + public void onClick(View v) { + if (v.getId() == R.id.btn_join) { + if (!joined) { + CommonUtil.hideInputBoard(getActivity(), et_channel); + // call when join button hit + String channelId = et_channel.getText().toString(); + // Check permission + checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { + @Override + public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { + if (allPermissionsGranted) { + // Permissions Granted + joinChannel(channelId); + } + } + }); + } else { + joined = false; + resetControlLayoutByJoined(); + /*After joining a channel, the user must call the leaveChannel method to end the + * call before joining another channel. This method returns 0 if the user leaves the + * channel and releases all resources related to the call. This method call is + * asynchronous, and the user has not exited the channel when the method call returns. + * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback. + * A successful leaveChannel method call triggers the following callbacks: + * 1:The local client: onLeaveChannel. + * 2:The remote client: onUserOffline, if the user leaving the channel is in the + * Communication channel, or is a BROADCASTER in the Live Broadcast profile. + * @returns 0: Success. + * < 0: Failure. + * PS: + * 1:If you call the destroy method immediately after calling the leaveChannel + * method, the leaveChannel process interrupts, and the SDK does not trigger + * the onLeaveChannel callback. + * 2:If you call the leaveChannel method during CDN live streaming, the SDK + * triggers the removeInjectStreamUrl method.*/ + engine.leaveChannel(); + join.setText(getString(R.string.join)); + audioSeatManager.downAllSeats(); + } + } + } + + private int getPitch1Value(String str) { + switch (str) { + case "Natural Minor": + return 2; + case "Breeze Minor": + return 3; + default: + return 1; + } + } + + private int getPitch2Value(String str) { + switch (str) { + case "A Pitch": + return 1; + case "A# Pitch": + return 2; + case "B Pitch": + return 3; + case "C# Pitch": + return 5; + case "D Pitch": + return 6; + case "D# Pitch": + return 7; + case "E Pitch": + return 8; + case "F Pitch": + return 9; + case "F# Pitch": + return 10; + case "G Pitch": + return 11; + case "G# Pitch": + return 12; + default: + return 4; + } + } + + /** + * @param channelId Specify the channel name that you want to join. + * Users that input the same channel name join the same channel. + */ + private void joinChannel(String channelId) { + /*In the demo, the default is to enter as the anchor.*/ + engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); + // audio config + engine.setAudioProfile( + Constants.AudioProfile.getValue(Constants.AudioProfile.valueOf(audioProfile.getSelectedItem().toString())), + Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString())) + ); + + /*Please configure accessToken in the string_config file. + * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see + * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token + * A token generated at the server. This applies to scenarios with high-security requirements. For details, see + * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ + TokenUtils.gen(requireContext(), channelId, 0, accessToken -> { + /* Allows a user to join a channel. + if you do not specify the uid, we will generate the uid for you*/ + + ChannelMediaOptions option = new ChannelMediaOptions(); + option.autoSubscribeAudio = true; + option.autoSubscribeVideo = true; + int res = engine.joinChannel(accessToken, channelId, 0, option); + if (res != 0) { + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + Log.e(TAG, RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + join.setEnabled(false); + }); + } + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { + /** + * Error code description can be found at: + * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int err) { + Log.e(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + showAlert(String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + } + + /**Occurs when a user leaves the channel. + * @param stats With this callback, the application retrieves the channel information, + * such as the call duration and statistics.*/ + @Override + public void onLeaveChannel(RtcStats stats) { + super.onLeaveChannel(stats); + Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); + showLongToast(String.format("local user %d leaveChannel!", myUid)); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + myUid = uid; + joined = true; + handler.post(new Runnable() { + @Override + public void run() { + join.setEnabled(true); + join.setText(getString(R.string.leave)); + resetControlLayoutByJoined(); + audioSeatManager.upLocalSeat(uid); + } + }); + } + + /**Since v2.9.0. + * This callback indicates the state change of the remote audio stream. + * PS: This callback does not work properly when the number of users (in the Communication profile) or + * broadcasters (in the Live-broadcast profile) in the channel exceeds 17. + * @param uid ID of the user whose audio state changes. + * @param state State of the remote audio + * REMOTE_AUDIO_STATE_STOPPED(0): The remote audio is in the default state, probably due + * to REMOTE_AUDIO_REASON_LOCAL_MUTED(3), REMOTE_AUDIO_REASON_REMOTE_MUTED(5), + * or REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7). + * REMOTE_AUDIO_STATE_STARTING(1): The first remote audio packet is received. + * REMOTE_AUDIO_STATE_DECODING(2): The remote audio stream is decoded and plays normally, + * probably due to REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2), + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4) or REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6). + * REMOTE_AUDIO_STATE_FROZEN(3): The remote audio is frozen, probably due to + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1). + * REMOTE_AUDIO_STATE_FAILED(4): The remote audio fails to start, probably due to + * REMOTE_AUDIO_REASON_INTERNAL(0). + * @param reason The reason of the remote audio state change. + * REMOTE_AUDIO_REASON_INTERNAL(0): Internal reasons. + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1): Network congestion. + * REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2): Network recovery. + * REMOTE_AUDIO_REASON_LOCAL_MUTED(3): The local user stops receiving the remote audio + * stream or disables the audio module. + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote audio + * stream or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_MUTED(5): The remote user stops sending the audio stream or + * disables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the audio stream + * or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel. + * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method + * until the SDK triggers this callback.*/ + @Override + public void onRemoteAudioStateChanged(int uid, int state, int reason, int elapsed) { + super.onRemoteAudioStateChanged(uid, state, reason, elapsed); + Log.i(TAG, "onRemoteAudioStateChanged->" + uid + ", state->" + state + ", reason->" + reason); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. + * @param uid ID of the user whose audio state changes. + * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole + * until this callback is triggered.*/ + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + Log.i(TAG, "onUserJoined->" + uid); + showLongToast(String.format("user %d joined!", uid)); + runOnUIThread(() -> audioSeatManager.upRemoteSeat(uid)); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. + * @param uid ID of the user whose audio state changes. + * @param reason Reason why the user goes offline: + * USER_OFFLINE_QUIT(0): The user left the current channel. + * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data + * packet was received within a certain period of time. If a user quits the + * call and the message is not passed to the SDK (due to an unreliable channel), + * the SDK assumes the user dropped offline. + * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from + * the host to the audience.*/ + @Override + public void onUserOffline(int uid, int reason) { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); + showLongToast(String.format("user %d offline! reason:%d", uid, reason)); + runOnUIThread(() -> audioSeatManager.downSeat(uid)); + } + + @Override + public void onLocalAudioStats(LocalAudioStats stats) { + super.onLocalAudioStats(stats); + runOnUIThread(() -> { + Map _stats = new LinkedHashMap<>(); + _stats.put("sentSampleRate", stats.sentSampleRate + ""); + _stats.put("sentBitrate", stats.sentBitrate + " kbps"); + _stats.put("internalCodec", stats.internalCodec + ""); + _stats.put("audioDeviceDelay", stats.audioDeviceDelay + " ms"); + audioSeatManager.getLocalSeat().updateStats(_stats); + }); + } + + @Override + public void onRemoteAudioStats(RemoteAudioStats stats) { + super.onRemoteAudioStats(stats); + runOnUIThread(() -> { + Map _stats = new LinkedHashMap<>(); + _stats.put("numChannels", stats.numChannels + ""); + _stats.put("receivedBitrate", stats.receivedBitrate + " kbps"); + _stats.put("audioLossRate", stats.audioLossRate + ""); + _stats.put("jitterBufferDelay", stats.jitterBufferDelay + " ms"); + audioSeatManager.getRemoteSeat(stats.uid).updateStats(_stats); + }); + } + }; + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (parent.getTag() != null) { + parent.setTag(null); + return; + } + + if (parent == audioScenario) { + engine.setAudioScenario(Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString()))); + return; + } + + // Voice Beautifier / Effects Preset layout + List voiceBeautifierSpinner = Arrays.asList(chatBeautifier, timbreTransformation); + if (voiceBeautifierSpinner.contains(parent)) { + String item = parent.getSelectedItem().toString(); + int voiceBeautifierValue = getVoiceBeautifierValue(item); + engine.setVoiceBeautifierPreset(voiceBeautifierValue); + + for (Spinner spinner : voiceBeautifierSpinner) { + if (spinner != parent) { + if (spinner.getSelectedItemPosition() != 0) { + spinner.setTag("reset"); + spinner.setSelection(0); + } + } + } + return; + } + + List audioEffectSpinner = Arrays.asList(voiceChanger, styleTransformation, roomAcoustics, pitchCorrection); + if (audioEffectSpinner.contains(parent)) { + String item = parent.getSelectedItem().toString(); + int audioEffectPreset = getAudioEffectPreset(item); + engine.setAudioEffectPreset(audioEffectPreset); + + for (Spinner spinner : audioEffectSpinner) { + if (spinner != parent) { + if (spinner.getSelectedItemPosition() != 0) { + spinner.setTag("reset"); + spinner.setSelection(0); + } + } + } + + _voice3DLayout.setVisibility(audioEffectPreset == ROOM_ACOUSTICS_3D_VOICE ? View.VISIBLE : View.GONE); + _pitchModeLayout.setVisibility(audioEffectPreset == PITCH_CORRECTION ? View.VISIBLE : View.GONE); + _pitchValueLayout.setVisibility(audioEffectPreset == PITCH_CORRECTION ? View.VISIBLE : View.GONE); + return; + } + + if (parent == voiceConversion) { + String item = parent.getSelectedItem().toString(); + engine.setVoiceConversionPreset(getVoiceConversionValue(item)); + return; + } + + + if (parent == _pitchModeOption || parent == _pitchValueOption) { + int effectOption1 = getPitch1Value(_pitchModeOption.getSelectedItem().toString()); + int effectOption2 = getPitch2Value(_pitchValueOption.getSelectedItem().toString()); + engine.setAudioEffectParameters(PITCH_CORRECTION, effectOption1, effectOption2); + } + + if (parent == ainsMode) { + boolean enable = position > 0; + /* + The AI noise suppression modes: + 0: (Default) Balance mode. This mode allows for a balanced performance on noice suppression and time delay. + 1: Aggressive mode. In scenarios where high performance on noise suppression is required, such as live streaming + outdoor events, this mode reduces nosies more dramatically, but sometimes may affect the original character of the audio. + 2: Aggressive mode with low latency. The noise suppression delay of this mode is about only half of that of the balance + and aggressive modes. It is suitable for scenarios that have high requirements on noise suppression with low latency, + such as sing together online in real time. + */ + engine.setAINSMode(enable, position - 1); + } + + if (parent == voiceAITuner) { + boolean enable = position > 0; + String item = parent.getSelectedItem().toString(); + engine.enableVoiceAITuner(enable, enable ? Constants.VOICE_AI_TUNER_TYPE.valueOf(item) : Constants.VOICE_AI_TUNER_TYPE.VOICE_AI_TUNER_MATURE_MALE); + } + } + + private int getVoiceConversionValue(String label) { + switch (label) { + case "VOICE_CHANGER_NEUTRAL": + return VOICE_CHANGER_NEUTRAL; + case "VOICE_CHANGER_SWEET": + return VOICE_CHANGER_SWEET; + case "VOICE_CHANGER_SOLID": + return VOICE_CHANGER_SOLID; + case "VOICE_CHANGER_BASS": + return VOICE_CHANGER_BASS; + case "VOICE_CHANGER_CARTOON": + return VOICE_CHANGER_CARTOON; + case "VOICE_CHANGER_CHILDLIKE": + return VOICE_CHANGER_CHILDLIKE; + case "VOICE_CHANGER_PHONE_OPERATOR": + return VOICE_CHANGER_PHONE_OPERATOR; + case "VOICE_CHANGER_MONSTER": + return VOICE_CHANGER_MONSTER; + case "VOICE_CHANGER_TRANSFORMERS": + return VOICE_CHANGER_TRANSFORMERS; + case "VOICE_CHANGER_GROOT": + return VOICE_CHANGER_GROOT; + case "VOICE_CHANGER_DARTH_VADER": + return VOICE_CHANGER_DARTH_VADER; + case "VOICE_CHANGER_IRON_LADY": + return VOICE_CHANGER_IRON_LADY; + case "VOICE_CHANGER_SHIN_CHAN": + return VOICE_CHANGER_SHIN_CHAN; + case "VOICE_CHANGER_GIRLISH_MAN": + return VOICE_CHANGER_GIRLISH_MAN; + case "VOICE_CHANGER_CHIPMUNK": + return VOICE_CHANGER_CHIPMUNK; + case "VOICE_CONVERSION_OFF": + default: + return VOICE_CONVERSION_OFF; + } + } + + private int getVoiceBeautifierValue(String label) { + int value; + switch (label) { + case "CHAT_BEAUTIFIER_MAGNETIC": + value = CHAT_BEAUTIFIER_MAGNETIC; + break; + case "CHAT_BEAUTIFIER_FRESH": + value = CHAT_BEAUTIFIER_FRESH; + break; + case "CHAT_BEAUTIFIER_VITALITY": + value = CHAT_BEAUTIFIER_VITALITY; + break; + case "TIMBRE_TRANSFORMATION_VIGOROUS": + value = TIMBRE_TRANSFORMATION_VIGOROUS; + break; + case "TIMBRE_TRANSFORMATION_DEEP": + value = TIMBRE_TRANSFORMATION_DEEP; + break; + case "TIMBRE_TRANSFORMATION_MELLOW": + value = TIMBRE_TRANSFORMATION_MELLOW; + break; + case "TIMBRE_TRANSFORMATION_FALSETTO": + value = TIMBRE_TRANSFORMATION_FALSETTO; + break; + case "TIMBRE_TRANSFORMATION_FULL": + value = TIMBRE_TRANSFORMATION_FULL; + break; + case "TIMBRE_TRANSFORMATION_CLEAR": + value = TIMBRE_TRANSFORMATION_CLEAR; + break; + case "TIMBRE_TRANSFORMATION_RESOUNDING": + value = TIMBRE_TRANSFORMATION_RESOUNDING; + break; + case "TIMBRE_TRANSFORMATION_RINGING": + value = TIMBRE_TRANSFORMATION_RINGING; + break; + case "ULTRA_HIGH_QUALITY_VOICE": + value = ULTRA_HIGH_QUALITY_VOICE; + break; + default: + value = VOICE_BEAUTIFIER_OFF; + } + return value; + } + + private int getAudioEffectPreset(String label) { + int value; + switch (label) { + case "ROOM_ACOUSTICS_KTV": + value = ROOM_ACOUSTICS_KTV; + break; + case "ROOM_ACOUSTICS_VOCAL_CONCERT": + value = ROOM_ACOUSTICS_VOCAL_CONCERT; + break; + case "ROOM_ACOUSTICS_STUDIO": + value = ROOM_ACOUSTICS_STUDIO; + break; + case "ROOM_ACOUSTICS_PHONOGRAPH": + value = ROOM_ACOUSTICS_PHONOGRAPH; + break; + case "ROOM_ACOUSTICS_VIRTUAL_STEREO": + value = ROOM_ACOUSTICS_VIRTUAL_STEREO; + break; + case "ROOM_ACOUSTICS_SPACIAL": + value = ROOM_ACOUSTICS_SPACIAL; + break; + case "ROOM_ACOUSTICS_ETHEREAL": + value = ROOM_ACOUSTICS_ETHEREAL; + break; + case "ROOM_ACOUSTICS_3D_VOICE": + value = ROOM_ACOUSTICS_3D_VOICE; + break; + case "VOICE_CHANGER_EFFECT_UNCLE": + value = VOICE_CHANGER_EFFECT_UNCLE; + break; + case "VOICE_CHANGER_EFFECT_OLDMAN": + value = VOICE_CHANGER_EFFECT_OLDMAN; + break; + case "VOICE_CHANGER_EFFECT_BOY": + value = VOICE_CHANGER_EFFECT_BOY; + break; + case "VOICE_CHANGER_EFFECT_SISTER": + value = VOICE_CHANGER_EFFECT_SISTER; + break; + case "VOICE_CHANGER_EFFECT_GIRL": + value = VOICE_CHANGER_EFFECT_GIRL; + break; + case "VOICE_CHANGER_EFFECT_PIGKING": + value = VOICE_CHANGER_EFFECT_PIGKING; + break; + case "VOICE_CHANGER_EFFECT_HULK": + value = VOICE_CHANGER_EFFECT_HULK; + break; + case "STYLE_TRANSFORMATION_RNB": + value = STYLE_TRANSFORMATION_RNB; + break; + case "STYLE_TRANSFORMATION_POPULAR": + value = STYLE_TRANSFORMATION_POPULAR; + break; + case "PITCH_CORRECTION": + value = PITCH_CORRECTION; + break; + default: + value = AUDIO_EFFECT_OFF; + } + return value; + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + + } + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (!fromUser) { + return; + } + if (seekBar == _voice3DCircle) { + int cicle = (int) (1 + 59 * progress * 1.0f / seekBar.getMax()); + // [1,60], 10 default + engine.setAudioEffectParameters(ROOM_ACOUSTICS_3D_VOICE, cicle, 0); + } else if (seekBar == customPitch) { + double pitch = 0.5 + 1.5 * progress * 1.0f / seekBar.getMax(); + // pitch: [0.5,2.0], 1.0 default + engine.setLocalVoicePitch(pitch); + } else if (seekBar == customBandGain) { + int value = (int) (-15 + 30 * progress * 1.0f / seekBar.getMax()); + // [-15,15], 0 default + engine.setLocalVoiceEqualization(Constants.AUDIO_EQUALIZATION_BAND_FREQUENCY.valueOf(customBandFreq.getSelectedItem().toString()), value); + } else if (seekBar == customReverbValue) { + Constants.AUDIO_REVERB_TYPE reverbKey = Constants.AUDIO_REVERB_TYPE.valueOf(customReverbKey.getSelectedItem().toString()); + int value; + // AUDIO_REVERB_DRY_LEVEL(0):dry signal, [-20, 10] dB + // AUDIO_REVERB_WET_LEVEL(1):wet signal, [-20, 10] dB + // AUDIO_REVERB_ROOM_SIZE(2):[0, 100] dB + // AUDIO_REVERB_WET_DELAY(3):Wet signal, [0, 200] ms + // AUDIO_REVERB_STRENGTH(4): [0, 100] + if (reverbKey == Constants.AUDIO_REVERB_TYPE.AUDIO_REVERB_DRY_LEVEL || reverbKey == Constants.AUDIO_REVERB_TYPE.AUDIO_REVERB_WET_LEVEL) { + value = (int) (-20 + 30 * progress * 1.0f / seekBar.getMax()); + } else if (reverbKey == Constants.AUDIO_REVERB_TYPE.AUDIO_REVERB_WET_DELAY) { + value = (int) (200 * progress * 1.0f / seekBar.getMax()); + } else { + value = (int) (100 * progress * 1.0f / seekBar.getMax()); + } + engine.setLocalVoiceReverb(reverbKey, value); + } else if (seekBar == customVoiceFormant) { + // [-1, 1] + double value = (progress - 50) * 1.0f / 100; + engine.setLocalVoiceFormant(value); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/audiosafety/AudioSafety.kt b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/audiosafety/AudioSafety.kt new file mode 100644 index 000000000..bd289f248 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/audiosafety/AudioSafety.kt @@ -0,0 +1,362 @@ +package io.agora.api.example.examples.advanced.audiosafety + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import androidx.fragment.app.setFragmentResultListener +import io.agora.api.example.MainApplication +import io.agora.api.example.R +import io.agora.api.example.common.BaseFragment +import io.agora.api.example.common.widget.AudioOnlyLayout +import io.agora.api.example.common.widget.AudioSeatManager +import io.agora.api.example.utils.CommonUtil +import io.agora.api.example.utils.PermissonUtils +import io.agora.api.example.utils.TokenUtils +import io.agora.rtc2.ChannelMediaOptions +import io.agora.rtc2.Constants +import io.agora.rtc2.IRtcEngineEventHandler +import io.agora.rtc2.RtcEngine +import io.agora.rtc2.RtcEngineConfig +import kotlin.random.Random + +/** + * Audio Safety Example + * + * This example demonstrates how to use AudioSafetyManager to: + * - Record audio from local and remote users in a circular buffer + * - Report users and generate WAV files with audio evidence + * - Support moderation/safety features for voice chat + */ +class AudioSafety : BaseFragment(), View.OnClickListener { + + companion object { + private const val TAG = "AudioSafety" + } + + private lateinit var etChannel: EditText + private lateinit var btnJoin: Button + private var engine: RtcEngine? = null + private var audioSafetyManager: AudioSafetyManager? = null + private var audioSeatManager: AudioSeatManager? = null + private val myUid = + Random.nextInt(10000, 100000000) // Random UID known in advance, used for both config.localUid and joinChannel + private var joined: Boolean = false + + // Current config values (can be updated from config page) + private var currentEnableRegisterLocal: Boolean = true + private var currentBufferDurationSeconds: Int = 300 + + // Debounce for reportUser to prevent rapid consecutive calls + private var lastReportTime: Long = 0 + private val REPORT_DEBOUNCE_MS = 1000L // Minimum 1 second between reports + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handler = Handler(Looper.getMainLooper()) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_audio_safety, container, false) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + etChannel = view.findViewById(R.id.et_channel) + btnJoin = view.findViewById(R.id.btn_join) + btnJoin.setOnClickListener(this) + + // Initialize audio seat manager for displaying users (8 seats) + audioSeatManager = AudioSeatManager( + view.findViewById(R.id.audio_place_01), + view.findViewById(R.id.audio_place_02), + view.findViewById(R.id.audio_place_03), + view.findViewById(R.id.audio_place_04), + view.findViewById(R.id.audio_place_05), + view.findViewById(R.id.audio_place_06), + view.findViewById(R.id.audio_place_07), + view.findViewById(R.id.audio_place_08) + ) + + // Set click listeners on audio seats to report users + setupReportListeners(view) + + // Listen for config result from config page + setFragmentResultListener(AudioSafetyConfigFragment.REQUEST_KEY) { _, bundle -> + currentEnableRegisterLocal = bundle.getBoolean(AudioSafetyConfigFragment.ARG_ENABLE_REGISTER_LOCAL, true) + currentBufferDurationSeconds = bundle.getInt(AudioSafetyConfigFragment.ARG_BUFFER_DURATION_SECONDS, 300) + + // Recreate AudioSafetyManager with new config if engine exists + engine?.let { rtcEngine -> + audioSafetyManager?.release() + val newConfig = AudioSafetyManagerConfig( + context = requireContext(), + rtcEngine = rtcEngine, + localUid = myUid, + enableRegisterLocal = currentEnableRegisterLocal, + bufferDurationSeconds = currentBufferDurationSeconds + ) + audioSafetyManager = AudioSafetyManager(newConfig) + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + val context = context ?: return + + try { + // Initialize RtcEngine + val config = RtcEngineConfig() + config.mContext = context.applicationContext + config.mAppId = getString(R.string.agora_app_id) + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING + config.mEventHandler = iRtcEngineEventHandler + val mainApp = activity?.application as? MainApplication + config.mAreaCode = mainApp?.globalSettings?.areaCode ?: 0 + + engine = RtcEngine.create(config) + + // Set reporting parameters + engine?.setParameters( + "{" + "\"rtc.report_app_scenario\":" + "{" + "\"appScenario\":" + 100 + "," + "\"serviceType\":" + 11 + "," + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + "}" + "}" + ) + + // Set local access point if configured + val localAccessPointConfiguration = + (activity?.application as? MainApplication)?.globalSettings?.privateCloudConfig + if (localAccessPointConfiguration != null) { + engine?.setLocalAccessPoint(localAccessPointConfiguration) + } + + // Use current config values (default or updated from config page) + // Use the random UID generated at class initialization + // This same UID will be used for both config.localUid and joinChannel + val audioSafetyManagerConfig = AudioSafetyManagerConfig( + context = context, + rtcEngine = engine!!, + localUid = myUid, // Random UID known in advance, will be used for joinChannel + enableRegisterLocal = currentEnableRegisterLocal, + bufferDurationSeconds = currentBufferDurationSeconds + ) + // Initialize AudioSafetyManager + audioSafetyManager = AudioSafetyManager(audioSafetyManagerConfig) + } catch (e: Exception) { + e.printStackTrace() + activity?.onBackPressed() + } + } + + override fun onDestroy() { + super.onDestroy() + + // Cleanup + if (engine != null) { + engine?.leaveChannel() + } + + audioSafetyManager?.release() + audioSafetyManager = null + + handler.post { + RtcEngine.destroy() + } + engine = null + } + + override fun onClick(v: View) { + when (v.id) { + R.id.btn_join -> { + if (!joined) { + CommonUtil.hideInputBoard(activity, etChannel) + val channelId = etChannel.text.toString() + + // Check permissions + checkOrRequestPermisson(object : PermissonUtils.PermissionResultCallback { + override fun onPermissionsResult( + allPermissionsGranted: Boolean, permissions: Array, grantResults: IntArray + ) { + if (allPermissionsGranted) { + joinChannel(channelId) + } + } + }) + } else { + joined = false + engine?.leaveChannel() + btnJoin.text = getString(R.string.join) + audioSeatManager?.downAllSeats() + audioSafetyManager?.stopRecording() + } + } + } + } + + private fun joinChannel(channelId: String) { + engine?.setClientRole(Constants.CLIENT_ROLE_BROADCASTER) + engine?.setDefaultAudioRoutetoSpeakerphone(true) + + // AudioSafetyManager is already initialized and registered before joining channel + // Use the same UID that was set in config (myUid was set when creating config) + TokenUtils.gen(requireContext(), channelId, myUid) { token -> + val option = ChannelMediaOptions() + option.autoSubscribeAudio = true + option.autoSubscribeVideo = true + + val res = engine?.joinChannel(token, channelId, myUid, option) ?: -1 + if (res != 0) { + showAlert(RtcEngine.getErrorDescription(res)) + Log.e(TAG, RtcEngine.getErrorDescription(res)) + return@gen + } + + btnJoin.isEnabled = false + } + } + + private fun setupReportListeners(view: View) { + // Set click listeners on all audio seats (8 seats) + val seatIds = listOf( + R.id.audio_place_01, + R.id.audio_place_02, + R.id.audio_place_03, + R.id.audio_place_04, + R.id.audio_place_05, + R.id.audio_place_06, + R.id.audio_place_07, + R.id.audio_place_08 + ) + + seatIds.forEach { seatId -> + view.findViewById(seatId)?.let { seat -> + // Enable clickable and focusable for click events + seat.isClickable = true + seat.isFocusable = true + + seat.setOnClickListener { clickedSeat -> + val uid = clickedSeat.tag as? Int + if (uid == null) { + showShortToast("No user in this seat") + return@setOnClickListener + } + + if (!joined) { + showShortToast("Please join a channel first") + return@setOnClickListener + } + + // Allow reporting any user including local user + reportUser(uid) + } + } + } + } + + private fun reportUser(uid: Int) { + if (!joined) { + showShortToast("Please join a channel first") + return + } + + // Debounce: prevent rapid consecutive reports + val currentTime = System.currentTimeMillis() + if (currentTime - lastReportTime < REPORT_DEBOUNCE_MS) { + showShortToast("Please wait before reporting again") + return + } + lastReportTime = currentTime + + // Show loading indicator + showShortToast("Generating audio evidence for user $uid...") + + audioSafetyManager?.reportUser(uid, object : AudioSafetyManager.ReportCallback { + override fun onSuccess(wavFileUri: String) { + // Callback is executed in worker thread, post to main thread for UI update + // Use postDelayed with 0 delay to ensure it's queued, not executed immediately + handler.post { + showLongToast("Audio evidence saved: $wavFileUri") + Log.d(TAG, "Reported user $uid, WAV file: $wavFileUri") + + // Here you can upload the WAV file to your moderation service + // For example: + // uploadToModerationService(wavFileUri, uid) + } + } + + override fun onError(error: String) { + // Callback is executed in worker thread, post to main thread for UI update + handler.post { + showShortToast("Failed to generate audio evidence: $error") + Log.e(TAG, "Failed to report user $uid: $error") + } + } + }) + } + + private val iRtcEngineEventHandler = object : IRtcEngineEventHandler() { + + override fun onError(err: Int) { + Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))) + } + + override fun onJoinChannelSuccess(channel: String, uid: Int, elapsed: Int) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)) + showShortToast(String.format("Joined channel %s as user %d", channel, uid)) + + joined = true + + handler.post { + btnJoin.isEnabled = true + btnJoin.text = getString(R.string.leave) + + // Start recording (observer already registered before joining channel) + audioSafetyManager?.startRecording() + + // Show local user in seat + audioSeatManager?.upLocalSeat(uid) + } + } + + override fun onLeaveChannel(stats: RtcStats) { + super.onLeaveChannel(stats) + Log.i(TAG, String.format("local user %d leaveChannel!", myUid)) + showShortToast(String.format("Left channel")) + + handler.post { + audioSafetyManager?.stopRecording() + } + } + + override fun onUserJoined(uid: Int, elapsed: Int) { + super.onUserJoined(uid, elapsed) + Log.i(TAG, "onUserJoined->$uid") + showShortToast(String.format("User %d joined!", uid)) + + handler.post { + audioSeatManager?.upRemoteSeat(uid) + if (audioSeatManager?.getRemoteSeat(uid) != null) { + // Register remote user for audio monitoring + audioSafetyManager?.registerUser(uid) + } + } + } + + override fun onUserOffline(uid: Int, reason: Int) { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)) + showShortToast(String.format("User %d left", uid)) + + handler.post { + audioSeatManager?.downSeat(uid) + // Unregister user when user leaves + audioSafetyManager?.unregisterUser(uid) + } + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/audiosafety/AudioSafetyConfigFragment.kt b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/audiosafety/AudioSafetyConfigFragment.kt new file mode 100644 index 000000000..a6a168b30 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/audiosafety/AudioSafetyConfigFragment.kt @@ -0,0 +1,112 @@ +package io.agora.api.example.examples.advanced.audiosafety + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.navigation.fragment.findNavController +import io.agora.api.example.R +import io.agora.api.example.annotation.Example +import io.agora.api.example.common.BaseFragment +import io.agora.api.example.common.model.Examples + +/** + * Configuration page for AudioSafety settings + */ +@Example( + index = 23, + group = Examples.ADVANCED, + name = R.string.item_audio_safety, + actionId = R.id.action_mainFragment_to_AudioSafetyConfig, + tipsId = R.string.audio_safety +) +class AudioSafetyConfigFragment : BaseFragment() { + + private lateinit var cbEnableRegisterLocal: CheckBox + private lateinit var etBufferDurationSeconds: EditText + private lateinit var btnNext: Button + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_audio_safety_config, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Initialize views + cbEnableRegisterLocal = view.findViewById(R.id.cb_enable_register_local) + etBufferDurationSeconds = view.findViewById(R.id.et_buffer_duration_seconds) + btnNext = view.findViewById(R.id.btn_next) + + // Set up save button listener + btnNext.setOnClickListener { + if (saveSettings()) { + // Came from mainFragment, navigate to AudioSafety with saved config + val bundle = Bundle().apply { + putBoolean(ARG_ENABLE_REGISTER_LOCAL, cbEnableRegisterLocal.isChecked) + putInt(ARG_BUFFER_DURATION_SECONDS, etBufferDurationSeconds.text.toString().toIntOrNull() ?: 300) + } + findNavController().navigate(R.id.action_AudioSafetyConfig_to_AudioSafety, bundle) + } + } + } + + private fun saveSettings(): Boolean { + val bufferDurationText = etBufferDurationSeconds.text.toString().trim() + + if (bufferDurationText.isEmpty()) { + etBufferDurationSeconds.error = "Buffer duration cannot be empty" + etBufferDurationSeconds.requestFocus() + return false + } + + val newBufferDurationSeconds = try { + bufferDurationText.toInt() + } catch (e: NumberFormatException) { + etBufferDurationSeconds.error = "Invalid number" + etBufferDurationSeconds.requestFocus() + return false + } + + if (newBufferDurationSeconds <= 0) { + etBufferDurationSeconds.error = "Buffer duration must be greater than 0" + etBufferDurationSeconds.requestFocus() + return false + } + + if (newBufferDurationSeconds > 3600) { + etBufferDurationSeconds.error = "Buffer duration should not exceed 3600 seconds (1 hour)" + etBufferDurationSeconds.requestFocus() + return false + } + + // Return result to previous fragment via Fragment Result API + val result = Bundle().apply { + putBoolean(ARG_ENABLE_REGISTER_LOCAL, cbEnableRegisterLocal.isChecked) + putInt(ARG_BUFFER_DURATION_SECONDS, newBufferDurationSeconds) + } + setFragmentResult(REQUEST_KEY, result) + return true + } + + companion object { + const val ARG_ENABLE_REGISTER_LOCAL = "enable_register_local" + const val ARG_BUFFER_DURATION_SECONDS = "buffer_duration_seconds" + const val REQUEST_KEY = "audio_safety_config_result" + } +} + diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/audiosafety/AudioSafetyManager.kt b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/audiosafety/AudioSafetyManager.kt new file mode 100644 index 000000000..82283c4b4 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/audiosafety/AudioSafetyManager.kt @@ -0,0 +1,557 @@ +package io.agora.api.example.examples.advanced.audiosafety + +import android.content.Context +import android.util.Log +import kotlin.jvm.Volatile +import io.agora.rtc2.Constants +import io.agora.rtc2.IAudioFrameObserver +import io.agora.rtc2.RtcEngine +import io.agora.rtc2.audio.AudioParams +import kotlinx.coroutines.CancellationException +import java.io.File +import java.io.FileOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.min + +data class AudioSafetyManagerConfig( + val context: Context, + val rtcEngine: RtcEngine, + val localUid: Int, + val enableRegisterLocal: Boolean = true, + val bufferDurationSeconds: Int = 300, +) + +/** + * AudioSafetyManager - Manages audio recording for safety/moderation purposes + * + * Features: + * - Circular buffer for in-memory audio storage (48kHz, Mono, 16-bit PCM) + * - Captures audio from remote users via onPlaybackAudioFrameBeforeMixing + * - Captures local user audio via onRecordAudioFrame + * - Generates WAV files on demand when reporting a user + * - Thread-safe operations + */ +class AudioSafetyManager( + private val config: AudioSafetyManagerConfig +) : IAudioFrameObserver { + + companion object { + private const val TAG = "AudioSafetyManager" + + // Audio format constants + private const val SAMPLE_RATE = 48000 + private const val CHANNELS = 1 // Mono + private const val BITS_PER_SAMPLE = 16 + private const val BYTES_PER_SAMPLE = BITS_PER_SAMPLE / 8 + + // Calculate buffer size for specified duration + private fun calculateBufferSize(durationSeconds: Int): Int { + // Buffer size = sample_rate * channels * bytes_per_sample * duration_seconds + return SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE * durationSeconds + } + } + + @Volatile private var isRecording: Boolean = false + + // Thread-safe storage for each user's audio buffer + private val userBuffers = ConcurrentHashMap() + + // Set of registered user IDs that should be monitored + private val registeredUsers = ConcurrentHashMap.newKeySet() + + // Coroutine scope for writing audio data to memory buffer (high priority, fast) + // Separate from file I/O to avoid blocking audio data capture + private val audioWriteScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Coroutine scope for file I/O operations (can be slower, won't block audio capture) + private val fileProcessingScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + // Register audio frame observer BEFORE joining channel + val result = config.rtcEngine.registerAudioFrameObserver(this) + if (result != 0) { + Log.e(TAG, "Failed to register audio frame observer: $result") + } + + if (config.enableRegisterLocal){ + registerUser(config.localUid) + } + + // Set audio frame parameters + // For recording (local user) + config.rtcEngine.setRecordingAudioFrameParameters( + SAMPLE_RATE, + CHANNELS, + Constants.RAW_AUDIO_FRAME_OP_MODE_READ_ONLY, + 1024 + ) + + // For playback before mixing (remote users) + // Note: setPlaybackAudioFrameBeforeMixingParameters is required for onPlaybackAudioFrameBeforeMixing callback + config.rtcEngine.setPlaybackAudioFrameBeforeMixingParameters( + SAMPLE_RATE, + CHANNELS, + 1024 + ) + + Log.d(TAG, "AudioSafetyManager initialized and registered (before joining channel)") + } + + /** + * Register a user ID to start monitoring their audio + * @param uid User ID to monitor + */ + fun registerUser(uid: Int) { + registeredUsers.add(uid) + Log.d(TAG, "Registered user $uid for audio monitoring") + } + + /** + * Unregister a user ID to stop monitoring their audio + * @param uid User ID to stop monitoring + */ + fun unregisterUser(uid: Int) { + registeredUsers.remove(uid) + // Optionally remove buffer to free memory + userBuffers.remove(uid) + Log.d(TAG, "Unregistered user $uid from audio monitoring") + } + + /** + * Start recording (called when joining channel) + */ + fun startRecording() { + isRecording = true + Log.d(TAG, "Recording started") + } + + /** + * Stop recording and cleanup + * Note: This method should be called from a background thread to avoid blocking + */ + fun stopRecording() { + isRecording = false + // Clear buffers in background to avoid blocking caller thread + // For large buffers, clearing can take time + fileProcessingScope.launch { + userBuffers.values.forEach { it.clear() } + userBuffers.clear() + registeredUsers.clear() + Log.d(TAG, "Recording stopped") + } + } + + /** + * Unregister from RtcEngine + * Should be called when leaving channel or destroying engine + */ + fun release() { + stopRecording() + config.rtcEngine.registerAudioFrameObserver(null) + + // Cancel coroutine scopes + audioWriteScope.cancel() + fileProcessingScope.cancel() + + Log.d(TAG, "AudioSafetyManager released") + } + + /** + * Callback interface for report result + */ + interface ReportCallback { + fun onSuccess(wavFileUri: String) + fun onError(error: String) + } + + /** + * Report a user and generate WAV file (executed in worker thread) + * @param targetUid User ID to report + * @param callback Callback to receive result + */ + fun reportUser(targetUid: Int, callback: ReportCallback) { + if (!isRecording) { + callback.onError("Cannot report user: not recording") + return + } + + // Execute file generation in file processing coroutine scope to avoid blocking audio write operations + // Multiple reports for the same user can run concurrently - each gets a snapshot of the buffer + fileProcessingScope.launch { + try { + // Check if still recording (may have changed during coroutine execution) + if (!isRecording) { + callback.onError("Recording stopped during report generation") + return@launch + } + + val buffer = userBuffers[targetUid] + if (buffer == null) { + callback.onError("No audio data found for user $targetUid") + return@launch + } + + // Create a snapshot of the buffer (non-destructive read, can be called multiple times) + val pcmData = buffer.readAll() + if (pcmData.isEmpty()) { + callback.onError("No audio data available for user $targetUid") + return@launch + } + + val wavFile = withContext(Dispatchers.IO) { + generateWavFile(targetUid, pcmData) + } + Log.d(TAG, "Generated WAV file for user $targetUid: $wavFile") + callback.onSuccess(wavFile) + } catch (e: CancellationException) { + // Scope was cancelled, don't call callback as manager is being released + Log.d(TAG, "Report generation cancelled for user $targetUid") + throw e // Re-throw to respect cancellation + } catch (e: Exception) { + Log.e(TAG, "Failed to generate WAV file for user $targetUid", e) + callback.onError("Failed to generate WAV file: ${e.message}") + } + } + } + + /** + * Generate WAV file from PCM data + */ + private fun generateWavFile(uid: Int, pcmData: ByteArray): String { + val fileName = "audio_report_${uid}_${System.currentTimeMillis()}.wav" + val externalFilesDir = config.context.getExternalFilesDir(null) + ?: throw IllegalStateException("External files directory is not available") + val file = File(externalFilesDir, fileName) + + FileOutputStream(file).use { fos -> + // Write WAV header + writeWavHeader(fos, pcmData.size) + + // Write PCM data + fos.write(pcmData) + fos.flush() + } + + return file.absolutePath + } + + /** + * Write WAV file header + */ + private fun writeWavHeader(fos: FileOutputStream, dataSize: Int) { + val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN) + + // RIFF header + header.put("RIFF".toByteArray()) + header.putInt(36 + dataSize) // File size - 8 + header.put("WAVE".toByteArray()) + + // fmt chunk + header.put("fmt ".toByteArray()) + header.putInt(16) // fmt chunk size + header.putShort(1.toShort()) // Audio format (1 = PCM) + header.putShort(CHANNELS.toShort()) // Number of channels + header.putInt(SAMPLE_RATE) // Sample rate + header.putInt(SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE) // Byte rate + header.putShort((CHANNELS * BYTES_PER_SAMPLE).toShort()) // Block align + header.putShort(BITS_PER_SAMPLE.toShort()) // Bits per sample + + // data chunk + header.put("data".toByteArray()) + header.putInt(dataSize) // Data size + + fos.write(header.array()) + } + + /** + * Get or create buffer for a registered user + * This should only be called from coroutine scope to avoid blocking audio thread + * + * Note: ConcurrentHashMap.getOrPut is thread-safe, but the lambda may be executed + * multiple times in case of concurrent access. This is acceptable as buffer creation + * is idempotent and the final result is the same. + */ + private fun getOrCreateBuffer(uid: Int): CircularAudioBuffer? { + // Fast path: buffer already exists + userBuffers[uid]?.let { return it } + + // Slow path: create new buffer + // ConcurrentHashMap.getOrPut is thread-safe, lambda may execute multiple times but result is the same + return userBuffers.getOrPut(uid) { + val bufferSize = calculateBufferSize(config.bufferDurationSeconds) + CircularAudioBuffer(bufferSize) + } + } + + // IAudioFrameObserver implementation + + override fun onRecordAudioFrame( + channel: String?, + audioFrameType: Int, + samples: Int, + bytesPerSample: Int, + channels: Int, + samplesPerSec: Int, + byteBuffer: ByteBuffer?, + renderTimeMs: Long, + bufferLength: Int + ): Boolean { + // Early return checks in audio callback to avoid unnecessary operations + if (!isRecording || byteBuffer == null) { + return false + } + + val targetUid = config.localUid + if (targetUid == 0 || !registeredUsers.contains(targetUid)) { + return false + } + + // Convert to target format if needed (48kHz, Mono, 16-bit) + if (samplesPerSec != SAMPLE_RATE || channels != CHANNELS || bytesPerSample != BYTES_PER_SAMPLE) { + // Skip if format doesn't match (or implement resampling/conversion if needed) + return false + } + + // Fast copy data in audio thread (deep copy to avoid buffer modification) + // Minimize operations in audio thread to prevent blocking + val remaining = byteBuffer.remaining() + if (remaining <= 0) { + return false + } + + val data = ByteArray(remaining) + val position = byteBuffer.position() + byteBuffer.get(data) + byteBuffer.position(position) // Restore position + + // Submit to audio write coroutine scope for processing (non-blocking, fast operation) + audioWriteScope.launch { + val buffer = getOrCreateBuffer(targetUid) + buffer?.write(data, 0, data.size) + } + + return false // Don't modify the audio + } + + override fun onPlaybackAudioFrame( + channel: String?, + audioFrameType: Int, + samples: Int, + bytesPerSample: Int, + channels: Int, + samplesPerSec: Int, + byteBuffer: ByteBuffer?, + renderTimeMs: Long, + bufferLength: Int + ): Boolean { + return false + } + + override fun onMixedAudioFrame( + channel: String?, + audioFrameType: Int, + samples: Int, + bytesPerSample: Int, + channels: Int, + samplesPerSec: Int, + byteBuffer: ByteBuffer?, + renderTimeMs: Long, + bufferLength: Int + ): Boolean { + return false + } + + override fun onEarMonitoringAudioFrame( + type: Int, + samplesPerChannel: Int, + bytesPerSample: Int, + channels: Int, + samplesPerSec: Int, + buffer: ByteBuffer?, + renderTimeMs: Long, + avsync_type: Int + ): Boolean { + return false + } + + override fun onPlaybackAudioFrameBeforeMixing( + channelId: String?, + uid: Int, + type: Int, + samplesPerChannel: Int, + bytesPerSample: Int, + channels: Int, + samplesPerSec: Int, + buffer: ByteBuffer?, + renderTimeMs: Long, + avsync_type: Int, + rtpTimestamp: Int, + presentationMs: Long + ): Boolean { + // Early return checks in audio callback to avoid unnecessary operations + if (!isRecording || buffer == null) { + return false + } + + if (uid == 0 || !registeredUsers.contains(uid)) { + return false + } + + // Convert to target format if needed (48kHz, Mono, 16-bit) + if (samplesPerSec != SAMPLE_RATE || channels != CHANNELS || bytesPerSample != BYTES_PER_SAMPLE) { + // Skip if format doesn't match (or implement resampling/conversion if needed) + return false + } + + // Fast copy data in audio thread (deep copy to avoid buffer modification) + // Minimize operations in audio thread to prevent blocking + val remaining = buffer.remaining() + if (remaining <= 0) { + return false + } + + val data = ByteArray(remaining) + val position = buffer.position() + buffer.get(data) + buffer.position(position) // Restore position + + // Submit to audio write coroutine scope for processing (non-blocking, fast operation) + audioWriteScope.launch { + val audioBuffer = getOrCreateBuffer(uid) + audioBuffer?.write(data, 0, data.size) + } + + return false // Don't modify the audio + } + + override fun getObservedAudioFramePosition(): Int { + // Return 0 to observe all audio frame positions + // Or return bitwise OR of Constants.AUDIO_FRAME_POSITION_* values + return 0 + } + + override fun getRecordAudioParams(): AudioParams? { + return null // Use parameters set by setRecordingAudioFrameParameters + } + + override fun getPlaybackAudioParams(): AudioParams? { + return null // Use parameters set by setPlaybackAudioFrameParameters + } + + override fun getMixedAudioParams(): AudioParams? { + return null + } + + override fun getEarMonitoringAudioParams(): AudioParams? { + return null + } + + /** + * Circular buffer for storing PCM audio data + * Thread-safe implementation using synchronized blocks + */ + private class CircularAudioBuffer(val capacity: Int) { + private val buffer = ByteArray(capacity) + private var writePosition = 0 + private var isFull = false + private val lock = Any() + + /** + * Write audio data to the circular buffer + * @param data Audio data to write + * @param offset Offset in data array + * @param length Length of data to write + */ + fun write(data: ByteArray, offset: Int, length: Int) { + synchronized(lock) { + var remaining = length + var srcOffset = offset + + while (remaining > 0) { + val available = if (isFull) capacity else writePosition + val spaceAvailable = capacity - available + val toWrite = min(remaining, spaceAvailable) + + if (toWrite == 0) { + // Buffer is full, start overwriting from beginning + writePosition = 0 + isFull = true + continue + } + + val endPos = writePosition + toWrite + if (endPos <= capacity) { + // Simple case: write doesn't wrap around + System.arraycopy(data, srcOffset, buffer, writePosition, toWrite) + writePosition = endPos + } else { + // Write wraps around + val firstPart = capacity - writePosition + System.arraycopy(data, srcOffset, buffer, writePosition, firstPart) + System.arraycopy(data, srcOffset + firstPart, buffer, 0, toWrite - firstPart) + writePosition = toWrite - firstPart + } + + remaining -= toWrite + srcOffset += toWrite + + if (writePosition == capacity) { + writePosition = 0 + isFull = true + } + } + } + } + + /** + * Read all available audio data from the buffer (snapshot, non-destructive) + * This method creates a snapshot of the current buffer state without affecting writes + * Can be called multiple times to get the latest data + * @return ByteArray containing all audio data (most recent data) + */ + fun readAll(): ByteArray { + synchronized(lock) { + return if (isFull) { + // Buffer is full, return all data starting from writePosition + // This gives us the most recent data (oldest data is at writePosition) + val result = ByteArray(capacity) + System.arraycopy(buffer, writePosition, result, 0, capacity - writePosition) + System.arraycopy(buffer, 0, result, capacity - writePosition, writePosition) + result + } else { + // Buffer not full, return data from 0 to writePosition + val result = ByteArray(writePosition) + System.arraycopy(buffer, 0, result, 0, writePosition) + result + } + } + } + + /** + * Get the current size of data in the buffer + */ + fun size(): Int { + synchronized(lock) { + return if (isFull) capacity else writePosition + } + } + + /** + * Clear the buffer + */ + fun clear() { + synchronized(lock) { + writePosition = 0 + isFull = false + } + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/AudioPlayer.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/AudioPlayer.java new file mode 100644 index 000000000..84c814cef --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/AudioPlayer.java @@ -0,0 +1,73 @@ +package io.agora.api.example.examples.advanced.customaudio; + +import android.media.AudioFormat; +import android.media.AudioTrack; +import android.util.Log; + +public class AudioPlayer { + + private static final int DEFAULT_PLAY_MODE = AudioTrack.MODE_STREAM; + private static final String TAG = "AudioPlayer"; + + private AudioTrack mAudioTrack; + private AudioStatus mAudioStatus = AudioStatus.STOPPED ; + + public AudioPlayer(int streamType, int sampleRateInHz, int channelConfig, int audioFormat){ + if(mAudioStatus == AudioStatus.STOPPED) { + int Val = 0; + if(1 == channelConfig) + Val = AudioFormat.CHANNEL_OUT_MONO; + else if(2 == channelConfig) + Val = AudioFormat.CHANNEL_OUT_STEREO; + else + Log.e(TAG, "channelConfig is wrong !"); + + int mMinBufferSize = AudioTrack.getMinBufferSize(sampleRateInHz, Val, audioFormat); + Log.e(TAG, " sampleRateInHz :" + sampleRateInHz + " channelConfig :" + channelConfig + " audioFormat: " + audioFormat + " mMinBufferSize: " + mMinBufferSize); + if (mMinBufferSize == AudioTrack.ERROR_BAD_VALUE) { + Log.e(TAG,"AudioTrack.ERROR_BAD_VALUE : " + AudioTrack.ERROR_BAD_VALUE) ; + } + + mAudioTrack = new AudioTrack(streamType, sampleRateInHz, Val, audioFormat, mMinBufferSize, DEFAULT_PLAY_MODE); + if (mAudioTrack.getState() == AudioTrack.STATE_UNINITIALIZED) { + throw new RuntimeException("Error on AudioTrack created"); + } + mAudioStatus = AudioStatus.INITIALISING; + } + Log.e(TAG, "mAudioStatus: " + mAudioStatus); + } + + public boolean startPlayer() { + if(mAudioStatus == AudioStatus.INITIALISING) { + mAudioTrack.play(); + mAudioStatus = AudioStatus.RUNNING; + } + Log.e("AudioPlayer", "mAudioStatus: " + mAudioStatus); + return true; + } + + public void stopPlayer() { + if(null != mAudioTrack){ + mAudioStatus = AudioStatus.STOPPED; + mAudioTrack.stop(); + mAudioTrack.release(); + mAudioTrack = null; + } + Log.e(TAG, "mAudioStatus: " + mAudioStatus); + } + + public boolean play(byte[] audioData, int offsetInBytes, int sizeInBytes) { + if(mAudioStatus == AudioStatus.RUNNING) { + mAudioTrack.write(audioData, offsetInBytes, sizeInBytes); + }else{ + Log.e(TAG, "=== No data to AudioTrack !! mAudioStatus: " + mAudioStatus); + } + return true; + } + + public enum AudioStatus { + INITIALISING, + RUNNING, + STOPPED + } +} \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java new file mode 100755 index 000000000..9d3be1823 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java @@ -0,0 +1,351 @@ +package io.agora.api.example.examples.advanced.customaudio; + +import static io.agora.api.example.common.model.Examples.ADVANCED; + +import android.content.Context; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.os.Bundle; +import android.os.Process; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.nio.ByteBuffer; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.widget.AudioSeatManager; +import io.agora.api.example.utils.CommonUtil; +import io.agora.api.example.utils.PermissonUtils; +import io.agora.api.example.utils.TokenUtils; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.Constants; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.RtcEngineEx; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +/** + * This demo demonstrates how to make a one-to-one voice call + */ +@Example(index = 6, group = ADVANCED, name = R.string.item_customaudiorender, actionId = R.id.action_mainFragment_to_CustomAudioRender, tipsId = R.string.customaudiorender) +public class CustomAudioRender extends BaseFragment implements View.OnClickListener { + private static final String TAG = CustomAudioRender.class.getSimpleName(); + private EditText et_channel; + private Button join; + private boolean joined = false; + /** + * The constant engine. + */ + public static RtcEngineEx engine; + + private static final Integer SAMPLE_RATE = 44100; + private static final Integer SAMPLE_NUM_OF_CHANNEL = 2; + private static final Integer BITS_PER_SAMPLE = 16; + private static final Integer SAMPLES = 441; + private static final Integer BUFFER_SIZE = SAMPLES * BITS_PER_SAMPLE / 8 * SAMPLE_NUM_OF_CHANNEL; + private static final Integer PULL_INTERVAL = SAMPLES * 1000 / SAMPLE_RATE; + + private Thread pullingTask; + private volatile boolean pulling = false; + private AudioPlayer audioPlayer; + + private AudioSeatManager audioSeatManager; + + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_custom_audio_render, container, false); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + join = view.findViewById(R.id.btn_join); + et_channel = view.findViewById(R.id.et_channel); + view.findViewById(R.id.btn_join).setOnClickListener(this); + + audioSeatManager = new AudioSeatManager( + view.findViewById(R.id.audio_place_01), + view.findViewById(R.id.audio_place_02), + view.findViewById(R.id.audio_place_03), + view.findViewById(R.id.audio_place_04), + view.findViewById(R.id.audio_place_05), + view.findViewById(R.id.audio_place_06), + view.findViewById(R.id.audio_place_07), + view.findViewById(R.id.audio_place_08), + view.findViewById(R.id.audio_place_09)); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null) { + return; + } + try { + RtcEngineConfig config = new RtcEngineConfig(); + /* + * The context of Android Activity + */ + config.mContext = context.getApplicationContext(); + /* + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = getString(R.string.agora_app_id); + /* Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /* + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT); + config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = (RtcEngineEx) RtcEngine.create(config); + /* + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + + audioPlayer = new AudioPlayer(AudioManager.STREAM_MUSIC, + SAMPLE_RATE, + SAMPLE_NUM_OF_CHANNEL, + AudioFormat.ENCODING_PCM_16BIT); + } catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + pulling = false; + if (pullingTask != null) { + try { + pullingTask.join(); + pullingTask = null; + } catch (InterruptedException e) { + // do nothing + } + } + audioPlayer.stopPlayer(); + /*leaveChannel and Destroy the RtcEngine instance*/ + if (engine != null) { + engine.leaveChannel(); + } + handler.post(RtcEngine::destroy); + engine = null; + } + + + @Override + public void onClick(View v) { + if (v.getId() == R.id.btn_join) { + if (!joined) { + CommonUtil.hideInputBoard(getActivity(), et_channel); + // call when join button hit + String channelId = et_channel.getText().toString(); + // Check permission + checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { + @Override + public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { + if (allPermissionsGranted) { + // Permissions Granted + joinChannel(channelId); + } + } + }); + } else { + joined = false; + /*After joining a channel, the user must call the leaveChannel method to end the + * call before joining another channel. This method returns 0 if the user leaves the + * channel and releases all resources related to the call. This method call is + * asynchronous, and the user has not exited the channel when the method call returns. + * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback. + * A successful leaveChannel method call triggers the following callbacks: + * 1:The local client: onLeaveChannel. + * 2:The remote client: onUserOffline, if the user leaving the channel is in the + * Communication channel, or is a BROADCASTER in the Live Broadcast profile. + * @returns 0: Success. + * < 0: Failure. + * PS: + * 1:If you call the destroy method immediately after calling the leaveChannel + * method, the leaveChannel process interrupts, and the SDK does not trigger + * the onLeaveChannel callback. + * 2:If you call the leaveChannel method during CDN live streaming, the SDK + * triggers the removeInjectStreamUrl method.*/ + engine.leaveChannel(); + pulling = false; + join.setText(getString(R.string.join)); + audioSeatManager.downAllSeats(); + if (pullingTask != null) { + try { + pullingTask.join(); + pullingTask = null; + } catch (InterruptedException e) { + // do nothing + } + } + } + } + } + + /** + * @param channelId Specify the channel name that you want to join. + * Users that input the same channel name join the same channel. + */ + private void joinChannel(String channelId) { + + engine.setExternalAudioSink(true, SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL); + + /*Please configure accessToken in the string_config file. + * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see + * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token + * A token generated at the server. This applies to scenarios with high-security requirements. For details, see + * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ + TokenUtils.gen(requireContext(), channelId, 0, ret -> { + + ChannelMediaOptions option = new ChannelMediaOptions(); + option.channelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + option.clientRoleType = Constants.CLIENT_ROLE_BROADCASTER; + + + /* Allows a user to join a channel. + if you do not specify the uid, we will generate the uid for you*/ + int res = engine.joinChannel(ret, channelId, 0, option); + + if (res != 0) { + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + join.setEnabled(false); + }); + + } + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { + + /** + * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int err) { + Log.e(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + showAlert(String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + joined = true; + handler.post(new Runnable() { + @Override + public void run() { + audioSeatManager.upLocalSeat(uid); + join.setEnabled(true); + join.setText(getString(R.string.leave)); + pulling = true; + audioPlayer.startPlayer(); + if (pullingTask == null) { + pullingTask = new Thread(new PullingTask()); + pullingTask.start(); + } + } + }); + } + + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + runOnUIThread(() -> audioSeatManager.upRemoteSeat(uid)); + } + + @Override + public void onUserOffline(int uid, int reason) { + super.onUserOffline(uid, reason); + runOnUIThread(() -> audioSeatManager.downSeat(uid)); + } + }; + + /** + * The type Pulling task. + */ + class PullingTask implements Runnable { + /** + * The Number. + */ + long number = 0; + + @Override + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO); + while (pulling) { + Log.i(TAG, "pushExternalAudioFrame times:" + number++); + + ByteBuffer frame = ByteBuffer.allocateDirect(BUFFER_SIZE); + engine.pullPlaybackAudioFrame(frame, BUFFER_SIZE); + byte[] data = new byte[frame.remaining()]; + frame.get(data, 0, data.length); + + // simple audio filter + for (int i = 0; i < data.length; i++) { + data[i] = (byte) (data[i] + 5); + } + + audioPlayer.play(data, 0, BUFFER_SIZE); + } + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java new file mode 100755 index 000000000..aa49bb8ff --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java @@ -0,0 +1,366 @@ +package io.agora.api.example.examples.advanced.customaudio; + +import static io.agora.api.example.common.model.Examples.ADVANCED; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.Switch; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.widget.AudioSeatManager; +import io.agora.api.example.utils.AudioFileReader; +import io.agora.api.example.utils.CommonUtil; +import io.agora.api.example.utils.PermissonUtils; +import io.agora.api.example.utils.TokenUtils; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.Constants; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.RtcEngineEx; +import io.agora.rtc2.audio.AudioTrackConfig; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +/** + * This demo demonstrates how to make a one-to-one voice call + */ +@Example( + index = 5, + group = ADVANCED, + name = R.string.item_customaudiosource, + actionId = R.id.action_mainFragment_to_CustomAudioSource, + tipsId = R.string.customaudio +) +public class CustomAudioSource extends BaseFragment implements View.OnClickListener, CompoundButton.OnCheckedChangeListener { + private static final String TAG = CustomAudioSource.class.getSimpleName(); + private EditText et_channel; + private Button join; + private int myUid; + private boolean joined = false; + public static RtcEngineEx engine; + private Switch mic, pcm; + private ChannelMediaOptions option = new ChannelMediaOptions(); + private int pushTimes = 0; + + private AudioSeatManager audioSeatManager; + private AudioFileReader audioPushingHelper; + private int customAudioTrack = -1; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handler = new Handler(); + initMediaOption(); + } + + private void initMediaOption() { + option.autoSubscribeAudio = true; + option.autoSubscribeVideo = true; + option.publishMicrophoneTrack = true; + option.publishCustomAudioTrack = false; + option.clientRoleType = Constants.CLIENT_ROLE_BROADCASTER; + option.enableAudioRecordingOrPlayout = true; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_custom_audio_source, container, false); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + join = view.findViewById(R.id.btn_join); + et_channel = view.findViewById(R.id.et_channel); + view.findViewById(R.id.btn_join).setOnClickListener(this); + mic = view.findViewById(R.id.microphone); + pcm = view.findViewById(R.id.localAudio); + mic.setOnCheckedChangeListener(this); + pcm.setOnCheckedChangeListener(this); + + audioSeatManager = new AudioSeatManager( + view.findViewById(R.id.audio_place_01), + view.findViewById(R.id.audio_place_02), + view.findViewById(R.id.audio_place_03), + view.findViewById(R.id.audio_place_04), + view.findViewById(R.id.audio_place_05), + view.findViewById(R.id.audio_place_06), + view.findViewById(R.id.audio_place_07), + view.findViewById(R.id.audio_place_08), + view.findViewById(R.id.audio_place_09) + ); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null) { + return; + } + try { + RtcEngineConfig config = new RtcEngineConfig(); + /** + * The context of Android Activity + */ + config.mContext = context.getApplicationContext(); + /** + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = getString(R.string.agora_app_id); + /** Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT); + config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = (RtcEngineEx) RtcEngine.create(config); + /** + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + + audioPushingHelper = new AudioFileReader(requireContext(), (buffer, timestamp) -> { + if (joined && engine != null && customAudioTrack != -1) { + int ret = engine.pushExternalAudioFrame(buffer, timestamp, AudioFileReader.SAMPLE_RATE, AudioFileReader.SAMPLE_NUM_OF_CHANNEL, Constants.BytesPerSample.TWO_BYTES_PER_SAMPLE, customAudioTrack); + Log.i(TAG, "pushExternalAudioFrame times:" + (++pushTimes) + ", ret=" + ret); + } + }); + } catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (customAudioTrack != -1) { + engine.destroyCustomAudioTrack(customAudioTrack); + customAudioTrack = -1; + } + if (audioPushingHelper != null) { + audioPushingHelper.stop(); + } + /**leaveChannel and Destroy the RtcEngine instance*/ + if (engine != null) { + engine.leaveChannel(); + } + handler.post(RtcEngine::destroy); + engine = null; + } + + + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + if (compoundButton.getId() == R.id.microphone) { + option.publishMicrophoneTrack = checked; + engine.updateChannelMediaOptions(option); + } else if (compoundButton.getId() == R.id.localAudio) { + option.publishCustomAudioTrackId = customAudioTrack; + option.publishCustomAudioTrack = checked; + engine.updateChannelMediaOptions(option); + engine.enableCustomAudioLocalPlayback(customAudioTrack, checked); + } + } + + + @Override + public void onClick(View v) { + if (v.getId() == R.id.btn_join) { + if (!joined) { + CommonUtil.hideInputBoard(getActivity(), et_channel); + // call when join button hit + String channelId = et_channel.getText().toString(); + // Check permission + checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { + @Override + public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { + if (allPermissionsGranted) { + // Permissions Granted + joinChannel(channelId); + } + } + }); + } else { + joined = false; + /**After joining a channel, the user must call the leaveChannel method to end the + * call before joining another channel. This method returns 0 if the user leaves the + * channel and releases all resources related to the call. This method call is + * asynchronous, and the user has not exited the channel when the method call returns. + * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback. + * A successful leaveChannel method call triggers the following callbacks: + * 1:The local client: onLeaveChannel. + * 2:The remote client: onUserOffline, if the user leaving the channel is in the + * Communication channel, or is a BROADCASTER in the Live Broadcast profile. + * @returns 0: Success. + * < 0: Failure. + * PS: + * 1:If you call the destroy method immediately after calling the leaveChannel + * method, the leaveChannel process interrupts, and the SDK does not trigger + * the onLeaveChannel callback. + * 2:If you call the leaveChannel method during CDN live streaming, the SDK + * triggers the removeInjectStreamUrl method.*/ + engine.leaveChannel(); + join.setText(getString(R.string.join)); + mic.setEnabled(false); + pcm.setEnabled(false); + pcm.setChecked(false); + mic.setChecked(true); + if (audioPushingHelper != null) { + audioPushingHelper.stop(); + } + audioSeatManager.downAllSeats(); + } + } + } + + /** + * @param channelId Specify the channel name that you want to join. + * Users that input the same channel name join the same channel. + */ + private void joinChannel(String channelId) { + /**In the demo, the default is to enter as the anchor.*/ + engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); + /**Sets the external audio source. + * @param enabled Sets whether to enable/disable the external audio source: + * true: Enable the external audio source. + * false: (Default) Disable the external audio source. + * @param sampleRate Sets the sample rate (Hz) of the external audio source, which can be + * set as 8000, 16000, 32000, 44100, or 48000 Hz. + * @param channels Sets the number of channels of the external audio source: + * 1: Mono. + * 2: Stereo. + * @return + * 0: Success. + * < 0: Failure. + * PS: Ensure that you call this method before the joinChannel method.*/ + AudioTrackConfig config = new AudioTrackConfig(); + config.enableLocalPlayback = false; + customAudioTrack = engine.createCustomAudioTrack(Constants.AudioTrackType.AUDIO_TRACK_MIXABLE, config); + + /**Please configure accessToken in the string_config file. + * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see + * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token + * A token generated at the server. This applies to scenarios with high-security requirements. For details, see + * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ + TokenUtils.gen(requireContext(), channelId, 0, ret -> { + + /** Allows a user to join a channel. + if you do not specify the uid, we will generate the uid for you*/ + int res = engine.joinChannel(ret, channelId, 0, option); + if (res != 0) { + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + join.setEnabled(false); + }); + } + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { + + /** + * Error code description can be found at: + * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int err) { + Log.e(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + showAlert(String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + myUid = uid; + joined = true; + handler.post(new Runnable() { + @Override + public void run() { + mic.setEnabled(true); + pcm.setEnabled(true); + join.setEnabled(true); + join.setText(getString(R.string.leave)); + if (audioPushingHelper != null) { + pushTimes = 0; + audioPushingHelper.start(); + } + audioSeatManager.upLocalSeat(uid); + if (pcm.isChecked()) { + engine.enableCustomAudioLocalPlayback(0, true); + } + } + }); + } + + + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + runOnUIThread(() -> audioSeatManager.upRemoteSeat(uid)); + } + + @Override + public void onUserOffline(int uid, int reason) { + super.onUserOffline(uid, reason); + runOnUIThread(() -> audioSeatManager.downSeat(uid)); + } + }; + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java new file mode 100644 index 000000000..b2c134dc6 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java @@ -0,0 +1,319 @@ +package io.agora.api.example.examples.audio; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Locale; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.model.Examples; +import io.agora.api.example.databinding.FragmentAudioWaveformBinding; +import io.agora.api.example.utils.CommonUtil; +import io.agora.api.example.utils.TokenUtils; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.Constants; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +@Example( + index = 7, + group = Examples.ADVANCED, + name = R.string.item_audiowaveform, + actionId = R.id.action_mainFragment_to_AudioWaveform, + tipsId = R.string.audiorouter_palyer +) +public class AudioWaveform extends BaseFragment { + private static final String TAG = "AudioWaveform"; + private FragmentAudioWaveformBinding mBinding; + private RtcEngine engine; + private int myUid; + private boolean joined = false; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null) { + return; + } + try { + RtcEngineConfig config = new RtcEngineConfig(); + /* + * The context of Android Activity + */ + config.mContext = context.getApplicationContext(); + /* + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = getString(R.string.agora_app_id); + /* Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /* + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAreaCode = ((MainApplication) requireActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = RtcEngine.create(config); + /* + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + + } catch (Exception e) { + e.printStackTrace(); + requireActivity().onBackPressed(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + /*leaveChannel and Destroy the RtcEngine instance*/ + if (engine != null) { + engine.leaveChannel(); + } + handler.post(RtcEngine::destroy); + engine = null; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + mBinding = FragmentAudioWaveformBinding.inflate(inflater); + return mBinding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mBinding.btnJoin.setOnClickListener(v -> { + if (!joined) { + CommonUtil.hideInputBoard(requireActivity(), mBinding.etChannel); + joinChannel(mBinding.etChannel.getText().toString()); + joined = true; + mBinding.btnJoin.setText(R.string.leave); + mBinding.waveformView.clear(); + } else { + engine.leaveChannel(); + joined = false; + mBinding.btnJoin.setText(R.string.join); + } + }); + } + + + /** + * @param channelId Specify the channel name that you want to join. + * Users that input the same channel name join the same channel. + */ + private void joinChannel(String channelId) { + + engine.enableAudio(); + engine.setDefaultAudioRoutetoSpeakerphone(true); + + /* + * Enables the reporting of users' volume indication. + * + * @param interval Sets the time interval between two consecutive volume indications + * ≤ 0: Disables the volume indication. + * > 0: Time interval (ms) between two consecutive volume indications. The lowest value is 50. + * @param smooth The smoothing factor that sets the sensitivity of the audio volume indicator. The value ranges between 0 and 10. + * The recommended value is 3. The greater the value, the more sensitive the indicator. + * @param reportVad true: Enables the voice activity detection of the local user. Once it is enabled, + * the vad parameter of the onAudioVolumeIndication callback reports the voice activity status of the local user. + * false: (Default) Disables the voice activity detection of the local user. Once it is disabled, + * the vad parameter of the onAudioVolumeIndication callback does not report the voice activity status of the local user, + * except for the scenario where the engine automatically detects the voice activity of the local user. + */ + engine.enableAudioVolumeIndication(1000, 3, true); + + /*In the demo, the default is to enter as the anchor.*/ + ChannelMediaOptions option = new ChannelMediaOptions(); + option.channelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + option.clientRoleType = Constants.CLIENT_ROLE_BROADCASTER; + option.autoSubscribeAudio = true; + option.autoSubscribeVideo = true; + option.publishMicrophoneTrack = true; + + /*Please configure accessToken in the string_config file. + * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see + * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token + * A token generated at the server. This applies to scenarios with high-security requirements. For details, see + * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ + TokenUtils.gen(requireContext(), channelId, 0, ret -> { + + /* Allows a user to join a channel. + if you do not specify the uid, we will generate the uid for you*/ + int res = engine.joinChannel(ret, channelId, 0, option); + if (res != 0) { + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + Log.e(TAG, RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + mBinding.btnJoin.setEnabled(false); + }); + + } + + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { + /** + * Error code description can be found at: + * en: ... + * cn: ... + */ + @Override + public void onError(int error) { + Log.w(TAG, String.format("onError code %d message %s", error, RtcEngine.getErrorDescription(error))); + runOnUIThread(() -> mBinding.btnJoin.setEnabled(true)); + } + + /**Occurs when a user leaves the channel. + * @param stats With this callback, the application retrieves the channel information, + * such as the call duration and statistics.*/ + @Override + public void onLeaveChannel(RtcStats stats) { + super.onLeaveChannel(stats); + Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); + showLongToast(String.format(Locale.US, "local user %d leaveChannel!", myUid)); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format(Locale.US, "onJoinChannelSuccess channel %s uid %d", channel, uid)); + myUid = uid; + joined = true; + runOnUIThread(() -> mBinding.btnJoin.setEnabled(true)); + } + + /**Since v2.9.0. + * This callback indicates the state change of the remote audio stream. + * PS: This callback does not work properly when the number of users (in the Communication profile) or + * broadcasters (in the Live-broadcast profile) in the channel exceeds 17. + * @param uid ID of the user whose audio state changes. + * @param state State of the remote audio + * REMOTE_AUDIO_STATE_STOPPED(0): The remote audio is in the default state, probably due + * to REMOTE_AUDIO_REASON_LOCAL_MUTED(3), REMOTE_AUDIO_REASON_REMOTE_MUTED(5), + * or REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7). + * REMOTE_AUDIO_STATE_STARTING(1): The first remote audio packet is received. + * REMOTE_AUDIO_STATE_DECODING(2): The remote audio stream is decoded and plays normally, + * probably due to REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2), + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4) or REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6). + * REMOTE_AUDIO_STATE_FROZEN(3): The remote audio is frozen, probably due to + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1). + * REMOTE_AUDIO_STATE_FAILED(4): The remote audio fails to start, probably due to + * REMOTE_AUDIO_REASON_INTERNAL(0). + * @param reason The reason of the remote audio state change. + * REMOTE_AUDIO_REASON_INTERNAL(0): Internal reasons. + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1): Network congestion. + * REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2): Network recovery. + * REMOTE_AUDIO_REASON_LOCAL_MUTED(3): The local user stops receiving the remote audio + * stream or disables the audio module. + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote audio + * stream or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_MUTED(5): The remote user stops sending the audio stream or + * disables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the audio stream + * or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel. + * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method + * until the SDK triggers this callback.*/ + @Override + public void onRemoteAudioStateChanged(int uid, int state, int reason, int elapsed) { + super.onRemoteAudioStateChanged(uid, state, reason, elapsed); + Log.i(TAG, "onRemoteAudioStateChanged->" + uid + ", state->" + state + ", reason->" + reason); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. + * @param uid ID of the user whose audio state changes. + * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole + * until this callback is triggered.*/ + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + Log.i(TAG, "onUserJoined->" + uid); + showLongToast(String.format(Locale.US, "user %d joined!", uid)); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. + * @param uid ID of the user whose audio state changes. + * @param reason Reason why the user goes offline: + * USER_OFFLINE_QUIT(0): The user left the current channel. + * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data + * packet was received within a certain period of time. If a user quits the + * call and the message is not passed to the SDK (due to an unreliable channel), + * the SDK assumes the user dropped offline. + * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from + * the host to the audience.*/ + @Override + public void onUserOffline(int uid, int reason) { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); + showLongToast(String.format(Locale.US, "user %d offline! reason:%d", uid, reason)); + } + + /** + * Reports the volume information of users. + * + * @param speakers The volume information of the users. See AudioVolumeInfo. + * An empty speakers array in the callback indicates that no remote user is in the channel or is sending a stream. + * @param totalVolume The volume of the speaker. The value range is [0,255]. + * In the callback for the local user, totalVolume is the volume of the local user who sends a stream. + * In the callback for remote users, totalVolume is the sum of the volume of all remote users (up to three) + * whose instantaneous volume is the highest. If the user calls startAudioMixing [2/2], then totalVolume is the volume after audio mixing. + */ + @Override + public void onAudioVolumeIndication(AudioVolumeInfo[] speakers, int totalVolume) { + super.onAudioVolumeIndication(speakers, totalVolume); + runOnUIThread(() -> mBinding.waveformView.addData((short) totalVolume)); + } + }; +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java new file mode 100755 index 000000000..258cc4b13 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java @@ -0,0 +1,755 @@ +package io.agora.api.example.examples.basic; + +import static io.agora.api.example.common.model.Examples.BASIC; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.ServiceInfo; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.drawable.Icon; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.provider.Settings; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.EditText; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.Switch; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.app.NotificationManagerCompat; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.agora.api.example.MainActivity; +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.widget.AudioSeatManager; +import io.agora.api.example.utils.CommonUtil; +import io.agora.api.example.utils.PermissonUtils; +import io.agora.api.example.utils.TokenUtils; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.Constants; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +/** + * This demo demonstrates how to make a one-to-one voice call + * + * @author cjw + */ +@Example( + index = 2, + group = BASIC, + name = R.string.item_joinaudio, + actionId = R.id.action_mainFragment_to_joinChannelAudio, + tipsId = R.string.joinchannelaudio +) +public class JoinChannelAudio extends BaseFragment implements View.OnClickListener { + private static final String TAG = JoinChannelAudio.class.getSimpleName(); + private Spinner channelProfileInput; + private Spinner audioProfileInput; + private Spinner audioScenarioInput; + private Spinner audioRouteInput; + private EditText et_channel; + private Button mute, join; + private SeekBar record, playout, inear; + private Switch inEarSwitch; + private RtcEngine engine; + private int myUid; + private boolean joined = false; + private AudioSeatManager audioSeatManager; + + private SeekBar.OnSeekBarChangeListener seekBarChangeListener = new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (seekBar.getId() == record.getId()) { + engine.adjustRecordingSignalVolume(progress); + } else if (seekBar.getId() == playout.getId()) { + engine.adjustPlaybackSignalVolume(progress); + } else if (seekBar.getId() == inear.getId()) { + if (progress == 0) { + engine.enableInEarMonitoring(false); + } else { + engine.enableInEarMonitoring(true); + engine.setInEarMonitoringVolume(progress); + } + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handler = new Handler(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_joinchannel_audio, container, false); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + join = view.findViewById(R.id.btn_join); + et_channel = view.findViewById(R.id.et_channel); + audioProfileInput = view.findViewById(R.id.audio_profile_spinner); + channelProfileInput = view.findViewById(R.id.channel_profile_spinner); + audioScenarioInput = view.findViewById(R.id.audio_scenario_spinner); + audioRouteInput = view.findViewById(R.id.audio_route_spinner); + audioScenarioInput.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (joined) { + int scenario = Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenarioInput.getSelectedItem().toString())); + engine.setAudioScenario(scenario); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + audioRouteInput.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (!joined) { + return; + } + boolean isCommunication = getString(R.string.channel_profile_communication).equals(channelProfileInput.getSelectedItem()); + if (isCommunication) { + int route = Constants.AUDIO_ROUTE_EARPIECE; + if (getString(R.string.audio_route_earpiece).equals(parent.getSelectedItem())) { + route = Constants.AUDIO_ROUTE_EARPIECE; + } else if (getString(R.string.audio_route_speakerphone).equals(parent.getSelectedItem())) { + route = Constants.AUDIO_ROUTE_SPEAKERPHONE; + } else if (getString(R.string.audio_route_headset).equals(parent.getSelectedItem())) { + route = Constants.AUDIO_ROUTE_HEADSET; + } else if (getString(R.string.audio_route_headset_bluetooth).equals(parent.getSelectedItem())) { + route = Constants.AUDIO_ROUTE_BLUETOOTH_DEVICE_HFP; + } + int ret = engine.setRouteInCommunicationMode(route); + showShortToast("setRouteInCommunicationMode route=" + route + ", ret=" + ret); + } else { + boolean isSpeakerPhone = false; + if (getString(R.string.audio_route_earpiece).equals(parent.getSelectedItem())) { + isSpeakerPhone = false; + } else if (getString(R.string.audio_route_speakerphone).equals(parent.getSelectedItem())) { + isSpeakerPhone = true; + } + int ret = engine.setEnableSpeakerphone(isSpeakerPhone); + showShortToast("setEnableSpeakerphone enable=" + isSpeakerPhone + ", ret=" + ret); + } + + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + view.findViewById(R.id.btn_join).setOnClickListener(this); + mute = view.findViewById(R.id.microphone); + mute.setOnClickListener(this); + record = view.findViewById(R.id.recordingVol); + playout = view.findViewById(R.id.playoutVol); + inear = view.findViewById(R.id.inEarMonitorVol); + record.setOnSeekBarChangeListener(seekBarChangeListener); + playout.setOnSeekBarChangeListener(seekBarChangeListener); + inear.setOnSeekBarChangeListener(seekBarChangeListener); + inEarSwitch = view.findViewById(R.id.inEarMonitorSwitch); + inEarSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + engine.enableInEarMonitoring(isChecked); + inear.setEnabled(isChecked); + }); + record.setEnabled(false); + playout.setEnabled(false); + inear.setEnabled(false); + inEarSwitch.setEnabled(false); + + audioSeatManager = new AudioSeatManager( + view.findViewById(R.id.audio_place_01), + view.findViewById(R.id.audio_place_02), + view.findViewById(R.id.audio_place_03), + view.findViewById(R.id.audio_place_04), + view.findViewById(R.id.audio_place_05), + view.findViewById(R.id.audio_place_06) + ); + + if (savedInstanceState != null) { + joined = savedInstanceState.getBoolean("joined"); + if (joined) { + myUid = savedInstanceState.getInt("myUid"); + ArrayList seatRemoteUidList = savedInstanceState.getIntegerArrayList("seatRemoteUidList"); + mute.setEnabled(true); + join.setEnabled(true); + join.setText(getString(R.string.leave)); + record.setEnabled(true); + playout.setEnabled(true); + inear.setEnabled(inEarSwitch.isChecked()); + inEarSwitch.setEnabled(true); + audioSeatManager.upLocalSeat(myUid); + + for (Integer uid : seatRemoteUidList) { + audioSeatManager.upRemoteSeat(uid); + } + } + } + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Check if the context is valid + Context context = getContext(); + if (context == null || engine != null) { + return; + } + try { + RtcEngineConfig config = new RtcEngineConfig(); + /* + * The context of Android Activity + */ + config.mContext = context.getApplicationContext(); + /* + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = getString(R.string.agora_app_id); + /* Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /* + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenarioInput.getSelectedItem().toString())); + config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = RtcEngine.create(config); + /* + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + } catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + enableNotifications(); + } + + private void enableNotifications() { + if (NotificationManagerCompat.from(requireContext()).areNotificationsEnabled()) { + Log.d(TAG, "Notifications enable!"); + return; + } + Log.d(TAG, "Notifications not enable!"); + new AlertDialog.Builder(requireContext()) + .setTitle("Tip") + .setMessage(R.string.notifications_enable_tip) + .setPositiveButton(R.string.setting, (dialog, which) -> { + Intent intent = new Intent(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.setAction(Settings.ACTION_APP_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().getPackageName()); + intent.putExtra(Settings.EXTRA_CHANNEL_ID, requireContext().getApplicationInfo().uid); + } else { + intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + } + startActivity(intent); + dialog.dismiss(); + }) + .show(); + } + + @Override + public void onPause() { + super.onPause(); + startRecordingService(); + } + + private void startRecordingService() { + if (joined) { + Context context = getContext(); + if (context != null) { + Intent intent = new Intent(context, LocalRecordingService.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + // join state + outState.putBoolean("joined", joined); + outState.putInt("myUid", myUid); + outState.putIntegerArrayList("seatRemoteUidList", audioSeatManager.getSeatRemoteUidList()); + } + + @Override + public void onResume() { + super.onResume(); + stopRecordingService(); + } + + private void stopRecordingService() { + Context context = getContext(); + if (context != null) { + Intent intent = new Intent(context, LocalRecordingService.class); + requireContext().stopService(intent); + } + } + + @Override + protected void onBackPressed() { + joined = false; + stopRecordingService(); + /*leaveChannel and Destroy the RtcEngine instance*/ + if (engine != null) { + engine.leaveChannel(); + } + handler.post(RtcEngine::destroy); + engine = null; + super.onBackPressed(); + } + + @Override + public void onClick(View v) { + if (v.getId() == R.id.btn_join) { + if (!joined) { + CommonUtil.hideInputBoard(getActivity(), et_channel); + // call when join button hit + String channelId = et_channel.getText().toString(); + // Check permission + checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { + @Override + public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { + if (allPermissionsGranted) { + // Permissions Granted + joinChannel(channelId); + audioProfileInput.setEnabled(false); + channelProfileInput.setEnabled(false); + } + } + }); + } else { + joined = false; + /*After joining a channel, the user must call the leaveChannel method to end the + * call before joining another channel. This method returns 0 if the user leaves the + * channel and releases all resources related to the call. This method call is + * asynchronous, and the user has not exited the channel when the method call returns. + * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback. + * A successful leaveChannel method call triggers the following callbacks: + * 1:The local client: onLeaveChannel. + * 2:The remote client: onUserOffline, if the user leaving the channel is in the + * Communication channel, or is a BROADCASTER in the Live Broadcast profile. + * @returns 0: Success. + * < 0: Failure. + * PS: + * 1:If you call the destroy method immediately after calling the leaveChannel + * method, the leaveChannel process interrupts, and the SDK does not trigger + * the onLeaveChannel callback. + * 2:If you call the leaveChannel method during CDN live streaming, the SDK + * triggers the removeInjectStreamUrl method.*/ + engine.leaveChannel(); + join.setText(getString(R.string.join)); + audioRouteInput.setSelection(0); + mute.setText(getString(R.string.closemicrophone)); + mute.setEnabled(false); + audioProfileInput.setEnabled(true); + channelProfileInput.setEnabled(true); + record.setEnabled(false); + playout.setEnabled(false); + inear.setEnabled(false); + inEarSwitch.setEnabled(false); + inEarSwitch.setChecked(false); + audioSeatManager.downAllSeats(); + } + } else if (v.getId() == R.id.microphone) { + mute.setActivated(!mute.isActivated()); + mute.setText(getString(mute.isActivated() ? R.string.openmicrophone : R.string.closemicrophone)); + /*Turn off / on the microphone, stop / start local audio collection and push streaming.*/ + engine.muteLocalAudioStream(mute.isActivated()); + } + } + + /** + * @param channelId Specify the channel name that you want to join. + * Users that input the same channel name join the same channel. + */ + private void joinChannel(String channelId) { + /*In the demo, the default is to enter as the anchor.*/ + engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); + + int channelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + if (getString(R.string.channel_profile_communication).equals(channelProfileInput.getSelectedItem())) { + channelProfile = Constants.CHANNEL_PROFILE_COMMUNICATION; + } else if (getString(R.string.channel_profile_game).equals(channelProfileInput.getSelectedItem())) { + channelProfile = Constants.CHANNEL_PROFILE_GAME; + } else if (getString(R.string.channel_profile_communication_1v1).equals(channelProfileInput.getSelectedItem())) { + channelProfile = Constants.CHANNEL_PROFILE_COMMUNICATION_1v1; + } else if (getString(R.string.channel_profile_cloud_gaming).equals(channelProfileInput.getSelectedItem())) { + channelProfile = Constants.CHANNEL_PROFILE_CLOUD_GAMING; + } + engine.setChannelProfile(channelProfile); + + + int audioProfile = Constants.AudioProfile.getValue(Constants.AudioProfile.valueOf(audioProfileInput.getSelectedItem().toString())); + engine.setAudioProfile(audioProfile); + + int scenario = Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenarioInput.getSelectedItem().toString())); + engine.setAudioScenario(scenario); + + ChannelMediaOptions option = new ChannelMediaOptions(); + option.autoSubscribeAudio = true; + option.autoSubscribeVideo = true; + + /*Please configure accessToken in the string_config file. + * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see + * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token + * A token generated at the server. This applies to scenarios with high-security requirements. For details, see + * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ + TokenUtils.gen(requireContext(), channelId, 0, ret -> { + + /* Allows a user to join a channel. + if you do not specify the uid, we will generate the uid for you*/ + int res = engine.joinChannel(ret, channelId, 0, option); + if (res != 0) { + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + Log.e(TAG, RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + join.setEnabled(false); + }); + + } + + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { + /** + * Error code description can be found at: + * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int error) { + Log.w(TAG, String.format("onError code %d message %s", error, RtcEngine.getErrorDescription(error))); + } + + /**Occurs when a user leaves the channel. + * @param stats With this callback, the application retrieves the channel information, + * such as the call duration and statistics.*/ + @Override + public void onLeaveChannel(RtcStats stats) { + super.onLeaveChannel(stats); + Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); + showLongToast(String.format("local user %d leaveChannel!", myUid)); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + myUid = uid; + joined = true; + runOnUIThread(() -> { + mute.setEnabled(true); + join.setEnabled(true); + join.setText(getString(R.string.leave)); + record.setEnabled(true); + playout.setEnabled(true); + inear.setEnabled(inEarSwitch.isChecked()); + inEarSwitch.setEnabled(true); + audioSeatManager.upLocalSeat(uid); + }); + } + + /**Since v2.9.0. + * This callback indicates the state change of the remote audio stream. + * PS: This callback does not work properly when the number of users (in the Communication profile) or + * broadcasters (in the Live-broadcast profile) in the channel exceeds 17. + * @param uid ID of the user whose audio state changes. + * @param state State of the remote audio + * REMOTE_AUDIO_STATE_STOPPED(0): The remote audio is in the default state, probably due + * to REMOTE_AUDIO_REASON_LOCAL_MUTED(3), REMOTE_AUDIO_REASON_REMOTE_MUTED(5), + * or REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7). + * REMOTE_AUDIO_STATE_STARTING(1): The first remote audio packet is received. + * REMOTE_AUDIO_STATE_DECODING(2): The remote audio stream is decoded and plays normally, + * probably due to REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2), + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4) or REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6). + * REMOTE_AUDIO_STATE_FROZEN(3): The remote audio is frozen, probably due to + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1). + * REMOTE_AUDIO_STATE_FAILED(4): The remote audio fails to start, probably due to + * REMOTE_AUDIO_REASON_INTERNAL(0). + * @param reason The reason of the remote audio state change. + * REMOTE_AUDIO_REASON_INTERNAL(0): Internal reasons. + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1): Network congestion. + * REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2): Network recovery. + * REMOTE_AUDIO_REASON_LOCAL_MUTED(3): The local user stops receiving the remote audio + * stream or disables the audio module. + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote audio + * stream or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_MUTED(5): The remote user stops sending the audio stream or + * disables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the audio stream + * or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel. + * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method + * until the SDK triggers this callback.*/ + @Override + public void onRemoteAudioStateChanged(int uid, int state, int reason, int elapsed) { + super.onRemoteAudioStateChanged(uid, state, reason, elapsed); + Log.i(TAG, "onRemoteAudioStateChanged->" + uid + ", state->" + state + ", reason->" + reason); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. + * @param uid ID of the user whose audio state changes. + * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole + * until this callback is triggered.*/ + @Override + public void onUserJoined(int uid, int elapsed) { + super.onUserJoined(uid, elapsed); + Log.i(TAG, "onUserJoined->" + uid); + showLongToast(String.format("user %d joined!", uid)); + runOnUIThread(() -> { + audioSeatManager.upRemoteSeat(uid); + }); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. + * @param uid ID of the user whose audio state changes. + * @param reason Reason why the user goes offline: + * USER_OFFLINE_QUIT(0): The user left the current channel. + * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data + * packet was received within a certain period of time. If a user quits the + * call and the message is not passed to the SDK (due to an unreliable channel), + * the SDK assumes the user dropped offline. + * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from + * the host to the audience.*/ + @Override + public void onUserOffline(int uid, int reason) { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); + showLongToast(String.format("user %d offline! reason:%d", uid, reason)); + runOnUIThread(() -> { + audioSeatManager.downSeat(uid); + }); + } + + @Override + public void onLocalAudioStats(LocalAudioStats stats) { + super.onLocalAudioStats(stats); + runOnUIThread(() -> { + Map _stats = new LinkedHashMap<>(); + _stats.put("sentSampleRate", stats.sentSampleRate + ""); + _stats.put("sentBitrate", stats.sentBitrate + " kbps"); + _stats.put("internalCodec", stats.internalCodec + ""); + _stats.put("audioDeviceDelay", stats.audioDeviceDelay + " ms"); + audioSeatManager.getLocalSeat().updateStats(_stats); + }); + } + + @Override + public void onRemoteAudioStats(RemoteAudioStats stats) { + super.onRemoteAudioStats(stats); + runOnUIThread(() -> { + Map _stats = new LinkedHashMap<>(); + _stats.put("numChannels", stats.numChannels + ""); + _stats.put("receivedBitrate", stats.receivedBitrate + " kbps"); + _stats.put("audioLossRate", stats.audioLossRate + ""); + _stats.put("jitterBufferDelay", stats.jitterBufferDelay + " ms"); + audioSeatManager.getRemoteSeat(stats.uid).updateStats(_stats); + }); + } + + @Override + public void onAudioRouteChanged(int routing) { + super.onAudioRouteChanged(routing); + showShortToast("onAudioRouteChanged : " + routing); + runOnUIThread(() -> { + String selectedRouteStr = getString(R.string.audio_route_speakerphone); + if (routing == Constants.AUDIO_ROUTE_EARPIECE) { + selectedRouteStr = getString(R.string.audio_route_earpiece); + } else if (routing == Constants.AUDIO_ROUTE_SPEAKERPHONE) { + selectedRouteStr = getString(R.string.audio_route_speakerphone); + } else if (routing == Constants.AUDIO_ROUTE_HEADSET) { + selectedRouteStr = getString(R.string.audio_route_headset); + } else if (routing == Constants.AUDIO_ROUTE_BLUETOOTH_DEVICE_HFP) { + selectedRouteStr = getString(R.string.audio_route_headset_bluetooth); + } else if (routing == Constants.AUDIO_ROUTE_USBDEVICE) { + selectedRouteStr = getString(R.string.audio_route_headset_typec); + } + + int selection = 0; + for (int i = 0; i < audioRouteInput.getAdapter().getCount(); i++) { + String routeStr = (String) audioRouteInput.getItemAtPosition(i); + if (routeStr.equals(selectedRouteStr)) { + selection = i; + break; + } + } + audioRouteInput.setSelection(selection); + }); + } + }; + + + /** + * The service will display a microphone foreground notification, + * which can ensure keeping recording when the activity destroyed by system for memory leak or other reasons. + * Note: The "android.permission.FOREGROUND_SERVICE" permission is required. + * And the android:foregroundServiceType should be microphone. + */ + public static class LocalRecordingService extends Service { + private static final int NOTIFICATION_ID = 1234567900; + private static final String CHANNEL_ID = "api_audio_channel_id"; + + + @Override + public void onCreate() { + super.onCreate(); + Notification notification = getDefaultNotification(); + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + this.startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE); + } else { + this.startForeground(NOTIFICATION_ID, notification); + } + } catch (Exception ex) { + Log.e(TAG, "", ex); + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private Notification getDefaultNotification() { + ApplicationInfo appInfo = this.getApplicationContext().getApplicationInfo(); + String name = this.getApplicationContext().getPackageManager().getApplicationLabel(appInfo).toString(); + int icon = appInfo.icon; + + try { + Bitmap iconBitMap = BitmapFactory.decodeResource(this.getApplicationContext().getResources(), icon); + if (iconBitMap == null || iconBitMap.getByteCount() == 0) { + Log.w(TAG, "Couldn't load icon from icon of applicationInfo, use android default"); + icon = R.mipmap.ic_launcher; + } + } catch (Exception ex) { + Log.w(TAG, "Couldn't load icon from icon of applicationInfo, use android default"); + icon = R.mipmap.ic_launcher; + } + + Intent intent = new Intent(this, MainActivity.class); + intent.setAction("io.agora.api.example.ACTION_NOTIFICATION_CLICK"); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + int requestCode = (int) System.currentTimeMillis(); + + PendingIntent activityPendingIntent = PendingIntent.getActivity( + this, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT); + NotificationManager mNotificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); + mNotificationManager.createNotificationChannel(mChannel); + builder = new Notification.Builder(this, CHANNEL_ID); + } else { + builder = new Notification.Builder(this); + } + + builder.setContentTitle("Agora Recording ...") + .setContentText("Tap here to return to the app.") + .setContentIntent(activityPendingIntent) + .setAutoCancel(true) + .setOngoing(true) + .setPriority(Notification.PRIORITY_HIGH) + .setSmallIcon(icon) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setWhen(System.currentTimeMillis()); + + Icon iconObj = Icon.createWithResource(this, icon); + Notification.Action action = new Notification.Action.Builder(iconObj, "Return to the app", activityPendingIntent).build(); + builder.addAction(action); + + return builder.build(); + } + + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudioByToken.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudioByToken.java new file mode 100755 index 000000000..94a8f4e52 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudioByToken.java @@ -0,0 +1,385 @@ +package io.agora.api.example.examples.basic; + +import static io.agora.api.example.common.model.Examples.BASIC; + +import android.os.Bundle; +import android.os.Handler; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.EditText; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.Switch; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.LinkedHashMap; +import java.util.Map; + +import io.agora.api.example.MainApplication; +import io.agora.api.example.R; +import io.agora.api.example.annotation.Example; +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.widget.AudioSeatManager; +import io.agora.api.example.utils.CommonUtil; +import io.agora.rtc2.ChannelMediaOptions; +import io.agora.rtc2.Constants; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.proxy.LocalAccessPointConfiguration; + +/**This demo demonstrates how to make a one-to-one voice call + * @author cjw*/ +@Example( + index = 0, + group = BASIC, + name = R.string.item_joinaudio_by_token, + actionId = R.id.action_mainFragment_to_joinChannelAudioByToken, + tipsId = R.string.joinchannelaudioByToken +) +public class JoinChannelAudioByToken extends BaseFragment implements View.OnClickListener +{ + private static final String TAG = JoinChannelAudioByToken.class.getSimpleName(); + private EditText et_app_id, et_channel, et_token; + private Button join; + private RtcEngine engine; + private int myUid; + private boolean joined = false; + private AudioSeatManager audioSeatManager; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) + { + super.onCreate(savedInstanceState); + handler = new Handler(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) + { + View view = inflater.inflate(R.layout.fragment_joinchannel_audio_by_token, container, false); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) + { + super.onViewCreated(view, savedInstanceState); + join = view.findViewById(R.id.btn_join); + et_app_id = view.findViewById(R.id.et_app_id); + et_channel = view.findViewById(R.id.et_channel); + et_token = view.findViewById(R.id.et_token); + view.findViewById(R.id.btn_join).setOnClickListener(this); + audioSeatManager = new AudioSeatManager( + view.findViewById(R.id.audio_place_01), + view.findViewById(R.id.audio_place_02), + view.findViewById(R.id.audio_place_03), + view.findViewById(R.id.audio_place_04), + view.findViewById(R.id.audio_place_05), + view.findViewById(R.id.audio_place_06) + ); + } + + private boolean createRtcEngine(String appId) { + try + { + RtcEngineConfig config = new RtcEngineConfig(); + /** + * The context of Android Activity + */ + config.mContext = requireContext().getApplicationContext(); + /** + * The App ID issued to you by Agora. See How to get the App ID + */ + config.mAppId = appId; + /** Sets the channel profile of the Agora RtcEngine. + CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. + Use this profile in one-on-one calls or group calls, where all users can talk freely. + CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast + channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; + an audience can only receive streams.*/ + config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; + /** + * IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events. + */ + config.mEventHandler = iRtcEngineEventHandler; + config.mAreaCode = ((MainApplication)getActivity().getApplication()).getGlobalSettings().getAreaCode(); + engine = RtcEngine.create(config); + /** + * This parameter is for reporting the usages of APIExample to agora background. + * Generally, it is not necessary for you to set this parameter. + */ + engine.setParameters("{" + + "\"rtc.report_app_scenario\":" + + "{" + + "\"appScenario\":" + 100 + "," + + "\"serviceType\":" + 11 + "," + + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" + + "}" + + "}"); + /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ + LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); + if (localAccessPointConfiguration != null) { + // This api can only be used in the private media server scenario, otherwise some problems may occur. + engine.setLocalAccessPoint(localAccessPointConfiguration); + } + + return true; + } + catch (Exception e) + { + showAlert(e.getMessage()); + } + + return false; + } + + private void destroyRtcEngine(){ + /**leaveChannel and Destroy the RtcEngine instance*/ + if(engine != null) + { + engine.leaveChannel(); + RtcEngine.destroy(); + engine = null; + } + } + + @Override + public void onDestroy() + { + super.onDestroy(); + destroyRtcEngine(); + } + + @Override + public void onClick(View v) + { + if (v.getId() == R.id.btn_join) + { + if (!joined) + { + CommonUtil.hideInputBoard(getActivity(), et_channel); + // call when join button hit + String appId = et_app_id.getText().toString(); + String channelId = et_channel.getText().toString(); + String token = et_token.getText().toString(); + + if(TextUtils.isEmpty(appId)){ + showLongToast(getString(R.string.app_id_empty)); + return; + } + + if (createRtcEngine(appId)) { + joinChannel(channelId, token); + } + } + else + { + joined = false; + join.setText(getString(R.string.join)); + audioSeatManager.downAllSeats(); + destroyRtcEngine(); + } + } + } + + /** + * @param channelId Specify the channel name that you want to join. + * Users that input the same channel name join the same channel.*/ + private void joinChannel(String channelId, String token) + { + /**In the demo, the default is to enter as the anchor.*/ + engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); + + engine.setDefaultAudioRoutetoSpeakerphone(true); + + ChannelMediaOptions option = new ChannelMediaOptions(); + option.autoSubscribeAudio = true; + option.autoSubscribeVideo = true; + + /** Allows a user to join a channel. + if you do not specify the uid, we will generate the uid for you*/ + int res = engine.joinChannel(token, channelId, 0, option); + if (res != 0) + { + engine.leaveChannel(); + // Usually happens with invalid parameters + // Error code description can be found at: + // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html + showAlert(RtcEngine.getErrorDescription(Math.abs(res))); + Log.e(TAG, RtcEngine.getErrorDescription(Math.abs(res))); + return; + } + // Prevent repeated entry + join.setEnabled(false); + } + + /**IRtcEngineEventHandler is an abstract class providing default implementation. + * The SDK uses this class to report to the app on SDK runtime events.*/ + private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() + { + /** + * Error code description can be found at: + * en: https://api-ref.agora.io/en/voice-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + * cn: https://docs.agora.io/cn/voice-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror + */ + @Override + public void onError(int err) { + super.onError(err); + showLongToast("onError code=" + err + ", msg=" + RtcEngine.getErrorDescription(err)); + runOnUIThread(() -> join.setEnabled(true)); + if(err== Constants.ERR_INVALID_TOKEN){ + engine.leaveChannel(); + showAlert(getString(R.string.token_invalid)); + }else if(err== Constants.ERR_TOKEN_EXPIRED){ + engine.leaveChannel(); + showAlert(getString(R.string.token_expired)); + } + } + + /**Occurs when a user leaves the channel. + * @param stats With this callback, the application retrieves the channel information, + * such as the call duration and statistics.*/ + @Override + public void onLeaveChannel(RtcStats stats) + { + super.onLeaveChannel(stats); + Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); + //showLongToast(String.format("local user %d leaveChannel!", myUid)); + } + + /**Occurs when the local user joins a specified channel. + * The channel name assignment is based on channelName specified in the joinChannel method. + * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. + * @param channel Channel name + * @param uid User ID + * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ + @Override + public void onJoinChannelSuccess(String channel, int uid, int elapsed) + { + Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); + myUid = uid; + joined = true; + handler.post(new Runnable() + { + @Override + public void run() + { + join.setEnabled(true); + join.setText(getString(R.string.leave)); + audioSeatManager.upLocalSeat(uid); + } + }); + } + + /**Since v2.9.0. + * This callback indicates the state change of the remote audio stream. + * PS: This callback does not work properly when the number of users (in the Communication profile) or + * broadcasters (in the Live-broadcast profile) in the channel exceeds 17. + * @param uid ID of the user whose audio state changes. + * @param state State of the remote audio + * REMOTE_AUDIO_STATE_STOPPED(0): The remote audio is in the default state, probably due + * to REMOTE_AUDIO_REASON_LOCAL_MUTED(3), REMOTE_AUDIO_REASON_REMOTE_MUTED(5), + * or REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7). + * REMOTE_AUDIO_STATE_STARTING(1): The first remote audio packet is received. + * REMOTE_AUDIO_STATE_DECODING(2): The remote audio stream is decoded and plays normally, + * probably due to REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2), + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4) or REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6). + * REMOTE_AUDIO_STATE_FROZEN(3): The remote audio is frozen, probably due to + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1). + * REMOTE_AUDIO_STATE_FAILED(4): The remote audio fails to start, probably due to + * REMOTE_AUDIO_REASON_INTERNAL(0). + * @param reason The reason of the remote audio state change. + * REMOTE_AUDIO_REASON_INTERNAL(0): Internal reasons. + * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1): Network congestion. + * REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2): Network recovery. + * REMOTE_AUDIO_REASON_LOCAL_MUTED(3): The local user stops receiving the remote audio + * stream or disables the audio module. + * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote audio + * stream or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_MUTED(5): The remote user stops sending the audio stream or + * disables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the audio stream + * or enables the audio module. + * REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel. + * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method + * until the SDK triggers this callback.*/ + @Override + public void onRemoteAudioStateChanged(int uid, int state, int reason, int elapsed) { + super.onRemoteAudioStateChanged(uid, state, reason, elapsed); + Log.i(TAG, "onRemoteAudioStateChanged->" + uid + ", state->" + state + ", reason->" + reason); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. + * @param uid ID of the user whose audio state changes. + * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole + * until this callback is triggered.*/ + @Override + public void onUserJoined(int uid, int elapsed) + { + super.onUserJoined(uid, elapsed); + Log.i(TAG, "onUserJoined->" + uid); + showLongToast(String.format("user %d joined!", uid)); + runOnUIThread(() -> { + audioSeatManager.upRemoteSeat(uid); + }); + } + + /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. + * @param uid ID of the user whose audio state changes. + * @param reason Reason why the user goes offline: + * USER_OFFLINE_QUIT(0): The user left the current channel. + * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data + * packet was received within a certain period of time. If a user quits the + * call and the message is not passed to the SDK (due to an unreliable channel), + * the SDK assumes the user dropped offline. + * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from + * the host to the audience.*/ + @Override + public void onUserOffline(int uid, int reason) + { + Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); + showLongToast(String.format("user %d offline! reason:%d", uid, reason)); + runOnUIThread(() -> { + audioSeatManager.downSeat(uid); + }); + } + + @Override + public void onLocalAudioStats(LocalAudioStats stats) { + super.onLocalAudioStats(stats); + runOnUIThread(() -> { + Map _stats = new LinkedHashMap<>(); + _stats.put("sentSampleRate", stats.sentSampleRate + ""); + _stats.put("sentBitrate", stats.sentBitrate + " kbps"); + _stats.put("internalCodec", stats.internalCodec + ""); + _stats.put("audioDeviceDelay", stats.audioDeviceDelay + " ms"); + audioSeatManager.getLocalSeat().updateStats(_stats); + }); + } + + @Override + public void onRemoteAudioStats(RemoteAudioStats stats) { + super.onRemoteAudioStats(stats); + runOnUIThread(() -> { + Map _stats = new LinkedHashMap<>(); + _stats.put("numChannels", stats.numChannels + ""); + _stats.put("receivedBitrate", stats.receivedBitrate + " kbps"); + _stats.put("audioLossRate", stats.audioLossRate + ""); + _stats.put("jitterBufferDelay", stats.jitterBufferDelay + " ms"); + audioSeatManager.getRemoteSeat(stats.uid).updateStats(_stats); + }); + } + }; +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/AudioFileReader.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/AudioFileReader.java new file mode 100644 index 000000000..387463604 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/AudioFileReader.java @@ -0,0 +1,116 @@ +package io.agora.api.example.utils; + +import android.content.Context; +import android.os.Process; + +import java.io.IOException; +import java.io.InputStream; + +public class AudioFileReader { + private static final String AUDIO_FILE = "output.raw"; + public static final int SAMPLE_RATE = 44100; + public static final int SAMPLE_NUM_OF_CHANNEL = 2; + public static final int BITS_PER_SAMPLE = 16; + + public static final float BYTE_PER_SAMPLE = 1.0f * BITS_PER_SAMPLE / 8 * SAMPLE_NUM_OF_CHANNEL; + public static final float DURATION_PER_SAMPLE = 1000.0f / SAMPLE_RATE; // ms + public static final float SAMPLE_COUNT_PER_MS = SAMPLE_RATE * 1.0f / 1000; // ms + + private static final int BUFFER_SAMPLE_COUNT = (int) (SAMPLE_COUNT_PER_MS * 10); // 10ms sample count + private static final int BUFFER_BYTE_SIZE = (int) (BUFFER_SAMPLE_COUNT * BYTE_PER_SAMPLE); // byte + private static final long BUFFER_DURATION = (long) (BUFFER_SAMPLE_COUNT * DURATION_PER_SAMPLE); // ms + + private final Context context; + private final OnAudioReadListener audioReadListener; + private volatile boolean pushing = false; + private InnerThread thread; + private InputStream inputStream; + + public AudioFileReader(Context context, OnAudioReadListener listener){ + this.context = context; + this.audioReadListener = listener; + } + + public void start() { + if(thread == null){ + thread = new InnerThread(); + thread.start(); + } + } + + public void stop(){ + pushing = false; + if(thread != null){ + try { + thread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + thread = null; + } + } + } + + public interface OnAudioReadListener { + void onAudioRead(byte[] buffer, long timestamp); + } + + private class InnerThread extends Thread{ + + @Override + public void run() { + super.run(); + try { + inputStream = context.getAssets().open(AUDIO_FILE); + } catch (IOException e) { + e.printStackTrace(); + } + Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO); + pushing = true; + + long start_time = System.currentTimeMillis();; + int sent_audio_frames = 0; + while (pushing) { + if(audioReadListener != null){ + audioReadListener.onAudioRead(readBuffer(), System.currentTimeMillis()); + } + ++ sent_audio_frames; + long next_frame_start_time = sent_audio_frames * BUFFER_DURATION + start_time; + long now = System.currentTimeMillis(); + + if(next_frame_start_time > now){ + long sleep_duration = next_frame_start_time - now; + try { + Thread.sleep(sleep_duration); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } finally { + inputStream = null; + } + } + } + + private byte[] readBuffer() { + int byteSize = BUFFER_BYTE_SIZE; + byte[] buffer = new byte[byteSize]; + try { + if (inputStream.read(buffer) < 0) { + inputStream.reset(); + return readBuffer(); + } + } catch (IOException e) { + e.printStackTrace(); + } + return buffer; + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/ClassUtils.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/ClassUtils.java new file mode 100644 index 000000000..a2b9585b9 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/ClassUtils.java @@ -0,0 +1,284 @@ +package io.agora.api.example.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.util.Log; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import dalvik.system.DexFile; + +public class ClassUtils +{ + private static final String TAG = ClassUtils.class.getSimpleName(); + private static final String EXTRACTED_NAME_EXT = ".classes"; + private static final String EXTRACTED_SUFFIX = ".zip"; + + private static final String SECONDARY_FOLDER_NAME = "code_cache" + File.separator + "secondary-dexes"; + + private static final String PREFS_FILE = "multidex.version"; + private static final String KEY_DEX_NUMBER = "dex.number"; + + private static final int VM_WITH_MULTIDEX_VERSION_MAJOR = 2; + private static final int VM_WITH_MULTIDEX_VERSION_MINOR = 1; + + private static SharedPreferences getMultiDexPreferences(Context context) + { + return context.getSharedPreferences(PREFS_FILE, Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB ? Context.MODE_PRIVATE : Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS); + } + + /** + * By specifying the package name, scan all ClassName contained under the package + * + * @param context + * @param packageName + * @return Collection of all classes + */ + public static Set getFileNameByPackageName(Context context, final String packageName) throws PackageManager.NameNotFoundException, IOException, InterruptedException + { + final Set classNames = new HashSet<>(); + + List paths = getSourcePaths(context); + final CountDownLatch parserCtl = new CountDownLatch(paths.size()); + + for (final String path : paths) + { + DefaultPoolExecutor.getInstance().execute(new Runnable() + { + @Override + public void run() + { + DexFile dexfile = null; + + try + { + if (path.endsWith(EXTRACTED_SUFFIX)) + { + //NOT use new DexFile(path), because it will throw "permission error in /data/dalvik-cache" + dexfile = DexFile.loadDex(path, path + ".tmp", 0); + } + else + { + dexfile = new DexFile(path); + } + + Enumeration dexEntries = dexfile.entries(); + while (dexEntries.hasMoreElements()) + { + String className = dexEntries.nextElement(); + if (className.startsWith(packageName)) + { + classNames.add(className); + } + } + } + catch (Throwable ignore) + { + Log.e("ARouter", "Scan map file in dex files made error.", ignore); + } + finally + { + if (null != dexfile) + { + try + { + dexfile.close(); + } + catch (Throwable ignore) + { + } + } + + parserCtl.countDown(); + } + } + }); + } + + parserCtl.await(); + + Log.d(TAG, "Filter " + classNames.size() + " classes by packageName <" + packageName + ">"); + return classNames; + } + + /** + * get all the dex path + * + * @param context the application context + * @return all the dex path + * @throws PackageManager.NameNotFoundException + * @throws IOException + */ + public static List getSourcePaths(Context context) throws PackageManager.NameNotFoundException, IOException + { + ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0); + File sourceApk = new File(applicationInfo.sourceDir); + + List sourcePaths = new ArrayList<>(); + sourcePaths.add(applicationInfo.sourceDir); //add the default apk path + + //the prefix of extracted file, ie: test.classes + String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT; + + /** If MultiDex already supported by VM, we will not to load Classesx.zip from + * Secondary Folder, because there is none.*/ + if (!isVMMultidexCapable()) + { + //the total dex numbers + int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1); + File dexDir = new File(applicationInfo.dataDir, SECONDARY_FOLDER_NAME); + + for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) + { + //for each dex file, ie: test.classes2.zip, test.classes3.zip... + String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX; + File extractedFile = new File(dexDir, fileName); + if (extractedFile.isFile()) + { + sourcePaths.add(extractedFile.getAbsolutePath()); + //we ignore the verify zip part + } + else + { + throw new IOException("Missing extracted secondary dex file '" + extractedFile.getPath() + "'"); + } + } + } + + if (io.agora.api.example.BuildConfig.DEBUG) + { // Search instant run support only debuggable + sourcePaths.addAll(tryLoadInstantRunDexFile(applicationInfo)); + } + return sourcePaths; + } + + /** + * Get instant run dex path, used to catch the branch usingApkSplits=false. + */ + private static List tryLoadInstantRunDexFile(ApplicationInfo applicationInfo) + { + List instantRunSourcePaths = new ArrayList<>(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && null != applicationInfo.splitSourceDirs) + { + // add the split apk, normally for InstantRun, and newest version. + instantRunSourcePaths.addAll(Arrays.asList(applicationInfo.splitSourceDirs)); + Log.d(TAG, "Found InstantRun support"); + } + else + { + try + { + // This man is reflection from Google instant run sdk, he will tell me where the dex files go. + Class pathsByInstantRun = Class.forName("com.android.tools.fd.runtime.Paths"); + Method getDexFileDirectory = pathsByInstantRun.getMethod("getDexFileDirectory", String.class); + String instantRunDexPath = (String) getDexFileDirectory.invoke(null, applicationInfo.packageName); + + File instantRunFilePath = new File(instantRunDexPath); + if (instantRunFilePath.exists() && instantRunFilePath.isDirectory()) + { + File[] dexFile = instantRunFilePath.listFiles(); + for (File file : dexFile) + { + if (null != file && file.exists() && file.isFile() && file.getName().endsWith(".dex")) + { + instantRunSourcePaths.add(file.getAbsolutePath()); + } + } + Log.d(TAG, "Found InstantRun support"); + } + + } + catch (Exception e) + { + Log.e(TAG, "InstantRun support error, " + e.getMessage()); + } + } + + return instantRunSourcePaths; + } + + /** + * Identifies if the current VM has a native support for multidex, meaning there is no need for + * additional installation by this library. + * + * @return true if the VM handles multidex + */ + private static boolean isVMMultidexCapable() + { + boolean isMultidexCapable = false; + String vmName = null; + + try + { + if (isYunOS()) + { // YunOS need special judgment + vmName = "'YunOS'"; + isMultidexCapable = Integer.valueOf(System.getProperty("ro.build.version.sdk")) >= 21; + } + else + { // Native Android system + vmName = "'Android'"; + String versionString = System.getProperty("java.vm.version"); + if (versionString != null) + { + Matcher matcher = Pattern.compile("(\\d+)\\.(\\d+)(\\.\\d+)?").matcher(versionString); + if (matcher.matches()) + { + try + { + int major = Integer.parseInt(matcher.group(1)); + int minor = Integer.parseInt(matcher.group(2)); + isMultidexCapable = (major > VM_WITH_MULTIDEX_VERSION_MAJOR) + || ((major == VM_WITH_MULTIDEX_VERSION_MAJOR) + && (minor >= VM_WITH_MULTIDEX_VERSION_MINOR)); + } + catch (NumberFormatException ignore) + { + // let isMultidexCapable be false + } + } + } + } + } + catch (Exception ignore) + { + + } + + Log.i(TAG, "VM with name " + vmName + (isMultidexCapable ? " has multidex support" : " does not have multidex support")); + return isMultidexCapable; + } + + /** + * Determine whether the system is a YunOS system + */ + private static boolean isYunOS() + { + try + { + String version = System.getProperty("ro.yunos.version"); + String vmName = System.getProperty("java.vm.name"); + return (vmName != null && vmName.toLowerCase().contains("lemur")) + || (version != null && version.trim().length() > 0); + } + catch (Exception ignore) + { + return false; + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/CommonUtil.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/CommonUtil.java new file mode 100644 index 000000000..b78399dc2 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/CommonUtil.java @@ -0,0 +1,18 @@ +package io.agora.api.example.utils; + +import android.app.Activity; +import android.content.Context; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; + +/** + * @author cjw + */ +public class CommonUtil { + + public static void hideInputBoard(Activity activity, EditText editText) + { + InputMethodManager imm = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(editText.getWindowToken(), 0); + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/DefaultPoolExecutor.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/DefaultPoolExecutor.java new file mode 100644 index 000000000..f096d04bf --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/DefaultPoolExecutor.java @@ -0,0 +1,97 @@ +package io.agora.api.example.utils; + +import android.util.Log; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Executors + * + * @version 1.0 + * @since 16/4/28 4:07 PM + */ +public class DefaultPoolExecutor extends ThreadPoolExecutor +{ + private static final String TAG = DefaultPoolExecutor.class.getSimpleName(); + // Thread args + private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); + private static final int INIT_THREAD_COUNT = CPU_COUNT + 1; + private static final int MAX_THREAD_COUNT = INIT_THREAD_COUNT; + private static final long SURPLUS_THREAD_LIFE = 30L; + + private static DefaultPoolExecutor instance; + + public static DefaultPoolExecutor getInstance() + { + if (null == instance) + { + synchronized (DefaultPoolExecutor.class) + { + if (null == instance) + { + instance = new DefaultPoolExecutor( + INIT_THREAD_COUNT, + MAX_THREAD_COUNT, + SURPLUS_THREAD_LIFE, + TimeUnit.SECONDS, + new ArrayBlockingQueue(64), + new DefaultThreadFactory()); + } + } + } + return instance; + } + + private DefaultPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory) + { + super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, new RejectedExecutionHandler() + { + @Override + public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) + { + Log.e(TAG, "Task rejected, too many task!"); + } + }); + } + + /* thread execution complete, handle possible exceptions. + * @param r the runnable that has completed + * @param t the exception that caused termination, or null if + */ + @Override + protected void afterExecute(Runnable r, Throwable t) + { + super.afterExecute(r, t); + if (t == null && r instanceof Future) + { + try + { + ((Future) r).get(); + } + catch (CancellationException ce) + { + t = ce; + } + catch (ExecutionException ee) + { + t = ee.getCause(); + } + catch (InterruptedException ie) + { + Thread.currentThread().interrupt(); // ignore/reset + } + } + if (t != null) + { + Log.w(TAG, "Running task appeared exception! Thread [" + Thread.currentThread().getName() + "], because [" + t.getMessage() + "]\n" + TextUtils.formatStackTrace(t.getStackTrace())); + } + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/DefaultThreadFactory.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/DefaultThreadFactory.java new file mode 100644 index 000000000..73fe50d33 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/DefaultThreadFactory.java @@ -0,0 +1,53 @@ +package io.agora.api.example.utils; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * DefaultThreadFactory + * + * @author zhilong Contact me. + * @version 1.0 + * @since 15/12/25 10:51 AM + */ +public class DefaultThreadFactory implements ThreadFactory +{ + private static final String TAG = DefaultThreadFactory.class.getSimpleName(); + + private static final AtomicInteger poolNumber = new AtomicInteger(1); + + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final ThreadGroup group; + private final String namePrefix; + + public DefaultThreadFactory() + { + SecurityManager s = System.getSecurityManager(); + group = (s != null) ? s.getThreadGroup() : + Thread.currentThread().getThreadGroup(); + namePrefix = "ARouter task pool No." + poolNumber.getAndIncrement() + ", thread No."; + } + + public Thread newThread(@NonNull Runnable runnable) + { + String threadName = namePrefix + threadNumber.getAndIncrement(); + Log.i(TAG, "Thread production, name is [" + threadName + "]"); + Thread thread = new Thread(group, runnable, threadName, 0); + if (thread.isDaemon()) + { //Make non-background thread + thread.setDaemon(false); + } + if (thread.getPriority() != Thread.NORM_PRIORITY) + { + thread.setPriority(Thread.NORM_PRIORITY); + } + + // Catching exceptions in multi-threaded processing + thread.setUncaughtExceptionHandler((thread1, ex) -> Log.i(TAG, "Running task appeared exception! Thread [" + thread1.getName() + "], because [" + ex.getMessage() + "]")); + return thread; + } +} \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/ErrorUtil.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/ErrorUtil.java new file mode 100644 index 000000000..303a69188 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/ErrorUtil.java @@ -0,0 +1,7 @@ +package io.agora.api.example.utils; + +/** + * @author cjw + */ +public class ErrorUtil { +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/FileUtils.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/FileUtils.java new file mode 100644 index 000000000..415dd8909 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/FileUtils.java @@ -0,0 +1,107 @@ +package io.agora.api.example.utils; + +import android.content.Context; +import android.content.res.AssetManager; +import android.text.TextUtils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +public class FileUtils { + + public static final String SEPARATOR = File.separator; + + public static void copyFilesFromAssets(Context context, String assetsPath, String storagePath) { + String temp = ""; + + if (TextUtils.isEmpty(storagePath)) { + return; + } else if (storagePath.endsWith(SEPARATOR)) { + storagePath = storagePath.substring(0, storagePath.length() - 1); + } + + if (TextUtils.isEmpty(assetsPath) || assetsPath.equals(SEPARATOR)) { + assetsPath = ""; + } else if (assetsPath.endsWith(SEPARATOR)) { + assetsPath = assetsPath.substring(0, assetsPath.length() - 1); + } + + AssetManager assetManager = context.getAssets(); + try { + File file = new File(storagePath); + if (!file.exists()) {//If the folder does not exist, create a new folder + file.mkdirs(); + } + + // Get all file and directory names under assets + String[] fileNames = assetManager.list(assetsPath); + if (fileNames.length > 0) {//If it's a directory + for (String fileName : fileNames) { + if (!TextUtils.isEmpty(assetsPath)) { + temp = assetsPath + SEPARATOR + fileName;//Complete assets resource path + } + + String[] childFileNames = assetManager.list(temp); + if (!TextUtils.isEmpty(temp) && childFileNames.length > 0) {//Check if it's a file or folder: if it's a folder + copyFilesFromAssets(context, temp, storagePath + SEPARATOR + fileName); + } else {//If it's a file + InputStream inputStream = assetManager.open(temp); + readInputStream(storagePath + SEPARATOR + fileName, inputStream); + } + } + } else {//If it's a file like doc_test.txt or apk/app_test.apk + InputStream inputStream = assetManager.open(assetsPath); + if (assetsPath.contains(SEPARATOR)) {//apk/app_test.apk + assetsPath = assetsPath.substring(assetsPath.lastIndexOf(SEPARATOR), assetsPath.length()); + } + readInputStream(storagePath + SEPARATOR + assetsPath, inputStream); + } + } catch (IOException e) { + e.printStackTrace(); + } + + } + + /** + * Read data from input stream and write to output stream + * + * @param storagePath Target file path + * @param inputStream Input stream + */ + public static void readInputStream(String storagePath, InputStream inputStream) { + File file = new File(storagePath); + try { + if (!file.exists()) { + // 1. Create channel object + FileOutputStream fos = new FileOutputStream(file); + // 2. Define storage space + byte[] buffer = new byte[inputStream.available()]; + // 3. Start reading file + int lenght = 0; + while ((lenght = inputStream.read(buffer)) != -1) {// Read buffer bytes from input stream in a loop + // Write data from Buffer to outputStream object + fos.write(buffer, 0, lenght); + } + fos.flush();// Flush buffer + // 4. Close streams + fos.close(); + inputStream.close(); + } + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + +} + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/PermissonUtils.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/PermissonUtils.java new file mode 100644 index 000000000..4627ce22f --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/PermissonUtils.java @@ -0,0 +1,49 @@ +package io.agora.api.example.utils; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import androidx.core.content.ContextCompat; + +import java.util.ArrayList; +import java.util.List; + +public class PermissonUtils { + private static final String TAG = "PermissonUtils"; + + public static String[] getCommonPermission() { + List permissionList = new ArrayList<>(); + permissionList.add(Manifest.permission.RECORD_AUDIO); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissionList.add(Manifest.permission.READ_PHONE_STATE); + permissionList.add(Manifest.permission.BLUETOOTH_CONNECT); + } + String[] permissionArray = new String[permissionList.size()]; + permissionList.toArray(permissionArray); + return permissionArray; + } + + //check array permission is granted + public static boolean checkPermissions(Context context, String[] permissions) { + for (String permission : permissions) { + if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } + + //check single permission is granted + public static boolean checkPermission(Context context, String permission) { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED; + } + + + // Callback interface for permission results + public interface PermissionResultCallback { + void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults); + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TextUtils.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TextUtils.java new file mode 100644 index 000000000..0f8034b3a --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TextUtils.java @@ -0,0 +1,15 @@ +package io.agora.api.example.utils; + +public class TextUtils { + /** + * Print thread stack + */ + public static String formatStackTrace(StackTraceElement[] stackTrace) { + StringBuilder sb = new StringBuilder(); + for (StackTraceElement element : stackTrace) { + sb.append(" at ").append(element.toString()); + sb.append("\n"); + } + return sb.toString(); + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TokenUtils.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TokenUtils.java new file mode 100644 index 000000000..7d11f19e6 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TokenUtils.java @@ -0,0 +1,147 @@ +package io.agora.api.example.utils; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.Objects; + +import io.agora.api.example.R; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okhttp3.logging.HttpLoggingInterceptor; + +public class TokenUtils { + private static final String TAG = "TokenGenerator"; + private final static OkHttpClient client; + + static { + HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor(); + interceptor.setLevel(HttpLoggingInterceptor.Level.BODY); + client = new OkHttpClient.Builder() + .addInterceptor(interceptor) + .build(); + } + + public static void genToken(Context context, String channelName, int uid, OnTokenGenCallback onGetToken) { + String cert = context.getString(R.string.agora_app_certificate); + if (cert.isEmpty()) { + onGetToken.onTokenGen(""); + } else { + gen(context.getString(R.string.agora_app_id), context.getString(R.string.agora_app_certificate), channelName, uid, ret -> { + if (onGetToken != null) { + runOnUiThread(() -> { + onGetToken.onTokenGen(ret); + }); + } + }, ret -> { + Log.e(TAG, "for requesting token error.", ret); + if (onGetToken != null) { + runOnUiThread(() -> { + onGetToken.onTokenGen(null); + }); + } + }); + } + } + + public static void gen(Context context, String channelName, int uid, OnTokenGenCallback onGetToken){ + gen(context.getString(R.string.agora_app_id), context.getString(R.string.agora_app_certificate), channelName, uid, ret -> { + if(onGetToken != null){ + runOnUiThread(() -> { + onGetToken.onTokenGen(ret); + }); + } + }, ret -> { + Log.e(TAG, "for requesting token error, use config token instead."); + if (onGetToken != null) { + runOnUiThread(() -> { + onGetToken.onTokenGen(null); + }); + } + }); + } + + private static void runOnUiThread(@NonNull Runnable runnable){ + if(Thread.currentThread() == Looper.getMainLooper().getThread()){ + runnable.run(); + }else{ + new Handler(Looper.getMainLooper()).post(runnable); + } + } + + private static void gen(String appId, String certificate, String channelName, int uid, OnTokenGenCallback onGetToken, OnTokenGenCallback onError) { + if(TextUtils.isEmpty(appId) || TextUtils.isEmpty(certificate) || TextUtils.isEmpty(channelName)){ + if(onError != null){ + onError.onTokenGen(new IllegalArgumentException("appId=" + appId + ", certificate=" + certificate + ", channelName=" + channelName)); + } + return; + } + JSONObject postBody = new JSONObject(); + try { + postBody.put("appId", appId); + postBody.put("appCertificate", certificate); + postBody.put("channelName", channelName); + postBody.put("expire", 900);// s + postBody.put("src", "Android"); + postBody.put("ts", System.currentTimeMillis() + ""); + postBody.put("type", 1); // 1: RTC Token ; 2: RTM Token + postBody.put("uid", uid + ""); + } catch (JSONException e) { + if(onError != null){ + onError.onTokenGen(e); + } + } + + Request request = new Request.Builder() + .url("https://service.agora.io/toolbox-global/v1/token/generate") + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(postBody.toString(), null)) + .build(); + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + if(onError != null){ + onError.onTokenGen(e); + } + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { + ResponseBody body = response.body(); + if (body != null) { + try { + JSONObject jsonObject = new JSONObject(body.string()); + JSONObject data = jsonObject.optJSONObject("data"); + String token = Objects.requireNonNull(data).optString("token"); + if(onGetToken != null){ + onGetToken.onTokenGen(token); + } + } catch (Exception e) { + if(onError != null){ + onError.onTokenGen(e); + } + } + } + } + }); + } + + public interface OnTokenGenCallback { + void onTokenGen(T ret); + } + +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/YUVUtils.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/YUVUtils.java new file mode 100644 index 000000000..e5f16b33c --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/YUVUtils.java @@ -0,0 +1,221 @@ +package io.agora.api.example.utils; + +import static android.renderscript.Element.RGBA_8888; +import static android.renderscript.Element.U8; +import static android.renderscript.Element.U8_4; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.ImageFormat; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.renderscript.Allocation; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicBlur; +import android.renderscript.ScriptIntrinsicYuvToRGB; +import android.renderscript.Type.Builder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +public class YUVUtils { + + public static void encodeI420(byte[] i420, int[] argb, int width, int height) { + final int frameSize = width * height; + + int yIndex = 0; // Y start index + int uIndex = frameSize; // U statt index + int vIndex = frameSize * 5 / 4; // V start index: w*h*5/4 + + int a, R, G, B, Y, U, V; + int index = 0; + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + a = (argb[index] & 0xff000000) >> 24; // is not used obviously + R = (argb[index] & 0xff0000) >> 16; + G = (argb[index] & 0xff00) >> 8; + B = (argb[index] & 0xff) >> 0; + + // well known RGB to YUV algorithm + Y = ((66 * R + 129 * G + 25 * B + 128) >> 8) + 16; + U = ((-38 * R - 74 * G + 112 * B + 128) >> 8) + 128; + V = ((112 * R - 94 * G - 18 * B + 128) >> 8) + 128; + + // I420(YUV420p) -> YYYYYYYY UU VV + i420[yIndex++] = (byte) ((Y < 0) ? 0 : ((Y > 255) ? 255 : Y)); + if (j % 2 == 0 && i % 2 == 0) { + i420[uIndex++] = (byte) ((U < 0) ? 0 : ((U > 255) ? 255 : U)); + i420[vIndex++] = (byte) ((V < 0) ? 0 : ((V > 255) ? 255 : V)); + } + index++; + } + } + } + + public static void encodeNV21(byte[] yuv420sp, int[] argb, int width, int height) { + final int frameSize = width * height; + + int yIndex = 0; + int uvIndex = frameSize; + + int a, R, G, B, Y, U, V; + int index = 0; + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + a = (argb[index] & 0xff000000) >> 24; // a is not used obviously + R = (argb[index] & 0xff0000) >> 16; + G = (argb[index] & 0xff00) >> 8; + B = (argb[index] & 0xff) >> 0; + + // well known RGB to YUV algorithm + Y = ((66 * R + 129 * G + 25 * B + 128) >> 8) + 16; + U = ((-38 * R - 74 * G + 112 * B + 128) >> 8) + 128; + V = ((112 * R - 94 * G - 18 * B + 128) >> 8) + 128; + + // NV21 has a plane of Y and interleaved planes of VU each sampled by a factor of 2 + // meaning for every 4 Y pixels there are 1 V and 1 U. Note the sampling is every other + // pixel AND every other scanline. + yuv420sp[yIndex++] = (byte) ((Y < 0) ? 0 : ((Y > 255) ? 255 : Y)); + if (j % 2 == 0 && index % 2 == 0) { + yuv420sp[uvIndex++] = (byte) ((V < 0) ? 0 : ((V > 255) ? 255 : V)); + yuv420sp[uvIndex++] = (byte) ((U < 0) ? 0 : ((U > 255) ? 255 : U)); + } + index++; + } + } + } + + public static void swapYU12toYUV420SP(byte[] yu12bytes, byte[] i420bytes, int width, int height, int yStride, int uStride, int vStride) { + System.arraycopy(yu12bytes, 0, i420bytes, 0, yStride * height); + int startPos = yStride * height; + int yv_start_pos_u = startPos; + int yv_start_pos_v = startPos + startPos / 4; + for (int i = 0; i < startPos / 4; i++) { + i420bytes[startPos + 2 * i + 0] = yu12bytes[yv_start_pos_v + i]; + i420bytes[startPos + 2 * i + 1] = yu12bytes[yv_start_pos_u + i]; + } + } + + public static Bitmap i420ToBitmap(int width, int height, int rotation, int bufferLength, byte[] buffer, int yStride, int uStride, int vStride) { + byte[] NV21 = new byte[bufferLength]; + swapYU12toYUV420SP(buffer, NV21, width, height, yStride, uStride, vStride); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + int[] strides = {yStride, yStride}; + YuvImage image = new YuvImage(NV21, ImageFormat.NV21, width, height, strides); + + image.compressToJpeg( + new Rect(0, 0, image.getWidth(), image.getHeight()), + 100, baos); + + // rotate picture when saving to file + Matrix matrix = new Matrix(); + matrix.postRotate(rotation); + byte[] bytes = baos.toByteArray(); + try { + baos.close(); + } + catch (IOException e) { + e.printStackTrace(); + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + } + + public static Bitmap blur(Context context, Bitmap image, float radius) { + RenderScript rs = RenderScript.create(context); + Bitmap outputBitmap = Bitmap.createBitmap(image.getWidth(), image.getHeight(), Bitmap.Config.ARGB_8888); + Allocation in = Allocation.createFromBitmap(rs, image); + Allocation out = Allocation.createFromBitmap(rs, outputBitmap); + ScriptIntrinsicBlur intrinsicBlur = ScriptIntrinsicBlur.create(rs, U8_4(rs)); + intrinsicBlur.setRadius(radius); + intrinsicBlur.setInput(in); + intrinsicBlur.forEach(out); + + out.copyTo(outputBitmap); + image.recycle(); + rs.destroy(); + + return outputBitmap; + } + + public static byte[] bitmapToI420(int inputWidth, int inputHeight, Bitmap scaled) { + int[] argb = new int[inputWidth * inputHeight]; + scaled.getPixels(argb, 0, inputWidth, 0, 0, inputWidth, inputHeight); + byte[] yuv = new byte[inputWidth * inputHeight * 3 / 2]; + YUVUtils.encodeI420(yuv, argb, inputWidth, inputHeight); + scaled.recycle(); + return yuv; + } + + public static byte[] toWrappedI420(ByteBuffer bufferY, + ByteBuffer bufferU, + ByteBuffer bufferV, + int width, + int height) { + int chromaWidth = (width + 1) / 2; + int chromaHeight = (height + 1) / 2; + int lengthY = width * height; + int lengthU = chromaWidth * chromaHeight; + int lengthV = lengthU; + + + int size = lengthY + lengthU + lengthV; + + byte[] out = new byte[size]; + + for (int i = 0; i < size; i++) { + if (i < lengthY) { + out[i] = bufferY.get(i); + } else if (i < lengthY + lengthU) { + int j = (i - lengthY) / chromaWidth; + int k = (i - lengthY) % chromaWidth; + out[i] = bufferU.get(j * width + k); + } else { + int j = (i - lengthY - lengthU) / chromaWidth; + int k = (i - lengthY - lengthU) % chromaWidth; + out[i] = bufferV.get(j * width + k); + } + } + + return out; + } + /** + * Convert I420 to NV21 + */ + public static byte[] I420ToNV21(byte[] data, int width, int height) { + byte[] ret = new byte[data.length]; + int total = width * height; + + ByteBuffer bufferY = ByteBuffer.wrap(ret, 0, total); + ByteBuffer bufferVU = ByteBuffer.wrap(ret, total, total / 2); + + bufferY.put(data, 0, total); + for (int i = 0; i < total / 4; i += 1) { + bufferVU.put(data[i + total + total / 4]); + bufferVU.put(data[total + i]); + } + + return ret; + } + + public static Bitmap NV21ToBitmap(Context context, byte[] nv21, int width, int height) { + RenderScript rs = RenderScript.create(context); + ScriptIntrinsicYuvToRGB yuvToRgbIntrinsic = ScriptIntrinsicYuvToRGB.create(rs, U8_4(rs)); + Builder yuvType = null; + yuvType = (new Builder(rs, U8(rs))).setX(nv21.length); + Allocation in = Allocation.createTyped(rs, yuvType.create(), 1); + Builder rgbaType = (new Builder(rs, RGBA_8888(rs))).setX(width).setY(height); + Allocation out = Allocation.createTyped(rs, rgbaType.create(), 1); + in.copyFrom(nv21); + yuvToRgbIntrinsic.setInput(in); + yuvToRgbIntrinsic.forEach(out); + Bitmap bmpout = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + out.copyTo(bmpout); + return bmpout; + } + +} diff --git a/Android/APIExample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/Android/APIExample-Audio/app/src/main/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from Android/APIExample/app/src/main/res/drawable-v24/ic_launcher_foreground.xml rename to Android/APIExample-Audio/app/src/main/res/drawable-v24/ic_launcher_foreground.xml diff --git a/Android/APIExample-Audio/app/src/main/res/drawable/ic_launcher_background.xml b/Android/APIExample-Audio/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/drawable/ic_local.png b/Android/APIExample-Audio/app/src/main/res/drawable/ic_local.png new file mode 100644 index 000000000..4968510c0 Binary files /dev/null and b/Android/APIExample-Audio/app/src/main/res/drawable/ic_local.png differ diff --git a/Android/APIExample-Audio/app/src/main/res/drawable/ic_remote.png b/Android/APIExample-Audio/app/src/main/res/drawable/ic_remote.png new file mode 100644 index 000000000..99c244ef7 Binary files /dev/null and b/Android/APIExample-Audio/app/src/main/res/drawable/ic_remote.png differ diff --git a/Android/APIExample-Audio/app/src/main/res/drawable/ic_speaker.png b/Android/APIExample-Audio/app/src/main/res/drawable/ic_speaker.png new file mode 100644 index 000000000..b1a45ff89 Binary files /dev/null and b/Android/APIExample-Audio/app/src/main/res/drawable/ic_speaker.png differ diff --git a/Android/APIExample-Audio/app/src/main/res/layout/activity_main.xml b/Android/APIExample-Audio/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..400fb109a --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/layout/activity_setting_layout.xml b/Android/APIExample-Audio/app/src/main/res/layout/activity_setting_layout.xml new file mode 100644 index 000000000..3c95f8512 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/activity_setting_layout.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/res/layout/activity_setting_private_cloud.xml b/Android/APIExample-Audio/app/src/main/res/layout/activity_setting_private_cloud.xml new file mode 100644 index 000000000..483a3be84 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/activity_setting_private_cloud.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/layout/dialog_spatial_sound.xml b/Android/APIExample-Audio/app/src/main/res/layout/dialog_spatial_sound.xml new file mode 100644 index 000000000..fafa10227 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/dialog_spatial_sound.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_audio_safety.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_audio_safety.xml new file mode 100644 index 000000000..ccb8d2e9a --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_audio_safety.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_audio_safety_config.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_audio_safety_config.xml new file mode 100644 index 000000000..3ead7727d --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_audio_safety_config.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + +