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..d9cfdc887 --- /dev/null +++ b/.github/ci/build/build_android.groovy @@ -0,0 +1,56 @@ +// -*- mode: groovy -*- +// vim: set filetype=groovy : +@Library('agora-build-pipeline-library') _ +import groovy.transform.Field + +buildUtils = new agora.build.BuildUtils() + +compileConfig = [ + "sourceDir": "api-examples", + "docker": "hub.agoralab.co/server/apiexample_build_android:latest", + "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" + ] + ] + archive.archiveFiles(archiveInfos) + sh "rm -rf *.zip || true" +} + +pipelineLoad(this, "ApiExample", "build", "android", "apiexample_linux") diff --git a/.github/ci/build/build_android.sh b/.github/ci/build/build_android.sh new file mode 100644 index 000000000..053f65542 --- /dev/null +++ b/.github/ci/build/build_android.sh @@ -0,0 +1,121 @@ +################################## +# --- 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 +################################## + +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 + +ls ~/.gradle || (mkdir -p /tmp/.gradle && ln -s /tmp/.gradle ~/.gradle && touch ~/.gradle/ln_$(date "+%y%m%d%H") && ls ~/.gradle) + +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 + +unzip_name=`ls -S -d */ | grep Agora | sed 's/\///g'` +echo unzip_name: $unzip_name + +rm -rf ./$unzip_name/rtc/bin +rm -rf ./$unzip_name/rtc/demo +rm ./$unzip_name/rtc/commits +rm ./$unzip_name/rtc/package_size_report.txt +mkdir ./$unzip_name/rtc/samples +mkdir ./$unzip_name/rtc/samples/API-example + +if [ ! -z "$(echo $sdk_url | grep 'audio')" ] || [ ! -z "$(echo $sdk_url | grep 'VOICE')" ] +then +audio_suffix=-Audio +else +audio_suffix= +fi +echo audio_suffix: $audio_suffix + +cp -rf ./Android/APIExample${audio_suffix}/** ./$unzip_name/rtc/samples/API-example +7za a -tzip result.zip -r $unzip_name > log.txt +mv result.zip $WORKSPACE/withAPIExample_$(date "+%d%H%M")_$zip_name + +# install android sdk +which java +java --version +source ~/.bashrc +export ANDROID_HOME=/usr/lib/android_sdk +echo ANDROID_HOME: $ANDROID_HOME + +# compile apk +cd ./$unzip_name/rtc/samples/API-example +pwd + +## config appId +sed -i -e "s#YOUR APP ID#${APP_ID}#g" app/src/main/res/values/string_configs.xml +sed -i -e "s#YOUR APP CERTIFICATE##g" app/src/main/res/values/string_configs.xml +sed -i -e "s#YOUR ACCESS TOKEN##g" app/src/main/res/values/string_configs.xml +rm -f app/src/main/res/values/string_configs.xml-e +cat app/src/main/res/values/string_configs.xml + +## config simple filter +sed -i -e "s#simpleFilter = false#simpleFilter = true#g" gradle.properties +mkdir -p agora-simple-filter/src/main/agoraLibs +cp -r ../../sdk/arm64-v8a agora-simple-filter/src/main/agoraLibs/ +cp -r ../../sdk/armeabi-v7a agora-simple-filter/src/main/agoraLibs/ +curl -o opencv4.zip https://agora-adc-artifacts.s3.cn-north-1.amazonaws.com.cn/androidLibs/opencv4.zip +unzip opencv4.zip +mkdir -p agora-simple-filter/src/main/libs +mv arm64-v8a agora-simple-filter/src/main/libs +mv armeabi-v7a agora-simple-filter/src/main/libs +sed -i -e "s#jniLibs/#libs/#g" agora-simple-filter/src/main/cpp/CMakeLists.txt + +./gradlew clean || exit 1 +./gradlew :app:assembleDebug || exit 1 +cp app/build/outputs/apk/debug/app-debug.apk ./APIExample_Android_$(date "+%y%m%d%H").apk +7za a -tzip result.zip -r *.apk > log.txt +mv result.zip $WORKSPACE/APIExample_Android${audio_suffix}_$(date "+%y%m%d%H%M")_apk.zip +ls $WORKSPACE +cd - + diff --git a/.github/ci/build/build_ios.groovy b/.github/ci/build/build_ios.groovy new file mode 100644 index 000000000..b1faf2479 --- /dev/null +++ b/.github/ci/build/build_ios.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_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. + ] + ] + archive.archiveFiles(archiveInfos) + sh "rm -rf *.zip || true" +} + +pipelineLoad(this, "ApiExample", "build", "ios", "apiexample_mac") \ 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..79fb1dfc9 --- /dev/null +++ b/.github/ci/build/build_ios.sh @@ -0,0 +1,115 @@ +#!/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 +################################## + +echo is_generate_validate_app: $is_generate_validate_app +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 + +zip_name=${sdk_url##*/} +echo zip_name: $zip_name + +python3 $WORKSPACE/artifactory_utils.py --action=download_file --file=$sdk_url +7za x ./$zip_name -y + +unzip_name=`ls -S -d */ | grep Agora` +echo unzip_name: $unzip_name + +rm -rf ./$unzip_name/bin +rm ./$unzip_name/commits +rm ./$unzip_name/package_size_report.txt +mkdir ./$unzip_name/samples +mkdir ./$unzip_name/samples/API-Example +if [ $? -eq 0 ]; then + echo "success" +else + echo "failed" + exit 1 +fi + +cp -rf ./iOS/** ./$unzip_name/samples/API-Example + +result=$(echo $sdk_url | grep "VOICE") +if [ ! -z "$result" ] +then + echo "包含" + rm -rf ./$unzip_name/samples/API-Example/APIExample + mv ./$unzip_name/samples/API-Example/APIExample-Audio ./$unzip_name/samples/APIExample-Audio + mv ./$unzip_name/samples/APIExample-Audio/sdk.podspec ./$unzip_name/ + python3 ./.github/ci/build/modify_podfile.py ./$unzip_name/samples/APIExample-Audio/Podfile + if [ $? -eq 0 ]; then + echo "success" + else + echo "failed" + exit 1 + fi + if [ $is_generate_validate_app = true ]; then + ./.github/ci/build/build_ios_ipa.sh ./$unzip_name/samples/APIExample-Audio + fi + +else + echo "不包含" + rm -rf ./$unzip_name/samples/API-Example/APIExample-Audio + mv ./$unzip_name/samples/API-Example/APIExample ./$unzip_name/samples/APIExample + mv ./$unzip_name/samples/APIExample/sdk.podspec ./$unzip_name/ + python3 ./.github/ci/build/modify_podfile.py ./$unzip_name/samples/APIExample/Podfile + if [ $? -eq 0 ]; then + echo "success" + else + echo "failed" + exit 1 + fi + if [ $is_generate_validate_app = true ]; then + ./.github/ci/build/build_ios_ipa.sh ./$unzip_name/samples/APIExample + fi +fi + +rm -rf ./$unzip_name/samples/API-Example +7za a -tzip result.zip -r $unzip_name +cp result.zip $WORKSPACE/withAPIExample_${BUILD_NUMBER}_$zip_name diff --git a/.github/ci/build/build_ios_ipa.sh b/.github/ci/build/build_ios_ipa.sh new file mode 100755 index 000000000..01501f2f0 --- /dev/null +++ b/.github/ci/build/build_ios_ipa.sh @@ -0,0 +1,123 @@ +CURRENT_PATH=$PWD + +# 获取项目目录 +PROJECT_PATH="$( cd "$1" && pwd )" + +cd ${PROJECT_PATH} && pod install + +if [ $? -eq 0 ]; then + echo "success" +else + echo "failed" + exit 1 +fi + +# 项目target名 +TARGET_NAME=${PROJECT_PATH##*/} + +KEYCENTER_PATH=${PROJECT_PATH}"/"${TARGET_NAME}"/Common/KeyCenter.swift" + +METHOD_PATH=${PROJECT_PATH}"/ExportOptions.plist" + +# 打包环境 +CONFIGURATION=$method + +#工程文件路径 +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: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 + +#修改打包方式 +/usr/libexec/PlistBuddy -c "Set :method $CONFIGURATION" $METHOD_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 + +# Xcode clean +xcodebuild clean -workspace "${APP_PATH}" -configuration "${CONFIGURATION}" -scheme "${TARGET_NAME}" + +# 时间戳 +CURRENT_TIME=$(date "+%Y-%m-%d %H-%M-%S") + +# 归档路径 +ARCHIVE_PATH="${PROJECT_PATH}/${TARGET_NAME} ${CURRENT_TIME}/${TARGET_NAME}.xcarchive" +# 编译环境 + +# 导出路径 +EXPORT_PATH="${PROJECT_PATH}/${TARGET_NAME} ${CURRENT_TIME}" + +# 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}" -destination 'generic/platform=iOS' + +# 导出ipa +xcodebuild -exportArchive -archivePath "${ARCHIVE_PATH}" -exportPath "${EXPORT_PATH}" -exportOptionsPlist "${PLIST_PATH}" + +# 上传IPA +7za a "$WORKSPACE/${TARGET_NAME}_${BUILD_NUMBER}_IPA.zip" -r "${EXPORT_PATH}/${TARGET_NAME}.ipa" + +# 删除IPA文件夹 +rm -rf "${EXPORT_PATH}" + +# rm -rf "${EXPORT_PATH}/${TARGET_NAME}.xcarchive" +# rm -rf "${EXPORT_PATH}/Packaging.log" +# rm -rf "${EXPORT_PATH}/ExportOptions.plist" +# rm -rf "${EXPORT_PATH}/DistributionSummary.plist" + +#复原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..7760a16f8 --- /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", "apiexample_mac") \ 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..315de4f6b --- /dev/null +++ b/.github/ci/build/build_mac.sh @@ -0,0 +1,85 @@ +################################## +# --- 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 +################################## + +echo is_generate_validate_app:$is_generate_validate_app +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 + +zip_name=${sdk_url##*/} +echo zip_name: $zip_name + +python3 $WORKSPACE/artifactory_utils.py --action=download_file --file=$sdk_url +7za x ./$zip_name -y + +unzip_name=`ls -S -d */ | grep Agora` +echo unzip_name: $unzip_name + +rm -rf ./$unzip_name/bin +rm ./$unzip_name/commits +rm ./$unzip_name/package_size_report.txt +mkdir ./$unzip_name/samples +mkdir ./$unzip_name/samples/APIExample +if [ $? -eq 0 ]; then + echo "success" +else + echo "failed" + exit 1 +fi +cp -rf ./macOS/** ./$unzip_name/samples/APIExample +mv ./$unzip_name/samples/APIExample/sdk.podspec ./$unzip_name/ +python3 ./.github/ci/build/modify_podfile.py ./$unzip_name/samples/APIExample/Podfile + + +if [ $is_generate_validate_app = true ]; then + ./.github/ci/build/build_mac_ipa.sh ./$unzip_name/samples/APIExample +fi + +7za a -tzip result.zip -r $unzip_name +# 7za a -tzip result.zip -r Agora_Native_SDK_for_Mac_FULL +cp result.zip $WORKSPACE/withAPIExample_${BUILD_NUMBER}_$zip_name diff --git a/.github/ci/build/build_mac_ipa.sh b/.github/ci/build/build_mac_ipa.sh new file mode 100755 index 000000000..3eb0c737e --- /dev/null +++ b/.github/ci/build/build_mac_ipa.sh @@ -0,0 +1,106 @@ +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=developer-id + +#工程文件路径 +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") + +# 归档路径 +ARCHIVE_PATH="${PROJECT_PATH}/${TARGET_NAME} ${CURRENT_TIME}/${TARGET_NAME}.xcarchive" +# 编译环境 + +# 导出路径 +EXPORT_PATH="${PROJECT_PATH}/${TARGET_NAME} ${CURRENT_TIME}" + +# 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}" + +# 导出ipa +xcodebuild -exportArchive -archivePath "${ARCHIVE_PATH}" -exportPath "${EXPORT_PATH}" -exportOptionsPlist "${PLIST_PATH}" + +# 删除archive文件 +rm -rf "${EXPORT_PATH}/${TARGET_NAME}.xcarchive" +rm -rf "${EXPORT_PATH}/Packaging.log" +rm -rf "${EXPORT_PATH}/ExportOptions.plist" +rm -rf "${EXPORT_PATH}/DistributionSummary.plist" + +# 上传IPA +7za a "$WORKSPACE/${TARGET_NAME}_Mac_${BUILD_NUMBER}_APP.zip" -r "${EXPORT_PATH}" + +# 删除IPA文件夹 +rm -rf "${EXPORT_PATH}" + +#复原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..7bdbb98f3 --- /dev/null +++ b/.github/ci/build/build_windows.bat @@ -0,0 +1,101 @@ +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 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% + +echo off +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 + +echo off +REM curl --silent %sdk_url% ./ +python %WORKSPACE%\\artifactory_utils.py --action=download_file --file=%sdk_url% +7z x ./%zip_name% -y +echo on + +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 +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_%date:~4,2%%date:~7,2%%time:~0,2%%time:~3,2%_%zip_name% +del /F result.zip +del /F %WORKSPACE%\\%zip_name% + +cd Agora_Native_SDK_for_Windows_FULL\samples\API-example +echo "compile start..." +call installThirdParty.bat +"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe" "APIExample.sln" /p:platform="Win32" /p:configuration="Release" +7z a -tzip result.zip -r Release +copy result.zip %WORKSPACE%\\APIExample_windows_%date:~4,2%%date:~7,2%%time:~0,2%%time:~3,2%_Release_exe.zip +del /F result.zip +echo "compile done." 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..733d2530c --- /dev/null +++ b/.github/ci/build/modify_ios_keycenter.py @@ -0,0 +1,35 @@ +import os, sys + +def modfiy(path, isReset): + appId = os.environ.get('APP_ID') + 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' + 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..1f63ee796 --- /dev/null +++ b/.github/ci/build/modify_podfile.py @@ -0,0 +1,22 @@ +import os, sys + +def modfiy(path): + with open(path, 'r', encoding='utf-8') as file: + contents = [] + for num, line in enumerate(file): + if "pod 'Agora" in line: + line = '\t'+"pod 'sdk', :path => '../../sdk.podspec'" + elif "pod 'sdk" in line: + line = "" + contents.append(line) + file.close() + + with open(path, 'w', encoding='utf-8') as fw: + for content in contents: + fw.write(content + "\n") + fw.close() + + +if __name__ == '__main__': + path = sys.argv[1:][0] + modfiy(path.strip()) \ No newline at end of file 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..23f880c8a --- /dev/null +++ b/Android/APIExample-Audio/README.md @@ -0,0 +1,52 @@ +# 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. + 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..84cc668ee --- /dev/null +++ b/Android/APIExample-Audio/README.zh.md @@ -0,0 +1,53 @@ +# 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可以直接不填 + 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..334dd33a0 --- /dev/null +++ b/Android/APIExample-Audio/app/build.gradle @@ -0,0 +1,82 @@ +apply plugin: 'com.android.application' + +def localSdkPath= "${rootProject.projectDir.absolutePath}/../../sdk" + +android { + compileSdkVersion 32 + buildToolsVersion "32.0.0" + + defaultConfig { + applicationId "io.agora.api.example.audio" + minSdkVersion 21 + targetSdkVersion 32 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + jniLibs.srcDirs += 'src/main/jniLibs' + if(new File("${localSdkPath}").exists()){ + jniLibs.srcDirs += "${localSdkPath}" + } + } + } + + buildFeatures{ + viewBinding true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + if(new File("${localSdkPath}").exists()){ + implementation fileTree(dir: "${localSdkPath}", include: ['*.jar', '*.aar']) + } + else{ + def agora_sdk_version = "4.1.1" + // case 1: full single lib with voice only + implementation "io.agora.rtc:voice-sdk:${agora_sdk_version}" + // case 2: partial libs with voice only + // implementation "io.agora.rtc:voice-rtc-basic:${agora_sdk_version}" + // implementation "io.agora.rtc:spatial-audio:${agora_sdk_version}" + // implementation "io.agora.rtc:audio-beauty:${agora_sdk_version}" + // implementation "io.agora.rtc:aiaec:${agora_sdk_version}" + // implementation "io.agora.rtc:drm-loader:${agora_sdk_version}" + // implementation "io.agora.rtc:drm:${agora_sdk_version}" + } + + implementation 'androidx.appcompat:appcompat:1.5.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + + // Java language implementation + implementation "androidx.navigation:navigation-fragment:2.5.0" + implementation "androidx.navigation:navigation-ui:2.5.0" + + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.recyclerview:recyclerview:1.2.1' + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + + implementation 'io.github.luizgrp.sectionedrecyclerviewadapter:sectionedrecyclerviewadapter:1.2.0' + implementation 'com.yanzhenjie:permission:2.0.3' + 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..f7a3f52f1 --- /dev/null +++ b/Android/APIExample-Audio/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# 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.** \ 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..939f096bc --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..48889e115 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/MainActivity.java @@ -0,0 +1,47 @@ +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() { + NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment); + return NavigationUI.navigateUp(navController, appBarConfiguration) + || super.onSupportNavigateUp(); + } + + @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..7e81063ae --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/ReadyFragment.java @@ -0,0 +1,120 @@ +package io.agora.api.example; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.os.Build; +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 com.yanzhenjie.permission.AndPermission; +import com.yanzhenjie.permission.runtime.Permission; + +import java.util.ArrayList; +import java.util.List; + +import io.agora.api.example.common.BaseFragment; +import io.agora.api.example.common.Constant; +import io.agora.api.example.common.model.ExampleBean; + +/** + * @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); + setHasOptionsMenu(true); + 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 -> { + runOnPermissionGranted(new Runnable() { + @Override + public void run() { + 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); + } + } + }); + } + }); + }); + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.setGroupVisible(R.id.main_setting_group, false); + } + + @SuppressLint("WrongConstant") + private void runOnPermissionGranted(@NonNull Runnable runnable) { + List permissionList = new ArrayList<>(); + permissionList.add(Permission.READ_EXTERNAL_STORAGE); + permissionList.add(Permission.WRITE_EXTERNAL_STORAGE); + permissionList.add(Permission.RECORD_AUDIO); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissionList.add(Manifest.permission.BLUETOOTH_CONNECT); + } + + String[] permissionArray = new String[permissionList.size()]; + permissionList.toArray(permissionArray); + + if (AndPermission.hasPermissions(this, permissionArray)) { + runnable.run(); + return; + } + // Request permission + AndPermission.with(this).runtime().permission( + permissionArray + ).onGranted(permissions -> + { + // Permissions Granted + runnable.run(); + }).start(); + } +} 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..b91f221b4 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/BaseFragment.java @@ -0,0 +1,90 @@ +package io.agora.api.example.common; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Menu; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; + +import io.agora.api.example.R; + +public class BaseFragment extends Fragment +{ + protected Handler handler; + private AlertDialog mAlertDialog; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) + { + super.onCreate(savedInstanceState); + handler = new Handler(Looper.getMainLooper()); + } + + protected void showAlert(String message) { + + 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(); + } + mAlertDialog.setMessage(message); + mAlertDialog.show(); + }); + } + + protected final void showLongToast(final String msg) + { + runOnUIThread(() -> { + Context context = getContext(); + if(context == null){ + return; + } + Toast.makeText(context, msg, Toast.LENGTH_LONG).show(); + }); + } + + protected final void runOnUIThread(Runnable runnable){ + this.runOnUIThread(runnable, 0); + } + + 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; + } + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.setGroupVisible(R.id.main_setting_group, false); + } +} 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-Audio/app/src/main/java/io/agora/api/example/common/gles/Drawable2dLandmarks.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/Drawable2dLandmarks.java new file mode 100644 index 000000000..29d798b12 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/Drawable2dLandmarks.java @@ -0,0 +1,33 @@ +/* + * 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 Drawable2dLandmarks extends Drawable2d { + + + private float pointsCoords[] = new float[150]; + + public Drawable2dLandmarks() { + updateVertexArray(pointsCoords); + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramLandmarks.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramLandmarks.java new file mode 100644 index 000000000..541d6e3c2 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramLandmarks.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * 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.hardware.Camera; +import android.opengl.GLES20; +import android.opengl.Matrix; + +import java.util.Arrays; + +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 { + + private static final String vertexShaderCode = + // This matrix member variable provides a hook to manipulate + // the coordinates of the objects that use this vertex shader + "uniform mat4 uMVPMatrix;" + + "attribute vec4 vPosition;" + + "uniform float uPointSize;" + + "void main() {" + + // the matrix must be included as a modifier of gl_Position + // Note that the uMVPMatrix factor *must be first* in order + // for the matrix multiplication product to be correct. + " gl_Position = uMVPMatrix * vPosition;" + + " gl_PointSize = uPointSize;" + + "}"; + + private static final String fragmentShaderCode = + "precision mediump float;" + + "uniform vec4 vColor;" + + "void main() {" + + " gl_FragColor = vColor;" + + "}"; + + private static final float color[] = {0.63671875f, 0.76953125f, 0.22265625f, 1.0f}; + + private int mPositionHandle; + private int mColorHandle; + private int mMVPMatrixHandle; + private int mPointSizeHandle; + + private float mPointSize = 6.0f; + + public ProgramLandmarks() { + super(vertexShaderCode, fragmentShaderCode); + } + + @Override + protected Drawable2d getDrawable2d() { + return new Drawable2dLandmarks(); + } + + @Override + protected void getLocations() { + // get handle to vertex shader's vPosition member + mPositionHandle = GLES20.glGetAttribLocation(mProgramHandle, "vPosition"); + GlUtil.checkGlError("vPosition"); + // get handle to fragment shader's vColor member + mColorHandle = GLES20.glGetUniformLocation(mProgramHandle, "vColor"); + GlUtil.checkGlError("vColor"); + // get handle to shape's transformation matrix + mMVPMatrixHandle = GLES20.glGetUniformLocation(mProgramHandle, "uMVPMatrix"); + GlUtil.checkGlError("glGetUniformLocation"); + mPointSizeHandle = GLES20.glGetUniformLocation(mProgramHandle, "uPointSize"); + GlUtil.checkGlError("uPointSize"); + } + + @Override + public void drawFrame(int textureId, float[] texMatrix, float[] mvpMatrix) { + // Add program to OpenGL environment + GLES20.glUseProgram(mProgramHandle); + + // Enable a handle to the triangle vertices + GLES20.glEnableVertexAttribArray(mPositionHandle); + + // Prepare the triangle coordinate data + GLES20.glVertexAttribPointer( + mPositionHandle, Drawable2d.COORDS_PER_VERTEX, + GLES20.GL_FLOAT, false, + Drawable2d.VERTEXTURE_STRIDE, mDrawable2d.vertexArray()); + + // Set color for drawing the triangle + GLES20.glUniform4fv(mColorHandle, 1, color, 0); + + // Apply the projection and view transformation + GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mvpMtx, 0); + + GLES20.glUniform1f(mPointSizeHandle, mPointSize); + + // Draw the triangle + GLES20.glDrawArrays(GLES20.GL_POINTS, 0, mDrawable2d.vertexCount()); + + // Disable vertex array + GLES20.glDisableVertexAttribArray(mPositionHandle); + } + + public void drawFrame(int x, int y, int width, int height) { + drawFrame(0, null, null, x, y, width, height); + } + + private final float[] mvpMtx = new float[16]; + private int mCameraType; + private int mOrientation; + private int mWidth; + private int mHeight; + + public void refresh(float[] landmarksData, int width, int height, int orientation, int cameraType) { + if (mWidth != width || mHeight != height || mOrientation != orientation || mCameraType != cameraType) { + float[] orthoMtx = new float[16]; + float[] rotateMtx = new float[16]; + Matrix.orthoM(orthoMtx, 0, 0, width, 0, height, -1, 1); + Matrix.setRotateM(rotateMtx, 0, 360 - orientation, 0.0f, 0.0f, 1.0f); + if (cameraType == Camera.CameraInfo.CAMERA_FACING_BACK) { + Matrix.rotateM(rotateMtx, 0, 180, 1.0f, 0.0f, 0.0f); + } + Matrix.multiplyMM(mvpMtx, 0, rotateMtx, 0, orthoMtx, 0); + + mWidth = width; + mHeight = height; + mOrientation = orientation; + mCameraType = cameraType; + } + + updateVertexArray(Arrays.copyOf(landmarksData, landmarksData.length)); + } +} diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramTexture2d.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramTexture2d.java new file mode 100644 index 000000000..73221726b --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/ProgramTexture2d.java @@ -0,0 +1,124 @@ +/* + * 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.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 ProgramTexture2d 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 "normal" 2D textures. + private static final String FRAGMENT_SHADER_2D = + "precision mediump float;\n" + + "varying vec2 vTextureCoord;\n" + + "uniform sampler2D sTexture;\n" + + "void main() {\n" + + " gl_FragColor = vec4(texture2D(sTexture, vTextureCoord).rgb, 1.0);\n" + + "}\n"; + + private int muMVPMatrixLoc; + private int muTexMatrixLoc; + private int maPositionLoc; + private int maTextureCoordLoc; + + public ProgramTexture2d() { + super(VERTEX_SHADER, FRAGMENT_SHADER_2D); + } + + @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(GLES20.GL_TEXTURE_2D, 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(GLES20.GL_TEXTURE_2D, 0); + GLES20.glUseProgram(0); + } + +} 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-Audio/app/src/main/java/io/agora/api/example/common/gles/core/OffscreenSurface.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/OffscreenSurface.java new file mode 100644 index 000000000..447a1416c --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/OffscreenSurface.java @@ -0,0 +1,39 @@ +/* + * 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; + +/** + * Off-screen EGL surface (pbuffer). + *

+ * It's good practice to explicitly release() the surface, preferably from a "finally" block. + */ +public class OffscreenSurface extends EglSurfaceBase { + /** + * Creates an off-screen surface with the specified width and height. + */ + public OffscreenSurface(EglCore eglCore, int width, int height) { + super(eglCore); + createOffscreenSurface(width, height); + } + + /** + * Releases any resources associated with the surface. + */ + public void release() { + releaseEglSurface(); + } +} 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-Audio/app/src/main/java/io/agora/api/example/common/gles/core/WindowSurface.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/WindowSurface.java new file mode 100644 index 000000000..2c784f6ab --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/gles/core/WindowSurface.java @@ -0,0 +1,95 @@ +/* + * 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.view.Surface; + +/** + * Recordable EGL window surface. + *

+ * It's good practice to explicitly release() the surface, preferably from a "finally" block. + */ +public class WindowSurface extends EglSurfaceBase { + private Surface mSurface; + private boolean mReleaseSurface; + + /** + * Associates an EGL surface with the native window surface. + *

+ * Set releaseSurface to true if you want the Surface to be released when release() is + * called. This is convenient, but can interfere with framework classes that expect to + * manage the Surface themselves (e.g. if you release a SurfaceView's Surface, the + * surfaceDestroyed() callback won't fire). + */ + public WindowSurface(EglCore eglCore, Surface surface, boolean releaseSurface) { + super(eglCore); + createWindowSurface(surface); + mSurface = surface; + mReleaseSurface = releaseSurface; + } + + /** + * Associates an EGL surface with the SurfaceTexture. + */ + public WindowSurface(EglCore eglCore, SurfaceTexture surfaceTexture) { + super(eglCore); + createWindowSurface(surfaceTexture); + } + + public WindowSurface(EglCore eglCore, int width, int height) { + super(eglCore); + createOffscreenSurface(width, height); + } + + /** + * Releases any resources associated with the EGL surface (and, if configured to do so, + * with the Surface as well). + *

+ * Does not require that the surface's EGL context be current. + */ + public void release() { + releaseEglSurface(); + if (mSurface != null) { + if (mReleaseSurface) { + mSurface.release(); + } + mSurface = null; + } + } + + /** + * Recreate the EGLSurface, using the new EglBase. The caller should have already + * freed the old EGLSurface with releaseEglSurface(). + *

+ * This is useful when we want to update the EGLSurface associated with a Surface. + * For example, if we want to share with a different EGLContext, which can only + * be done by tearing down and recreating the context. (That's handled by the caller; + * this just creates a new EGLSurface for the Surface we were handed earlier.) + *

+ * If the previous EGLSurface isn't fully destroyed, e.g. it's still current on a + * context somewhere, the create call will fail with complaints from the Surface + * about already being connected. + */ + public void recreate(EglCore newEglCore) { + if (mSurface == null) { + throw new RuntimeException("not yet implemented for SurfaceTexture"); + } + mEglCore = newEglCore; // switch to new context + createWindowSurface(mSurface); // create new 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..218fcea0f --- /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 config; + } + 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..e531ca345 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/model/StatisticsInfo.java @@ -0,0 +1,169 @@ +package io.agora.api.example.common.model; + +import io.agora.rtc2.IRtcEngineEventHandler.LastmileProbeResult; +import io.agora.rtc2.IRtcEngineEventHandler.LocalAudioStats; +import io.agora.rtc2.IRtcEngineEventHandler.LocalVideoStats; +import io.agora.rtc2.IRtcEngineEventHandler.RemoteAudioStats; +import io.agora.rtc2.IRtcEngineEventHandler.RemoteVideoStats; +import io.agora.rtc2.IRtcEngineEventHandler.RtcStats; + +public class StatisticsInfo { + private LocalVideoStats localVideoStats = new LocalVideoStats(); + private LocalAudioStats localAudioStats = new LocalAudioStats(); + private RemoteVideoStats remoteVideoStats = new RemoteVideoStats(); + private RemoteAudioStats remoteAudioStats = new RemoteAudioStats(); + private RtcStats rtcStats = new RtcStats(); + private int quality; + private LastmileProbeResult lastMileProbeResult; + + public void setLocalVideoStats(LocalVideoStats localVideoStats) { + this.localVideoStats = localVideoStats; + } + + public void setLocalAudioStats(LocalAudioStats localAudioStats) { + this.localAudioStats = localAudioStats; + } + + public void setRemoteVideoStats(RemoteVideoStats remoteVideoStats) { + this.remoteVideoStats = remoteVideoStats; + } + + public void setRemoteAudioStats(RemoteAudioStats remoteAudioStats) { + this.remoteAudioStats = remoteAudioStats; + } + + public void setRtcStats(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(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..05bd94dea --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/widget/AudioSeatManager.java @@ -0,0 +1,75 @@ +package io.agora.api.example.common.widget; + +import android.view.View; + +public class AudioSeatManager { + + private final AudioOnlyLayout[] audioOnlyLayouts; + + public AudioSeatManager(AudioOnlyLayout... seats){ + audioOnlyLayouts = new AudioOnlyLayout[seats.length]; + for (int i = 0; i < audioOnlyLayouts.length; i++) { + audioOnlyLayouts[i] = seats[i]; + } + } + + public void upLocalSeat(int uid) { + AudioOnlyLayout localSeat = audioOnlyLayouts[0]; + localSeat.setTag(uid); + localSeat.setVisibility(View.VISIBLE); + localSeat.updateUserInfo(uid + "", true); + } + + 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); + } + } + + 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); + } + } + + public AudioOnlyLayout getLocalSeat(){ + return audioOnlyLayouts[0]; + } + + 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; + } + + 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/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..418654ed0 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java @@ -0,0 +1,586 @@ +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 com.yanzhenjie.permission.AndPermission; +import com.yanzhenjie.permission.runtime.Permission; + +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.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; + +@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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + 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.valueOf(audioScenario.getSelectedItem().toString()).ordinal()); + } + } + + @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 + if (AndPermission.hasPermissions(this, Permission.Group.STORAGE, Permission.Group.MICROPHONE, Permission.Group.CAMERA)) + { + joinChannel(channelId); + return; + } + // Request permission + AndPermission.with(this).runtime().permission( + Permission.Group.STORAGE, + Permission.Group.MICROPHONE + ).onGranted(permissions -> + { + // Permissions Granted + joinChannel(channelId); + }).start(); + } + 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.valueOf(audioProfile.getSelectedItem().toString()).ordinal(), + Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString()).ordinal() + ); + + /**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..82c195915 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java @@ -0,0 +1,327 @@ +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.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.rtc2.Constants; +import io.agora.rtc2.IRtcEngineEventHandler; +import io.agora.rtc2.RtcEngine; +import io.agora.rtc2.RtcEngineConfig; +import io.agora.rtc2.internal.LastmileProbeConfig; + +@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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + } + 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){ + num = 0; + engine.startEchoTest(MAX_COUNT_DOWN); + 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..21076490a --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java @@ -0,0 +1,451 @@ +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 com.yanzhenjie.permission.AndPermission; +import com.yanzhenjie.permission.runtime.Permission; + +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.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; + +/** + * 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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + 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 + if (AndPermission.hasPermissions(this, Permission.Group.STORAGE, Permission.Group.MICROPHONE)) { + joinChannel(channelId); + return; + } + // Request permission + AndPermission.with(this).runtime().permission( + Permission.Group.STORAGE, + Permission.Group.MICROPHONE + ).onGranted(permissions -> + { + // Permissions Granted + joinChannel(channelId); + }).start(); + } 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 channel, int uid, int audioFrameType, int samples, int bytesPerSample, int channels, int samplesPerSec, ByteBuffer byteBuffer, long renderTimeMs, int bufferLength) { + 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..e7788b41c --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java @@ -0,0 +1,429 @@ +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 com.yanzhenjie.permission.AndPermission; +import com.yanzhenjie.permission.runtime.Permission; + +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.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; + +/** + * 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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + } + 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 + if (AndPermission.hasPermissions(this, Permission.Group.STORAGE, Permission.Group.MICROPHONE, Permission.Group.CAMERA)) + { + joinChannel(channelId); + return; + } + // Request permission + AndPermission.with(this).runtime().permission( + Permission.Group.STORAGE, + Permission.Group.MICROPHONE + ).onGranted(permissions -> + { + // Permissions Granted + joinChannel(channelId); + }).start(); + } + 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..825792648 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java @@ -0,0 +1,676 @@ +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.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.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.spatialaudio.ILocalSpatialAudioEngine; +import io.agora.spatialaudio.LocalSpatialAudioConfig; +import io.agora.spatialaudio.RemoteVoicePositionInfo; +import io.agora.spatialaudio.SpatialAudioZone; + +@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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + engine.enableAudio(); + + localSpatial = ILocalSpatialAudioEngine.create(); + LocalSpatialAudioConfig localSpatialAudioConfig = new LocalSpatialAudioConfig(); + localSpatialAudioConfig.mRtcEngine = engine; + localSpatial.initialize(localSpatialAudioConfig); + + //localSpatial.muteAllRemoteAudioStreams(true); + 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}; + 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}); + localSpatial.updatePlayerPositionInfo(mediaPlayerLeft.getMediaPlayerId(), getVoicePositionInfo(mediaPlayerLeftIv)); + } else { + zoneTv.setVisibility(View.INVISIBLE); + SpatialAudioZone worldZone = new SpatialAudioZone(); + worldZone.upLength = AXIS_MAX_DISTANCE * 2; + worldZone.forwardLength = AXIS_MAX_DISTANCE * 2; + worldZone.rightLength = AXIS_MAX_DISTANCE * 2; + localSpatial.setZones(new SpatialAudioZone[]{worldZone}); + localSpatial.updatePlayerPositionInfo(mediaPlayerLeft.getMediaPlayerId(), getVoicePositionInfo(mediaPlayerLeftIv)); + } + }); + } + + 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.MediaPlayerError error) { + if (state.equals(PLAYER_STATE_OPEN_COMPLETED)) { + mediaPlayer.setLoopCount(-1); + mediaPlayer.play(); + } + } + + @Override + public void onPositionChanged(long position_ms) { + + } + + @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 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; + case MotionEvent.ACTION_UP: + break; + } + return true; + } + + 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 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/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.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 + ""); + localSpatial.updateRemotePosition(uid, getVoicePositionInfo(remoteLeftTv)); + + 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..d212f82b0 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java @@ -0,0 +1,786 @@ +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_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_NEUTRAL; +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_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 com.yanzhenjie.permission.AndPermission; +import com.yanzhenjie.permission.runtime.Permission; + +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.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; + +@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, + customBandFreq, customReverbKey; + private ViewGroup _voice3DLayout, _pitchModeLayout, _pitchValueLayout; + private SeekBar _voice3DCircle, customPitch, customBandGain, customReverbValue; + + 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); + + 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); + + // 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() + + customPitch.setOnSeekBarChangeListener(this); + customBandGain.setOnSeekBarChangeListener(this); + customReverbValue.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); + + customPitch.setEnabled(joined); + customBandFreq.setEnabled(joined); + customBandGain.setEnabled(joined); + customReverbKey.setEnabled(joined); + customReverbValue.setEnabled(joined); + + + chatBeautifier.setSelection(0); + voiceChanger.setSelection(0); + timbreTransformation.setSelection(0); + roomAcoustics.setSelection(0); + pitchCorrection.setSelection(0); + voiceConversion.setSelection(0); + + customPitch.setProgress(0); + customBandGain.setProgress(0); + customReverbValue.setProgress(0); + } + + @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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + } 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 + if (AndPermission.hasPermissions(this, Permission.Group.STORAGE, Permission.Group.MICROPHONE, Permission.Group.CAMERA)) { + joinChannel(channelId); + return; + } + // Request permission + AndPermission.with(this).runtime().permission( + Permission.Group.STORAGE, + Permission.Group.MICROPHONE + ).onGranted(permissions -> + { + // Permissions Granted + joinChannel(channelId); + }).start(); + } 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.valueOf(audioProfile.getSelectedItem().toString()).ordinal(), + Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString()).ordinal() + ); + + /**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/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.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.valueOf(audioScenario.getSelectedItem().toString()).ordinal()); + 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); + } + } + + 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_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(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); + } + } + + @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/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..75b8bb6a1 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java @@ -0,0 +1,384 @@ +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.Handler; +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 com.yanzhenjie.permission.AndPermission; +import com.yanzhenjie.permission.runtime.Permission; + +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.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; + +/** + * 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; + public static RtcEngineEx engine; + private ChannelMediaOptions option = new ChannelMediaOptions(); + + 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; + + @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_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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + + engine.setExternalAudioSource(true, SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL); + + 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 + if (AndPermission.hasPermissions(this, Permission.Group.STORAGE, Permission.Group.MICROPHONE, Permission.Group.CAMERA)) { + joinChannel(channelId); + return; + } + // Request permission + AndPermission.with(this).runtime().permission( + Permission.Group.STORAGE, + Permission.Group.MICROPHONE + ).onGranted(permissions -> + { + // Permissions Granted + joinChannel(channelId); + }).start(); + } 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)); + 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) { + /**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.*/ + engine.setExternalAudioSource(true, SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL, 2, false, 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))); + 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.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)); + } + }; + + class PullingTask implements Runnable { + long number = 0; + + @Override + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO); + while (pulling) { + Log.i(TAG, "pushExternalAudioFrame times:" + number++); + long before = System.currentTimeMillis(); + + ByteBuffer frame = ByteBuffer.allocateDirect(BUFFER_SIZE); + engine.pullPlaybackAudioFrame(frame, BUFFER_SIZE); + byte[] data = new byte[frame.remaining()]; + frame.get(data, 0, data.length); + audioPlayer.play(data, 0, BUFFER_SIZE); + + long now = System.currentTimeMillis(); + long consuming = now - before; + if(consuming < PULL_INTERVAL){ + try { + Thread.sleep(PULL_INTERVAL - consuming); + } catch (InterruptedException e) { + Log.e(TAG, "PushingTask Interrupted"); + } + } + } + } + } +} 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..b4abaa337 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java @@ -0,0 +1,428 @@ +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.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.CompoundButton; +import android.widget.EditText; +import android.widget.Switch; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yanzhenjie.permission.AndPermission; +import com.yanzhenjie.permission.runtime.Permission; + +import java.io.IOException; +import java.io.InputStream; + +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.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; + +/** + * 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 static final String AUDIO_FILE = "output.raw"; + 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 PUSH_INTERVAL = SAMPLES * 1000 / SAMPLE_RATE; + + private InputStream inputStream; + private Thread pushingTask; + private boolean pushing = false; + + private AudioSeatManager audioSeatManager; + + @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; + } + + 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 = BUFFER_SIZE; + byte[] buffer = new byte[byteSize]; + try { + if (inputStream.read(buffer) < 0) { + inputStream.reset(); + return readBuffer(); + } + } catch (IOException e) { + e.printStackTrace(); + } + return buffer; + } + + @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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + openAudioFile(); + } catch (Exception e) { + e.printStackTrace(); + getActivity().onBackPressed(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + pushing = false; + /**leaveChannel and Destroy the RtcEngine instance*/ + if (engine != null) { + engine.leaveChannel(); + } + handler.post(RtcEngine::destroy); + engine = null; + closeAudioFile(); + } + + + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean b) { + if (compoundButton.getId() == R.id.microphone) { + if (b) { + option.publishMicrophoneTrack = true; + } else { + option.publishMicrophoneTrack = false; + } + engine.updateChannelMediaOptions(option); + } else if (compoundButton.getId() == R.id.localAudio) { + if (b) { + option.publishCustomAudioTrack = true; + } else { + option.publishCustomAudioTrack = false; + } + engine.updateChannelMediaOptions(option); + engine.enableCustomAudioLocalPlayback(0, b); + } + } + + + @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 + if (AndPermission.hasPermissions(this, Permission.Group.STORAGE, Permission.Group.MICROPHONE, Permission.Group.CAMERA)) { + joinChannel(channelId); + return; + } + // Request permission + AndPermission.with(this).runtime().permission( + Permission.Group.STORAGE, + Permission.Group.MICROPHONE + ).onGranted(permissions -> + { + // Permissions Granted + joinChannel(channelId); + }).start(); + } 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(); + pushing = false; + join.setText(getString(R.string.join)); + mic.setEnabled(false); + pcm.setEnabled(false); + pcm.setChecked(false); + mic.setChecked(true); + if(pushingTask != null){ + try { + pushingTask.join(); + pushingTask = null; + } catch (InterruptedException e) { + // do nothing + } + } + 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.*/ + engine.setExternalAudioSource(true, SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL, 2, false, 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))); + 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.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)); + pushing = true; + if(pushingTask == null){ + pushingTask = new Thread(new PushingTask()); + pushingTask.start(); + } + audioSeatManager.upLocalSeat(uid); + } + }); + } + + + @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)); + } + }; + + class PushingTask implements Runnable { + long number = 0; + + @Override + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO); + while (pushing) { + Log.i(TAG, "pushExternalAudioFrame times:" + number++); + long before = System.currentTimeMillis(); + engine.pushExternalAudioFrame(readBuffer(), 0); + long now = System.currentTimeMillis(); + long consuming = now - before; + if(consuming < PUSH_INTERVAL){ + try { + Thread.sleep(PUSH_INTERVAL - consuming); + } catch (InterruptedException e) { + Log.e(TAG, "PushingTask Interrupted"); + } + } + } + } + } +} 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..0dfddb06a --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java @@ -0,0 +1,517 @@ +package io.agora.api.example.examples.basic; + +import static io.agora.api.example.common.model.Examples.BASIC; + +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.Switch; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yanzhenjie.permission.AndPermission; +import com.yanzhenjie.permission.runtime.Permission; + +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.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; + +/**This demo demonstrates how to make a one-to-one voice call + * @author cjw*/ +@Example( + index = 1, + 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 audioProfileInput; + private Spinner audioScenarioInput; + private EditText et_channel; + private Button mute, join, speaker; + 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); + audioScenarioInput = view.findViewById(R.id.audio_scenario_spinner); + audioScenarioInput.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if(joined){ + int scenario = Constants.AudioScenario.valueOf(audioScenarioInput.getSelectedItem().toString()).ordinal(); + engine.setAudioScenario(scenario); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + view.findViewById(R.id.btn_join).setOnClickListener(this); + mute = view.findViewById(R.id.microphone); + mute.setOnClickListener(this); + speaker = view.findViewById(R.id.btn_speaker); + speaker.setOnClickListener(this); + speaker.setActivated(true); + 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) + ); + } + + @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.valueOf(audioScenarioInput.getSelectedItem().toString()).ordinal(); + 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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + } + 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 + if (AndPermission.hasPermissions(this, Permission.Group.STORAGE, Permission.Group.MICROPHONE, Permission.Group.CAMERA)) + { + joinChannel(channelId); + audioProfileInput.setEnabled(false); + return; + } + // Request permission + AndPermission.with(this).runtime().permission( + Permission.Group.STORAGE, + Permission.Group.MICROPHONE + ).onGranted(permissions -> + { + // Permissions Granted + joinChannel(channelId); + audioProfileInput.setEnabled(false); + }).start(); + } + 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)); + speaker.setText(getString(R.string.speaker)); + speaker.setEnabled(false); + mute.setText(getString(R.string.closemicrophone)); + mute.setEnabled(false); + audioProfileInput.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()); + } + else if (v.getId() == R.id.btn_speaker) + { + speaker.setActivated(!speaker.isActivated()); + speaker.setText(getString(speaker.isActivated() ? R.string.speaker : R.string.earpiece)); + /**Turn off / on the speaker and change the audio playback route.*/ + engine.setEnableSpeakerphone(speaker.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 profile = Constants.AudioProfile.valueOf(audioProfileInput.getSelectedItem().toString()).ordinal(); + engine.setAudioProfile(profile); + + int scenario = Constants.AudioScenario.valueOf(audioScenarioInput.getSelectedItem().toString()).ordinal(); + engine.setAudioScenario(scenario); + + engine.setDefaultAudioRoutetoSpeakerphone(true); + + 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/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() + { + speaker.setEnabled(true); + 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); + }); + } + }; +} 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..e669ce423 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudioByToken.java @@ -0,0 +1,502 @@ +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; + +/**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 Spinner audioProfileInput; + private Spinner audioScenarioInput; + private EditText et_app_id, et_channel, et_token; + private Button mute, join, speaker; + 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(engine == null){ + return; + } + 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_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); + audioProfileInput = view.findViewById(R.id.audio_profile_spinner); + audioScenarioInput = view.findViewById(R.id.audio_scenario_spinner); + audioScenarioInput.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if(joined){ + int scenario = Constants.AudioScenario.valueOf(audioScenarioInput.getSelectedItem().toString()).ordinal(); + engine.setAudioScenario(scenario); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + view.findViewById(R.id.btn_join).setOnClickListener(this); + mute = view.findViewById(R.id.microphone); + mute.setOnClickListener(this); + speaker = view.findViewById(R.id.btn_speaker); + speaker.setOnClickListener(this); + speaker.setActivated(true); + 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) -> { + if(engine == null){ + return; + } + 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) + ); + } + + 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.mAudioScenario = Constants.AudioScenario.valueOf(audioScenarioInput.getSelectedItem().toString()).ordinal(); + 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.*/ + engine.setLocalAccessPoint(((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig()); + + 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); + audioProfileInput.setEnabled(false); + } + } + else + { + joined = false; + join.setText(getString(R.string.join)); + speaker.setText(getString(R.string.speaker)); + speaker.setEnabled(false); + mute.setText(getString(R.string.closemicrophone)); + mute.setEnabled(false); + audioProfileInput.setEnabled(true); + record.setEnabled(false); + playout.setEnabled(false); + inear.setEnabled(false); + inEarSwitch.setEnabled(false); + inEarSwitch.setChecked(false); + audioSeatManager.downAllSeats(); + destroyRtcEngine(); + } + } + else if (v.getId() == R.id.microphone) + { + if(engine == null){ + return; + } + 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()); + } + else if (v.getId() == R.id.btn_speaker) + { + if(engine == null){ + return; + } + speaker.setActivated(!speaker.isActivated()); + speaker.setText(getString(speaker.isActivated() ? R.string.speaker : R.string.earpiece)); + /**Turn off / on the speaker and change the audio playback route.*/ + engine.setEnableSpeakerphone(speaker.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, String token) + { + /**In the demo, the default is to enter as the anchor.*/ + engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); + + int profile = Constants.AudioProfile.valueOf(audioProfileInput.getSelectedItem().toString()).ordinal(); + engine.setAudioProfile(profile); + + int scenario = Constants.AudioScenario.valueOf(audioScenarioInput.getSelectedItem().toString()).ordinal(); + engine.setAudioScenario(scenario); + + 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() + { + speaker.setEnabled(true); + 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); + }); + } + }; +} 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..0c281272d --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/ClassUtils.java @@ -0,0 +1,285 @@ +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; +import io.agora.api.example.BuildConfig; + +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 (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..324fc3087 --- /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 + */ +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..7a6f99fca --- /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 + */ +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..f3c1210fa --- /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()) {//如果文件夹不存在,则创建新的文件夹 + file.mkdirs(); + } + + // 获取assets目录下的所有文件及目录名 + String[] fileNames = assetManager.list(assetsPath); + if (fileNames.length > 0) {//如果是目录 apk + for (String fileName : fileNames) { + if (!TextUtils.isEmpty(assetsPath)) { + temp = assetsPath + SEPARATOR + fileName;//补全assets资源路径 + } + + String[] childFileNames = assetManager.list(temp); + if (!TextUtils.isEmpty(temp) && childFileNames.length > 0) {//判断是文件还是文件夹:如果是文件夹 + copyFilesFromAssets(context, temp, storagePath + SEPARATOR + fileName); + } else {//如果是文件 + InputStream inputStream = assetManager.open(temp); + readInputStream(storagePath + SEPARATOR + fileName, inputStream); + } + } + } else {//如果是文件 doc_test.txt或者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(); + } + + } + + /** + * 读取输入流中的数据写入输出流 + * + * @param storagePath 目标文件路径 + * @param inputStream 输入流 + */ + public static void readInputStream(String storagePath, InputStream inputStream) { + File file = new File(storagePath); + try { + if (!file.exists()) { + // 1.建立通道对象 + FileOutputStream fos = new FileOutputStream(file); + // 2.定义存储空间 + byte[] buffer = new byte[inputStream.available()]; + // 3.开始读文件 + int lenght = 0; + while ((lenght = inputStream.read(buffer)) != -1) {// 循环从输入流读取buffer字节 + // 将Buffer中的数据写到outputStream对象中 + fos.write(buffer, 0, lenght); + } + fos.flush();// 刷新缓冲区 + // 4.关闭流 + 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/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..65b2ee411 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TokenUtils.java @@ -0,0 +1,125 @@ +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 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 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://test-toolbox.bj2.agoralab.co/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..2cdb1f1f6 --- /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; + } + /** + * I420转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-Audio/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/Android/APIExample-Audio/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file 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_custom_audio_render.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_custom_audio_render.xml new file mode 100644 index 000000000..52f933a6a --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_custom_audio_render.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_custom_audio_source.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_custom_audio_source.xml new file mode 100644 index 000000000..044f6a45c --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_custom_audio_source.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_joinchannel_audio.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_joinchannel_audio.xml new file mode 100644 index 000000000..fd6293146 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_joinchannel_audio.xml @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_joinchannel_audio_by_token.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_joinchannel_audio_by_token.xml new file mode 100644 index 000000000..9a5b51af7 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_joinchannel_audio_by_token.xml @@ -0,0 +1,289 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_main.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_main.xml new file mode 100644 index 000000000..3fea5bcaa --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_main.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_play_audio_files.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_play_audio_files.xml new file mode 100644 index 000000000..2bd54b6e4 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_play_audio_files.xml @@ -0,0 +1,340 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_precall_test.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_precall_test.xml new file mode 100755 index 000000000..28145eb6d --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_precall_test.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_raw_audio.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_raw_audio.xml new file mode 100644 index 000000000..fca514f00 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_raw_audio.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_ready_layout.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_ready_layout.xml new file mode 100644 index 000000000..155790ea9 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_ready_layout.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_rhythm_player.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_rhythm_player.xml new file mode 100644 index 000000000..1a35380e3 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_rhythm_player.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_spatial_sound.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_spatial_sound.xml new file mode 100644 index 000000000..a925cec4c --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_spatial_sound.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/APIExample-Audio/app/src/main/res/layout/fragment_voice_effects.xml b/Android/APIExample-Audio/app/src/main/res/layout/fragment_voice_effects.xml new file mode 100644 index 000000000..9990b1a41 --- /dev/null +++ b/Android/APIExample-Audio/app/src/main/res/layout/fragment_voice_effects.xml @@ -0,0 +1,529 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +