diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1d2981 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties + + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..565f8c2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,4 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { +alias(libs.plugins.android.application) apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4387edc --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..36e9aa2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,22 @@ +[versions] +agp = "8.4.2" +junit = "4.13.2" +junitVersion = "1.2.1" +espressoCore = "3.6.1" +appcompat = "1.7.0" +material = "1.12.0" +activity = "1.9.1" +constraintlayout = "2.1.4" + +[libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } +ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..fc1c002 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Aug 01 09:56:40 CEST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/hsdemo/.gitignore b/hsdemo/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hsdemo/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hsdemo/build.gradle b/hsdemo/build.gradle new file mode 100644 index 0000000..2f192e2 --- /dev/null +++ b/hsdemo/build.gradle @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace 'com.zebra.hsdemo' + compileSdk 34 + + defaultConfig { + applicationId "com.zebra.hsdemo" + minSdk 33 + targetSdk 34 + 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 + } +} + +dependencies { + + implementation libs.appcompat + implementation libs.material + implementation libs.activity + implementation libs.constraintlayout + testImplementation libs.junit + androidTestImplementation libs.ext.junit + androidTestImplementation libs.espresso.core + implementation 'com.github.ltrudu:CriticalPermissionsHelper:+' + +} \ No newline at end of file diff --git a/hsdemo/proguard-rules.pro b/hsdemo/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hsdemo/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/hsdemo/src/androidTest/java/com/zebra/hsdemo/ExampleInstrumentedTest.java b/hsdemo/src/androidTest/java/com/zebra/hsdemo/ExampleInstrumentedTest.java new file mode 100644 index 0000000..724d8f9 --- /dev/null +++ b/hsdemo/src/androidTest/java/com/zebra/hsdemo/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.zebra.hsdemo; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.zebra.hsdemo", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/hsdemo/src/main/AndroidManifest.xml b/hsdemo/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e85ddbc --- /dev/null +++ b/hsdemo/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hsdemo/src/main/ic_launcher-playstore.png b/hsdemo/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..93230a6 Binary files /dev/null and b/hsdemo/src/main/ic_launcher-playstore.png differ diff --git a/hsdemo/src/main/java/com/zebra/hsdemo/FileUtils.java b/hsdemo/src/main/java/com/zebra/hsdemo/FileUtils.java new file mode 100644 index 0000000..2785d37 --- /dev/null +++ b/hsdemo/src/main/java/com/zebra/hsdemo/FileUtils.java @@ -0,0 +1,20 @@ +package com.zebra.hsdemo; + +import android.content.Context; +import android.net.Uri; +import androidx.core.content.FileProvider; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +public class FileUtils { + + public static byte[] readFileToByteArray(File file) throws IOException { + byte[] data = new byte[(int) file.length()]; + try (FileInputStream fis = new FileInputStream(file)) { + fis.read(data); + } + return data; + } +} diff --git a/hsdemo/src/main/java/com/zebra/hsdemo/MainActivity.java b/hsdemo/src/main/java/com/zebra/hsdemo/MainActivity.java new file mode 100644 index 0000000..4e61c76 --- /dev/null +++ b/hsdemo/src/main/java/com/zebra/hsdemo/MainActivity.java @@ -0,0 +1,546 @@ +package com.zebra.hsdemo; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothHeadset; +import android.bluetooth.BluetoothProfile; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioAttributes; +import android.media.AudioDeviceInfo; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.media.AudioTrack; +import android.media.MediaPlayer; +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.zebra.criticalpermissionshelper.CriticalPermissionsHelper; +import com.zebra.criticalpermissionshelper.EPermissionType; +import com.zebra.criticalpermissionshelper.IResultCallbacks; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.List; + +import androidx.appcompat.app.AppCompatActivity; + +public class MainActivity extends AppCompatActivity { + + private final String TAG = "hsdemo"; + private AudioManager audioManager; + private BluetoothAdapter bluetoothAdapter; + private BluetoothHeadset mBluetoothHeadset; + private List devices; + private boolean targetBt = false; + + + AudioManager recAudioManager = null; + AudioManager playAudioManager = null; + AudioRecord recorder = null; + Thread recordingThread = null; + MediaPlayer mp = null; + int bufSize = 0; + boolean isRecording = false; + float recordingGain = 1.0f; + float replayGain = 1.0f; + + public static final int sampleRate = 8000; + public static final int channelInConfig = AudioFormat.CHANNEL_IN_MONO; + public static final int channelOutConfig = AudioFormat.CHANNEL_OUT_MONO; + public static final int channelNumber = 1; + public static final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; + public static final int bitDepth = 16; + + private boolean isBluetoothConnected = false; + private BroadcastReceiver scoConnectReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + Log.d(TAG, ">>> BT SCO state changed !!! "); + if(AudioManager.ACTION_SCO_AUDIO_STATE_CHANGED.equals(action)) { + int status = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR ); + Log.d(TAG, "BT SCO state changed : " + status); + audioManager.setBluetoothScoOn(targetBt); + if(status == AudioManager.SCO_AUDIO_STATE_CONNECTED) { + isBluetoothConnected = true; + }else if(status == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) { + isBluetoothConnected = false; + } + } + } + }; + + public void registerStateReceiver() { + Log.d(TAG, "Register BT media receiver"); + registerReceiver(scoConnectReceiver, new + IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)); + } + + final BluetoothProfile.ServiceListener mProfileListener = new BluetoothProfile.ServiceListener() { + public void onServiceConnected(int profile, BluetoothProfile proxy) { + Log.d(TAG,"BT Onservice Connected"); + if (profile == BluetoothProfile.HEADSET) { + mBluetoothHeadset = (BluetoothHeadset) proxy; + } + } + public void onServiceDisconnected(int profile) { + if (profile == BluetoothProfile.HEADSET) { + mBluetoothHeadset = null; + } + } + }; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + setButtonVisibility(false); + + CriticalPermissionsHelper.grantPermission(this, EPermissionType.ALL_DANGEROUS_PERMISSIONS, new IResultCallbacks() { + @Override + public void onSuccess(String message, String resultXML) { + Log.d(TAG, EPermissionType.ALL_DANGEROUS_PERMISSIONS.toString() + " granted with success."); + CriticalPermissionsHelper.grantPermission(MainActivity.this, EPermissionType.MANAGE_EXTERNAL_STORAGE, new IResultCallbacks() { + @Override + public void onSuccess(String message, String resultXML) { + Log.d(TAG, EPermissionType.MANAGE_EXTERNAL_STORAGE.toString() + " granted with success."); + initHSDemo(); + } + + @Override + public void onError(String message, String resultXML) { + Log.d(TAG, "Error granting " + EPermissionType.MANAGE_EXTERNAL_STORAGE.toString() + " permission.\n" + message); + } + + @Override + public void onDebugStatus(String message) { + Log.d(TAG, "Debug Grant Permission " + EPermissionType.MANAGE_EXTERNAL_STORAGE.toString() + ": " + message); + } + }); + } + + @Override + public void onError(String message, String resultXML) { + Log.d(TAG, "Error granting " + EPermissionType.ALL_DANGEROUS_PERMISSIONS.toString() + " permission.\n" + message); + } + + @Override + public void onDebugStatus(String message) { + Log.d(TAG, "Debug Grant Permission " + EPermissionType.ALL_DANGEROUS_PERMISSIONS.toString() + ": " + message); + } + }); + + } + + private void setButtonVisibility(boolean visible) + { + findViewById(R.id.llGlobal).setVisibility(visible == true ? View.VISIBLE : View.GONE); + findViewById(R.id.tvMessage).setVisibility(visible == true ? View.GONE : View.VISIBLE); + } + + private void initHSDemo() + { + audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + + // Start Bluetooth SCO + audioManager.startBluetoothSco(); + + // Request audio focus + audioManager.requestAudioFocus(focusChange -> { + // Handle focus change + }, AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN); + + registerStateReceiver(); + + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + + bluetoothAdapter.getProfileProxy(this, mProfileListener, BluetoothProfile.HEADSET); + + findViewById(R.id.btStartRecording).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + startRecording(); + } + }); + findViewById(R.id.btStopRecording).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + stopRecording(); + } + }); + findViewById(R.id.btPlayWithMP).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + playWithMediaPlayer(); + } + }); + + findViewById(R.id.btPlayWithAT).setOnClickListener(new View.OnClickListener() + { + @Override + public void onClick(View view) { + playPcmFileWithAudioTrack(); + } + }); + setButtonVisibility(true); + + TextView tvRecordingGain = findViewById(R.id.tvRecordingGain); + tvRecordingGain.setText(String.format("%.1f", recordingGain)); + + ((SeekBar)findViewById(R.id.sbRecordingGain)).setProgress((int)(recordingGain * 10), false); + ((SeekBar)findViewById(R.id.sbRecordingGain)).setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + recordingGain = progress / 10.0f; + tvRecordingGain.setText(String.format("%.1f", recordingGain)); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }); + + TextView tvReplayGain = findViewById(R.id.tvReplayGain); + tvReplayGain.setText(String.format("%.1f", replayGain)); + + ((SeekBar)findViewById(R.id.sbReplayGain)).setProgress((int)(replayGain * 10), false); + ((SeekBar)findViewById(R.id.sbReplayGain)).setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + replayGain = progress / 10.0f; + tvReplayGain.setText(String.format("%.1f", replayGain)); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + // Stop Bluetooth SCO + if(audioManager != null) + audioManager.stopBluetoothSco(); + + if(bluetoothAdapter != null && mBluetoothHeadset != null) + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, mBluetoothHeadset); + + // Unregister the BroadcastReceiver + try { + Log.d(TAG, "Unregister BT media receiver"); + unregisterReceiver(scoConnectReceiver); + } catch (Exception e) { + Log.w(TAG, "Failed to unregister media state receiver",e); + } + } + + private void startBluetoothSCOAudio(boolean state) { + AudioManager audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + targetBt = state; + if (state != isBluetoothConnected) { + if(state) { + Log.d(TAG, "BT SCO on >>>"); // First we try to connect + audioManager.startBluetoothSco(); + } else { + Log.d(TAG, "BT SCO off >>>"); // We stop to use BT SCO + audioManager.setBluetoothScoOn(false); + // And we stop BT SCO connection + audioManager.stopBluetoothSco(); + } + } else if (state != audioManager.isBluetoothScoOn()) { + // BT SCO is already in desired connection state, we only have to use it + audioManager.setBluetoothScoOn(state); + } + } + + + + @SuppressLint("MissingPermission") + private void startRecording(){ + recAudioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + // Setting Mode is not mandatory, but based on the usecase (VoIP) + //recAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + if (isHeadsetConnected()) { + startBluetoothSCOAudio(true); + } // To check if BT Headset is available to connect SCO and record via BT + + + bufSize = AudioRecord.getMinBufferSize(sampleRate, channelInConfig, audioFormat); + try { + recorder = new AudioRecord( MediaRecorder.AudioSource.MIC , + sampleRate, channelInConfig, audioFormat, bufSize); + + } catch (Exception e) { + // handle exception + Log.e(TAG,"UNSUPPORTED Input Parameter : " + e); + return; + } + int recordstate = recorder.getState(); + if (recordstate == AudioRecord.STATE_INITIALIZED) { + Log.w(TAG,"startRecording recorder instance created"); + recorder.startRecording(); + isRecording = true; + recordingThread = new Thread(new Runnable() { + @Override + public void run() { + writeAudioDataToFile(); + } + },"AudioRecorder Thread"); + Log.w(TAG,"Recording thread to start"); + recordingThread.start(); + } + else { + Log.e(TAG,"UNSUPPORTED Input Parameter, recorder instance NOT created"); + } + } + + private String getFilename() + { + File myCacheFile = new File(getCacheDir(), "recorded_audio.pcm"); + return myCacheFile.getPath(); + } + + public byte[] applyGain(byte[] buffer, int read, float gain) { + for (int i = 0; i < read; i += 2) { + short sample = (short) ((buffer[i] & 0xFF) | (buffer[i + 1] << 8)); + sample = (short) Math.min(Math.max(sample * gain, Short.MIN_VALUE), Short.MAX_VALUE); + buffer[i] = (byte) (sample & 0xFF); + buffer[i + 1] = (byte) ((sample >> 8) & 0xFF); + } + return buffer; + } + + + private void writeAudioDataToFile() { + byte[] audioData = new byte[bufSize]; + String filePath = getFilename(); + FileOutputStream os = null; + + try { + os = new FileOutputStream(filePath); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + + while (isRecording) { + int read = recorder.read(audioData, 0, bufSize); + audioData = applyGain(audioData, read, recordingGain); + if (AudioRecord.ERROR_INVALID_OPERATION != read) { + try { + os.write(audioData); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + try { + os.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + + private void stopRecording(){ + isRecording = false; + if(null != recorder){ + Log.w(TAG, "StopRecording"); + //recAudioManager.setMode(AudioManager.MODE_NORMAL); + recorder.stop(); + recorder.release(); + recorder = null; + recordingThread = null; + } + if (recAudioManager.isBluetoothScoOn()) { + Log.w(TAG, "Disconnect BTSCO Record"); + startBluetoothSCOAudio(false); + } + else { + Log.d(TAG, "BTSCO is not connected"); + } + File recordedFile = new File(getFilename()); + if(recordedFile.exists()) + { + Log.d(TAG, "File exists:" + recordedFile.getPath()); + } + } + + public void playWithMediaPlayer() { + if (isHeadsetConnected()) { + startBluetoothSCOAudio(true); + } + + routeAudioToHeadset(); + + int maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, maxVolume,0); + + File recordedFile = new File(getFilename()); + if(recordedFile.exists()) { + Uri fileAsUri = null; + try { + fileAsUri = MediaFileUtils.encodePCMtoWavThenTransferFileToMediaStore(this, recordedFile, sampleRate, channelNumber, bitDepth, replayGain); + } catch (IOException e) { + Log.e(TAG, "Exception: " + e); + e.printStackTrace(); + return; + } + + // Create AudioAttributes + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(); + + + MediaPlayer mediaPlayer = new MediaPlayer(); + + mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(MediaPlayer mediaPlayer) { + mediaPlayer.start(); + } + }); + + + mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mp) { + mp.release(); + playAudioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + if (playAudioManager.isBluetoothScoOn()) { + Log.w(TAG, "Stop play Disconnect BTSCO play"); + startBluetoothSCOAudio(false); + } else + Log.w(TAG, "play BTSCO is not connected"); + + } + }); + try { + mediaPlayer.setVolume(1.0f,1.0f); + mediaPlayer.setAudioAttributes(audioAttributes); + mediaPlayer.setDataSource(this, fileAsUri); + mediaPlayer.prepareAsync(); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + private boolean isHeadsetConnected() { + boolean hasConnectedDevice = false; + AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS); + for (AudioDeviceInfo device : devices) { + if (device.getType() == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || + device.getType() == AudioDeviceInfo.TYPE_WIRED_HEADSET || + device.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + device.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) { + hasConnectedDevice = true; + } + } + boolean retVal = hasConnectedDevice && audioManager.isBluetoothScoAvailableOffCall(); + Log.d(TAG, "Can I do BT ? "+retVal); + return retVal; + } + + private void routeAudioToHeadset() { + if (isHeadsetConnected()) { + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + audioManager.setSpeakerphoneOn(false); + } else { + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.setSpeakerphoneOn(true); + } + } + + private void playPcmFileWithAudioTrack() { + byte[] audioData = null; + File fileToPlay = new File(getFilename()); + if(fileToPlay.exists()) + { + try { + audioData = FileUtils.readFileToByteArray(fileToPlay); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + if(audioData == null) + { + Toast.makeText(this, "Error while playing pcm file : audioData == null", Toast.LENGTH_LONG).show(); + return; + } + + audioData = applyGain(audioData, audioData.length, replayGain); + + if (isHeadsetConnected()) { + startBluetoothSCOAudio(true); + } + + routeAudioToHeadset(); + + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + + AudioFormat format = new AudioFormat.Builder() + .setSampleRate(sampleRate) + .setEncoding(audioFormat) + .setChannelMask(channelOutConfig) + .build(); + + int bufferSize = AudioTrack.getMinBufferSize(sampleRate, channelOutConfig, audioFormat); + AudioTrack audioTrack = new AudioTrack( + audioAttributes, + format, + bufferSize, + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE + ); + + audioTrack.setVolume(1.0f); + audioTrack.play(); + audioTrack.write(audioData, 0, audioData.length); + audioTrack.stop(); + audioTrack.release(); + + playAudioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + if (playAudioManager.isBluetoothScoOn()) { + startBluetoothSCOAudio(false); + } // To check if BT Headset is available to connect SCO and record via BT + + } + + +} diff --git a/hsdemo/src/main/java/com/zebra/hsdemo/MediaFileUtils.java b/hsdemo/src/main/java/com/zebra/hsdemo/MediaFileUtils.java new file mode 100644 index 0000000..4c1e74d --- /dev/null +++ b/hsdemo/src/main/java/com/zebra/hsdemo/MediaFileUtils.java @@ -0,0 +1,99 @@ +package com.zebra.hsdemo; + +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Environment; +import android.provider.MediaStore; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public class MediaFileUtils { + + public static Uri encodePCMtoWavThenTransferFileToMediaStore(Context context, File sourceFile, int sampleRate, int channels, int bitDepth, float gain) throws IOException { + // Convert PCM to WAV + File wavFile = new File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "converted_sound_file.wav"); + if(wavFile.exists()) + wavFile.delete(); + + convertPcmToWav(sourceFile, wavFile, sampleRate, channels, bitDepth, gain); + + // Insert the WAV file into the MediaStore + return insertFileIntoMediaStore(context, wavFile); + } + + private static void convertPcmToWav(File pcmFile, File wavFile, int sampleRate, int channels, int bitDepth, float gain) throws IOException { + byte[] pcmData = new byte[(int) pcmFile.length()]; + try (FileInputStream fis = new FileInputStream(pcmFile)) { + fis.read(pcmData); + } + + pcmData = applyGain(pcmData, pcmData.length, gain); + + try (FileOutputStream fos = new FileOutputStream(wavFile)) { + // Write WAV header + writeWavHeader(fos, pcmData.length, sampleRate, channels, bitDepth); + // Write PCM data + fos.write(pcmData); + } + } + + private static void writeWavHeader(FileOutputStream fos, int pcmDataLength, int sampleRate, int channels, int bitDepth) throws IOException { + int byteRate = sampleRate * channels * bitDepth / 8; + int blockAlign = channels * bitDepth / 8; + int dataSize = pcmDataLength; + int chunkSize = 36 + dataSize; + + fos.write(new byte[] { + 'R', 'I', 'F', 'F', // ChunkID + (byte) (chunkSize & 0xff), (byte) ((chunkSize >> 8) & 0xff), (byte) ((chunkSize >> 16) & 0xff), (byte) ((chunkSize >> 24) & 0xff), // ChunkSize + 'W', 'A', 'V', 'E', // Format + 'f', 'm', 't', ' ', // Subchunk1ID + 16, 0, 0, 0, // Subchunk1Size + 1, 0, // AudioFormat (PCM) + (byte) channels, 0, // NumChannels + (byte) (sampleRate & 0xff), (byte) ((sampleRate >> 8) & 0xff), (byte) ((sampleRate >> 16) & 0xff), (byte) ((sampleRate >> 24) & 0xff), // SampleRate + (byte) (byteRate & 0xff), (byte) ((byteRate >> 8) & 0xff), (byte) ((byteRate >> 16) & 0xff), (byte) ((byteRate >> 24) & 0xff), // ByteRate + (byte) blockAlign, 0, // BlockAlign + (byte) bitDepth, 0, // BitsPerSample + 'd', 'a', 't', 'a', // Subchunk2ID + (byte) (dataSize & 0xff), (byte) ((dataSize >> 8) & 0xff), (byte) ((dataSize >> 16) & 0xff), (byte) ((dataSize >> 24) & 0xff) // Subchunk2Size + }); + } + + private static Uri insertFileIntoMediaStore(Context context, File file) { + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, file.getName()); + values.put(MediaStore.MediaColumns.MIME_TYPE, "audio/wav"); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_MUSIC + "/MyMediaFiles"); + + Uri externalContentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + Uri fileUri = context.getContentResolver().insert(externalContentUri, values); + + try (OutputStream out = context.getContentResolver().openOutputStream(fileUri)) { + FileInputStream in = new FileInputStream(file); + byte[] buffer = new byte[1024]; + int length; + while ((length = in.read(buffer)) > 0) { + out.write(buffer, 0, length); + } + } catch (IOException e) { + e.printStackTrace(); + } + + return fileUri; + } + + public static byte[] applyGain(byte[] buffer, int read, float gain) { + for (int i = 0; i < read; i += 2) { + short sample = (short) ((buffer[i] & 0xFF) | (buffer[i + 1] << 8)); + sample = (short) Math.min(Math.max(sample * gain, Short.MIN_VALUE), Short.MAX_VALUE); + buffer[i] = (byte) (sample & 0xFF); + buffer[i + 1] = (byte) ((sample >> 8) & 0xFF); + } + return buffer; + } +} diff --git a/hsdemo/src/main/res/drawable/ic_launcher_background.xml b/hsdemo/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/hsdemo/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hsdemo/src/main/res/drawable/ic_launcher_foreground.xml b/hsdemo/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/hsdemo/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/hsdemo/src/main/res/drawable/icon.png b/hsdemo/src/main/res/drawable/icon.png new file mode 100644 index 0000000..ca0cc47 Binary files /dev/null and b/hsdemo/src/main/res/drawable/icon.png differ diff --git a/hsdemo/src/main/res/layout/activity_main.xml b/hsdemo/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..56e5e4e --- /dev/null +++ b/hsdemo/src/main/res/layout/activity_main.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + +